52 Commits
Author SHA1 Message Date
ekklesiaandClaude Opus 5.5 14291fde37 fix: a release job uses only the key it is given, never the runner's own
CI / checks (push) Failing after 2m13s
CI / package and container gate (push) Skipped
Measured on a throwaway Gitea: with the key secret unset, Preview's
publish job would have fallen back to the runner machine's own ssh
configuration — on the development VM, the production publish key — and
published a throwaway 9.9.9 to the real server. It was cancelled before
it ran. And with the key set, the job wrote over the runner account's own
~/.ssh/config and known_hosts.

Now, under Actions, a job without RELEASE_SSH_KEY stops; with it, the key,
its known_hosts and an ssh configuration live in the job's own directory
and every ssh and rsync goes through `ssh -F` (RELEASE_SSH_CONFIG in
release-lib), which reads nothing else. The key is deleted when the job
ends. By hand, nothing changes. Also records the end-to-end measurements
of Release 1 and of Preview's gate job in docs/releasing.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 20:24:50 -05:00
ekklesiaandClaude Opus 5.5 22f25854aa feat: Gitea CI, and releases built once, previewed and promoted as one file
ci.yml runs tools/check.sh and the container gate on every push and pull
request, with no secrets. Release 1 builds a version from its release
commit on master or release/*, gates it, and keeps it in a Gitea
pre-release. Release 2 gates that file again on the CI runner and
publishes it to canary (Preview) from the release runner. Release 3
promotes the file Preview ran, on a person's approval. Import records a
release from before Gitea kept the record. All of it is
tools/release-ci.sh calling the existing scripts.

docs/releasing.md: tracks, branches, each step, immutability, rollback,
secrets, runners, Nightly and the hardware lab as designs, the Gitea
administration still to do, and what was measured to make it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:47:42 -05:00
ekklesiaandClaude Opus 5.5 583f9b1809 feat: a CI mode for the gate, and a gate report publishing can rely on
--ci records an upgrade no room could take (master between releases, its
VERSION already on canary) as SKIP instead of failing every commit; its
report can never stand in for a release's. --still-current accepts a
report only for these exact bytes, passed, from exactly what canary and
stable serve now. update_canary.sh takes one through
RELEASE_GATE_REPORT_PASSED, so Preview's publish job needs the key and
not Docker.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:47:42 -05:00
ekklesiaandClaude Opus 5.5 0f96019714 feat: gitea-release, a release record that never replaces published bytes
Gitea lets anyone with write access swap a release asset for another of
the same name. tools/gitea-release, which the workflows use for every
release write, uploads only under a name the release lacks, leaves
identical bytes alone, refuses different ones, never moves a tag, and
only appends to the record. Measured on a throwaway Gitea 1.27.2 with the
automatic Actions token; tested here against a stand-in API.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:47:42 -05:00
ekklesiaandClaude Opus 5.5 5416112db5 build: one list of checks, run by the release build and by CI alike
tools/check.sh is what build-release.sh ran before building — every
script's syntax, the manual, every tests/test-*.sh — moved, not copied,
so a new test is run by both with nothing else to edit. It now runs every
test even after one fails, keeps each one's output and a summary table
for a CI run, and with --base checks the change's hygiene too.

The manual's revision date honours SOURCE_DATE_EPOCH, so a release built
from a commit a day later ships the manual that commit holds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:47:42 -05:00
ekklesiaandClaude Opus 5.5 4413114d2a docs: what a person checks before 0.2.6.6 goes to stable, and the file it is
Six boxes on the laptop: the reboot that waits for an idle, silent
computer, and the television switched on after the computer. The exact
file and its sha256, so that promotion names the bytes canary ran.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:39:37 -05:00
ekklesiaandClaude Opus 5.5 897a5f28ed fix: the gate installs a fresh machine as the stable room it is
A fresh device.conf says CHANNEL=stable, and the gate asked the fresh
install's updater about canary's manifest, which that machine never reads.
Found running the gate on 0.2.6.6: the updater check failed before any
upgrade was tried. The package was not at fault.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:16:10 -05:00
ekklesiaandClaude Opus 5.5 1a338b2d38 release: 0.2.6.6, for the canary machine only
A desktop Plasma makes after the layout has run — the television arriving
after a night-time reboot, as on Tiny Pebbles — now shows the classroom's
wallpaper, and every existing desktop is repaired before the shell starts.
Carries 0.2.6.5's reboot that waits until nobody is using the computer.

Built from this commit, gated on that file, and published to canary as it
is; the file and its sha256 are recorded in the Gitea release v0.2.6.6 and
in docs/testing.md. Stable stays on 0.2.6.4.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:06:18 -05:00
ekklesiaandClaude Opus 5.5 aaa3f8d063 docs: a desktop the layout never saw, and the television check only a room can do
What Tiny Pebbles showed, what the lab measured (a new activity gives the
same bare desktop; a file path as the fallback is ignored; live D-Bus is
refused by the lockdown), the two parts of the fix, and the check with a
real television switched on after a reboot — the one thing the lab cannot
do, because KWin will not start with no screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:06:08 -05:00
ekklesiaandClaude Opus 5.5 a9700536bf fix: the file the gate passed is the file published, and stable takes only the approved one
tools/release-gate.sh is the one gate: a fresh install, and the upgrade
from what canary and stable serve, each on a room of that channel, plus
any older release rooms are still on (RELEASE_GATE_FROM). The releases it
upgrades from are fetched over HTTPS and checked against their manifests,
so it needs no credential and a CI runner can run it.

update_canary.sh VERSION PACKAGE gates and publishes that file without
rebuilding it, since a second build is a second, untested file; the hash
is taken before the gate and checked after it. update_stable.sh VERSION
SHA256 refuses anything but the file that was approved.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:05:54 -05:00
ekklesiaandClaude Opus 5.5 6c04f743ed test: every rehearsal installs with apt and proves which version landed
The dirty-upgrade lab installed the new package with dpkg -i. Since
0.2.6.2 the package conflicts with khelpcenter, which dpkg refuses and apt
resolves, so the 0.2.2 rehearsal left the machine on 0.2.2 and asked every
question of the old release. Old package, new package and the reinstall
now all go through apt, as ekklesia-classroom-update does, and each stops
the run unless dpkg reports exactly that version, fully installed. The
container gate asserts the same of its previous release and its reinstall.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:05:54 -05:00
ekklesiaandClaude Opus 5.5 9fc86f9d4c test: the wallpaper repair, on Tiny Pebbles' own file, in the lab and on a real install
Unit: Tiny Pebbles' file (desktop 10, panel 1), a healthy River Kids file
left byte for byte, a late desktop beside a good one, idempotence, the
refusal under a running shell. Lab: Plasma makes a bare desktop itself
through a new activity, and the screen is read, not the file. Container
gate: the installed package's fallback is the package, and its repair
fixes the room's file and leaves the panel alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:05:54 -05:00
ekklesiaandClaude Opus 5.5 7b4ac39cd2 fix: a desktop Plasma makes after the layout has run gets the classroom's wallpaper
Tiny Pebbles came through the 0.2.2 upgrade on Plasma's own purple
wallpaper: its desktop was containment 10, made after the layout had run
(most likely the television arriving after a night-time reboot), with no
wallpaper configured. The look and feel's fallback could not catch it,
because it named a file and Plasma only accepts a wallpaper package there.

The fallback is now the EkklesiaClassroom package, whose one image links to
the classroom's picture. classroom-desktop-wallpaper, run before every
plasmashell start, writes the picture into every desktop containment the
file already has: found by plugin, never by number; panels and everything
else byte for byte; a file that is already right not written at all.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 18:05:44 -05:00
ekklesiaandClaude Opus 5.5 530f6f6563 release: 0.2.6.5, for the canary machine only
An update reboots the computer only once nobody has used it for half an
hour and nothing is playing, instead of the moment it is installed. The
update check stays daily at 03:00 local time, Persistent.

Published to canary: ekklesia-classroom_0.2.6.5_amd64.deb, sha256
48919410492d398d103c2fd2e4e5bd0a97761e7032f9438fbb08f1a29c045050, gated
as a fresh install and as an upgrade from 0.2.6.4 (which stable serves),
and rehearsed separately as the upgrade from 0.2.2. Upgraded live in the
lab 0.2.6.2 -> 0.2.6.4 -> 0.2.6.5: the timer stayed enabled, daily at
03:00, Persistent, with its last-check record kept. Stable still answers
0.2.6.4, promoted earlier today; nothing here wrote to it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 23:10:26 -05:00
ekklesiaandClaude Opus 5.5 b2cde1f414 docs: when a classroom updates, and when it reboots
Daily at 03:00 local time spread over half an hour, caught up once after a
night switched off, on the device's own channel only; and since 0.2.6.5 a
reboot that waits for half an hour with nobody using the computer and
nothing playing. With the journal commands to see each, and the checks
only real hardware can make.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 22:56:08 -05:00
ekklesiaandClaude Opus 5.5 7586326902 test: the lab measures the update schedule on its own systemd, powered off and on
Switched on at the time: the timer fires once and is next due tomorrow.
Switched off: the lab machine is really stopped across the time and
started after it; the missed check runs once, 50 s after starting, and
never loops. And the reboot-when-idle watcher against real kernel input,
a mouse made with /dev/uinput: no reboot while it moves, a reboot once it
is left alone. 11 checks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 22:56:08 -05:00
ekklesiaandClaude Opus 5.5 3c40e849ea fix: an update reboots the computer only once nobody is using it
The update check has been daily at 03:00, Persistent, since 0.1.5 — the
lab's installed stable machine logged it at 03:01 and 03:04 on successive
days — and it stays so. What changes is the reboot after an update. It
happened the moment the update was installed: harmless at 03:00, and a
room restarting itself mid-morning when a computer switched off at 03:00
caught up after being switched on.

Now the updater installs, with every check it had (the device's own
channel, a newer version, the manifest's SHA-256, name and version), and
starts classroom-reboot-when-idle, which reboots once no input device has
produced an event for 30 minutes and no sound card has played for as
long. At 03:00 that is about 03:30; switched off first, the next start is
on the new version. The service has no [Install] and nothing enables it.

The release gate checks after every install and upgrade that the timer is
enabled, daily at 03:00, Persistent, with no boot or repeat trigger and
no drop-in, and that the updater no longer reboots by itself.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 22:56:08 -05:00
ekklesiaandClaude Opus 5.5 23194a085c release: 0.2.6.4, for the canary machine only
Temporary workspace restore: Wonder Ink brings back the teacher's recent
tabs for two hours after they last used it — never the sign-in, which
WonderInk keeps as a Basic-auth password and which is not stored. The
admin-only link bubble is documented, not overridden.

Published to canary: ekklesia-classroom_0.2.6.4_amd64.deb, sha256
80388e7ac3d9a1a266ebc3454f64808b6a0e2cd75b06fa934caf9f2a25026253, gated
on that file as an upgrade from 0.2.6.3 and from 0.2.2. 0.2.6.3 and
0.2.6.2 are on the server as they were; latest.deb and the manifest read
back over HTTPS as 0.2.6.4. Stable still answers 0.2.2; nothing here
wrote to it. No desktop is affected.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 21:30:15 -05:00
ekklesiaandClaude Opus 5.5 fd4330a649 docs: temporary workspace restore, the admin link bubble, and Sunday's checks
wonder-ink.md: where WonderInk keeps its sign-in (Basic auth in a cookie)
and why only tabs are restored; what comes back and what never does; what
counts as use; when it is not restored; the test-only override. And the
link bubble teachers saw: WonderInk's CKEditor 5 for Admin accounts,
where a plain click on a link is an editing click by design; Viewers get
one-click links, measured on the real Saul lesson, and Wonder Ink does
not override the editor.

The manual says closing still signs you out and recent tabs come back
for two hours. testing.md has the Little Waders checklist.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 21:12:17 -05:00
ekklesiaandClaude Opus 5.5 78a844968c test: the lab restores real tabs across real launches, and follows real links
tools/lab/wonderink-workspace-check launches the installed command again
and again against a stand-in for WonderInk's start page whose sign-in is
Basic auth, as WonderInk's is: restored after 5 and 90 minutes; fresh
after two hours and a minute, a day and a week; reopened untouched until
it lapses; a click after a restore counts and the restore does not; a
busy page and an untouched print preview do not; Print does; closed,
reordered, worksheet and missing-worksheet tabs; killed outright; a
restore that crashes twice; corrupt, future-format and half-written
files; the file holding no credential; restored tabs printing. 59 checks.

The print check follows every kind of link on a signed-in page with one
click — new tab in front for links and window.open, nothing for buttons
and anchors, same-tab for no target, Back, closing — and prints a PDF and
a picture reached by a link, and a page after several tabs. 0.2.6.3's
scenarios are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 21:12:17 -05:00
ekklesiaandClaude Opus 5.5 b5d9535467 feat: Wonder Ink brings back recent tabs for two hours, never the sign-in
A teacher who closes Wonder Ink by accident, or whose computer crashes or
restarts, gets their recent tabs back if they open it again within two
hours of last using it. Later than that — overnight, next week — Wonder
Ink starts fresh and the file is removed.

It is a temporary workspace restore, not a sign-in. Measured on the real
site: WonderInk's rrf-auth cookie is "Basic " and the base64 of the
teacher's email and password, and its start-up authenticates again on
every load; keeping a sign-in would mean keeping a password. Nothing of
it is kept — no cookie, header or storage — and closing Wonder Ink still
signs the teacher out. What is kept is each tab's kind, address or
worksheet file, and title; the order; the tab in front; the time of last
use. An address with a credential in it is not kept at all.

Every WonderInk page has one address, so its tabs come back as one, at
the sign-in page. Its resource files are public and come back as they
were. A worksheet comes back only from Downloads/Wonder Ink, and only if
it is still there and still a PDF. The tab in front loads at once, the
rest when first shown: measured, a PDF loaded in a hidden tab showed at
10%.

Using it is the teacher's input — click, key, wheel, touch — never a
page's own activity, a print preview left open, or the restore itself,
so reopening cannot keep a workspace alive. A restore that crashes twice
is abandoned. Written atomically when tabs change and at most every 30 s
for the time of use; unreadable, future-format and clock-gone-back files
start fresh. The keeper stops filtering events at aboutToQuit: left in
place it crashed Wonder Ink on exit one run in several; 30 runs, none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 21:12:17 -05:00
ekklesiaandClaude Opus 5.5 5fef4e02a9 release: 0.2.6.3, for the canary machine only
Black and white is shown in the preview as it prints, and CUPS is given
the file shown; right-click on a picture in a lesson offers Print image,
which prints that picture with the picture layout, and Print page.

Published to canary: ekklesia-classroom_0.2.6.3_amd64.deb, sha256
2b8f26409b5374f08e8d461d8c711307d5180c3b120654df0150a587b7d64710, gated
on that file as an upgrade from 0.2.6.2 and from 0.2.2. 0.2.6.2 is still
on the server as it was. Stable still answers 0.2.2; nothing here wrote
to it. No desktop is affected.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 16:47:56 -05:00
ekklesiaandClaude Opus 5.5 f9a0ee08f9 docs: 0.2.6.3 — what was measured, the manual, and Sunday's checks
Why black and white is made with Ghostscript and how close it is to what
Elmer prints; why Print image goes through the engine's copy image, with
the table of what each other way failed on; what it cannot reach (a CSS
background picture) and what it costs (the clipboard lives in Wonder Ink
afterwards). The manual says Print page and Print image, and that the
preview turns grey. Sunday's paper checks for both, in testing.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 16:34:38 -05:00
ekklesiaandClaude Opus 5.5 cb12e59dea test: the lab prints from a signed-in lesson, and judges black and white on the job
A lesson site for the lab (session cookie, one-time addresses, a second
origin, a request log) and real right-clicks on its words and on a JPEG, a
PNG, a transparent PNG, a one-time picture, another site's, a blob:, a
WebP, a canvas, a linked picture, a broken one, and Cancel. Each is judged
on the job CUPS kept — where and how large, no other ink — and on the site
log: nothing was fetched again. The clipboard is the teacher's afterwards.

Black and white: a colourful lesson turned back and forth five times on
page 2, a worksheet, a picture at 50% top left, a page full of pictures,
back to Color, and Cancel. Every grey preview is grey pixel for pixel and
the job is byte-for-byte the file shown, through pxlmono. The three 0.2.6.2
black-and-white scenarios now wait for the grey file before Print, as a
teacher has to. 343 checks, none failing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 16:34:38 -05:00
ekklesiaandClaude Opus 5.5 594fbd810e fix: black and white is shown as it prints, and right-click prints the picture
Printing through Elmer found two things. Black and white came out grey
while the preview stayed in colour: 0.2.6.2 only told the driver. And
right-clicking a picture in a lesson and choosing Print printed the whole
page around it, where some pictures cannot be opened on their own at all.

Black and white is now made before the preview. Ghostscript makes a grey
copy, the preview shows it, and CUPS is given that same file, still with
ColorModel=Grayscale so Elmer's PCL 6 driver prints with pxlmono. Measured
first: Ghostscript's own grey of the colour original and of the copy give
the same greys (Elmer's driver is Ghostscript); poppler's do not. It takes
up to 4.4 s for a lesson page of pictures, so it runs in the background
with a minute to finish, is made once and kept, and a failure is said so
and never printed in colour instead. Ghostscript is now a named dependency;
cups-filters already brought it.

Right-click asks the engine's hit test: on a picture or a canvas it offers
Print image and Print page, elsewhere only Print page. The picture comes
from the engine's own copy image — the only one of five ways measured that
worked for a signed-in picture, a hotlink-protected one, a one-time
address, blob:, data:, another site's, a canvas and WebP — and it fetches
nothing, so the teacher's sign-in is not needed again. It goes through the
clipboard, which is kept and put back; measured under KWin with another
program owning it. A picture that never arrives is said so; the page is
not printed in its place. It then gets the 0.2.6.2 picture layout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 16:34:38 -05:00
ekklesiaandClaude Opus 5.5 550c0bf126 release: 0.2.6.2, for the canary machine only
Pictures print as the picture — no black bars — at the size and place a
teacher chooses; a presentation that runs out of memory says so and can
be tried again, and the import service survives it; and a notification's
Configure button no longer opens KDE's settings under the teacher account.

Published to canary: ekklesia-classroom_0.2.6.2_amd64.deb, sha256
a7ca958ac8352bb194a2685e4339fe8cafceb5fee871e4c023402c7320945618, gated
on that file as an upgrade from 0.2.6.1 and from 0.2.2. 0.2.6.1 is
still on the server as it was. Stable still answers 0.2.2; nothing here
wrote to it. The laptop is offline and will take this when it is next
on; no desktop is affected.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-24 07:35:24 -05:00
ekklesiaandClaude Opus 5.5 2325a5993b test: the worksheet check tears its pages down before their profile
Intermittently it segfaulted after every check had passed, leaving
teardown order to Python; a profile released under a live page does
that. Eight runs clean afterwards. The real Wonder Ink, closed six times
by its window's close button in the lab, exited 0 each time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-24 07:19:45 -05:00
ekklesiaandClaude Opus 5.5 2d1cc016c5 test: the lab installs a package the way a classroom does
from-package used dpkg -i and then apt-get -f install. With 0.2.6.2's
Conflicts: khelpcenter, dpkg refused and -f resolved it by dropping the
classroom package, so the lab ran every check against nothing. The
updater runs apt-get install on the file, which removes the conflict and
installs; measured, and now the lab does the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-24 07:07:54 -05:00
ekklesiaandClaude Opus 5.5 59cb51c68b docs: Sunday's checks for 0.2.6.2 — pictures, a large deck, the settings route
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-24 07:01:39 -05:00
ekklesiaandClaude Opus 5.5 0bbe2a1ee0 docs: the 0.3.0 backlog, from testing 0.2.6.1
Wonder Ink's next interface, the PDF viewer's Download and second Print,
readable worksheet names, a tab's sound indicator, the popup that Chrome
does not show, session retention, and the site's weight; Presentations
as a maximisable tile grid with click-to-advance and a hidden pointer;
the launcher's subtitles and Parent Call's flash. Recorded, not built.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-24 07:01:10 -05:00
ekklesiaandClaude Opus 5.5 cd798a33a1 fix: a notification's Configure button no longer opens system settings
The kernel memory killer's "Memory Shortage Avoided" notice carried a
Configure button that ran kcmshell6 and opened KDE's whole Notifications
settings under the teacher's account. Reproduced in the lab.

Plasma draws that button without consulting the Kiosk policy, but
kcmshell6 and System Settings do: every settings module Debian 13's
Plasma 6.3 installs (90) is now refused in the classroom's immutable
kdeglobals, so the same click shows only "disabled by the system
administrator". The clock's Configure Digital Clock goes with
plasma/allow_configure_when_locked=false; Configure Audio Devices
disappears on its own; the disabled window's Help button opened KDE Help
Center, which the package now conflicts with (plasma-desktop only
recommends it).

The maintenance terminal runs outside the classroom's configuration and
still opens every module, verified. converged-check fails if any module
on a machine is ever left unrestricted: 126 passed in the lab.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-24 06:59:53 -05:00
ekklesiaandClaude Opus 5.5 3855989029 fix: a presentation that runs out of memory says so, and the service survives it
On a 4 GB VM the kernel killed the import service mid-conversion. Measured:
LibreOffice needs 0.2 GB for a text deck and 1.4-2.1 GB for decks of many
different photographs, for about twenty seconds — a spike, not a leak.
What made it a problem was the rest: OOMPolicy=stop took the watcher down
with LibreOffice, it restarted and resumed the same conversion with no
limit, and the library said "Getting this ready" throughout.

The service is capped at 40% of memory, is first in line when memory runs
short everywhere, and survives a killed LibreOffice (OOMPolicy=continue).
A killed conversion is a failed import of kind "memory"; an import
interrupted twice is not tried a third time; the card says the computer
needed more memory, to close other programs and choose Try again; an
import nobody has worked on for 20 minutes shows the same card; and an
exclusive lock keeps it to one import at a time.

Lab, the real service and LibreOffice: at a 4 GB-machine ceiling the
2.1 GB deck still imported; with no swap and a 300 MB ceiling LibreOffice
was killed, the watcher kept its PID and the card offered Try again,
which worked; the whole service killed during two attempts gave up after
the second; three decks at once never ran two LibreOffices together.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 22:16:50 -05:00
ekklesiaandClaude Opus 5.5 940f63e0e5 fix: a picture prints as the picture, where and as large as the teacher asks
The black bars above and below a printed picture were neither in the
picture nor in the layout. A PNG or JPEG in a tab of its own is shown by
the engine on a viewer page whose background is rgb(14, 14, 14), and Qt
prints page backgrounds by default; 0.2.6.1 printed that page.

A picture is now taken as itself — drawn onto white, capped at 300 dpi
across Letter — and laid out on the printer's paper by the preview, with
Size (as large as fits down to 25%), Position (nine squares), Paper
(automatic, upright, sideways) and Fill the whole page, which says it
cuts the edges off and is never the default. The preview is the PDF that
prints, laid out again after each change. Worksheet PDFs and lesson pages
get no picture controls and are unchanged.

Page backgrounds are no longer printed for lesson pages either, as in
Chrome; Blink darkens light text when it does that, so a white-on-black
page prints readable grey on white. "Colour" is "Color" in the print
interface and the manual.

Lab: 135 print checks, now including eight picture layouts measured on
the job file CUPS kept — byte-identical to the preview, within 1 point of
where asked, no ink anywhere else. Printed from the real session with the
pointer: 50% top left, Elmer-66, the same. Not yet on paper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 21:41:15 -05:00
ekklesiaandClaude Opus 5.5 6946bae455 release: 0.2.6.1, for the canary machine only
Wonder Ink prints — lessons and worksheets, through a preview — without
the 0.2.2 crash; lessons are the size the browser shows them and zoom;
the tab strip looks like the classroom; and four-part versions work end
to end, with a published version never used again.

Published to canary: ekklesia-classroom_0.2.6.1_amd64.deb, sha256
7bd3aabfbbd6f727be87fea366ecaefb40bd5ada37124c96703e8b9ccf510bc3.
Gated on that exact file as an upgrade from 0.2.6 (the laptop) and from
0.2.2 (the desktops). Stable still answers 0.2.2 and nothing here wrote
to it; the three desktops are unchanged until paper comes out of Elmer
and the owner approves — see docs/testing.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 19:59:58 -05:00
ekklesiaandClaude Opus 5.5 259c43bee8 docs: the 0.2.6.1 GUI checks, as the lab's Plasma session showed them
Printing from the real context menu and preview, Cancel, a fullscreen
page and its return, the restart after a hard kill, and a worksheet at
150% lesson size (the PDF viewer keeps its own zoom).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 19:59:58 -05:00
ekklesiaandClaude Opus 5.5 1d9da3bca0 test: rehearse the stable rooms' upgrade on every canary, and list Sunday's checks
update_canary.sh now also validates the exact package it is about to
publish as an upgrade from whatever stable names — 0.2.2 today, several
releases behind the laptop's canary. A package that upgrades the laptop
and breaks the desktops has to fail before it is published.

The lab print check runs in the Plasma lab as well as a plain container:
its rig lived in /tmp, which is a tmpfs there that docker cp cannot write
into, and its inline-PDF server shared a port with another test server.

docs/testing.md has the physical checks for 0.2.6.1 — paper through
Elmer, the real keyboard's zoom keys, the machine's graphics through
--diagnose, sign-out with a real account — before stable moves.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 19:38:04 -05:00
ekklesiaandClaude Opus 5.5 88ea3e86fd feat: measure why lessons load slowly, and give a technician the same tool
Measured before changing anything, and nothing about loading changed,
because the evidence does not point at Wonder Ink. Same lab, same page,
timed by each engine until the sign-in form exists: Wonder Ink 2.86-3.00 s
cold and 2.20-2.49 s warm, Chrome 2.79-3.48 s and 1.96-2.15 s. The site is
the weight — 119-160 requests and 3.2 MB for a sign-in form, and its
console reports loading the Google Maps API twice. Memory about 610 MB
for one tab, 115 MB per further tab; idle CPU about 1%.

The lab draws in software for both engines, so it cannot show the one
likely cause it has no hardware for: QtWebEngine failing to use a
classroom's GPU where Chrome succeeds. classroom-wonderink --diagnose,
run from the maintenance terminal in the teacher's session, prints the
engine versions, the scale, the WebGL renderer (a driver, or software),
and the sign-in page's time from a cold cache, in a throwaway profile
beside any running Wonder Ink.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 19:23:20 -05:00
ekklesiaandClaude Opus 5.5 1cb9fd35d2 feat: Wonder Ink looks like the classroom, and its preview shows the page
The tab strip is drawn from the classroom's own palette, the one the
panel, Parent Call and Presentations read: larger titles, an accent
under the active tab, worksheets marked with a document icon. Its one
corner widget is the lesson's size and Print — not a new-tab button. With
one tab the strip stays hidden, as before; a change of size shows a
moment's badge instead, and the right-click menu and keys still work.
The window is named "Lesson — Wonder Ink" and carries its own icon.

The print preview showed "Page 1 could not be shown" in the real
session while printing correctly: poppler 25.03 does not write to stdout
when asked for "-" — it writes a file called "-.png" into the current
directory, which for Wonder Ink is the teacher's home. Pages are now
rendered into a directory of their own, and the lab print check fails
any preview that does not draw its page.

The teacher's manual has lesson size and printing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 19:13:04 -05:00
ekklesiaandClaude Opus 5.5 48b3097c43 feat: a lesson in Wonder Ink is the size the browser shows it, and zooms
Teachers found lessons in Wonder Ink too small next to the ordinary
browser. Measured by asking each page for devicePixelRatio: Wonder Ink
follows the display scale exactly (dpr 1.5 at 150%), and Chrome is
scaled TWICE (dpr 2.25) — under Plasma it follows the compositor itself
and classroom-browser also forces UI_SCALE, a flag from the sway session.

The browser is left as it is. Wonder Ink's pages start at zoom =
UI_SCALE, the room's own number (1.0 at 100%), and measured afterwards
match the browser: dpr 2.25 and 853 px against 2.25 and 854. The window
is not zoomed; Qt scales it once, correctly.

Ctrl+Plus/=/keypad, Ctrl+Minus, Ctrl+0, Ctrl+wheel and the right-click
menu set one level for every tab, remembered as LEVEL= in its own file,
relative to the room. Two actions on one key are ambiguous to Qt and it
then does neither — Ctrl+Minus did nothing until each command became one
action with all its keys, which also fixes Ctrl+Shift+Tab.

The engine profile is built by one function, which the lab's checks now
use: a check with its own profile showed every worksheet blank.

The lab fences 10.10.0.0/16 as well: the development host reaches the
real Elmer over Tailscale, and the lab provisions a queue pointing at it.
Four lab checks written for older code are corrected; one had failed on
every run since 0.2.3.

tools/lab/wonderink-zoom-check: 18 checks. Lab: converged 120, print 39,
worksheets 24, all passing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 19:04:58 -05:00
ekklesiaandClaude Opus 5.5 10433b850d fix: Wonder Ink prints without crashing, and prints worksheets at all
Little Waders, 0.2.2: SIGSEGV in QPrinter::resolution() every time a
teacher confirmed a print job. QWebEngineView.print() keeps the raw
QPrinter pointer and returns; Qt's didPrintPage() reads
printer->resolution() once Chromium has rendered the page, and by then
PyQt had deleted the printer, which print_view() held only as a local.
Reproduced in the lab with the 0.2.2 code, same backtrace, no job.

Keeping the printer alive fixed the crash and was measured, and Qt's
path was still not a print path: a worksheet PDF tab printed as one
blank page, a lesson went as 24.6 MB of 300 dpi pictures with no text,
and a queue refusing jobs was reported as printed.

Print is now: make a PDF (a worksheet already is one; a lesson page is
printToPdf'd onto the printer's own paper; an inline PDF is downloaded
and opened as a worksheet), show it in a preview with printer, pages,
copies and colour, and hand it to CUPS with lp — Chrome's route in the
same rooms. Nothing asynchronous holds a printer. Black and white uses
the driver's ColorModel=Grayscale, because Elmer's generic PCL 6 driver
ignores print-color-mode.

An exception escaping any slot no longer aborts Wonder Ink: PyQt6's
default is to kill the process, and it is now logged instead.

tests/test-classroom-printing.sh: 50 checks. tools/lab/wonderink-print-
check: 39 checks over 13 scenarios in the lab, against a queue built
from Elmer's PPD, judged by what CUPS received. Not yet on paper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 18:36:29 -05:00
ekklesiaandClaude Opus 5.5 690ef95aff feat: four-part versions, and a published version is never used again
0.2.6.1 is a maintenance release of 0.2.6. The build refused four
parts, update_canary.sh's automatic bump could not add one to "6.1", and
neither publish script would have noticed a version being published
twice — which is what happened to 0.2.6 this morning: it was rebuilt
with the Presentations hardening and uploaded over the first 0.2.6, and
a room that had installed the first can never be offered the second.

One release library now holds the version pattern (three or four parts,
no leading zeros, no .0 maintenance), orders by dpkg --compare-versions,
refuses a version that has ever been published or is not newer than all
of them, uploads with --ignore-existing and checks the server's
checksum, writes a manifest only for a package the server holds, and
reads the channel back over HTTPS afterwards. update_canary.sh takes the
version explicitly; update_stable.sh refuses to go backwards.

The classroom updater refuses a manifest whose version, package name or
checksum is malformed, and refuses a download whose own control file is
not the version the manifest promised, so a room can never loop on
reinstalling and rebooting.

tests/test-release-versions.sh: 87 checks against a stand-in server and
real dpkg. The Trixie gate now also rehearses a stable room: 0.2.2 to an
interim 0.2.6.1 upgraded with settings, channel and Presentations=false
intact, and the installed updater answering correctly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 18:19:37 -05:00
ekklesia 6f2d169355 fix: harden classroom presentations 2026-09-23 00:02:25 -05:00
ekklesiaandClaude Opus 5 ec1e0d1cd7 release: 0.2.6, for the canary machine only
Presentations, the teacher's manual, and the Wi-Fi hand-over fix.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

THE HIDDEN FLAG WAS THROWN AWAY. The stanza said wpa-scan-ssid 1 and the
parser read it — into options, where the only accessor ignored it. The
profile came out with 802-11-wireless.hidden: no, and NetworkManager
will not associate with an SSID it has not seen in a scan. A hidden one
is never in a scan. credentials() returned a pair; profile() now returns
the whole network, and add() writes the flag in BOTH directions rather
than only when true, because a default that happens to be right is not
the same as an answer.

IFDOWN RAN AFTER THE STANZA WAS COMMENTED OUT. ifupdown reads
/etc/network/interfaces to find out how to bring an interface down; with
the stanza gone it says "Ignoring unknown interface" and does nothing.
So ifupdown's own wpa_supplicant and DHCP client were still holding the
radio when NetworkManager was asked to associate with it, which is a
fight NetworkManager loses. Down first, while the file still says how;
rewrite the file second.

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

A '#' IN A PASSWORD WAS SILENTLY TRUNCATED. The parser cut every line at
its first '#'. interfaces(5) is explicit that only whole-line comments
exist, so a pre-shared key containing one became a prefix of itself —
and the only symptom is an association failing exactly the way a wrong
password fails.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-21 21:11:49 -05:00
133 changed files with 19635 additions and 509 deletions
+87
View File
@@ -0,0 +1,87 @@
# EVERY PUSH AND EVERY PULL REQUEST: THE REPOSITORY'S OWN CHECKS.
#
# This runs code nobody has reviewed yet. It is given no secret, it only
# ever runs on the ekklesia-ci runner, which holds no credential that
# reaches the update server or a classroom, and nothing it builds is kept
# as something that could be installed. See docs/releasing.md.
#
# The steps are the repository's own scripts. A test added to tests/ is
# run here with nothing in this file to change.
name: CI
on:
push:
branches: ['**']
pull_request:
jobs:
checks:
name: checks
runs-on: ekklesia-ci
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Sources, the manual, every test, and the change's hygiene
env:
PR_BASE: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE: ${{ github.event.before }}
run: |
# What the change is compared with: the pull request's base, or
# what the branch was before this push, or where it left master.
base="${PR_BASE:-$PUSH_BEFORE}"
if [ -z "$base" ] || ! git cat-file -e "$base^{commit}" 2>/dev/null; then
base="$(git merge-base HEAD origin/master 2>/dev/null || git rev-list --max-parents=0 HEAD | tail -n 1)"
fi
CHECK_LOG_DIR="$RUNNER_TEMP/checks" tools/check.sh --base "$base"
- name: Summary
if: always()
run: cat "$RUNNER_TEMP/checks/summary.md" 2>/dev/null || echo "no summary was written"
- name: Keep every check's full output
if: always()
uses: actions/upload-artifact@v3
with:
name: check-logs
path: ${{ runner.temp }}/checks
package:
name: package and container gate
needs: checks
runs-on: ekklesia-ci
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build the package this commit describes
run: |
version="$(cat VERSION)"
SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)" tools/build-release.sh "$version"
deb="dist/ekklesia-classroom_${version}_amd64.deb"
# It is the package it says it is, and building it changed nothing
# in the tree but, perhaps, the manual's revision date.
test "$(dpkg-deb --field "$deb" Package)" = ekklesia-classroom
test "$(dpkg-deb --field "$deb" Version)" = "$version"
test -z "$(git status --porcelain --untracked-files=no -- . ':(exclude)docs/manual/build')"
mkdir -p "$RUNNER_TEMP/package"
{ dpkg-deb --info "$deb"; echo; dpkg-deb --contents "$deb"; } > "$RUNNER_TEMP/package/contents.txt"
sha256sum "$deb" > "$RUNNER_TEMP/package/sha256.txt"
- name: Install it on Debian Trixie, fresh and over what the rooms run
run: |
version="$(cat VERSION)"
# --ci: an upgrade from a release that is not older than this
# commit's VERSION (master between releases) is recorded as
# skipped, not failed. A release build is never --ci.
RELEASE_GATE_REPORT="$RUNNER_TEMP/package/gate-report.txt" \
tools/release-gate.sh --ci "dist/ekklesia-classroom_${version}_amd64.deb"
- name: Keep the package's contents and the gate report (not the package)
if: always()
uses: actions/upload-artifact@v3
with:
name: package-report
path: ${{ runner.temp }}/package
+54
View File
@@ -0,0 +1,54 @@
# BUILD A RELEASE: ONE COMMIT, ONE PACKAGE, KEPT FOR GOOD.
#
# Run by hand from master or a release/* branch, on the release commit:
# the one whose VERSION says the version being released. It builds the
# package from exactly that commit, runs every check and the full
# container gate on that file, and creates the Gitea pre-release
# vVERSION holding the package, SHA256SUMS, build-info.json and the
# reports. Nothing is published: Preview and Stable are their own
# workflows, and each is a person's decision. See docs/releasing.md.
#
# A version that already has a Gitea release, a tag, or a package on the
# update server is refused. A version is built once.
name: Release 1 - build
on:
workflow_dispatch:
inputs:
version:
description: 'The version to build; VERSION at this commit must say the same (e.g. 0.2.6.7)'
required: true
from_versions:
description: 'Older releases rooms are still on, rehearsed as upgrades too (e.g. 0.2.2)'
required: false
default: ''
jobs:
build:
name: build, check, gate and record
runs-on: ekklesia-ci
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build, check, gate, and create the Gitea pre-release
id: build
env:
VERSION: ${{ github.event.inputs.version }}
FROM_VERSIONS: ${{ github.event.inputs.from_versions }}
GITEA_TOKEN: ${{ secrets.RELEASE_API_TOKEN || github.token }}
run: |
# shellcheck disable=SC2086 # FROM_VERSIONS is a list of versions
RELEASE_CI_WORK="$RUNNER_TEMP/release" tools/release-ci.sh build "$VERSION" $FROM_VERSIONS
- name: Keep the reports with this run as well
if: always()
uses: actions/upload-artifact@v3
with:
name: release-build-reports
path: |
dist/release-*/gate-report.txt
dist/release-*/checks.md
dist/release-*/build-info.json
${{ runner.temp }}/release/checks
+38
View File
@@ -0,0 +1,38 @@
# RECORD A RELEASE PUBLISHED BEFORE GITEA KEPT THE RECORD.
#
# For 0.2.6.6 and anything earlier worth recording: the package is taken
# from the update server as it is served, checked against the SHA-256
# given, and kept in a Gitea release tagged at the commit given. Whether
# it is on Preview or Stable is read off the server, never assumed.
# Nothing is built or published.
name: Release - import an existing release
on:
workflow_dispatch:
inputs:
version:
description: 'A version already on the update server'
required: true
sha256:
description: 'Its SHA-256'
required: true
commit:
description: 'The full commit it was built from'
required: true
jobs:
import:
name: record it
runs-on: ekklesia-ci
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Import
env:
VERSION: ${{ github.event.inputs.version }}
SHA256: ${{ github.event.inputs.sha256 }}
COMMIT: ${{ github.event.inputs.commit }}
GITEA_TOKEN: ${{ secrets.RELEASE_API_TOKEN || github.token }}
run: RELEASE_CI_WORK="$RUNNER_TEMP/release" tools/release-ci.sh import "$VERSION" "$SHA256" "$COMMIT"
+74
View File
@@ -0,0 +1,74 @@
# PUBLISH TO PREVIEW: THE BUILT FILE, TO THE CANARY CHANNEL.
#
# "Preview" is the canary channel on updates.riverofglorymn.org under a
# friendlier name; the URL and the laptop's device.conf are unchanged.
#
# Two jobs, so that no job has both the update server's key and Docker:
#
# gate on ekklesia-ci: the full container gate, again, on the file
# the Gitea release holds, against what canary and stable
# serve right now. No secret.
# publish on ekklesia-release: update_canary.sh publishes that same
# file on that gate's report — it refuses a report for other
# bytes, or one made before canary or stable moved — then reads
# everything back over HTTPS and records it on the release.
#
# Never automatic. Nothing publishes because a branch was pushed.
name: Release 2 - publish to Preview (canary)
on:
workflow_dispatch:
inputs:
version:
description: 'The version, as built by "Release 1 - build"'
required: true
sha256:
description: 'Its SHA-256, from that release: nothing else is published'
required: true
from_versions:
description: 'Older releases rooms are still on, rehearsed as upgrades too (e.g. 0.2.2)'
required: false
default: ''
jobs:
gate:
name: gate the release's file
runs-on: ekklesia-ci
outputs:
report: ${{ steps.gate.outputs.report }}
steps:
- uses: actions/checkout@v4
- name: The container gate, on exactly that file
id: gate
env:
VERSION: ${{ github.event.inputs.version }}
SHA256: ${{ github.event.inputs.sha256 }}
RELEASE_GATE_FROM: ${{ github.event.inputs.from_versions }}
GITEA_TOKEN: ${{ secrets.RELEASE_API_TOKEN || github.token }}
run: RELEASE_CI_WORK="$RUNNER_TEMP/release" tools/release-ci.sh gate "$VERSION" "$SHA256"
publish:
name: publish to canary and read it back
needs: gate
runs-on: ekklesia-release
steps:
- uses: actions/checkout@v4
- name: What publishing needs
run: |
command -v ssh rsync curl python3 dpkg-deb git >/dev/null \
|| { apt-get update -qq && apt-get install -y -qq --no-install-recommends \
openssh-client rsync curl python3 dpkg git ca-certificates >/dev/null; }
- name: Publish, on the gate's report, and record it
env:
VERSION: ${{ github.event.inputs.version }}
SHA256: ${{ github.event.inputs.sha256 }}
REPORT: ${{ needs.gate.outputs.report }}
GITEA_TOKEN: ${{ secrets.RELEASE_API_TOKEN || github.token }}
RELEASE_SSH_KEY: ${{ secrets.CLASSROOM_UPDATES_SSH_KEY }}
RELEASE_SSH_KNOWN_HOSTS: ${{ vars.CLASSROOM_UPDATES_KNOWN_HOSTS }}
RELEASE_SSH_ADDRESS: ${{ vars.CLASSROOM_UPDATES_ADDRESS }}
RELEASE_SSH_USER: ${{ vars.CLASSROOM_UPDATES_USER }}
run: RELEASE_CI_WORK="$RUNNER_TEMP/release" tools/release-ci.sh preview "$VERSION" "$SHA256" "$REPORT"
+57
View File
@@ -0,0 +1,57 @@
# PROMOTE TO STABLE: THE FILE PREVIEW RAN, AND NOTHING ELSE.
#
# Only after a person has done the physical checks in docs/testing.md on
# real hardware and says so here. No build, no gate: stable is pointed at
# the package already on the server, and update_stable.sh refuses any
# file but the SHA-256 given. The Gitea release must already record that
# exact file going to Preview.
#
# Merging code never runs this. Nothing runs it but a person.
name: Release 3 - promote to Stable
on:
workflow_dispatch:
inputs:
version:
description: 'The version on Preview to promote'
required: true
sha256:
description: 'Its SHA-256, exactly as approved'
required: true
approval:
description: 'Who approved it, when, and which physical checks passed on which machine'
required: true
confirm:
description: 'Type: PROMOTE <version> TO STABLE'
required: true
jobs:
promote:
name: promote the approved file and read it back
runs-on: ekklesia-release
steps:
- uses: actions/checkout@v4
- name: What promoting needs
run: |
command -v ssh rsync curl python3 dpkg-deb git >/dev/null \
|| { apt-get update -qq && apt-get install -y -qq --no-install-recommends \
openssh-client rsync curl python3 dpkg git ca-certificates >/dev/null; }
- name: Promote, and record it
env:
VERSION: ${{ github.event.inputs.version }}
SHA256: ${{ github.event.inputs.sha256 }}
APPROVAL: ${{ github.event.inputs.approval }}
CONFIRM: ${{ github.event.inputs.confirm }}
GITEA_TOKEN: ${{ secrets.RELEASE_API_TOKEN || github.token }}
RELEASE_SSH_KEY: ${{ secrets.CLASSROOM_UPDATES_SSH_KEY }}
RELEASE_SSH_KNOWN_HOSTS: ${{ vars.CLASSROOM_UPDATES_KNOWN_HOSTS }}
RELEASE_SSH_ADDRESS: ${{ vars.CLASSROOM_UPDATES_ADDRESS }}
RELEASE_SSH_USER: ${{ vars.CLASSROOM_UPDATES_USER }}
run: |
if [ "$CONFIRM" != "PROMOTE $VERSION TO STABLE" ]; then
echo "Confirmation must read exactly: PROMOTE $VERSION TO STABLE" >&2
exit 1
fi
RELEASE_CI_WORK="$RUNNER_TEMP/release" tools/release-ci.sh stable "$VERSION" "$SHA256" "$APPROVAL"
+1 -1
View File
@@ -1 +1 @@
0.2.4
0.2.6.6
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22" width="22" height="22">
<!-- The comment lives inside <svg>, not above it. gdk-pixbuf
recognises an SVG by sniffing the first bytes of the file, and a
comment before the opening tag pushes it out of that window:
every GTK program then reports "couldn't recognize the image file
format" while Qt renders it happily. Measured on both of the
classroom's other icons, and not learned again here. -->
<!-- Presentations.
Breeze's own view-presentation is a bar chart on a board, which in
a church reads as a business meeting rather than as tonight's
Bible study. x-office-presentation is a projector screen and comes
closest, but it is a filetype mark: it says "a file of this kind"
where the panel needs "the place your slides are".
So: a screen on a stand with a slide showing on it — a title line
and two lines of text, which is the shape of every one of these
sessions. Same 22-unit grid, same single-path style and the same
ColorScheme-Text class as Parent Call, so it recolours with the
session and needs no second file for the light theme. -->
<defs>
<style type="text/css">.ColorScheme-Text { color: #fcfcfc; }</style>
</defs>
<g class="ColorScheme-Text" style="fill:currentColor;fill-opacity:1;stroke:none">
<!-- The screen: an outline, so the slide inside it reads as content
rather than the whole mark being one solid block at 22px. -->
<path d="M2 3h18a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm1 2v8h16V5z"/>
<!-- The slide on it: a title, and two lines under it. -->
<path d="M5 7h7v1.5H5zm0 3h12v1H5zm0 2.2h9v1H5z" style="fill-opacity:0.85"/>
<!-- The stand. A presentation is a thing set up in front of a room,
and the two feet are what say so at this size. -->
<path d="M10.25 16h1.5v3.2l2.9 1.45-.67 1.34-2.98-1.49-2.98 1.49-.67-1.34 2.9-1.45z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,19 @@
[Desktop Entry]
Type=Application
Name=Help
GenericName=Classroom manual
Comment=How to use this classroom computer
# Breeze's own. A question mark in a circle is the one mark that needs no
# explaining, and this is the one place in the classroom where borrowing
# from Breeze is better than drawing something of our own.
Icon=help-contents
Exec=/usr/local/bin/classroom-manual
Terminal=false
Categories=Documentation;
StartupNotify=true
# Chrome in application mode reports the app id Chrome derives from the
# --app URL, not this entry, so the panel cannot match the manual window
# to this launcher. That is deliberate and harmless: Help is a Quick
# Launch button, which is a launcher and never a task, so there is no
# button here waiting to be matched.
NoDisplay=false
@@ -0,0 +1,28 @@
[Desktop Entry]
Type=Application
Name=Presentations
GenericName=Slides
Comment=Show a presentation that was downloaded in the browser
# Drawn for this: a screen with a slide on it and a corner turned, which
# is a presentation rather than a document or a photograph. Breeze's
# view-presentation is a chart on a board, which in a church reads as a
# business meeting. Uses ColorScheme-Text like the classroom's other two
# marks, so it recolours with the session and needs no light-theme twin.
Icon=ekklesia-presentations
Exec=/usr/local/bin/classroom-presentations
Terminal=false
Categories=Office;Presentation;
StartupNotify=true
# How the panel matches the window on screen to this launcher. GTK sets
# the Wayland app id from GLib.set_prgname, which classroom-presentations
# sets to exactly this. A mismatch here is not an error anywhere — it is
# silently two buttons on the panel instead of one.
StartupWMClass=ekklesia-presentations
# One window. Pressing the panel button again brings the library forward
# rather than opening a second copy of it.
X-GNOME-SingleWindow=true
# Deliberately visible to the applications index, like the disc player and
# Parent Call: the panel resolves its launcher through that index, and an
# entry hidden from it gives the bar a blank page instead of the mark.
# There is no application menu in this classroom for it to clutter.
NoDisplay=false
+11 -1
View File
@@ -24,8 +24,18 @@ cursorTheme=breeze_cursors
library=org.kde.breeze
theme=Breeze
# The picture a desktop gets when nothing has said which. That is every
# desktop Plasma makes on its own after the layout has run: a television
# switched on after the computer, a second screen, a new activity.
#
# A wallpaper PACKAGE NAME, not a file. Plasma's DefaultWallpaper looks it
# up as wallpapers/<name> and nothing else. This line was a path until
# 0.2.6.6, which Plasma cannot load as a package, so every such desktop
# fell back to Plasma's own purple one — measured, and it is what Tiny
# Pebbles showed after the 0.2.2 upgrade. The package is a directory whose
# one image is a link to the classroom's file; see build-release.sh.
[Wallpaper]
Image=/usr/share/backgrounds/ekklesia/classroom-calm.png
Image=EkklesiaClassroom
[KSplash]
Theme=None
@@ -12,19 +12,22 @@
// WHICH APPLICATIONS THIS DEVICE OFFERS.
//
// classroom-desktop-layout rewrites the three lines between the markers
// to match device.conf and this machine's hardware, and nothing else in
// classroom-desktop-layout rewrites the lines between the markers to
// match device.conf and this machine's hardware, and nothing else in
// this file. Each application is its own line because each is its own
// setting: a laptop taking children's church needs Wonder Ink and a tower
// used for Bible study does not, so the shape of the machine is never
// allowed to decide what it is for.
//
// All true here is the shipped default, which is what an existing
// classroom must come up as.
// The first three are true here, which is what an existing classroom must
// come up as. PRESENTATIONS is false, which is what an existing classroom
// must come up as too: no room has ever had it, and an upgrade may not
// put a button on a bar nobody asked for.
/* CLASSROOM-FEATURES-BEGIN */
var WONDERINK = true;
var DVD = true;
var PARENT_CALL = true;
var PRESENTATIONS = false;
/* CLASSROOM-FEATURES-END */
var WALLPAPER = "/usr/share/backgrounds/ekklesia/classroom-calm.png";
@@ -34,6 +37,13 @@ var WALLPAPER = "/usr/share/backgrounds/ekklesia/classroom-calm.png";
var PANEL_HEIGHT = 76;
// The idle desktop: the wallpaper, and nothing on it.
//
// Only the desktops that exist right now. A screen that appears later —
// the television switched on after a reboot at three in the morning, which
// is what Tiny Pebbles had — gets a desktop Plasma makes without this
// file. That one takes the look and feel's default wallpaper, which is the
// classroom's package, and classroom-desktop-wallpaper writes it in by
// name before the shell next starts.
var screens = desktops();
for (var i = 0; i < screens.length; i++) {
var desktop = screens[i];
@@ -84,6 +94,10 @@ if (PARENT_CALL) {
parentCall.writeConfig("maxSectionCount", 1);
}
// Help is intentionally absent from this release's teacher-facing panel.
// Its launcher, source pages and build pipeline remain installed for the
// documentation correction pass; only the unfinished entry point is hidden.
var leftGap = panel.addWidget("org.kde.plasma.panelspacer");
leftGap.currentConfigGroup = ["General"];
leftGap.writeConfig("expanding", true);
@@ -114,6 +128,17 @@ if (DVD) {
launchers.push("applications:ekklesia-dvd.desktop");
}
// LAST, AND THAT IS THE WHOLE REASON IT IS HERE.
//
// Every existing room has Wonder Ink, the browser and the disc player in
// that order, and teachers have been pressing them in that order for
// months. Appending keeps all three exactly where they are on every
// machine that upgrades; putting Presentations anywhere else in the row
// would move a button somebody reaches for without looking.
if (PRESENTATIONS) {
launchers.push("applications:ekklesia-presentations.desktop");
}
tasks.writeConfig("launchers", launchers.join(","));
// THE ORDER STAYS PUT WHEN AN APPLICATION OPENS.
+115
View File
@@ -26,6 +26,12 @@ editable_desktop_icons=false
editable_bookmarks=false
movable_toolbars=false
plasma/plasmashell/unlockedDesktop=false
# A locked panel still offered "Configure Digital Clock…" on the clock's
# right-click menu — measured on Plasma 6.3. This is Plasma's own key for
# "no widget settings while the desktop is locked", and with it that entry
# is gone. (The clock's "Adjust Date and Time…" stays, and opens a System
# Settings with nothing in it: every module is restricted below.)
plasma/allow_configure_when_locked=false
plasma-desktop/scripting_console=false
# Nothing in the classroom should offer to file a bug or open a manual.
action/help_report_bug=false
@@ -47,6 +53,115 @@ action/help_contents=false
# is not shown doors that will not open.
[KDE Control Module Restrictions][$i]
kcm_networkmanagement=false
#
# AND EVERY OTHER SETTINGS MODULE. A notification's Configure button —
# measured on Plasma 6.3, from the "Memory Shortage Avoided" notice the
# kernel's memory killer raised — runs `kcmshell6 notifications`, which
# opened KDE's whole notification settings under the teacher's account:
# every application and system service, with Apply. The notification
# applet draws that button without asking this group; kcmshell6 and
# System Settings do ask it, and with the module listed here the same
# click opens only "The module kcm_notifications is disabled ... by the
# system administrator". Measured.
#
# KDE has no wildcard for this, so the list is every module installed by
# Debian 13's Plasma 6.3, as found on the lab machine. A teacher has a use
# for none of them. tools/lab/converged-check fails if a module is ever
# installed that is not listed here, so a Plasma update cannot quietly
# reopen a door.
#
# The maintenance terminal is untouched: it runs with
# XDG_CONFIG_DIRS=/etc/xdg, outside this file, so System Settings started
# from it is the whole of System Settings.
kcm_about-distro=false
kcm_access=false
kcm_activities=false
kcm_audio_information=false
kcm_autostart=false
kcm_baloofile=false
kcm_block_devices=false
kcm_bluetooth=false
kcm_bolt=false
kcm_clock=false
kcm_colors=false
kcm_componentchooser=false
kcm_cpu=false
kcm_cursortheme=false
kcm_desktoppaths=false
kcm_desktoptheme=false
kcm_device_automounter=false
kcm_disks=false
kcm_edid=false
kcm_egl=false
kcm_energyinfo=false
kcm_feedback=false
kcm_filetypes=false
kcm_firewall=false
kcm_firmware_security=false
kcm_fontinst=false
kcm_fonts=false
kcm_gamecontroller=false
kcm_glx=false
kcm_icons=false
kcm_interrupts=false
kcm_kded=false
kcm_keyboard=false
kcm_keys=false
kcm_kgamma=false
kcm_krunner_kill=false
kcm_krunnersettings=false
kcm_kscreen=false
kcm_kwallet5=false
kcm_kwin_effects=false
kcm_kwin_scripts=false
kcm_kwin_virtualdesktops=false
kcm_kwindecoration=false
kcm_kwinoptions=false
kcm_kwinrules=false
kcm_kwinscreenedges=false
kcm_kwinsupportinfo=false
kcm_kwintabbox=false
kcm_kwintouchscreen=false
kcm_kwinxwayland=false
kcm_landingpage=false
kcm_lookandfeel=false
kcm_memory=false
kcm_mouse=false
kcm_netpref=false
kcm_network=false
kcm_nightlight=false
kcm_notifications=false
kcm_opencl=false
kcm_pci=false
kcm_plasmasearch=false
kcm_powerdevilprofilesconfig=false
kcm_proxy=false
kcm_pulseaudio=false
kcm_qtquicksettings=false
kcm_recentFiles=false
kcm_regionandlang=false
kcm_samba=false
kcm_screenlocker=false
kcm_sddm=false
kcm_smserver=false
kcm_solid_actions=false
kcm_soundtheme=false
kcm_splashscreen=false
kcm_style=false
kcm_tablet=false
kcm_touchpad=false
kcm_touchscreen=false
kcm_usb=false
kcm_users=false
kcm_virtualkeyboard=false
kcm_vulkan=false
kcm_wallpaper=false
kcm_wayland=false
kcm_webshortcuts=false
kcm_workspace=false
kcm_xserver=false
kcmspellchecking=false
kwincompositing=false
# Not immutable. Marking this group [$i] froze the colour scheme so hard
# that `classroom-theme light` changed the running session and then lost
+7 -8
View File
@@ -82,15 +82,14 @@ NoiseStrength=2
[org.kde.kdecoration2][$i]
library=org.kde.breeze
theme=Breeze
# A pin and a close button, and nothing else.
#
# The pin is the Live View story: keeping a window above the others is a
# window-manager state, not a feature the classroom has to invent. There is
# deliberately no minimise button, because a minimised window is the one
# thing a teacher cannot see the state of — measured, not assumed. Double
# clicking the title bar still maximises, so nothing is unreachable.
# A close button, and nothing else. `F` was the keep-above button, rendered
# by Breeze as an unexplained double chevron beside Close. Classroom windows
# that must stay above others request that state themselves; teachers do not
# need a title-bar toggle for it. There is deliberately no minimise button,
# because a minimised window is the one thing a teacher cannot see the state
# of. Double clicking the title bar still maximises ordinary windows.
ButtonsOnLeft=
ButtonsOnRight=FX
ButtonsOnRight=X
ShowToolTips=false
BorderSize=None
BorderSizeAuto=false
+20 -3
View File
@@ -1,6 +1,6 @@
# Window rules.
#
# There are two: one about video, one about the Parent Call card.
# There are three: video, the Parent Call card, and the presentation surface.
#
# Everything else the classroom used to arrange by hand is ordinary window
# manager behaviour and needs no rule: a dialog stays in front of the window
@@ -17,8 +17,8 @@
# 314-line window arbiter was doing.
[General]
count=2
rules=classroom-video,classroom-parent-call
count=3
rules=classroom-video,classroom-parent-call,classroom-presentation
[classroom-video]
Description=Video is an appliance screen, not a window
@@ -48,3 +48,20 @@ skippagerrule=2
# button, rather than behind it.
above=true
aboverule=2
[classroom-presentation]
Description=A presentation is the whole display, not a large window
# The library and Quick Look have the same Wayland app id, so the stable
# presenter title is part of this match. Matching the app alone would make
# every Presentations window fullscreen.
wmclass=ekklesia-presentations
wmclassmatch=1
wmclasscomplete=false
title=Presenting
titlematch=1
fullscreen=true
fullscreenrule=2
noborder=true
noborderrule=2
skiptaskbar=true
skiptaskbarrule=2
@@ -0,0 +1,12 @@
{
"KPlugin": {
"Authors": [
{
"Name": "Ekklesia"
}
],
"Id": "EkklesiaClassroom",
"License": "GPLv2+",
"Name": "Ekklesia Classroom"
}
}
+97
View File
@@ -0,0 +1,97 @@
# Backlog — 0.3.0 and later
Recorded after hands-on testing of 0.2.6.1 in September 2026. **None of
this is in 0.2.6.2 to 0.2.6.4**: 0.2.6.2 is limited to picture printing,
the Presentations memory failure and the settings lockdown, 0.2.6.3 to
the black-and-white preview and Print image, and 0.2.6.4 to temporary
workspace restore (the two items that were here about links and sign-in
retention are answered in wonder-ink.md). Each item says what was
observed, so whoever picks it up starts from the evidence.
## Wonder Ink
- **An interface like Chrome's, visibly the classroom's.** Familiar tabs
and back/forward/reload, without an address bar, new-tab button or
anything that takes a teacher somewhere the lesson did not. The 0.2.6.1
tab strip is a step towards it, not the design.
- **The PDF viewer's own Download button.** It saves a copy, which the
download handler then opens as another worksheet tab. Remove it, or
intercept the download from a worksheet tab so it does nothing visible.
(0.2.2 already skips re-opening a save from a worksheet tab — see
`from_worksheet` in `on_download` — so check first which route it now
takes.)
- **One Print, not two.** A worksheet tab has the viewer's printer icon
and Wonder Ink's Print; both now open the same preview, but a teacher
sees two buttons. One teacher-facing workflow.
- **Readable worksheet names.** Tabs show the downloaded file's name,
which for many worksheets is a long generated prefix. Strip it, or take
the name from the PDF's own title.
- **Print image and CSS background pictures** (from 0.2.6.3). The engine's
hit test does not call a CSS background a picture, so right-click there
offers only Print page. Only worth doing if Sunday's testing finds
Wonder Ink's site draws artwork that way; the lab has no account to
tell. See wonder-ink.md, *Print image*.
- **The clipboard after Print image** (from 0.2.6.3). It is put back, but
owned by Wonder Ink afterwards, so text copied elsewhere before is gone
once Wonder Ink closes — there is no clipboard manager in the session.
Measured, and judged smaller than leaving the picture there.
- **A speaker on the tab that is playing sound** (`QWebEnginePage.recentlyAudible`).
- **A picture in its own tab, in a room above 100%** (seen testing
0.2.6.4). Page zoom is the room's `UI_SCALE`, and the engine's picture
viewer takes it too, so at 200% a slide opened from a lesson is shown
twice its size with scrollbars rather than fitted to the window, as
Chrome shows it. Printing is unaffected.
- **Staying signed in, if WonderInk ever issues a real session token.**
Today its sign-in is the teacher's email and password in a Basic-auth
cookie (measured, 0.2.6.4), so keeping it would mean keeping a password;
Wonder Ink does not. Worth asking WonderInk for a server-issued token
that can be revoked; until then, closing Wonder Ink signs the teacher
out, and only the recent tabs come back.
- **Workspace time as a room setting**, if two hours turns out wrong for
some room: `IDLE_LIMIT_SECONDS` in `classroom_wonderink_workspace.py` is
the one place, and a device.conf key would be a small change.
- **The site's sluggishness, where it is ours.** Measured in 0.2.6.1:
Wonder Ink and Chrome take the same time (about 2.9 s cold) and the
site is the weight — 119 – 160 requests, 3.2 MB, the Google Maps API
loaded twice. Ours to look at: whether a classroom's QtWebEngine has its
GPU (`classroom-wonderink --diagnose` on the machines), and the page
shell being revalidated on every warm load (62 – 87 ms).
- **The browser is scaled twice** under Plasma (dpr 2.25 at 150%):
`classroom-browser` forces `UI_SCALE` on a Chrome that already follows
the compositor. Deliberately left in 0.2.6.x because teachers know that
size, and Wonder Ink was matched to it. Decide once, for both.
## Presentations
- **A normal, maximisable application** instead of the small floating
library window.
- **Consistently sized tiles in a responsive grid.**
- **No unnecessary internal scrollbars or stretched layouts.** 0.2.6.2
had to give a row extra height by hand when a card carries the
memory-failure message; the layout should size to its cards.
- **Keep the window's size and position** when importing a presentation
or changing a cover.
- **Click the slide to advance.**
- **Hide the pointer after a moment of stillness while presenting**, and
bring it back on movement.
- **Keep the keyboard controls**, and never advance when a playback
control itself is clicked.
- **Remote control of a presentation** — later than 0.3.0.
## The launcher and Parent Call
- **Remove the subtitles under DVD and Presentations** on the launcher.
- **Parent Call appears among the ordinary applications for a moment and
then disappears**, which looks like a crash. Keep it reachable through
its background or notification route without it flashing up as a
window.
## The settings lockdown
- A notification's **Configure** button still shows, and opens a small
"module disabled" window (0.2.6.2). Hiding it needs a change to Plasma's
notification applet, not configuration — revisit if Plasma gains a
Kiosk key for it.
- The settings-module list is per Plasma release. `tools/lab/converged-check`
fails when a new one appears; the list in `config/plasma/xdg/kdeglobals`
then needs the new name.
+42
View File
@@ -116,3 +116,45 @@ and nothing needs reinstalling.
To put the session screen back, remove the D-Bus shadow and the
`kglobalshortcutsrc` entries; both are package files, so an update will
restore them, which is the intended direction.
## Settings the teacher must not reach (0.2.6.2)
During testing the kernel's memory killer raised Plasma's *Memory Shortage
Avoided* notice, and its **Configure** button opened KDE's Notifications
settings under the teacher's account — every application and system
service, with Apply. Reproduced in the lab: the button runs
`kcmshell6 notifications`.
Plasma draws that button on any notification whose application has KDE
notification settings, without asking the Kiosk policy. What does ask is
the thing it launches: `kcmshell6` and System Settings refuse a module
listed under `[KDE Control Module Restrictions]`. KDE has no wildcard for
that group, so **every settings module Debian 13's Plasma 6.3 installs —
90, found on the lab machine — is listed**, immutable, in the classroom's
`kdeglobals`. Measured afterwards:
| Entry point | Before | After |
| --- | --- | --- |
| A notification's Configure button | Notifications settings, editable | *The module kcm_notifications is disabled … by the system administrator* |
| That window's **Help** button | KDE Help Center: menus, man pages, file dialogs | not installed (`Conflicts: khelpcenter`; plasma-desktop only recommends it) |
| Volume: right-click | *Configure Audio Devices…* | entry gone — the applet asks the policy |
| Clock: right-click | *Adjust Date and Time…*, *Configure Digital Clock…* | *Configure* gone (`plasma/allow_configure_when_locked=false`); *Adjust* opens a System Settings with nothing in it |
| Bell, empty panel: right-click | no menu | no menu |
| Network | already refused (0.2.4) | unchanged |
The button itself still shows on such a notification, and still opens the
small "disabled" window: hiding it would mean replacing Plasma's
notification applet, which is exactly what this appliance does not do.
Notifications themselves are untouched — Parent Call, the room's
messages, Wonder Ink's "Sent to the printer" all still appear.
**The maintenance route is unaffected.** `classroom-admin-terminal` starts
Konsole with `XDG_CONFIG_DIRS=/etc/xdg`, outside this file, so anything a
technician starts from it — `systemsettings`, `kcmshell6 kcm_notifications`
— is the whole of it. Verified in the lab: the same module refused to the
teacher opened fully, editable, from the maintenance terminal.
`tools/lab/converged-check` fails if any settings module on the machine is
not refused in the classroom's cascade, so a Plasma update that adds one
cannot quietly reopen this; and it checks the maintenance cascade still
refuses nothing.
+24
View File
@@ -0,0 +1,24 @@
---
title: Your classroom computer
order: 0
---
This computer is set up for one thing: running your room. It switches
itself on into the classroom, and there is nothing on it to break.
You do not need a password. You do not need to sign in. You do not need
to shut it down at the end — leave it on.
## The bar along the bottom
Everything you need is on the bar at the bottom of the screen.
The buttons in the middle are the things you can open. Which ones are
there depends on what your room is set up for, so your bar may have
fewer buttons than another room's.
The right-hand end shows the network, the volume, any messages and the
clock.
To open something, click its button once. To come back to it later,
click the same button again.
+28
View File
@@ -0,0 +1,28 @@
---
title: The Browser
order: 10
---
The **Browser** button opens the web: YouTube, a song, a video clip, a
website a lesson sends you to.
## Using it
1. Click **Browser** on the bar.
2. Type where you want to go in the bar at the top, or click **Home** to
go back to YouTube.
The room stays signed in to YouTube, so you do not have to.
## Playing a video on the television
Click the full-screen button in the corner of the video, the same as you
would at home. Press **Esc** to come back out of full screen.
## When you have finished
Leave it open, or close the window. The next person can open it again
from the bar.
Anything you sign in to yourself — a personal account, an email — sign
out of before you leave. The computer is shared.
+27
View File
@@ -0,0 +1,27 @@
---
title: Sound and volume
order: 20
---
Sound goes to the television when one is connected, and to the
computer's own speakers when one is not. You do not have to choose.
## Changing the volume
Use the volume keys on the keyboard, or click the speaker at the
right-hand end of the bar and drag the slider.
A large volume bar appears on the screen while you are changing it, so
you can see it from across the room.
## If there is no sound
1. Check the volume on the **television** as well as on the computer.
Both have to be up.
2. Check the television is showing this computer, and not something else
plugged into it.
3. If the television was switched on after the computer, the sound may
still be going to the computer's own speakers. Turn the volume down
and back up once; that is enough to move it across.
If none of that works, tell the office. Do not unplug anything.
+118
View File
@@ -0,0 +1,118 @@
---
title: Presentations
order: 40
feature: PRESENTATIONS_ENABLED
printed-if: If your computer has a Presentations button on the bar
---
**Presentations** holds the slides for your sessions. You download a
presentation in the browser once, and from then on it is on this screen,
ready to show on the television.
## Getting a presentation onto the computer
1. Click **Browser** and go to wherever the presentation is — your
email, a shared folder, wherever it was sent to you.
2. Download it the way you normally would.
3. Wait until the download has finished.
That is all. It appears in **Presentations** by itself a few seconds
later. You do not have to save it anywhere special, move it, or open it.
PowerPoint files and PDF files both work.
Your download stays in the browser exactly as it was. Nothing here
changes it, moves it or deletes it.
## Opening the library
Click **Presentations** on the bar.
![The Presentations library, with a card for each session](images/library.png)
Each session is a card with its name and a picture of one of its slides.
Find tonight's by looking at the picture.
If a session has only just been downloaded, its card says it is being
got ready. It will be ready in a moment.
## Quick Look — checking the slides first
**Quick Look** lets you read through a session before you show it to
anybody.
![Quick Look, showing one slide with Next and Previous underneath](images/quick-look.png)
* **Next** and **Previous** move through the slides.
* **Back to Presentations** returns to the library.
* **Present** starts the slideshow straight away.
Looking through a session in Quick Look does not count as having
presented it.
## Changing the picture on the card
The first slide of a session is often the same opening slide as every
other session, which makes the cards hard to tell apart. Use the slide
that names the session instead — usually the second one.
1. Click **Quick Look**.
2. Press **Next** until you reach the slide you want.
3. Click **Use this slide as the picture**.
The card changes straight away, and stays that way — after you close the
library, and after the computer is switched off and on again.
Choosing the picture does not change where the slideshow starts. It
always starts at the first slide.
## Showing a presentation
Click **Present**, on the card or inside Quick Look.
The slides fill the whole screen. Nothing else is on it.
| To do this | Press |
| --- | --- |
| Next slide | **Space**, the **right arrow**, the **down arrow** or **Page Down** |
| Previous slide | The **left arrow**, the **up arrow** or **Page Up** |
| First slide | **Home** |
| Last slide | **End** |
| Finish | **Esc** |
A presentation remote works too — the forward and back buttons on the
clicker are the same as Page Down and Page Up.
## Finishing
Press **Esc**.
You come straight back to the library. Going past the last slide does
the same thing.
## Removing a presentation
Click **Remove** on its card, and confirm.
That takes it off this screen. Your download in the browser is not
touched, so you can always get it back by downloading it again.
## Presentations you no longer need
The computer tidies up after itself, so the library does not slowly fill
up with old sessions.
A session you have shown is kept for a month after the last time you
showed it. Showing it again starts that month over.
A session you downloaded but never showed is kept for six months.
You do not have to do anything about this, and nothing disappears while
you are using it.
## If a presentation will not open
Its card says so, and offers only **Remove**. Usually the file did not
download properly.
Download it again. Your original download is still there either way.
+88
View File
@@ -0,0 +1,88 @@
---
title: Wonder Ink
order: 50
feature: WONDERINK_ENABLED
printed-if: If Wonder Ink is available in your classroom
---
**Wonder Ink** opens the lesson. It is its own application, not the
browser, and it opens straight into the lesson site.
## Using it
1. Click **Wonder Ink** on the bar.
2. Sign in with your Wonder Ink account.
A lesson often opens several tabs — the lesson, its video, an activity.
They appear in a row across the top. Click a tab to move between them,
and the **×** on a tab to close it.
There is no address bar, on purpose. Wonder Ink can take you anywhere
the lesson needs to go.
## Making the lesson bigger or smaller
Press **Ctrl** and **+** to make the lesson bigger, **Ctrl** and **−** to
make it smaller, and **Ctrl** and **0** to go back to normal. You can also
right-click the lesson and choose **Make the lesson bigger**. When several
tabs are open, the **− 100% +** buttons at the top right do the same.
The size applies to every tab and is remembered next time.
## Printing
Press **Ctrl** and **P**, click **Print** at the top right, or right-click
and choose **Print page…**. You will see the pages exactly as they will
print.
* Choose **Only some pages** to print, for example, `2` or `1-3`.
* Set **Copies** for the number of children.
* Choose **Black and white** to save color ink. The preview turns grey,
so you see what will come out; choose **Color** to go back.
**Printing a picture from the lesson.** Right-click the picture and
choose **Print image…**. Only that picture prints, not the page around
it. This works for pictures you cannot open by themselves.
In the preview for a picture, also under **Picture**: choose its
**Size** (for example 50%), click one of the nine **Position** squares to
put it in a corner or the middle, and choose **Paper** upright or
sideways. The preview changes as you choose, and shows exactly what will
print. **Fill the whole page** makes it cover the paper but cuts off the
edges. A picture that is open in a tab by itself prints the same way.
If Wonder Ink says a picture *could not be taken from the page*, it had
not finished loading: close the preview and try again, or choose **Print
page…** to print the whole page instead.
Click **Print**. Wonder Ink says when the printer has it and goes back to
the lesson. **Cancel** prints nothing.
## Worksheets and colouring pages
Click the worksheet link in the lesson. It opens in a new tab, ready to
read or print. Any other link in a lesson — a slide, a picture, a video —
also opens in a new tab with one click, and Wonder Ink shows it straight
away; the lesson stays open in its own tab. A worksheet's own **−** and
**+** at the top of the page make it bigger or smaller.
Worksheets you download are kept separately from everything else on the
computer, so they never get mixed up with anything.
## If Wonder Ink closes by accident
Open it again. If you used it in the last two hours, **your recent tabs
come back** — the slides, pictures and worksheets you had open, in the
same order. Wonder Ink's own lesson tab comes back at the sign-in page:
sign in again and open your lesson.
Your password is never kept. After two hours that nobody has used Wonder
Ink, it forgets the tabs and the next person starts with a fresh Wonder
Ink.
## When you have finished
**Close Wonder Ink at the end of your session.** Closing it signs you
out, so the next teacher in this room starts with their own account and
not yours. Your recent tabs are kept for two hours in case you need them
again, but never your sign-in.
+28
View File
@@ -0,0 +1,28 @@
---
title: Parent Call
order: 60
feature: PARENT_CALL_ENABLED
printed-if: If your computer has a Parent Call button in the bottom-left corner
---
**Parent Call** asks the sanctuary to fetch a child's grown-up, without
you leaving the room.
## Using it
1. Click **Parent Call**, in the bottom-left corner of the screen.
2. Type the child's name.
3. Click **Send**.
The card tells you what is happening. **Sent to sanctuary** means
somebody there has it.
## What it does and does not promise
**Sent to sanctuary** means the message arrived. It does not mean it has
been read out yet — somebody there decides when.
If it cannot reach the sanctuary it says so plainly. It will never tell
you a call went through when it did not.
If that happens, send somebody.
+30
View File
@@ -0,0 +1,30 @@
---
title: Playing a DVD
order: 70
feature: DVD_ENABLED
printed-if: If your computer has a DVD button and a disc drive
---
## Playing a disc
1. Put the disc in the drive.
2. Wait for **DVD Ready** to appear.
3. Click **DVD** on the bar.
The disc fills the screen and plays on the television. Discs never start
on their own — it always takes a button press.
## While it is playing
Move the mouse to the very bottom of the screen and a row of controls
slides up: previous chapter, play and pause, next chapter, the disc
menu, eject, and close.
**Space** also plays and pauses, and the arrow keys move around the disc
menu.
## Finishing
Click **✕** on that row to stop, or **⏏** to stop and eject the disc.
Take the disc with you.
+23
View File
@@ -0,0 +1,23 @@
---
title: Printing
order: 80
---
Printing goes to the church printer. There is nothing to choose and
nothing to set up.
## Printing something
1. Press **Ctrl+P**, or use the **Print** button on the page you are
looking at.
2. Check the number of copies.
3. Click **Print**.
## If nothing comes out
The printer may be off, out of paper, or somebody else's job may be
ahead of yours. Your job waits in the queue and prints when the printer
is ready again — you do not need to send it twice.
Sending it again two or three times is how ten copies of the same
worksheet appear later.
+35
View File
@@ -0,0 +1,35 @@
---
title: If something goes wrong
order: 90
---
Almost nothing here needs a technical person, and nothing you can do
from the keyboard will break the computer.
## The screen is black
Move the mouse or press a key. If it stays black, check the television
is on and showing this computer.
## A window will not close
Click its button on the bar to bring it back, and close it normally.
## The bar has gone
Wait a few seconds; it puts itself back.
If it does not, switch the computer off at the power button, wait ten
seconds, and switch it on again. It comes back to the same screen it
always does.
## Something asks for a password
Nothing a teacher needs to do asks for a password. If a password box
appears, you have reached something you did not mean to — press **Esc**
or click **Cancel**, and carry on.
## It is still wrong
Tell the office, and say **which room** and **what you were doing when
it happened**. That second part is the one that saves everybody time.
+85
View File
@@ -0,0 +1,85 @@
# The teacher's manual
**This directory is the only place teacher instructions are written.**
Everything a teacher reads comes out of the `.md` files beside this one:
* the **digital manual** on each classroom computer, which shows only the
features that computer actually has; and
* the **printable PDF**, which covers every feature with wording that
stays true in a room that does not have all of them.
Both are built by `tools/build-manual` from these files. There is no
second copy to keep in step, and there is no per-room manual: a room
differs from another room by its `device.conf`, and the manual reads the
same setting the panel does.
## Writing a page
Each page is Markdown with a small header:
```markdown
---
title: Presentations
order: 40
feature: PRESENTATIONS_ENABLED
printed-if: If your computer has a Presentations button
---
## Downloading a presentation
...
```
| Field | Means |
| --- | --- |
| `title` | The heading, and the entry in the contents |
| `order` | Where it comes. Common pages are 00–39, optional ones 40+ |
| `feature` | The `device.conf` setting that decides whether a computer shows this page. Leave it out for a page every room gets |
| `printed-if` | The conditional sentence the printed manual puts above the page. Required whenever `feature` is set |
Only `##` and `###` headings, ordinary paragraphs, `*` bullets, `1.`
numbered steps, simple `|` tables, `![caption](images/x.png)` pictures
and **bold** are supported. That is deliberate: the same text has to come
out well as a web page and as print, and a manual for teachers does not
need anything else.
## Writing style
The existing pages set it, and it matters more than the format:
* Say what the teacher does, not what the computer does.
* One instruction per step, in the order they happen.
* Name things the way the screen names them — **Present**, **Quick
Look**, the **bar** along the bottom.
* No paths, no package names, no settings files, no version numbers.
* If something can go wrong, say what to do about it in the same breath.
## Building
```
tools/build-manual # both, into docs/manual/build/
tools/build-manual --check # verify only; what the release runs
```
`tools/build-release.sh` builds and checks the manual on every release, so
the digital and printed versions cannot drift apart. The check fails the
build if a page declares a `feature` with no `printed-if`, if it names a
feature `device.conf` does not have, or if the built files are missing.
## Screenshots
Pictures live in `docs/manual/images/`. They are captured from the real
interface with `tools/lab/lab shot`, never drawn or mocked up. A manual
showing a screen that does not exist is worse than one with no pictures.
They are **embedded** into the digital manual as data URIs, so it stays a
single file that works wherever it is copied and with the network
unplugged. A picture named by a page that is not in `images/` fails the
build, as does one with no caption.
**The printed manual prints the caption, not the picture** — as
`[ Picture: ... — see the computer's own Help ]`. Embedding a PNG in a
PDF means decoding it and writing an `/XObject`, and this builder exists
so that a release needs nothing but Python. If the printed manual ever
needs real pictures, that is the thing to add, and it is a contained
change: `render_pdf` is the only function that would move.
File diff suppressed because one or more lines are too long
+408
View File
@@ -0,0 +1,408 @@
%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] /Count 12 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 15 0 R >>
endobj
4 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 16 0 R >>
endobj
5 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 17 0 R >>
endobj
6 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 18 0 R >>
endobj
7 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 19 0 R >>
endobj
8 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 20 0 R >>
endobj
9 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 21 0 R >>
endobj
10 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 22 0 R >>
endobj
11 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 23 0 R >>
endobj
12 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 24 0 R >>
endobj
13 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 25 0 R >>
endobj
14 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 26 0 R >>
endobj
15 0 obj
<< /Length 765 >>
stream
BT /F2 26 Tf 1 0 0 1 62.0 720.0 Tm (Classroom manual) Tj ET
BT /F1 13 Tf 1 0 0 1 62.0 677.1 Tm (River of Glory) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 656.6 Tm (Revision 29 September 2026) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 623.7 Tm (This manual covers every classroom computer in the church. Not every room has every button, so) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 608.8 Tm (some pages begin by saying when they apply. If your computer does not have what a page describes,) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 593.9 Tm (that page is not for your room.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 569.0 Tm (Keep this copy in the room. If the date above is older than the one on the computer's own Help, ask) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 554.1 Tm (the office for a new one.) Tj ET
endstream
endobj
16 0 obj
<< /Length 1250 >>
stream
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Your classroom computer) Tj ET
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
BT /F1 10.5 Tf 1 0 0 1 62.0 733.9 Tm (This computer is set up for one thing: running your room. It switches itself on into the) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 719.0 Tm (classroom, and there is nothing on it to break.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 699.0 Tm (You do not need a password. You do not need to sign in. You do not need to shut it down at the end -) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 684.1 Tm (leave it on.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 656.2 Tm (The bar along the bottom) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 635.5 Tm (Everything you need is on the bar at the bottom of the screen.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 615.6 Tm (The buttons in the middle are the things you can open. Which ones are there depends on what your) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 600.7 Tm (room is set up for, so your bar may have fewer buttons than another room's.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 580.7 Tm (The right-hand end shows the network, the volume, any messages and the clock.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 560.8 Tm (To open something, click its button once. To come back to it later, click the same button again.) Tj ET
endstream
endobj
17 0 obj
<< /Length 1408 >>
stream
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (The Browser) Tj ET
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
BT /F1 10.5 Tf 1 0 0 1 62.0 733.9 Tm (The Browser button opens the web: YouTube, a song, a video clip, a website a lesson sends you to.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 706.0 Tm (Using it) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 685.2 Tm (1.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 685.2 Tm (Click Browser on the bar.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 670.3 Tm (2.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 670.3 Tm (Type where you want to go in the bar at the top, or click Home to go back to YouTube.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 650.4 Tm (The room stays signed in to YouTube, so you do not have to.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 622.5 Tm (Playing a video on the television) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 601.7 Tm (Click the full-screen button in the corner of the video, the same as you would at home. Press Esc) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 586.8 Tm (to come back out of full screen.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 558.9 Tm (When you have finished) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 538.2 Tm (Leave it open, or close the window. The next person can open it again from the bar.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 518.2 Tm (Anything you sign in to yourself - a personal account, an email - sign out of before you leave.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 503.3 Tm (The computer is shared.) Tj ET
endstream
endobj
18 0 obj
<< /Length 1694 >>
stream
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Sound and volume) Tj ET
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
BT /F1 10.5 Tf 1 0 0 1 62.0 733.9 Tm (Sound goes to the television when one is connected, and to the computer's own speakers when one) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 719.0 Tm (is not. You do not have to choose.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 691.0 Tm (Changing the volume) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 670.3 Tm (Use the volume keys on the keyboard, or click the speaker at the right-hand end of the bar and) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 655.4 Tm (drag the slider.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 635.5 Tm (A large volume bar appears on the screen while you are changing it, so you can see it from across) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 620.6 Tm (the room.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 592.7 Tm (If there is no sound) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 571.9 Tm (1.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 571.9 Tm (Check the volume on the television as well as on the computer. Both have to be up.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 557.0 Tm (2.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 557.0 Tm (Check the television is showing this computer, and not something else plugged into it.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 542.1 Tm (3.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 542.1 Tm (If the television was switched on after the computer, the sound may still be going to the) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 527.2 Tm (computer's own speakers. Turn the volume down and back up once; that is enough to move it) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 512.3 Tm (across.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 492.4 Tm (If none of that works, tell the office. Do not unplug anything.) Tj ET
endstream
endobj
19 0 obj
<< /Length 4084 >>
stream
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Presentations) Tj ET
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
BT /F2 10.5 Tf 1 0 0 1 62.0 733.9 Tm (If your computer has a Presentations button on the bar.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 713.0 Tm (Presentations holds the slides for your sessions. You download a presentation in the browser) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 698.0 Tm (once, and from then on it is on this screen, ready to show on the television.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 670.1 Tm (Getting a presentation onto the computer) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 649.4 Tm (1.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 649.4 Tm (Click Browser and go to wherever the presentation is - your email, a shared folder,) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 634.5 Tm (wherever it was sent to you.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 619.6 Tm (2.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 619.6 Tm (Download it the way you normally would.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 604.7 Tm (3.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 604.7 Tm (Wait until the download has finished.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 584.7 Tm (That is all. It appears in Presentations by itself a few seconds later. You do not have to save) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 569.8 Tm (it anywhere special, move it, or open it.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 549.9 Tm (PowerPoint files and PDF files both work.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 530.0 Tm (Your download stays in the browser exactly as it was. Nothing here changes it, moves it or) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 515.1 Tm (deletes it.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 487.2 Tm (Opening the library) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 466.4 Tm (Click Presentations on the bar.) Tj ET
BT /F1 9.5 Tf 1 0 0 1 62.0 446.5 Tm ([ Picture: The Presentations library, with a card for each session - see the computer's own Help ]) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 428.0 Tm (Each session is a card with its name and a picture of one of its slides. Find tonight's by looking) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 413.1 Tm (at the picture.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 393.2 Tm (If a session has only just been downloaded, its card says it is being got ready. It will be ready) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 378.3 Tm (in a moment.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 350.4 Tm (Quick Look - checking the slides first) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 329.7 Tm (Quick Look lets you read through a session before you show it to anybody.) Tj ET
BT /F1 9.5 Tf 1 0 0 1 62.0 309.7 Tm ([ Picture: Quick Look, showing one slide with Next and Previous underneath - see the computer's own Help ]) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 291.3 Tm (-) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 291.3 Tm (Next and Previous move through the slides.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 276.3 Tm (-) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 276.3 Tm (Back to Presentations returns to the library.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 261.4 Tm (-) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 261.4 Tm (Present starts the slideshow straight away.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 241.5 Tm (Looking through a session in Quick Look does not count as having presented it.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 213.6 Tm (Changing the picture on the card) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 192.9 Tm (The first slide of a session is often the same opening slide as every other session, which makes) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 178.0 Tm (the cards hard to tell apart. Use the slide that names the session instead - usually the second) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 163.0 Tm (one.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 143.1 Tm (1.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 143.1 Tm (Click Quick Look.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 128.2 Tm (2.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 128.2 Tm (Press Next until you reach the slide you want.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 113.3 Tm (3.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 113.3 Tm (Click Use this slide as the picture.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 93.4 Tm (The card changes straight away, and stays that way - after you close the library, and after the) Tj ET
endstream
endobj
20 0 obj
<< /Length 3089 >>
stream
BT /F1 10.5 Tf 1 0 0 1 62.0 780.0 Tm (computer is switched off and on again.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 760.1 Tm (Choosing the picture does not change where the slideshow starts. It always starts at the first) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 745.2 Tm (slide.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 717.3 Tm (Showing a presentation) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 696.5 Tm (Click Present, on the card or inside Quick Look.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 676.6 Tm (The slides fill the whole screen. Nothing else is on it.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 656.7 Tm ( ) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 656.7 Tm (TO DO THIS PRESS) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 641.8 Tm ( ) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 641.8 Tm (Next slide Space, the right arrow, the down arrow or Page Down) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 626.9 Tm ( ) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 626.9 Tm (Previous slide The left arrow, the up arrow or Page Up) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 612.0 Tm ( ) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 612.0 Tm (First slide Home) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 597.1 Tm ( ) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 597.1 Tm (Last slide End) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 582.2 Tm ( ) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 582.2 Tm (Finish Esc) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 562.2 Tm (A presentation remote works too - the forward and back buttons on the clicker are the same as) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 547.3 Tm (Page Down and Page Up.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 519.4 Tm (Finishing) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 498.7 Tm (Press Esc.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 478.8 Tm (You come straight back to the library. Going past the last slide does the same thing.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 450.9 Tm (Removing a presentation) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 430.1 Tm (Click Remove on its card, and confirm.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 410.2 Tm (That takes it off this screen. Your download in the browser is not touched, so you can always get) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 395.3 Tm (it back by downloading it again.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 367.4 Tm (Presentations you no longer need) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 346.6 Tm (The computer tidies up after itself, so the library does not slowly fill up with old sessions.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 326.7 Tm (A session you have shown is kept for a month after the last time you showed it. Showing it again) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 311.8 Tm (starts that month over.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 291.9 Tm (A session you downloaded but never showed is kept for six months.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 272.0 Tm (You do not have to do anything about this, and nothing disappears while you are using it.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 244.1 Tm (If a presentation will not open) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 223.3 Tm (Its card says so, and offers only Remove. Usually the file did not download properly.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 203.4 Tm (Download it again. Your original download is still there either way.) Tj ET
endstream
endobj
21 0 obj
<< /Length 4396 >>
stream
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Wonder Ink) Tj ET
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
BT /F2 10.5 Tf 1 0 0 1 62.0 733.9 Tm (If Wonder Ink is available in your classroom.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 713.0 Tm (Wonder Ink opens the lesson. It is its own application, not the browser, and it opens straight) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 698.0 Tm (into the lesson site.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 670.1 Tm (Using it) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 649.4 Tm (1.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 649.4 Tm (Click Wonder Ink on the bar.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 634.5 Tm (2.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 634.5 Tm (Sign in with your Wonder Ink account.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 614.6 Tm (A lesson often opens several tabs - the lesson, its video, an activity. They appear in a row) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 599.7 Tm (across the top. Click a tab to move between them, and the x on a tab to close it.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 579.7 Tm (There is no address bar, on purpose. Wonder Ink can take you anywhere the lesson needs to go.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 551.8 Tm (Making the lesson bigger or smaller) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 531.1 Tm (Press Ctrl and + to make the lesson bigger, Ctrl and ? to make it smaller, and Ctrl and 0 to go) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 516.2 Tm (back to normal. You can also right-click the lesson and choose Make the lesson bigger. When) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 501.3 Tm (several tabs are open, the ? 100% + buttons at the top right do the same.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 481.4 Tm (The size applies to every tab and is remembered next time.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 453.4 Tm (Printing) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 432.7 Tm (Press Ctrl and P, click Print at the top right, or right-click and choose Print page?. You will) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 417.8 Tm (see the pages exactly as they will print.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 397.9 Tm (-) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 397.9 Tm (Choose Only some pages to print, for example, `2` or `1-3`.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 383.0 Tm (-) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 383.0 Tm (Set Copies for the number of children.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 368.1 Tm (-) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 368.1 Tm (Choose Black and white to save color ink. The preview turns grey, so you see what will come) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 353.1 Tm (out; choose Color to go back.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 333.2 Tm (Printing a picture from the lesson. Right-click the picture and choose Print image?. Only that) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 318.3 Tm (picture prints, not the page around it. This works for pictures you cannot open by themselves.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 298.4 Tm (In the preview for a picture, also under Picture: choose its Size \(for example 50%\), click one of) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 283.5 Tm (the nine Position squares to put it in a corner or the middle, and choose Paper upright or) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 268.6 Tm (sideways. The preview changes as you choose, and shows exactly what will print. Fill the whole) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 253.7 Tm (page makes it cover the paper but cuts off the edges. A picture that is open in a tab by itself) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 238.8 Tm (prints the same way.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 218.9 Tm (If Wonder Ink says a picture *could not be taken from the page*, it had not finished loading:) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 203.9 Tm (close the preview and try again, or choose Print page? to print the whole page instead.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 184.0 Tm (Click Print. Wonder Ink says when the printer has it and goes back to the lesson. Cancel prints) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 169.1 Tm (nothing.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 141.2 Tm (Worksheets and colouring pages) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 120.5 Tm (Click the worksheet link in the lesson. It opens in a new tab, ready to read or print. Any other) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 105.6 Tm (link in a lesson - a slide, a picture, a video - also opens in a new tab with one click, and Wonder) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 90.7 Tm (Ink shows it straight away; the lesson stays open in its own tab. A worksheet's own ? and + at the) Tj ET
endstream
endobj
22 0 obj
<< /Length 1446 >>
stream
BT /F1 10.5 Tf 1 0 0 1 62.0 780.0 Tm (top of the page make it bigger or smaller.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 760.1 Tm (Worksheets you download are kept separately from everything else on the computer, so they never) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 745.2 Tm (get mixed up with anything.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 717.3 Tm (If Wonder Ink closes by accident) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 696.5 Tm (Open it again. If you used it in the last two hours, your recent tabs come back - the slides,) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 681.6 Tm (pictures and worksheets you had open, in the same order. Wonder Ink's own lesson tab comes back at) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 666.7 Tm (the sign-in page: sign in again and open your lesson.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 646.8 Tm (Your password is never kept. After two hours that nobody has used Wonder Ink, it forgets the) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 631.9 Tm (tabs and the next person starts with a fresh Wonder Ink.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 604.0 Tm (When you have finished) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 583.2 Tm (Close Wonder Ink at the end of your session. Closing it signs you out, so the next teacher in) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 568.3 Tm (this room starts with their own account and not yours. Your recent tabs are kept for two hours in) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 553.4 Tm (case you need them again, but never your sign-in.) Tj ET
endstream
endobj
23 0 obj
<< /Length 1475 >>
stream
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Parent Call) Tj ET
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
BT /F2 10.5 Tf 1 0 0 1 62.0 733.9 Tm (If your computer has a Parent Call button in the bottom-left corner.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 713.0 Tm (Parent Call asks the sanctuary to fetch a child's grown-up, without you leaving the room.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 685.0 Tm (Using it) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 664.3 Tm (1.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 664.3 Tm (Click Parent Call, in the bottom-left corner of the screen.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 649.4 Tm (2.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 649.4 Tm (Type the child's name.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 634.5 Tm (3.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 634.5 Tm (Click Send.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 614.6 Tm (The card tells you what is happening. Sent to sanctuary means somebody there has it.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 586.7 Tm (What it does and does not promise) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 565.9 Tm (Sent to sanctuary means the message arrived. It does not mean it has been read out yet - somebody) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 551.0 Tm (there decides when.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 531.1 Tm (If it cannot reach the sanctuary it says so plainly. It will never tell you a call went through) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 516.2 Tm (when it did not.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 496.3 Tm (If that happens, send somebody.) Tj ET
endstream
endobj
24 0 obj
<< /Length 1500 >>
stream
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Playing a DVD) Tj ET
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
BT /F2 10.5 Tf 1 0 0 1 62.0 733.9 Tm (If your computer has a DVD button and a disc drive.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 705.0 Tm (Playing a disc) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 684.2 Tm (1.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 684.2 Tm (Put the disc in the drive.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 669.3 Tm (2.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 669.3 Tm (Wait for DVD Ready to appear.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 654.4 Tm (3.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 654.4 Tm (Click DVD on the bar.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 634.5 Tm (The disc fills the screen and plays on the television. Discs never start on their own - it always) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 619.6 Tm (takes a button press.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 591.7 Tm (While it is playing) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 570.9 Tm (Move the mouse to the very bottom of the screen and a row of controls slides up: previous chapter,) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 556.0 Tm (play and pause, next chapter, the disc menu, eject, and close.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 536.1 Tm (Space also plays and pauses, and the arrow keys move around the disc menu.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 508.2 Tm (Finishing) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 487.4 Tm (Click X on that row to stop, or eject to stop and eject the disc.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 467.5 Tm (Take the disc with you.) Tj ET
endstream
endobj
25 0 obj
<< /Length 1166 >>
stream
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Printing) Tj ET
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
BT /F1 10.5 Tf 1 0 0 1 62.0 733.9 Tm (Printing goes to the church printer. There is nothing to choose and nothing to set up.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 706.0 Tm (Printing something) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 685.2 Tm (1.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 685.2 Tm (Press Ctrl+P, or use the Print button on the page you are looking at.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 670.3 Tm (2.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 670.3 Tm (Check the number of copies.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 78.0 655.4 Tm (3.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 94.0 655.4 Tm (Click Print.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 627.5 Tm (If nothing comes out) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 606.7 Tm (The printer may be off, out of paper, or somebody else's job may be ahead of yours. Your job waits) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 591.8 Tm (in the queue and prints when the printer is ready again - you do not need to send it twice.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 571.9 Tm (Sending it again two or three times is how ten copies of the same worksheet appear later.) Tj ET
endstream
endobj
26 0 obj
<< /Length 1766 >>
stream
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (If something goes wrong) Tj ET
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
BT /F1 10.5 Tf 1 0 0 1 62.0 733.9 Tm (Almost nothing here needs a technical person, and nothing you can do from the keyboard will) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 719.0 Tm (break the computer.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 691.0 Tm (The screen is black) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 670.3 Tm (Move the mouse or press a key. If it stays black, check the television is on and showing this) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 655.4 Tm (computer.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 627.5 Tm (A window will not close) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 606.7 Tm (Click its button on the bar to bring it back, and close it normally.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 578.8 Tm (The bar has gone) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 558.1 Tm (Wait a few seconds; it puts itself back.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 538.2 Tm (If it does not, switch the computer off at the power button, wait ten seconds, and switch it on) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 523.2 Tm (again. It comes back to the same screen it always does.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 495.3 Tm (Something asks for a password) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 474.6 Tm (Nothing a teacher needs to do asks for a password. If a password box appears, you have reached) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 459.7 Tm (something you did not mean to - press Esc or click Cancel, and carry on.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 431.8 Tm (It is still wrong) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 411.0 Tm (Tell the office, and say which room and what you were doing when it happened. That second part is) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 396.1 Tm (the one that saves everybody time.) Tj ET
endstream
endobj
27 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>
endobj
28 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>
endobj
xref
0 29
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000187 00000 n
0000000326 00000 n
0000000465 00000 n
0000000604 00000 n
0000000743 00000 n
0000000882 00000 n
0000001021 00000 n
0000001160 00000 n
0000001300 00000 n
0000001440 00000 n
0000001580 00000 n
0000001720 00000 n
0000001860 00000 n
0000002677 00000 n
0000003980 00000 n
0000005441 00000 n
0000007188 00000 n
0000011325 00000 n
0000014467 00000 n
0000018916 00000 n
0000020415 00000 n
0000021943 00000 n
0000023496 00000 n
0000024715 00000 n
0000026534 00000 n
0000026632 00000 n
trailer
<< /Size 29 /Root 1 0 R >>
startxref
26735
%%EOF
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

+68
View File
@@ -154,6 +154,74 @@ that would be a one-way door.
The original is kept at `/etc/network/interfaces.before-ekklesia-classroom`.
### What 0.2.5 got wrong, and what 0.2.6 changed
The prayer-room laptop's Wi-Fi is **hidden** — `ROGC Classroom`, an SSID
with a space in it, announced by nothing. `--adopt` built a connection,
failed to bring it up, rolled back, and left the adapter belonging to
nobody. Four separate defects, all fixed:
**1. The hidden flag was thrown away.** The stanza said `wpa-scan-ssid 1`
and the parser read it — into `options`, where the only accessor ignored
it. The generated profile had `802-11-wireless.hidden: no`, and
NetworkManager will not associate with an SSID it has not seen in a scan.
A hidden one is never in a scan. `credentials()` returned a pair; there
is now a `profile()` that returns the whole network, and `add()` writes
`802-11-wireless.hidden` in **both** directions rather than only when
true — a default that happens to be right is not an answer.
**2. `ifdown` ran after the stanza was commented out.** ifupdown reads
`/etc/network/interfaces` to find out how to bring an interface down.
With the stanza gone it says *"Ignoring unknown interface"* and does
nothing — so ifupdown's own `wpa_supplicant` and DHCP client were still
holding the radio when NetworkManager was asked to associate with it.
Two supplicants on one interface is a fight NetworkManager loses. The
order is now: **`ifdown` first, while the file still says how; rewrite
the file second.**
**3. The rollback left the adapter unmanaged.** `restore()` ended with
`nmcli device set <dev> managed no`, which is right while ifupdown can
actually bring the interface up. When `ifup` fails too, those two lines
are a wireless adapter neither manager owns: no connection, and a panel
that cannot even scan. That is the state the laptop was found in, and it
is why `sudo nmcli device set wlp2s0 managed yes` was a manual recovery
step. The rollback now escalates — if ifupdown cannot get the network
back either, the interface is handed to NetworkManager rather than left
with neither, and it says so loudly.
**4. A `#` in a password was silently truncated.** The parser cut every
line at its first `#`. `interfaces(5)` is explicit that only whole-line
comments exist — *"end-of-line comments are NOT supported"* — so a
pre-shared key containing `#` became a prefix of itself, and the only
symptom is an association that fails exactly the way a wrong password
fails.
Two smaller ones went with them. The rollback un-comments its own marked
lines in place instead of copying the backup over the whole file, because
the backup is written once and never refreshed — a whole-file restore
silently reverted any *wired* change an administrator made in between.
And `nmcli -t` output is now split on its own escaping rules rather than
on `:`, with `LC_ALL=C`, so a network whose name contains a colon cannot
make a successful migration look like a failed one.
`--adopt` also now refuses a network it cannot recreate — WPA-Enterprise
has no pre-shared key to carry across — *before* taking the interface
away from ifupdown to find out, and reports any `unmanaged-devices` line
in NetworkManager's own configuration, which survives the stanza going
away and would otherwise leave the interface belonging to nobody after a
restart.
Verified against a real NetworkManager 1.52.1: the keyfile it writes
carries `hidden=true`, `ssid=ROGC Classroom` with its space, and a psk
containing `#` intact, at 0600 root:root with no `connection.permissions`.
`tests/test-classroom-wifi-adopt.sh` drives the whole hand-over against
stubbed tools and asserts the ordering, the rollback and the managed
state.
**The prayer-room laptop must not be migrated again.** Its stanza is
already gone, so `--adopt` reports "nothing to adopt" and exits 0 without
touching anything; there is a test for exactly that.
After the migration the panel shows the connection at the next login, the
room can scan, and the credential protection below applies to it like any
other system connection.
+286
View File
@@ -0,0 +1,286 @@
# Presentations
Optional per device, off by default. `PRESENTATIONS_ENABLED=true` in
`/etc/ekklesia-classroom/device.conf` puts a **Presentations** button on
the bar and starts the importer; nothing else in the room changes.
It depends on nothing else. A machine may have Presentations with no
Wonder Ink, no drive, no Parent Call, on Wi-Fi or a cable, as a laptop or
a tower. The Bible study laptop is the first installation.
## What was measured before any of this was designed
The brief was to use existing presentation software. So the existing
presentation software was tried first, on Debian 13 in the Plasma lab,
against a ten-slide deck in both PPTX and PDF. **The critical
requirement is that a teacher who ends or cancels a slideshow lands back
in the library and never in an editor.**
| Tried | What it actually did |
| --- | --- |
| `soffice --show deck.pptx` | Starts a real fullscreen slideshow — `app_id=libreoffice-impress`, `"Presenting: session7"`, `fullscreen=1`. But it needs `XDG_SESSION_TYPE=wayland` **and** `libreoffice-gtk3`/`-qt6`, or it dies with *"Failed to open display"*; it shows the LibreOffice splash on the way up; and a **"Tip of the Day: 1/225"** dialog opened on top of the slideshow on first run. Ending the show leaves the Impress editor. |
| `okular --presentation deck.pdf` | Opens **three** windows: the presentation, the ordinary Okular document window behind it, and an **"Information — Okular"** dialog. The full viewer UI — File, Open, Settings — sits behind the slides. |
| `zathura deck.pdf` | Opens windowed, not fullscreen. A document reader, not a presenter. |
| `pdfpc deck.pdf` | Single fullscreen window, the closest of the four. With `--single-screen` it came up windowed, and in the lab it did not respond to Right, Escape or `q`. A presenter-console tool with notes and a timer, and another dependency. |
None of them returns a teacher to a library, because none of them knows
there is one. Every one of them leaves an application on the screen that
can open any file on the machine.
### What was chosen instead
**LibreOffice converts once, headless, at import. Poppler renders the
pages. The classroom shows the pictures.**
```
Session 7.pptx --soffice --headless --convert-to pdf--> slides.pdf
slides.pdf --pdftoppm -png -scale-to-x 1920--------> page-001.png ...
```
Measured on the same deck:
| Step | Time |
| --- | --- |
| PPTX → PDF, ten slides | 0.34 s |
| All ten pages → PNG | 0.03 s |
| One page at 1920px | 0.10 s |
Fonts embed correctly (`BAAAAA+LiberationSans`, subset, embedded) and the
text survives, which is where a title comes from when the filename is a
hash.
This is not a new renderer. LibreOffice does exactly the rendering
Impress would have done live — it is the same engine — and poppler does
what every PDF viewer does. The classroom's own code displays a PNG.
Because playback is made from static page images, PowerPoint animations,
video, audio and other interactive content are not supported.
What it buys:
* **A teacher can never reach an editor**, because no editor ever runs
interactively. `soffice` runs once, with no window, at import.
* **Escape is ours.** The presenter is a window of the library's own
process, so ending a slideshow is the library raising itself, not a
second program being asked to exit.
* **Presenting is instant.** The pictures already exist; there is no
LibreOffice to start in front of a room.
* **PDF is first class.** A session exported from Google Slides as PDF
skips the conversion entirely and keeps exactly the appearance Google
gave it. PPT and PPTX join that path rather than having one of their
own.
* **Offline by construction.** Nothing after the download touches the
network.
`libreoffice-gtk3` is deliberately *not* a dependency: nothing
interactive runs, so no UI integration package is needed.
## Where things are
```
~/Downloads/ completed presentations move from here
~/Downloads/Wonder Ink/ Wonder Ink's worksheets. NEVER touched.
~/.local/share/ekklesia-classroom/presentations/
imported.json which downloads have been dealt with
<id>/
presentation.json title, pages, thumbnail slide, timers
original/
Session 7 - Romans.pptx the one original, name kept
slides.pdf what everything is rendered from
pages/page-001.png ... one per slide, 1920px wide
thumbnail.png the card picture, from the chosen slide
```
## Importing
`ekklesia-classroom-presentations.service` (user) runs
`classroom-presentation-import`, which looks at `~/Downloads` every four
seconds. Polling, not inotify: a teacher waits the same few seconds
either way, and this cannot miss an event across a restart.
A file is imported when **all** of these hold:
* its extension is `.pdf`, `.pptx` or `.ppt`;
* it is not inside the `Wonder Ink` directory;
* there is no `.crdownload`, `.part`, `.partial`, `.download` or `.tmp`
sibling — Chrome writes `Session 7.pptx.crdownload` and renames it when
the transfer finishes, so that sibling is the authoritative "still
arriving";
* it has not changed for six seconds, which catches a copy from a USB
stick that has no `.crdownload` to go by;
* it is not empty; and
* its fingerprint — path, size, modification time — is not already in
`imported.json`.
Two downloads of the same name are two presentations in separate id
directories. The second gets `(2)` after its title; neither overwrites the
other.
**Move and crash safety.** On one filesystem the completed download is
renamed atomically. Across filesystems it is copied to a hidden temporary,
flushed, published atomically, and only then removed from Downloads. The
metadata and transaction fingerprint are written first. On startup, an
interruption before the source was removed discards the partial destination
and retries; an interruption after the move resumes conversion from the
managed original. A presentation becomes ready only once every derived file
it needs is on disk.
A file that cannot be converted gets a card saying so, offering only
**Remove**. Its managed original remains recoverable even when PDF or page
generation fails.
## Memory (0.2.6.2)
On a 4 GB Plasma VM the kernel killed `ekklesia-classroom-presentations.service`
mid-import and Plasma said *Memory Shortage Avoided*. Measured afterwards,
LibreOffice converting one deck with the importer's own code:
| Deck | PPTX | LibreOffice peak | Rendering peak |
| --- | --- | --- | --- |
| 10 slides of text | 20 KB | 203 MB, 0.8 s | 38 MB |
| 30 slides, 12 photos shared | 23.5 MB | 738 MB, 16 s | 70 MB |
| 60 slides, the same 12 photos | 23.6 MB | 754 MB, 28 s | 70 MB |
| 30 slides, 30 different 12 MP photos | 58.7 MB | **1.43 GB**, 22 s | 69 MB |
| 20 slides, 20 different 27 MP photos | 143 MB | **2.08 GB**, 24 s | 70 MB |
It grows with the photographs, not the slides, and it is a spike rather
than a leak. PDF export with images reduced to 200 dpi saved only 5%,
because LibreOffice holds the full images regardless. So the failure was a
photo-heavy deck meeting a 4 GB machine with the room's other programs
open — and what made it a problem was what happened next. systemd's
default `OOMPolicy=stop` took the whole service down with the killed
LibreOffice; it restarted, `sweep()` resumed the same conversion with no
limit, and the library said *Getting this ready…* throughout.
Now:
* **`MemoryMax=40%`** — 1.6 GB on a 4 GB machine, 3.2 GB on 8 GB. Measured
in the lab at 1.6 GB, the 2.1 GB deck still imported in 33 s: much of
that peak is file-backed and reclaimable, and on a machine with swap the
rest is swapped rather than killed (1.4 GB of it at a 200 MB ceiling).
* **`OOMScoreAdjust=800`** — under pressure everywhere, LibreOffice goes
before the lesson on the screen, the browser or the panel.
* **`OOMPolicy=continue`** — a killed LibreOffice is a failed import, not a
dead service. Measured with no swap and a 300 MB ceiling: the kernel
killed LibreOffice, the watcher kept its PID with no restart, and the
import was recorded as `failed`, kind `memory`.
* **Two attempts, then stop.** Each attempt is counted before it starts.
Measured by killing the whole service during attempt 1 and again during
attempt 2: it resumed once, then said "giving up … the teacher can try
again" and did not start a third.
* **The card says so**, and offers **Try again**: *This needed more memory
than the computer had free. Close other programs, then choose Try
again.* Try again was pressed in the lab and the deck became ready.
A file that simply will not convert still says *This file could not be
opened* and offers no Try again, because trying again will not help.
* **Never stuck.** An import still "getting ready" 20 minutes after it
began — longer than any attempt can take — is shown as the same Try
again card, because nothing is working on it.
* **One import at a time**, by an exclusive lock as well as the one loop;
measured, three decks downloaded together converted one after another
with never more than one LibreOffice running.
What the lab cannot give is a real 4 GB machine's mix of programs; the
ceilings were emulated per service.
## The three things kept apart
This is the part most worth not breaking.
| | Changed by | Never changed by |
| --- | --- | --- |
| **Library cover** | *Use as cover* in Quick Look | presenting, importing |
| **Starting slide** | nothing — always slide 1 | choosing a thumbnail |
| **Retention** | a slide actually reaching the screen | Quick Look, opening the library, choosing a thumbnail, preview generation, a viewer that failed to start |
The first slide of a Bible study session is a generic opening shared by
every session; the one that names the session is usually the second. So
the thumbnail defaults to slide 1 and Quick Look exists to change it, and
changing it must not be mistaken for having presented anything.
`Presenter.show_page` calls `mark_presented()` at the moment the first
slide is drawn, and not before. Pressing **Present** is an intention; a
picture on the screen is a presentation.
## Retention
| State | Kept |
| --- | --- |
| Never presented | 180 days from import |
| Presented | 30 days from the **last** presentation |
Every presentation restarts the thirty days. No teacher-facing setting.
The cleanup runs hourly in the importer and:
* never removes a presentation whose id is in
`$XDG_RUNTIME_DIR/ekklesia-classroom-presenting` — the lock the
presenter holds while a slide is on the screen. In the runtime
directory so a crash cannot make a presentation permanently
un-removable;
* only ever removes a directory under the library root;
* removes the managed original and all derived assets together;
* never touches unrelated files in `~/Downloads`, and therefore never a
Wonder Ink worksheet;
* tolerates a directory that is already half gone.
## The presenter
A fullscreen `Gtk.Window` in the library's own process showing one
pre-rendered page at a time, letterboxed — never cropped, never
stretched.
| Key | Does |
| --- | --- |
| Space, →, ↓, Page Down, Return, `n` | Next slide |
| ←, ↑, Page Up, Backspace, `p` | Previous slide |
| Home / End | First / last slide |
| Escape, `q`, F5 | Finish, back to the library |
Page Up and Page Down are what the clicker in the cupboard sends.
Going past the last slide finishes, rather than showing a black "click to
exit" slide.
**Fullscreen is re-asserted, not assumed.** The presenter requests
fullscreen after its Wayland surface is mapped, and its stable `Presenting`
title lets a presenter-only KWin rule apply fullscreen and remove the border
without affecting the library or Quick Look. `window-state-event` reasserts
the state for as long as the presentation is running and stops the moment
Escape is pressed. The library is hidden during playback and restored on
exit; the Plasma panel itself is never hidden or reconfigured.
## Fonts
The pastor's material comes from Google Slides. These are all in Debian
main and install without a question:
| Package | Covers |
| --- | --- |
| `fonts-liberation2` | Arial, Times New Roman, Courier New — metric compatible |
| `fonts-crosextra-carlito` | Calibri |
| `fonts-crosextra-caladea` | Cambria |
| `fonts-croscore` | the same three under their Chrome OS names |
| `fonts-liberation-sans-narrow` | Arial Narrow |
| `fonts-roboto`, `fonts-open-sans`, `fonts-lato`, `fonts-montserrat` | the web fonts Google Slides offers |
| `fonts-urw-base35` | the PostScript standard 35 |
`ttf-mscorefonts-installer` is deliberately **absent**. It is in contrib,
it downloads from a third party at install time, and it shows an EULA
prompt — which an unattended classroom upgrade cannot answer.
## Checking a machine
```
classroom-presentation-import --status what the library holds
classroom-presentation-import --once one pass, then stop
classroom-presentation-import --cleanup apply retention now
classroom-manual --features which manual pages this device shows
```
`--status` prints titles, slide counts, the chosen thumbnail slide, both
timers and the expiry date. It is the fastest way to answer "why has this
gone" and "why has this not appeared".
## Later
The library's next round — a maximisable window, a tile grid, click to advance, a hidden pointer — is in [backlog.md](backlog.md).
+141
View File
@@ -71,6 +71,50 @@ The boot provisioning command never upgrades or reconfigures anything already in
never touches a repository it did not add. Classroom software updates
are the OTA channel's job, not this.
## When a classroom updates
**Every day at 03:00**, local time, spread over half an hour so the rooms
do not all ask at once: `ekklesia-classroom-update.timer`,
`OnCalendar=*-*-* 03:00:00`, `RandomizedDelaySec=30m`. It has been so
since 0.1.5; the lab's installed stable machine logged its checks at 03:01
on 26 September and 03:04 on 27 September.
**A computer that was switched off at 03:00** checks once, soon after it
is next switched on (`Persistent=true`). Measured on the lab's systemd
with the machine really stopped across the time: the missed check ran 50 s
after it started, once, and not again. Nothing else starts the check —
no boot trigger, no repeat — so it cannot loop.
Each check follows only the device's own `CHANNEL` (a stable room stays
on stable), installs only a newer version, and only a package whose
SHA-256, name and version are what the manifest says.
**The reboot after an update waits for nobody to be using the computer**
(since 0.2.6.5). It used to happen the moment the update was installed:
harmless at 03:00, and a room restarting itself mid-morning after a night
switched off. Now `classroom-reboot-when-idle` reboots once no keyboard,
mouse, touchpad or touchscreen has been touched for 30 minutes *and* no
sound has played for as long — a video to the room is not an idle
computer. At 03:00 that is about 03:30. Switched off before then, nothing
is lost: the update is installed and the next start is on it. Until the
reboot the session carries on as it was; the only other thing an update
starts at once, provisioning, does nothing on a machine that is already
set up.
Input is read from the kernel's input devices, whatever the desktop is
doing; sound from the kernel's own playback state (`/proc/asound`). Both
measured in the lab — a mouse made with `/dev/uinput` kept it from
rebooting, and leaving it alone let it — except the sound card, which the
lab does not have: that is a check on real hardware.
The schedule is a file the package installs, not a configuration file,
so every upgrade brings the packaged schedule. The release gate checks it
after a fresh install and after each upgrade.
systemctl list-timers ekklesia-classroom-update.timer # next and last check
journalctl -u ekklesia-classroom-update # what each check did
journalctl -u ekklesia-classroom-reboot-when-idle # a reboot that is waiting
## Permanent download addresses
```
@@ -110,3 +154,100 @@ curl -fsSL -o c.deb https://updates.riverofglorymn.org/canary/latest.deb
sha256sum c.deb
curl -fsS https://updates.riverofglorymn.org/canary/manifest.json
```
## Version numbers and releasing
```
0.2.6 a feature release
0.2.6.1 the first maintenance release of 0.2.6
0.2.6.2 the second
0.2.7 the next feature release
0.3.0 a larger milestone
```
Three parts for a feature release, four for a maintenance release of one.
The leading zero stays until Classroom is feature-complete; 1.0.0 belongs
to the wider Ekklesia platform's milestone. Nothing else is a version: no
leading zeros (dpkg reads `0.2.06` as `0.2.6`), no `0.2.6.0`, no suffixes.
The build, both publish scripts and every classroom's updater refuse
anything else, from one pattern held to one list of cases by
`tests/test-release-versions.sh`.
Which of two versions is newer is always `dpkg --compare-versions`, never
our own arithmetic, because dpkg is what installs them:
`0.2.2 < 0.2.6 < 0.2.6.1 < 0.2.6.2 < 0.2.6.10 < 0.2.7`.
### A published version is never used again
0.2.6 was rebuilt with later fixes and uploaded a second time under the
same number, replacing the first upload. A room that had installed the
first one could never be offered the second: the updater only installs a
version newer than its own. So now:
* `update_canary.sh VERSION` refuses a version that has ever been
published — on the server or in `repo/packages` — and one that is not
newer than every package ever published. It checks before building and
again after the Trixie gate.
* Packages are uploaded with `rsync --ignore-existing`, and the checksum on
the server is compared with the one built before anything points at it.
* A manifest is only written once the server holds exactly the package
and checksum it names.
* After publishing, the channel is read back over HTTPS the way a classroom
reads it: manifest, versioned package, `latest.deb` and the version dpkg
reads out of the file must all agree.
A publish that fails after the local copy into `repo/packages` has spent
that number. Use the next one.
### Canary (Preview)
Since 0.2.6.6 releases normally go through Gitea: built once, recorded as
a Gitea release, published and promoted by workflows that call these same
scripts. [releasing.md](releasing.md) has the whole path. By hand:
```
./update_canary.sh 0.2.6.7 dist/ekklesia-classroom_0.2.6.7_amd64.deb # gate and publish THAT file
./update_canary.sh 0.2.7 # build, gate and publish
```
The version is always given. With four parts there are two possible next
numbers, and which one a release is depends on what is in it. Run with no
argument it prints the current canary and both candidates, and builds
nothing.
Given a package, that file is what is gated and published; nothing is
rebuilt, because a second build is a second, untested file. The gate is
`tools/release-gate.sh`: a fresh install, and the upgrade from whatever
canary and stable serve, each on a room of that channel;
`RELEASE_GATE_FROM=0.2.2` adds an older release rooms are still on.
### Stable
```
./update_stable.sh 0.2.6.6 <sha256>
```
Only with the owner's written approval for that release. Stable is never
given a package of its own: it is pointed at one canary already published,
byte for byte, and refuses a version that is not newer than stable's
current one or whose package the server does not hold with the same
checksum. With the sha256 — always give it — it refuses any file but the
one that was approved.
### Checking a channel without changing it
```
ROOT=$PWD bash -c 'source tools/release-lib.sh; release_verify_channel stable; release_verify_channel canary'
```
### Rehearsing an upgrade
`tools/validate-trixie-release.sh NEW.deb [PREVIOUS.deb]` installs into a
clean Debian 13 container. `VALIDATION_CHANNEL=stable` rehearses a stable
room, for example the desktops' real path:
```
VALIDATION_CHANNEL=stable tools/validate-trixie-release.sh \
dist/ekklesia-classroom_0.2.6.1_amd64.deb \
repo/packages/ekklesia-classroom_0.2.2_amd64.deb
```
+525
View File
@@ -0,0 +1,525 @@
# Releasing Ekklesia Classroom
How a change becomes a package, how that package reaches a classroom, and
who decides each step. Written so it can be followed months from now by
somebody who was not there when it was set up.
The short version: **Gitea is the record, updates.riverofglorymn.org is
the delivery, and a person makes every decision that reaches a room.**
```
pull request ──► CI (checks, package, container gate) automatic, no secrets
│
merge to master or release/X.Y
│
Release 1 - build ──► Gitea pre-release vX.Y.Z: .deb, SHA256SUMS, a person runs it
│ build-info.json, gate report, notes
Release 2 - Preview ──► the same file on canary; read back; recorded a person runs it
│
physical checks on real hardware (docs/testing.md) a person does them
│
Release 3 - Stable ──► the same file on stable; read back; recorded a person runs it
```
Nothing is rebuilt after step 1. The file the gate passed is the file the
laptop installs and the file the desktops install, byte for byte, and its
SHA-256 is written down at every step.
## The tracks
| Track | What it is | Where classrooms get it | Who is on it |
| --- | --- | --- | --- |
| **Stable** | Approved for production: passed the gate, ran on Preview, and passed the physical checks on real hardware | `https://updates.riverofglorymn.org/stable/` | The three classroom desktops |
| **Preview** | A finished release candidate, frozen, waiting for its physical checks | `https://updates.riverofglorymn.org/canary/` — Preview **is** the canary channel | The prayer-room laptop |
| **Nightly** | Development builds of the next version, for lab machines | not yet — see [Nightly](#nightly-designed-not-yet-active) | Nobody yet; never a classroom |
"Preview" is a new name for an old channel. The URL, the manifest and the
laptop's `CHANNEL=canary` are unchanged: renaming a production path would
strand the one machine on it for no gain. The scripts still say canary.
A Gitea release is a **pre-release** while its version is only on Preview
and becomes a full release when it is promoted to Stable.
## Versions
As in `tools/release-lib.sh`: `0.2.6` is a feature release, `0.2.6.1` its
first maintenance release, `0.2.7` the next feature release. Three or four
numbers, no leading zeros, and ordering is always `dpkg --compare-versions`.
**A version is used once.** Once any file of that name has been on the
update server it is never rebuilt or replaced — 0.2.6 was, and no room that
had the first one could ever be offered the second. The release tag is
`v` + the version (`v0.2.6.6`) and is never moved.
## Branches
| Branch | What it holds | Who merges |
| --- | --- | --- |
| `master` | The integration branch: the next version's development (0.3.0 now). Every change arrives by pull request with CI green | Anyone with write access, after review |
| `release/X.Y` | A maintenance line (`release/0.2`): fixes only, each one a pull request, each release built from here. Created when a release has to be maintained while `master` moves on — which is now | Same |
| short-lived branches | One change each (`fix/…`, `feat/…`, `docs/…`), deleted after merge | — |
| `experiment/*` | Work that may never ship. Not built as releases | — |
Why this and not more: the repository has always been one line of work on
`master`, and what it lacked was only a place for 0.2.x to wait while 0.3.0
starts. One maintenance branch per line, created when it is needed, does
that. There is no `develop` branch, because `master` already is one, and no
branch per channel, because a channel is a manifest pointing at a file, not
a line of code.
**Releases are tags, not branches.** `v0.2.6.6` names the exact commit the
package was built from. Stable and Preview are not branches at all.
**Fixes flow forward.** A fix made on `release/0.2` is merged into `master`
afterwards (`git merge release/0.2`, as a pull request), never cherry-picked
backwards from `master`. A merge keeps both lines' histories honest and
makes it impossible to forget a fix in the newer line. If `master` has moved
too far for a clean merge, the fix is re-done on `master` in its own pull
request that says so.
**A frozen candidate.** 0.2.6.6 is tagged `v0.2.6.6` on `release/0.2`. While
it waits for its physical checks, 0.3.0 work lands on `master` and cannot
touch it. If the checks fail, the fix goes to `release/0.2`, becomes 0.2.6.7,
and is merged forward.
### Protecting the branches (Gitea settings)
For `master` and `release/*`: no force push, no deletion, and merging only
from a pull request whose **CI / checks** and **CI / package and container
gate** statuses are green. See [Gitea administration](#gitea-administration-still-to-do).
## Pull requests and commits: CI
`.gitea/workflows/ci.yml`, on every push to any branch and every pull
request, on the `ekklesia-ci` runner, with **no secrets**:
1. **checks** — `tools/check.sh --base <where the change started>`:
- every script's syntax by its `#!` line, every Python module, every JSON
policy and wallpaper metadata file;
- the change's hygiene: `git diff --check`, no `.deb`, `dist/`, `repo/` or
`packaging/root/` committed, and `VERSION` a valid release version;
- the teacher's manual, rebuilt and checked against itself;
- every `tests/test-*.sh`: configuration, migration, desktop layout and
wallpaper convergence, printing (no paper: the queue, the options, the
picture pipeline), Wonder Ink workspace, Presentations, audio, network
and Wi-Fi adoption, the updater and its schedule, release versions and
the release tooling, deployment support, and the Gitea release record.
The log of each is kept as the `check-logs` artifact, with a
`summary.md` table.
2. **package and container gate** — builds the package the commit's
`VERSION` describes, proves it says that version, and installs it on a
clean Debian 13 container with `tools/release-gate.sh --ci`: fresh, and as
an upgrade from whatever canary and stable serve. Between releases
`master`'s `VERSION` equals what canary already serves, so an upgrade
that is not possible is recorded as **SKIP**, with the reason, not
failed. The package contents and the gate report are kept; **the package
itself is not**, because it is not a release and must never be mistaken
for one.
A test added to `tests/` runs in both jobs, and in every release build, with
nothing else to edit: `tools/build-release.sh` runs the same
`tools/check.sh` before it builds.
What CI does not do: the GUI lab (`tools/lab/`), which needs the nested
Plasma session on the development VM, and anything on real hardware. Those
are run by hand before a release, and their results written into the
release commit and the Gitea release.
## Building a release
1. On `master` (a feature release) or `release/X.Y` (maintenance), make
the release commit: `VERSION` set to the new version and the manual
rebuilt, nothing else:
```
echo 0.2.6.7 > VERSION && tools/build-manual
git commit -m "release: 0.2.6.7, for the canary machine only" VERSION docs/manual/build
```
and merge it by pull request like anything else.
2. Run the GUI lab against a local build (`tools/lab/dirty-upgrade`, from
the release stable rooms are on and from canary's), and note the result.
3. In Gitea: **Actions → Release 1 - build → Run workflow**, on that branch,
with the version (and `0.2.2` or whatever older release rooms are still
on, to rehearse that upgrade too).
`tools/release-ci.sh build` then:
- refuses unless the branch is `master` or `release/*`, the checkout is
clean, and `VERSION` already says the version;
- refuses a version that has a Gitea release or tag, whose package is on the
update server, or that is not newer than what canary and stable serve;
- builds with `SOURCE_DATE_EPOCH` set to the commit's time, so the manual's
revision date is the commit's and the build changes nothing in the tree —
and **fails if it did change anything**, because then the package would not
be the commit;
- writes `SHA256SUMS` and `build-info.json` (version, package, sha256, full
commit, branch, time, the run that built it, and the version dpkg reads
from the file);
- runs the full gate, `tools/release-gate.sh`, on that file;
- creates the Gitea **pre-release** `vX.Y.Z` tagged at the commit, with the
package, `SHA256SUMS`, `build-info.json`, the gate report, the check
summary, and notes listing the changes since the previous tag.
The sha256 it prints is the release's name from then on.
## Publishing to Preview
**Actions → Release 2 - publish to Preview (canary) → Run workflow**, with the
version and its sha256 from the Gitea release. Two jobs:
- **gate**, on `ekklesia-ci` with no secret: downloads the release's `.deb`,
proves it is that sha256, that the release's `SHA256SUMS` says so, and that
dpkg reads the version from it; runs the full gate again, against what
canary and stable serve *now*; attaches the report to the release under a
new name.
- **publish**, on `ekklesia-release` with the update server's key:
downloads the same file and that report and runs
`update_canary.sh VERSION PACKAGE` with the report. `update_canary.sh`:
- refuses a version that has ever been published, or is not newer than
everything that has (asked of the server itself, over ssh);
- accepts the gate report only if it names this file's sha256, passed, and
upgraded from exactly the versions canary and stable serve now — a report
made before either moved is refused, and the gate has to be run again;
- uploads the package with `rsync --ignore-existing` and proves the server's
copy is the same bytes, writes the manifest **after** the package, swaps
`canary/latest.deb` by rename **after** the manifest, and reads all three
back over HTTPS the way a classroom does.
`tools/release-ci.sh preview` then checks that exactly one file was added
to the server's packages and none changed, and records on the Gitea release
that the file went to Preview, with its sha256 and the read-back.
The laptop takes it at its next update check (daily at 03:00, caught up after
a night off) and reboots once nobody is using it.
## Physical approval
The gate and the lab cannot stand in for a real television, a real lid, a
real DVD drive, real speakers or a real reboot. `docs/testing.md` lists what
a person checks for each release on real hardware — for 0.2.6.6, the section
[Before 0.2.6.6 goes to stable](testing.md#before-0266-goes-to-stable).
Only a person does these, and only a person records them. Nothing in CI
claims a physical check passed.
## Promoting to Stable
**Actions → Release 3 - promote to Stable → Run workflow**, with the version,
its sha256, the approval (who, when, which checks, on which machine), and the
confirmation `PROMOTE X.Y.Z TO STABLE`. On `ekklesia-release`:
- it refuses unless the Gitea release records **that sha256** going to
Preview: only what Preview ran can be promoted;
- `update_stable.sh VERSION SHA256` refuses any other file, refuses a version
not newer than stable, refuses unless the server already holds exactly that
package, then points `stable/manifest.json` and `stable/latest.deb` at it
and reads them back over HTTPS. **No build, no upload, no new file**;
- it proves no package on the server changed, records the promotion and the
approval on the Gitea release, and marks it no longer a pre-release.
The desktops take it at their next 03:00 check.
Merging code never promotes anything. There is no path from a push to
Stable that does not pass through a person running this workflow.
## Without a runner: the same steps by hand
Every workflow step is a script, so a release never depends on a runner
being up. From a checkout with the update server in `~/.ssh/config` as
`classroom-control` (as the development VM has always had) and a Gitea token
with repository write access:
```
export GITEA_URL=https://git.riverofglorymn.org
export GITEA_REPO=River_of_Glory_Church/Ekklesia_Classroom
export GITEA_TOKEN=… # never committed
tools/release-ci.sh build 0.2.6.7 0.2.2
tools/release-ci.sh gate 0.2.6.7 <sha256>
tools/release-ci.sh preview 0.2.6.7 <sha256> <gate-report name>
tools/release-ci.sh stable 0.2.6.7 <sha256> "physical checks passed on …, by …, on …"
```
And, as before and still true, the underlying tools alone:
```
./update_canary.sh 0.2.6.7 dist/ekklesia-classroom_0.2.6.7_amd64.deb # gate + publish that file
./update_stable.sh 0.2.6.7 <sha256> # promote that file
```
`update_canary.sh VERSION` with no package still builds one first, for
compatibility; handing it the file you tested is the better habit.
## Immutability: three locks
1. **The update server.** `release_check_new_version` refuses a version whose
file name has ever been used or that is not newer than every version ever
published; `rsync --ignore-existing` will not overwrite a package even if
that check were wrong; every upload is read back by sha256.
2. **The Gitea release.** Gitea itself lets anyone with write access delete an
asset and upload another of the same name, and Gitea 1.27 has no setting
to stop it. `tools/gitea-release` is the only thing the workflows use to
write releases, and it uploads an asset only under a name the release does
not have; the same bytes again are left alone; different bytes fail
loudly and change nothing. A tag is never moved. The record under
"## Record" is only appended to. A person with write access can still
delete things in the web UI — don't; nothing automated will put them back.
3. **The checksum everywhere.** The sha256 is in the Gitea release, its
`SHA256SUMS`, `build-info.json`, the gate reports, the update server's
manifest and the release commit's follow-up. Preview and Stable both
require it as an input and refuse a file that does not match.
## Gitea Releases and updates.riverofglorymn.org
| | Gitea release `vX.Y.Z` | updates.riverofglorymn.org |
| --- | --- | --- |
| Purpose | The permanent record: why, from what, how it was checked, what happened | Delivery to classrooms |
| Read by | People | Each classroom's updater, daily |
| Holds | The `.deb`, SHA256SUMS, build info, gate reports, notes, the record of Preview and Stable | `packages/*.deb`, and per channel `manifest.json` and `latest.deb` |
| Decides what a room installs | No | Yes: the channel's manifest |
The two always hold the same bytes. The update server stays the only thing
a classroom talks to: a Gitea outage, a Gitea upgrade or a Gitea migration
never touches a room.
## Rollback and recovery
**A classroom never installs an older version.** The updater only takes a
version newer than its own (it has to: otherwise two channels could
ping-pong a room), and `update_stable.sh` refuses to move stable backwards,
because a room would ignore it anyway. So:
- **A bad release on Preview only** (the laptop): do not promote it. Fix
forward: the fix, or a revert, becomes the next version, through the same
gate and Preview. The bad file stays on the server — it is a version that
was published, and deleting it would only hide what the laptop is running.
- **A bad release on Stable**: the same, faster. Build the next version from a
revert of the bad change (on `release/X.Y`), gate it, Preview it, and
promote it as soon as the specific failure is shown fixed on real hardware.
Stable points at a *new* version whose content is the last good one.
- **One room that must go back now**, before a fix exists: by hand on that
machine, `sudo apt-get install --allow-downgrades ./ekklesia-classroom_<good>_amd64.deb`
with the file from its Gitea release, then reboot. It stays there until a
newer version is published to its channel.
- **A publish interrupted half way**: the manifest is written after the
package and `latest.deb` after the manifest, so a room either sees the old
release or the whole new one. Re-running the same workflow is safe: the
version checks refuse to publish twice, so check the Gitea release's record
and `release_verify_channel` before re-running anything.
- **A lost Gitea**: the update server still has every package, and the tags
are in every clone. `Release - import an existing release` rebuilds the
record from the server's files.
## Secrets, variables and credentials
None of these are in the repository, and none are given to CI.
| Name | Kind | Holds | Used by |
| --- | --- | --- | --- |
| `CLASSROOM_UPDATES_SSH_KEY` | repository **secret** | The private key the release runner uses to publish to the update server | Release 2 (publish job), Release 3 |
| `CLASSROOM_UPDATES_ADDRESS` | repository variable | The update server's address from the runner (`10.40.0.2`) | Release 2, 3 |
| `CLASSROOM_UPDATES_USER` | repository variable | The account that key logs in as | Release 2, 3 |
| `CLASSROOM_UPDATES_KNOWN_HOSTS` | repository variable | The server's ssh host key line(s), so the job refuses any other host | Release 2, 3 |
| `RELEASE_API_TOKEN` | repository secret, **optional** | A token that may write releases. Only needed if the automatic Actions token ever cannot; measured on Gitea 1.27.2, it can | Release 1, 2, 3, import |
| automatic Actions token | provided by Gitea per job | Creates and records releases | Release 1, 2, 3, import |
Variables are not secret: a pull request's jobs can read them (measured). Put
nothing in a variable that would matter if it were printed.
**Least privilege for the publish key.** Today the development VM publishes
as `media@10.40.0.2`, and `media` is in the `sudo` and `docker` groups on that
host — which also runs Gitea, Home Assistant and Vaultwarden. That key must
**not** be the one put in Gitea. Before the release workflows are used:
1. On 10.40.0.2, create an account used for nothing else (say
`classroom-publish`), in no group but one that may write
`/srv/ekklesia/classroom-updates` — not `sudo`, not `docker`.
2. Generate a new key pair for it on a trusted machine; put the public half
in its `authorized_keys` with `restrict,from="<release runner address>"`;
put the private half in `CLASSROOM_UPDATES_SSH_KEY`; delete the local copy.
3. Set the three variables above.
The scripts need `ls`, `test`, `sha256sum`, `cat`, `ln`, `mv` and `rsync`
there, and nothing else.
## Runners
| Label | Runs | Needs | Must not have |
| --- | --- | --- | --- |
| `ekklesia-ci` | CI on every push and pull request; Release 1; the gate job of Release 2; import | Debian 13, Docker (the container gate), git, python3, dpkg-deb, curl, rsync, nodejs (for `actions/checkout`), the fonts and tools the tests use — in practice, a VM set up like the development VM | Any credential for the update server or a classroom; a route to classrooms it does not need |
| `ekklesia-release` | Release 2's publish job, Release 3 | ssh, rsync, curl, python3, dpkg-deb, git, nodejs; network to 10.40.0.2 | Docker; any key on its disk; any other label |
| `classroom-hw-*` (future) | Hardware checks, by hand only | A real classroom computer in the lab | Anything to do with production rooms |
**How the separation holds, and where it doesn't** — measured on a local
Gitea 1.27.2 with act_runner 0.6.1:
- A pull request from a **fork** does not run at all until a maintainer
approves it. Approved, it gets **no secrets**. It does get the variables
and a token, and it runs on whichever runner its workflow names —
including `ekklesia-release`, if it asks for that label.
- A branch pushed to this repository runs with the repository's secrets.
**Anyone with write access can publish**, by writing a workflow that uses the
key. Give write access accordingly.
The release scripts never use a runner's own ssh keys: under Actions the
key comes only from the secret, is written to the job's own directory with
its own `known_hosts` and an ssh configuration used with `ssh -F` (which
ignores `~/.ssh/config`), and is deleted when the job ends. A job without
the secret stops. **Never put a runner on a machine that holds the publish
key by hand** — the development VM does — in case that rule is ever broken.
So: the key lives only in Gitea's secret store, never on a runner's disk,
where an approved fork job could read it. The `ekklesia-release` runner runs
each job in a fresh container (`ekklesia-release:docker://node:22-trixie`,
without the Docker socket), so no job can leave anything behind for the
next. **Never approve a fork's run that changes `.gitea/workflows/`**
without reading it.
`ekklesia-ci` needs Docker for the container gate, and Docker is root on its
host. Treat that host as something any approved pull request can take over:
a VM with nothing on it worth taking, and no key to anything else.
**Registering a runner** (on the runner's host, as the account it runs as):
```
act_runner generate-config > config.yaml
# edit runner.labels in config.yaml: [ "ekklesia-ci:host" ]
# (measured: labels in the config file OVERRIDE --labels at registration;
# a generated config lists ubuntu-latest etc. and the runner then
# advertises those, and every job here waits for ever)
act_runner register --no-interactive -c config.yaml \
--instance https://git.riverofglorymn.org --token <from Gitea> --name ekklesia-ci-1
act_runner daemon -c config.yaml # as a systemd service
```
The registration token is under the repository's **Settings → Actions →
Runners**. Register at repository level, not instance level, so these
runners take no other repository's jobs.
Artifacts: `actions/upload-artifact@v3` works on Gitea 1.27.2; **v4 fails**
("not supported on GHES", measured). The workflows use v3.
## Nightly (designed, not yet active)
For installable 0.3.0 development builds, separate from a frozen Preview.
- **Source:** `master`, a commit whose CI passed.
- **When:** once a night if `master` changed since the last nightly, and on
demand. Not every commit: a lab machine that reboots into a new build every
hour tests nothing.
- **Version:** `<next>~nightly.<YYYYMMDD>.<run>`, for example
`0.3.0~nightly.20261014.3`. The `~` makes it sort **before** `0.3.0` and
after every 0.2.x, and each night after the last — so a lab machine moves
to the real 0.3.0 when it ships. The release version rules
(`tools/release-lib.sh`, and the same pattern in the updater) refuse `~`
today; they must accept it for the nightly channel only.
- **Server:** `/nightly/manifest.json`, `/nightly/latest.deb`, and packages in
`/nightly/packages/` — **not** `/packages/`. The rule that a release must be
newer than everything ever published reads `/packages/`; a nightly there
would make 0.2.6.7 "older" than a 0.3.0 nightly and block every
maintenance release.
- **Retention:** the last 14 nightlies on the server; older ones deleted from
`/nightly/packages/` only. No Gitea release per nightly; the build is a
workflow artifact, kept as long as Gitea keeps artifacts.
- **Opting in:** a lab machine's `device.conf` says `CHANNEL=nightly`, and its
first nightly is installed by hand.
- **Never a classroom:** the nightly updater refuses `CHANNEL=nightly` unless
`/etc/ekklesia-classroom/lab-machine` exists, and a nightly package refuses
to install without it. A nightly machine never reads the stable or canary
manifest, and it cannot drift onto them: its version is newer than any
0.2.x, so pointed at stable it would simply sit until stable passes it.
Taking it back to 0.2.x is a reinstall with `--allow-downgrades`, by hand.
- **Credential:** its own key, allowed to write `/srv/ekklesia/classroom-updates/nightly`
and nothing else (`CLASSROOM_NIGHTLY_SSH_KEY`).
What activating it needs, which is why it is not active: the updater and
release-lib changes above ship **inside the package**, so they are 0.3.0
work; plus the server directory, the key, and a `nightly.yml` workflow. That
is the next infrastructure task, after the backlog audit decides what 0.3.0
is.
## The hardware lab (designed, not yet built)
Spare classroom computers, each a runner labelled `classroom-hw-<kind>`
(`classroom-hw-desktop`, `classroom-hw-laptop`), registered to this
repository, in a lab network with no path to production rooms, on
`CHANNEL=nightly` or on whatever a job installs.
- **Triggered only by hand** (`workflow_dispatch`) with a version and sha256 —
never by a pull request or a push.
- **Installs the exact file** from its Gitea release through apt, proves dpkg
reports that version, then runs checks from `tools/hw/` (to be written):
HDMI connect/disconnect (the display count and the desktop's wallpaper
before and after), HDMI audio (the sink appears, is chosen, plays),
the DVD drive (seen, a disc read), the printer (queue state; a real page
only with an explicit input), Wi-Fi recovery (down, up, reconnected with
nobody typing), sleep/wake (`rtcwake`, and what is standing after), and
reboot/update (a job that reboots the machine and a second job that reads
what came back).
- **Reports** one line per check into `hardware-report-<machine>-<date>.txt`:
```
AUTO PASS hdmi-hotplug 2 -> 1 -> 2 outputs; desktop 1 kept its wallpaper
AUTO FAIL dvd-read no disc in /dev/sr0
MANUAL - picture-visible not checked by a machine
```
attached to the Gitea release, with a record line summarising it.
`AUTO` lines come only from a script that measured something. `MANUAL`
lines are only ever `-` from a machine; a person's result is written by the
person, in the Stable workflow's approval, and nowhere else. A machine
never ticks a physical check.
- **Does not block** CI or releases. Until it exists, physical checks are
done by a person from `docs/testing.md`, exactly as now.
## Gitea administration still to do
Things only a Gitea administrator can do, none of which this repository can
do for itself:
- [ ] Say which host the existing "Ekklesia" runner is on, and its labels. It
is not on 10.40.0.2, and the Gitea API does not show runners to anyone
but an administrator. Relabel it `ekklesia-ci:host` (in its config
file, see above) if it is fit for that role, or register a new one.
- [ ] Set up an `ekklesia-release` runner as above: container mode, no Docker
socket, repository level.
- [ ] Create the `classroom-publish` account and key on 10.40.0.2, and the
secret and three variables above.
- [ ] Protect `master` and `release/*`: no force push, no deletion, required
status checks **CI / checks** and **CI / package and container gate**.
- [ ] Under the repository's Actions settings, keep approval required for
runs from forks (the default, measured).
- [ ] Run **Release - import an existing release** for 0.2.6.6 (version,
sha256 and commit are in the release commit and in
`docs/testing.md`) so its Gitea release exists before its promotion.
## What was measured
On a throwaway Gitea 1.27.2 with act_runner 0.6.1 (the versions production
runs), on the development VM, 2026-09-29:
| Question | Answer |
| --- | --- |
| Do all five workflows parse, and plan only on their own events? | Yes: CI on push and pull_request; the release workflows only on a manual run |
| Does the CI checks job pass on a real runner? | Yes: all 31 checks, and their logs upload as an artifact |
| Does CI's package job build, and run the gate in its CI mode? | Yes: built, the tree unchanged, fresh install and both upgrades passed, the report marked `mode ci` |
| Does **Release 1 - build** work end to end? | Yes, at a throwaway version 9.9.9 against what canary (0.2.6.6) and stable (0.2.6.4) really serve: built from the commit with the tree unchanged, full gate passed, pre-release `v9.9.9` tagged at exactly that commit with the package, SHA256SUMS, build-info.json, gate report and check summary |
| Is a second build of the same version refused? | Yes: "v9.9.9 already has a Gitea release; a version is built once" |
| Does Preview's gate job work? | Yes: it downloaded the release's file, proved it against the sha256 given and SHA256SUMS, passed the full gate, attached a uniquely named report, recorded it, and handed its name to the publish job |
| If the key secret is missing, what would the publish job use? | **This machine's own ssh configuration** — on the development VM, the production publish key: a throwaway 9.9.9 would have gone to the real server. The job was cancelled before it ran, and since then a job under Actions without the key refuses, and one with it uses only its own configuration (`ssh -F`), never the runner's |
| Does a failing check stop a release build? | Yes: an undocumented workflow left in the tree failed `test-workflows`, and nothing was built |
| Can the automatic token create a release, attach an asset, edit the notes? | Yes |
| Does `tools/gitea-release` refuse different bytes under a published name, on real Gitea? | Yes |
| Do `github.event.inputs.X` and `inputs.X` both work? | Yes |
| Do job outputs pass from a job on one runner to a job on another? | Yes |
| Do `vars.*` and `secrets.*` reach a job, and is the secret masked? | Yes, and yes |
| `upload-artifact@v3` / `@v4`? | v3 works; v4 fails ("not supported on GHES") |
| Do labels given to `act_runner register` survive a generated config? | **No**: the config's labels win |
| Does a fork's pull request run without approval? | No: held until a maintainer approves |
| Approved, does it get secrets? | No. It gets variables, a token, and any runner it names |
Not measured, because it needs the real update server's key in a real
runner: Preview's publish job and Stable's promote job end to end. Their scripts are the ones
used by hand since 0.2.6, and `tests/test-release-ci.sh` runs them against a
stand-in server and a stand-in Gitea.
+12
View File
@@ -149,6 +149,18 @@ nothing else about the room's locks moves.
| `notif-13pt`, `notif-15pt` | the two font sizes that were weighed for it |
| `pc-typed-layer` | the card gone: the lab's keyboard tool closes it, which is why typing is still a bench test |
## Wonder Ink printing
Under the lab's real Plasma session, driven with the pointer. See
[wonder-ink.md](../wonder-ink.md), *Printing*.
| | |
| --- | --- |
| `pic-preview-default`, `pic-preview-half-topleft` | a picture in a tab of its own, laid out on Letter: as large as fits, then 50% top left (0.2.6.2) |
| `print-image-menu` | right-click on a picture inside a lesson: **Print image…** and **Print page…**; on the words there is only Print page (0.2.6.3) |
| `print-image-bw-half-topleft` | Print image on a signed-in picture, 50% top left, Black and white — the preview grey, as the job CUPS kept was (0.2.6.3) |
| `workspace-restored` | Wonder Ink reopened after an accidental close: the lesson's WonderInk tab at its sign-in page, its slide in front, its PDF behind (0.2.6.4, the real site) |
## Failures, kept
| | |
Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 719 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 906 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

+390 -1
View File
@@ -3,6 +3,36 @@
Run against the exact artifact published to canary, on the real endpoint,
before promoting to stable with `./update_stable.sh`.
## Before 0.2.6.6 goes to stable
The container gate and the lab passed both of these. Neither can prove
them on real hardware, so a person does, on the prayer-room laptop, which
takes 0.2.6.6 from canary. If every box is ticked, 0.2.6.6 is promoted
**as the same file**, never rebuilt:
```
ekklesia-classroom_0.2.6.6_amd64.deb
sha256 2b972391974265ba220c58ae0a8f287664a42228f062278956b89017bd5a54f7
./update_stable.sh 0.2.6.6 2b972391974265ba220c58ae0a8f287664a42228f062278956b89017bd5a54f7
```
**Waiting to reboot (since 0.2.6.5)** — details under [OTA](#ota):
- [ ] Using the computer after an update has installed: it does not restart
- [ ] A video playing with sound, mouse left alone: it does not restart
- [ ] Left untouched and silent, it restarts about 30 minutes later, on
the new version
- [ ] Switched off before that restart: it starts normally next time, on
the new version, and does not restart again
**The television arriving late (0.2.6.6)** — details under
[the television switched on after a night-time reboot](#the-television-switched-on-after-a-night-time-reboot):
- [ ] Rebooted with the television off at the set (if the room allows it),
then switched on: the classroom's landscape, not Plasma's purple
- [ ] The panel is the room's own: same launchers, same order, clock bottom
right
## Boot
- [ ] Cold boot reaches the classroom shell with no terminal or window junk
@@ -11,6 +41,39 @@ before promoting to stable with `./update_stable.sh`.
- [ ] Dock appears, centred
- [ ] Clock appears, bottom right
### The television switched on after a night-time reboot
This is how Tiny Pebbles lost its wallpaper after the 0.2.2 upgrade, and
the lab cannot do it: KWin will not start a session with no screen, and
the lab cannot plug one in. The lab proves the same code path through a
new activity (`tools/lab/wallpaper-check`); only a real television
proves that switching it on takes that path. See
[ui-scale.md](ui-scale.md#a-desktop-the-layout-never-saw-0266). Do it once, on a
desktop whose television is switched off at the set, not the remote:
1. [ ] Television **off** at the set. `sudo reboot`, and wait five minutes.
2. [ ] Television **on**. The classroom's landscape is on the screen,
**not** Plasma's purple wallpaper. The panel is the room's own.
3. [ ] From the maintenance terminal (Meta+Ctrl+Alt+Shift+K),
`classroom-desktop-wallpaper --check`. Note what it says. If it
names a desktop that "had no wallpaper configured", the television
really did arrive after the shell, and the screen in step 2 is the
fix working
4. [ ] `systemctl --user restart plasma-plasmashell.service`: the panel
blinks and comes back, open windows stay, the wallpaper is unchanged
5. [ ] `classroom-desktop-wallpaper --check` now says "the classroom
wallpaper already"
6. [ ] Reboot with the television on: the same picture and the same panel
Tiny Pebbles can confirm the cause **before** it takes the release, because
the update reboots it and a login rebuilds the file. If its panel was built
with no screen, the panel is `[Containments][1]` next to the desktop's 10:
```
grep -A6 '^\[Containments\]\[[0-9]*\]$' \
~/.config/plasma-org.kde.plasma.desktop-appletsrc | grep -E '^\[|^plugin='
```
## Provisioning
- [ ] `journalctl -u ekklesia-classroom-provision` shows a clean run
@@ -323,6 +386,35 @@ DVD_ENABLED=false
Nothing about `DEVICE_TYPE=laptop` turns those applications off; they are
off because those lines say so.
### Physical verification — the Wi-Fi hand-over
No lab can do this one: it needs the laptop's radio. Over SSH, watching,
with a way back into the room if it goes wrong:
1. `classroom-wifi --check` — expect it to name the interface and say
ifupdown owns it. Changes nothing.
2. `classroom-wifi --adopt --dry-run` — expect the three "would" lines.
Changes nothing. `/etc/network/interfaces` must still be identical.
3. `classroom-wifi --adopt`. It prints each step. Expect it to end with
*"is NetworkManager's now, and the room is online"*.
If it fails it puts ifupdown back by itself and says so; the machine
should still be reachable. `classroom-wifi --rollback` undoes it later.
4. `nmcli device status` — the Wi-Fi device must no longer say
`unmanaged`.
5. `nmcli networking connectivity` — must say `full`, not `none`.
6. `nmcli device wifi list` — must now list networks.
7. Log out and back in. The panel must show the Wi-Fi connection.
8. Reboot. It must reconnect on its own, with nobody typing a password.
9. As the **classroom** account, confirm the password is still
unreachable:
- `nmcli -s -g 802-11-wireless-security.psk connection show <name>`
must be empty
- the applet's *Show Network's QR Code* must not produce one
- `/etc/NetworkManager/system-connections/*.nmconnection` must be
unreadable
10. As **tech**, confirm maintenance still works: `sudo nmcli -s ...`
returns the key.
### Physical verification — a real lid and a real television
The lab has no lid and no HDMI hotplug, so none of this is proven by a
@@ -437,10 +529,307 @@ With `LIVE_ENABLED=true`:
- [ ] `./update_canary.sh` publishes package first, manifest last
- [ ] The endpoint on the canary channel picks the update up
- [ ] SHA-256 verification passes
- [ ] Install succeeds and the endpoint reboots
- [ ] Install succeeds; `systemctl list-timers ekklesia-classroom-update.timer`
shows the next check at about 03:00 tomorrow
- [ ] (0.2.6.5) Switched on in the morning after a night off, with an update
waiting: it installs, `journalctl -u ekklesia-classroom-reboot-when-idle`
says it is waiting, and it does **not** restart while you use it
- [ ] Play a video with sound and leave the mouse alone for 40 minutes: no
restart while it plays; half an hour after it stops, it restarts
- [ ] Left untouched with nothing playing, it restarts about 30 minutes after
the update, and comes back on the new version
- [ ] Everything above still works after the update
- [ ] Upgrade from 0.1.36 preserves every existing `device.conf` value
- [ ] `LIVE_ENABLED=false` is appended exactly once on upgrade and remains
single after reinstall
- [ ] `tools/validate-trixie-release.sh` passes against the 0.1.37 package and
the 0.1.36 package
## Presentations — 0.2.6, the prayer-room laptop
Everything here needs the real television, the real keyboard and the
pastor's own session files. The automated tests and the Plasma lab cover
the logic and the windows; none of them can tell you that Session 7's
text is the right size from the back of the room.
Enable it first, and reboot:
```
sudo sed -i 's/^PRESENTATIONS_ENABLED=.*/PRESENTATIONS_ENABLED=true/' \
/etc/ekklesia-classroom/device.conf
sudo reboot
```
### Download
- [ ] The bar has a **Presentations** button, after the Browser
- [ ] Wonder Ink and Parent Call are still absent from this laptop
- [ ] Download tonight's session in ordinary Chrome
- [ ] It does **not** appear in the library while it is still downloading
- [ ] It appears by itself within a few seconds of the download finishing
- [ ] The title is readable — not a hash, not a long filename
- [ ] The completed original moved out of Downloads into the managed library;
there is no duplicate
- [ ] Download the **PDF and the PPTX** of the same session: two cards,
the second titled `(2)`, neither overwriting the other
### Quick Look
- [ ] **Quick Look** opens on the currently selected cover slide
- [ ] **Next** and **Previous** move through every slide
- [ ] The text and layout are right — check a slide with a Bible verse
- [ ] Go to the session-number slide, usually slide 2
- [ ] **Use as cover** changes the card and shows **Current cover**
- [ ] **Back to Presentations** returns without presenting
- [ ] Close the library, open it again: the chosen picture is still there
- [ ] Reboot, open it again: still there
- [ ] `classroom-presentation-import --status` shows `presented never`
- [ ] At 1920×1080 and 1366×768, scale 1.0 and the larger supported scale,
the title, slide navigation, cover action, Back and Present all remain
above the panel without manually resizing
That last line is the one that matters. Quick Look, browsing every slide
and changing the picture must not have counted as a presentation.
### Present
- [ ] **Present** from the card fills the **external television**
- [ ] No panel, title bar, border, desktop or other application is visible
- [ ] It starts at slide **1**, not at the slide chosen for the picture
- [ ] **Present** from inside Quick Look does the same
- [ ] Space, the arrow keys and Page Up/Page Down all move slides
- [ ] The presentation remote's forward and back buttons work
- [ ] Fonts and layout are right on the television, from the back
- [ ] **Esc** returns to the library — **not** the Impress editor, not a
terminal, not a blank screen
- [ ] Going past the last slide does the same
- [ ] `--status` now shows a presentation time and a count
- [ ] Present it again: the count rises and the expiry moves out 30 days
- [ ] Unplug the network and present again: it still works
### The awkward ones
- [ ] A PDF and a PPTX of the same session look the same on screen
- [ ] A deliberately broken file gives a short message and only
**Remove** — no editor, no terminal
- [ ] A long session (40+ slides) opens without the library freezing
- [ ] Download a Wonder Ink worksheet: it does **not** appear in the
library
### Remove
Use a **test copy**. Not tomorrow's session.
- [ ] **Remove** asks first
- [ ] The card goes, and its slides and pictures go with it
- [ ] Its managed original goes too; unrelated downloads and another similarly
named presentation remain
- [ ] It does not come back on the next scan or after a reboot
### Help is temporarily hidden
- [ ] No **Help** launcher is visible on the teacher-facing bar
- [ ] The manual sources, generated HTML/PDF, launcher and feature-aware build
pipeline remain installed for the later documentation correction pass
- [ ] Presentations and Wonder Ink title bars show only Close, with no
double-chevron keep-above control
### Wi-Fi — look, do not touch
The laptop's Wi-Fi is already working through NetworkManager and its
ifupdown stanza is already gone. **Do not run `--adopt` on it.**
- [ ] `classroom-wifi --check` says there is no wireless interface under
ifupdown's control
- [ ] `classroom-wifi --adopt` says "nothing to adopt" and changes nothing
- [ ] `nmcli -g 802-11-wireless.hidden connection show "ROGC Classroom"`
still reports `yes`
- [ ] The laptop still reconnects after a reboot
## Wonder Ink — 0.2.6.1 to 0.2.6.4, before any goes to stable
Everything below was done in the lab except what a lab cannot do: paper,
a classroom's own graphics, a real keyboard, its network and its screen.
Those are these checks. Stable stays on 0.2.2 until they pass.
Before starting, on each machine: `dpkg-query -W ekklesia-classroom`
must say `0.2.6.4`, which carries everything 0.2.6.1 to 0.2.6.3 did. The desktops only get it once stable is moved, so on
a desktop install it by hand for the test:
```
curl -fsSL -o c.deb https://updates.riverofglorymn.org/canary/latest.deb
sha256sum c.deb # must match .../canary/manifest.json
sudo apt-get install -y ./c.deb && sudo reboot
```
This does **not** move the machine to canary (see provisioning.md): its
next automatic update follows stable again, which is still 0.2.2 — so
after testing, either promote or reinstall 0.2.2 the same way.
### Little Waders — printing through Elmer
- [ ] Open Wonder Ink, sign in, open a lesson with a worksheet
- [ ] Click the worksheet; it opens as a tab named after the file, with a
document icon
- [ ] Ctrl+P (or right-click → Print page…): the preview shows the worksheet's
own pages, page count right, ‹ › turn them
- [ ] Printer shows **Elmer**, preselected
- [ ] Print → a notification "Sent to the printer — … — Elmer", Wonder Ink
stays open on the lesson
- [ ] **The worksheet comes out of Elmer**, every page, right size, in colour
- [ ] Pages `2`, Copies `2`, Black and white → the preview turns grey,
and exactly two grey copies of page 2 come out
- [ ] Print a normal lesson page the same way; it comes out, Letter
- [ ] Open the preview and press Cancel → nothing prints
- [ ] Print three times in a row → three printouts, no crash
- [ ] `lpstat -W completed -o` shows each job; `journalctl --user -b | grep
"printed '"` shows Wonder Ink's line for each
### Pictures (0.2.6.2)
- [ ] Open a colouring page that is a picture on its own (a PNG or JPEG
tab, not a PDF) and press Print: the preview shows it on white — **no
black bars** — with a **Picture** section under Color
- [ ] Size 50%, the top-left square: the preview shows it there, the
rest of the page blank. Print it: **the paper matches the preview**
- [ ] Paper *Sideways*, then *Upright*: the preview turns; print one
- [ ] *Fill the whole page*: the preview shows the edges cut off; Cancel
prints nothing
- [ ] A worksheet PDF still prints with no Picture section
- [ ] The print dialog says **Color**, not Colour
### Black and white, and Print image (0.2.6.3)
The lab measured all of this on the files CUPS kept, and under the real
Plasma session for the clipboard. What it cannot show is Wonder Ink's own
site (no account), real paper, and a classroom's own keyboard and mouse.
- [ ] A lesson page with colour on it: Ctrl+P, then **Black and white** →
the preview says *Preparing black and white…* for a moment, then the
page is **grey**. **Color** → colour again. Back and forth three
times: it is instant after the first, and stays on the same page
- [ ] Print it in black and white: **the paper matches the grey preview**
- [ ] The same with a worksheet PDF (a few pages), in black and white
- [ ] Choose Black and white, then Color, then Print → it comes out **in
colour**
- [ ] In a lesson, **right-click on a picture** (not the words): the menu
has **Print image…** and **Print page…**. Right-click on the words:
only **Print page…**
- [ ] **Print image…** on a picture you cannot open in a tab by itself:
the preview shows *that picture* on white, with the **Picture**
section — not the lesson page
- [ ] Size **50%**, the **top-left** square, Black and white → the preview
shows a grey picture in the top-left quarter. Print it: **the paper
matches the preview**
- [ ] Print image on a picture with a transparent background (a clipart
animal, a logo) → white around it on paper, not black
- [ ] Print image, then **Cancel** → nothing prints
- [ ] Copy some words in the browser, then use Print image in Wonder
Ink, then paste in the browser → the words paste, not the picture
- [ ] If any picture says *could not be taken from the page*: note which
lesson and which picture. Right-click the same picture again once
the page has finished loading. If it still says so, that picture is
drawn in a way the engine does not treat as a picture — tell us
where it is
- [ ] If right-clicking an obvious picture offers **only** Print page, the
site draws it as a background. Note which lesson — it is the one
case Print image cannot reach
### Size and zoom
- [ ] A lesson in Wonder Ink is the same size as the same page in the
browser on that machine (it was smaller in 0.2.2)
- [ ] **Ctrl and +** (the real keyboard), **Ctrl and −**, **Ctrl and 0**
change it; a "Lesson size" badge appears
- [ ] Open a second tab: it is the same size; the − 100% + and Print
buttons are at the top right
- [ ] Close Wonder Ink and open it again: the size is remembered
- [ ] A worksheet tab's own − / + (in the PDF toolbar) zoom it
- [ ] A lesson video still goes fullscreen and comes back with the tabs
### Loading, and the machine's graphics
From the maintenance terminal (Meta+Ctrl+Alt+Shift+K), in the session:
```
classroom-wonderink --diagnose
```
- [ ] Note the **GPU line**. A driver's name is right. "none … drawing in
software" means QtWebEngine has no GPU on this machine — compare
`chrome://gpu` in the browser; if Chrome has one, that is the cause of
slow lessons and is the next thing to fix
- [ ] Note the sign-in time (the lab: about 2.9 s wired). Much slower here
with a driver named above points at the network
- [ ] `free -m`: Wonder Ink needs about 610 MB plus 115 MB per extra tab
### Temporary workspace restore (0.2.6.4) — Little Waders
Use a **Viewer** account. An Admin sees lessons in WonderInk's editor,
where a click on a link shows its Edit/Unlink bubble — WonderInk's design,
not Wonder Ink's; Ctrl+click opens it. See wonder-ink.md, *Links in a
lesson*.
1. [ ] Sign in to the real WonderInk site
2. [ ] Open three lesson/resource tabs: the lesson, and two of its links —
for example *Saul Welcome Question Slide* and a video or worksheet
3. [ ] Click the **middle** tab
4. [ ] Close Wonder Ink (the × at the top right)
5. [ ] Open it again straight away from the panel
6. [ ] The WonderInk tab asks you to **sign in** — sign in normally (your
password is never kept, so it always will)
7. [ ] The **recent tabs are back**: the slide and the other resource
8. [ ] **In the same order** as before
9. [ ] With the **middle tab in front**
10. [ ] Open a worksheet from a lesson, then close and reopen Wonder Ink:
the worksheet tab comes back, named after the file
11. [ ] Print from a restored tab (the slide or the worksheet) through
Elmer: **the paper matches the preview**
12. [ ] Links still open with one click in a new tab, as before (Viewer)
13. [ ] With the tabs open, **reboot** the computer; within two hours of
using Wonder Ink, open it again
14. [ ] **The recent tabs are back** (after signing in on the WonderInk tab)
15. [ ] **Expiry, without waiting two hours.** Close Wonder Ink. Open the
maintenance terminal (Meta+Ctrl+Alt+Shift+K) and run
`CLASSROOM_WONDERINK_TEST_WORKSPACE_SECONDS=120 classroom-wonderink`.
This is **test-only**: it lasts as long as that one Wonder Ink,
can only shorten the time, and nothing in the classroom sets it
16. [ ] Open a couple of tabs in it, click in one, then touch nothing for
**three minutes** and close it
17. [ ] Open Wonder Ink from the panel: it **starts fresh**, one tab on the
WonderInk start page, none of the old tabs
### Presentations under memory pressure (0.2.6.2, the laptop)
- [ ] Download a large, photo-heavy deck with Wonder Ink and Chrome open:
it either becomes ready, or its card says *This needed more memory …
choose Try again* — never *Getting this ready…* for more than a few
minutes
- [ ] Close the other programs and choose **Try again**: it becomes ready
- [ ] `systemctl --user status ekklesia-classroom-presentations` is
active, and `journalctl --user -u ekklesia-classroom-presentations`
shows no restart loop
### Settings the teacher must not reach (0.2.6.2)
- [ ] When any notification shows the sliders (Configure) button, clicking
it opens only *The module … is disabled*; its Help button does
nothing
- [ ] Right-click the clock: no *Configure Digital Clock…*; *Adjust Date
and Time…* opens an empty System Settings
- [ ] Right-click the volume: no *Configure Audio Devices…*
- [ ] The maintenance chord (Meta+Ctrl+Alt+Shift+K) still opens a
terminal, and `systemsettings` started from it is complete
### After a reboot, and the other rooms
- [ ] Reboot Little Waders: it comes up as the classroom, Wonder Ink opens
- [ ] On one other desktop, 0.2.6.1 installed: the browser, the DVD player
where there is a drive, Parent Call where enabled, sound through the
television, the panel order — all as on 0.2.2
- [ ] **Presentations is not on the panel** of any desktop
(`grep PRESENTATIONS_ENABLED /etc/ekklesia-classroom/device.conf`
says `false`)
If anything here fails, leave the desktops on stable 0.2.2 and fix it in
the next maintenance release on canary.
+72 -4
View File
@@ -47,6 +47,69 @@ package installs it at `/usr/share/backgrounds/ekklesia/classroom-calm.png`, the
path used by the packaged Plasma layout. The layout is rebuilt at login, so
upgraded classrooms receive the package wallpaper without per-room copying.
### A desktop the layout never saw (0.2.6.6)
Tiny Pebbles came through the 0.2.2 → 0.2.6.4 upgrade showing Plasma's own
purple wallpaper. Its desktop was containment 10 with no `Wallpaper` group;
River Kids' is containment 1 with one. The package and the picture were the
same on both.
The layout sets the wallpaper on the desktops that exist *while it runs*.
Plasma makes a desktop for every screen or activity that appears later, and
it makes those without the layout. 0.2.2's updater rebooted as soon as it
had installed, at 03:00–03:30. A television switched off at the set is
usually no screen at all, so the layout would have run with no desktop, and
the desktop Plasma made when the television came on was bare. A panel built
with no screen takes number 1 and its eight applets 2–9, which is exactly
how a desktop ends up as 10. **The television is inferred, not observed**.
See the hardware check in [testing.md](testing.md#the-television-switched-on-after-a-night-time-reboot).
Measured in the lab (Plasma 6.3.6, Debian 13):
| What was tried | What happened |
| --- | --- |
| Activity manager delayed 20 s at login | Plasma waited for it; desktop 1, wallpaper right. Not the cause |
| A new activity created mid-session | Plasma made desktop 11 with no `Wallpaper` group, the same shape as Tiny Pebbles' 10. The screen showed Plasma's *Next*; the panel was untouched |
| Look-and-feel default `Image=` set to a **file path** (0.2.2–0.2.6.5) | Ignored. `DefaultWallpaper` in libkworkspace6 looks the value up as a wallpaper **package** under `wallpapers/<name>`, the way Breeze says `Image=Next` |
| The same default set to a **package name**, whose one image links to the file | Desktop 11, still with no `Image`, showed the classroom's picture after a shell restart |
| Setting it live over D-Bus (`evaluateScript`, `plasma-apply-wallpaperimage`) | Refused: "Widgets are locked". The classroom's lockdown forbids the scripting console, and it stays forbidden |
| Disabling the only screen to reproduce a screenless start | KWin refuses ("Disabling all outputs … is not allowed"), and the nested lab cannot hot-plug a screen |
So there are two parts, and they do different jobs:
* **The default is now a package**, `/usr/share/wallpapers/EkklesiaClassroom`.
Its image is a link to the file above, and the look and feel names it.
Any desktop Plasma makes without the layout shows the classroom's picture
with nothing having to run. This is the fix for Tiny Pebbles.
* **`classroom-desktop-wallpaper`** writes `wallpaperplugin=org.kde.image` and
the classroom `Image` into every desktop containment already in the file,
so the configuration says what the screen shows. It finds desktops by
plugin (`org.kde.plasma.folder`, `org.kde.desktopcontainment`), never by
number. It changes nothing else, and it does not write a file that is
already right. It runs as plasmashell's `ExecStartPre`, the one moment
the shell does not own the file: every login, every reboot, every restart
after a crash. At a login it usually finds nothing, because
`classroom-desktop-reset` has just removed the file for the layout to
rebuild.
To apply it in a running session without logging out, from the maintenance
terminal:
```
classroom-desktop-wallpaper --check # read-only
systemctl --user restart plasma-plasmashell.service # the panel blinks, windows stay
```
Run without `--check` while the shell is up, it refuses and prints that
second line rather than edit the file underneath the shell.
Tests: `tests/test-classroom-desktop-wallpaper.sh` (in the build) covers
Tiny Pebbles' own file, a healthy River Kids file, differing numbers,
idempotence and the guard. `tools/lab/wallpaper-check` covers real Plasma,
reading the colour off the screen, and runs inside `tools/lab/dirty-upgrade`.
`tools/validate-trixie-release.sh` covers the installed package when
upgrading from a previous release.
## What the one number reaches
`classroom-ui-scale` prints it, clamped, and everything that cannot read
@@ -55,10 +118,15 @@ the display scale for itself asks that command rather than the file:
* **Plasma** — the panel, the clock, notifications, the volume display and
every dialog, because `classroom-scale` sets it as the display's scale
through `kscreen-doctor` at every login.
* **Chrome and Wonder Ink** — `--force-device-scale-factor`, because
Chrome on Wayland was measured not to follow the compositor's output
scale on its own. It is the same number, not a second scaling: Chrome
replaces its detected scale with it.
* **Chrome** — `--force-device-scale-factor`, because Chrome on the sway
session was measured not to follow the compositor's output scale on its
own. **Under Plasma it does**, so this is now a second scaling: measured
at 150%, Chrome's pages report `devicePixelRatio` 2.25, not 1.5. Left as
it is in 0.2.6.1 because it is the size teachers know; see
[wonder-ink.md](wonder-ink.md#how-large-a-lesson-is).
* **Wonder Ink** — its window follows the compositor as every Qt
application does, and its *pages* start at a zoom of `UI_SCALE`, so a
lesson is the size the browser shows it.
* **The disc player's own surfaces** — they are measured in the logical
pixels the compositor is already scaling, so they follow without being
told.
+552 -17
View File
@@ -71,7 +71,51 @@ wrong a teacher is standing in front of a class.
The right-click menu is built rather than filtered, so a future Qt cannot
grow view-source or save-page back into it without somebody noticing in
the code: back, forward, reload, open in a new tab when there is a link,
cut/copy/paste in a text field, print.
cut/copy/paste in a text field, print image when there is a picture under
the pointer, print page.
### Links in a lesson (0.2.6.4)
A link in a lesson opens with one click in a new tab, and Wonder Ink shows
that tab; the lesson stays in its own. That is what the site asks for —
every link in a lesson's content carries `target="_blank"` — and what the
engine does with it (`createWindow`, answered with a tab that takes the
focus). Measured on the real site, on the real *Rise and Fall of Saul*
lesson: "Saul Welcome Question Slide" is
`<a target="_blank" href="…/SaulWelcomeQuestionSlide.png">`, and one click
opened the slide in a new tab, at 100%, at 200% and in Full Screen.
**The URL bubble teachers saw is WonderInk's editor, shown to admins.**
Testing 0.2.6.3 found that clicking such a link showed a small bubble with
the address and Edit / Remove instead of following it. Found by reading
the site's code and confirmed with the account roles:
* the site shows lesson text in **CKEditor 5** (35.2.1, its
`RTE_USE_CKEDITOR_PRO` component). For a **Viewer** the content is
read-only and a link is a link; for an **Admin** it is editable, in
place;
* in an editable CKEditor 5, a plain click on a link is an editing click
by design (`LinkEditing._enableLinkOpen` opens a link only with Ctrl, ⌘
on a Mac); the editor answers it with its link balloon — the address,
*Edit link*, *Unlink*. It is the same code in Chrome;
* moving the teacher's account to Viewer made the bubble go away.
So Wonder Ink does not override it. Doing so would mean catching clicks
inside WonderInk's editor, which is how admins edit lessons, to fix
something the account's role already decides. An admin who wants to
follow a link straight away: **Ctrl+click** it.
What each kind of link does, as `tools/lab/wonderink-print-check links:*`
clicks them on a signed-in lesson page:
| | |
| --- | --- |
| a text link, a linked picture, another site's link (`target="_blank"`) | a new tab, shown |
| a worksheet PDF | a new tab in the PDF viewer; Print fetches it and prints it as a worksheet |
| a picture | a new tab; Print lays it out as a picture, and right-click offers Print image |
| a lesson button that opens a window | a new tab, shown |
| a lesson button, an in-page anchor (`#…`) | what the page does; no tab |
| a link with no target | followed in the same tab, as in a browser; Back returns |
Keys: Ctrl+W closes a tab, Alt+Left and Alt+Right navigate, Ctrl+P prints,
Ctrl+Tab switches. Ctrl+T, Ctrl+N and Ctrl+L are deliberately unbound.
@@ -140,9 +184,8 @@ Alongside it, two Qt defaults are turned off rather than left implicit:
`LocalContentCanAccessFileUrls` (on by default, and would let a local
document read other local documents) and `LocalContentCanAccessRemoteUrls`.
The print dialog's **Print to File** option is also removed. Choosing it
enables an output path with a "..." button, which is a file chooser, in a
room whose whole design is that there is no file chooser.
Printing has no **Print to File** and no file chooser of any kind: the
preview (below) offers the room's CUPS printers and nothing else.
### What a teacher still cannot reach
@@ -182,30 +225,518 @@ is Chrome's business and stays that way.
Downloads folder. That is partly tidiness and mostly the security
argument: it is the only place a worksheet can come from.
## Printing
### The crash (0.2.2, Little Waders)
Wonder Ink closed completely every time a teacher confirmed a print job:
`SIGSEGV in QPrinter::resolution()`, twice, both through QtWebEngine's
print-completion path.
`QWebEngineView.print(printer)` is asynchronous. In Qt 6.8.2
(`qwebengineview.cpp`) it stores the raw `QPrinter*` and returns; when
Chromium has rendered the page, `didPrintPage()` calls
`currentPrinter->resolution()` and hands the printer to a worker thread.
0.2.2 made the printer a local variable in `print_view()`, so PyQt deleted
it when the function returned and Qt read freed memory. Reproduced in the
lab with the 0.2.2 code: `QPrinter::resolution()` ←
`QWebEnginePagePrivate::didPrintPage` ← `WebContentsAdapter::didPrintPage`,
the Little Waders backtrace, and no job in CUPS.
Keeping the printer alive until `printFinished` stops the crash — that was
built and measured first — and it is still not a working print path:
| Measured, QtWebEngine 6.8.2, lab queue built from Elmer's PPD | Qt `view.print()` | `printToPdf()` + `lp` |
| --- | --- | --- |
| A three-page lesson | 24.6 MB of 300 dpi pictures, no text, A4 on a test queue | 18 KB vector PDF with its text, on Letter, 0.43 s through Elmer's filters |
| A worksheet PDF tab | **one blank page** | the worksheet file itself |
| A PDF a site shows inline | — | `printToPdf` never answers (`CreatePreviewDocument got 0 page count`) — so it is downloaded and printed as a file |
| A queue refusing jobs | reported as **printed** | "Refusing did not take the job: Destination … is not accepting jobs." |
| Black and white on Elmer | — | IPP `print-color-mode` is **ignored** by the generic PCL 6 driver ("Unknown option", prints colour); `ColorModel=Grayscale` gives `pxlmono` |
Qt's path also mishandles three things its own source makes plain: a
second `print()` on a page that is still printing is refused with no
`printFinished`, the worker keeps the printer after the tab has gone, and
quitting destroys the printer under that worker.
So 0.2.6.1 does not use Qt printing at all. **Print** makes a PDF — a
worksheet already is one; a lesson page is rendered by the engine onto the
printer's own paper with Chrome-like 10 mm margins — shows it, and hands it
to CUPS with `lp`, which is how Chrome prints in the same rooms. Nothing
asynchronous ever holds a printer, so closing a tab or Wonder Ink cannot
break a job: once `lp` returns, the job is CUPS's.
### The preview
One dialog, for lessons and worksheets alike:
* the document as it will print, a whole page at a time, ‹ › to turn;
* **Printer** — the CUPS destinations, the system default preselected
(Elmer, in every room);
* **Pages** — all, or some: `2`, `1-3`, `1, 3, 5-6`, checked against the
document with the reason given if wrong;
* **Copies** — 1 to 50;
* **Color** — colour or black and white, only where the printer's driver
has a grey mode, and asked for in that driver's own terms. Since 0.2.6.3
the preview turns grey too (below);
* **Cancel** sends nothing; **Print** returns to the lesson once CUPS has
the job, and a notification says where it went. A refused job keeps the
preview open with CUPS's reason.
Choosing a printer with different paper lays a lesson page out again. A
page that closes its own tab while the preview is open is reported, not
printed. Print pressed again while a preview is open does nothing, and so
does a page calling `window.print()` meanwhile. The PDF viewer's own print
button opens the same preview.
### Pictures (0.2.6.2)
A picture a lesson opens in a tab of its own — a colouring page as a PNG
or JPEG — printed in 0.2.6.1 with **black bars** above and below it.
Measured: the bars were neither in the picture nor in the page layout.
The engine shows a lone picture on a viewer page whose background is
rgb(14, 14, 14), scaled to the window, and Qt prints page backgrounds by
default; the dark page went onto the paper around the artwork.
So a picture is no longer printed as that page. The preview takes the
picture itself — drawn onto white, which is what transparent parts are on
paper, at up to 3300 pixels (300 dpi across Letter) — and lays it out on
the printer's own paper, and the teacher chooses:
* **Size** — as large as fits, 75%, 50%, 33% or 25% of that;
* **Position** — nine squares, like the page itself;
* **Paper** — automatic (landscape for a wide picture), upright or
sideways;
* **Fill the whole page** — covers the printable area and says it cuts the
edges off. Nothing is cropped unless this is ticked.
The preview is the PDF that will print, laid out again after every change.
`tools/lab/wonderink-print-check` measures it on the file CUPS actually
kept (`PreserveJobFiles`), which is byte-for-byte the preview's: the
picture within 1 point of where it was asked for, the paper turned as
asked, and no ink anywhere else on the page. Worksheet PDFs and lesson
pages have no picture controls; nothing about them changed.
Page backgrounds are also no longer printed for lesson pages, as in
Chrome, where "Background graphics" is off unless asked for. A white-on-
black page prints dark grey on white: Blink darkens light text when the
background is left off, measured.
Temporary PDFs live in the teacher's `$XDG_RUNTIME_DIR` and are removed
when the preview closes.
### Black and white is shown as it prints (0.2.6.3)
Printing through Elmer found that **Black and white** came out grey on
paper while the preview stayed in colour. 0.2.6.2 only told the driver:
the preview was the colour PDF, CUPS was given the colour PDF, and
`ColorModel=Grayscale` turned it grey inside Elmer's filters.
So black and white is now made before the preview, not after it. When it
is chosen, the preview makes a grey copy of the document with
Ghostscript, shows that copy, and Print hands CUPS that same file — still
with `ColorModel=Grayscale`, so Elmer's generic PCL 6 driver prints it
with `pxlmono` as before rather than as a colour job that happens to be
grey. Choosing **Color** shows and prints the colour document again.
Measured before building it (Ghostscript 10.05.1, in the lab):
| | |
| --- | --- |
| a lesson page, a picture page, a leader guide, a fillable form | grey and only grey; same pages, page sizes and words; text still text; a filled-in form field and a drawn mark kept, in grey |
| against what Elmer would have printed | Ghostscript's grey rendering of the colour original and of the grey copy give the **same greys**: within 1 level in 255 on every filled area and picture. Small text can land a fraction of a pixel over — on a real lesson page, 12 of 32,754 8×8 blocks, all on one line of body text, in pairs of opposite sign. Elmer's driver *is* Ghostscript. (poppler's own grey differs on 7% of a coloured page, which is why the copy is not made with poppler) |
| how long | 0.05 s for a worksheet, 0.2 s for a text lesson page, 1.3 s for a full-page photograph, 2 s for 42 pages — and **4.4 s** for one lesson page with eleven full-size pictures, under the real Plasma session. `-dUseFastColor` saves 40% and changes the greys, so it is not used |
| a PDF Ghostscript cannot read | exits 1 and **still leaves a file behind** |
From those:
* the copy is made in the background, the preview says *Preparing black
and white…* and Print waits for it — four seconds is long enough to
look like a frozen window. It has a minute before it is given up on,
not the 20 s a page render has: a lesson of photographs on a slower
laptop is slow, not stuck. Cancel stops it at once;
* it is made once per document and kept, and each drawn page is kept, so
turning Color and Black and white back and forth is instant after the
first time, and stays on the page the teacher was looking at;
* a conversion is judged by its exit status and its page count, never by
whether a file appeared. If it fails the preview says so and offers
Color. It **never prints the colour file in its place**: the teacher
asked for black and white;
* pages are never turned (pdfwrite otherwise rotates a page to match its
text), and annotations are drawn into the page as they print, so no
later filter can draw them again in colour;
* a printer whose driver has no grey mode is not offered black and white,
and choosing one returns the preview to colour.
`tools/lab/wonderink-print-check` shows a colourful lesson in colour,
turns it to black and white and back five times on page 2, and prints
page 2 twice: every grey preview is grey pixel for pixel, every colour
one is not, the grey copy is made once, and the job CUPS kept is
**byte-for-byte the file the preview showed**, grey on every page, sent
with `ColorModel=Grayscale` through `pxlmono`. The same for a worksheet,
a 50% top-left picture, a return to Color (which prints in colour through
`pxlcolor`), and Cancel.
### Print image: a picture from inside a lesson (0.2.6.3)
Right-clicking a picture in a lesson and choosing Print printed the whole
page around it. Some of Wonder Ink's pictures cannot be opened on their
own through the site at all, so a teacher had no way to give that
picture the picture layout 0.2.6.2 introduced.
The right-click menu now says what it will print. It asks the engine's
own hit test what is under the pointer:
| Under the pointer | Measured `mediaType` | The menu offers |
| --- | --- | --- |
| a picture (`<img>`, also inside a link, a frame, from `blob:` or `data:`) | Image | **Print image…** and **Print page…** |
| a `<canvas>` | Canvas | **Print image…** and **Print page…** |
| words, empty space, a CSS background picture | None | **Print page…** |
Print page is always there. Ctrl+P and the Print button print the page,
as before.
**How the picture is taken** was measured before anything was built, on
a lab site with each kind of picture a lesson might have, a real
right-click on each, and the site logging every request:
| | signed-in picture | hotlink-protected | one-time address | `blob:` / `data:` | another site's | canvas | WebP |
| --- | --- | --- | --- | --- | --- | --- | --- |
| engine's "download image" | ✓ | ✓ | **404** — fetched again | ✓ | ✓ | **nothing** | a file Qt cannot read |
| `page.download(url)` | ✓ | **403** — no Referer | **404** | – | ✓ | – | the same |
| `fetch()` from the page | ✓ | ✓ | ✓ | – | **refused** (CORS) | – | the same |
| draw it on a canvas | ✓ | ✓ | ✓ | ✓ | **refused** (tainted) | – | ✓ |
| **engine's "copy image"** | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Only the engine's own **copy image** hands over the picture in every
case, and it is what Wonder Ink uses. It is the bitmap the page has
already decoded — full size, transparency kept, any format Chromium
reads (Qt itself cannot read WebP here) — and **it fetches nothing**: the
site saw no request at all. So a picture behind the teacher's sign-in
needs no sign-in again, and a picture the site hands out once is still
there. 0.01 to 0.05 s in the lab.
It goes through the clipboard, which is the teacher's. So what is on it
is kept first and put back as soon as the picture arrives — whenever it
arrives, even after Cancel. Measured under the real Plasma session
(KWin, Wayland) with another program owning the clipboard: saved in
0.002 s, the picture in 0.016 s, and that other program then pasted its
own text again.
The picture then goes into exactly the 0.2.6.2 picture preview — drawn on
white, capped at 300 dpi across Letter, Size, Position, Paper, Fill the
whole page, Color — named *Picture from* the lesson's title.
**When it cannot be taken.** A picture that never loaded is still a
picture to the hit test, and the engine then copies nothing. After three
seconds the preview says *This picture could not be taken from the page*
and that nothing was printed, with Print off. It does **not** fall back
to printing the page; Print page is one right-click away and says what
it does.
What it cannot do, and says nothing about because it cannot know:
* **a CSS background picture** is not a picture to the engine's hit
test — Chrome's own menu has no "Save image" for one either. The menu
offers only Print page. If Wonder Ink's site draws its artwork this
way, that is the next thing to look at; nothing in the lab says it does;
* **a video** frame is not offered;
* **the teacher's clipboard is put back into Wonder Ink.** Under Wayland
with no clipboard manager — this session has none — the clipboard
belongs to whichever program last set it. After a Print image that is
Wonder Ink, so text copied in another program before it can still be
pasted while Wonder Ink is open, and is gone once Wonder Ink closes.
Measured in the lab session. Leaving the picture there instead would
lose it at once.
`tools/lab/wonderink-print-check` runs a signed-in lesson site
(`tools/lab/wonderink-lesson-site.py`: session cookie, one-time
addresses, a second origin) and right-clicks for real: the words (Print
page only, and the page prints), a JPEG, a PNG, a transparent PNG, a
one-time picture, another site's, a `blob:`, a WebP, a canvas, a picture
inside a link, a JPEG at 50% top left in black and white, a picture that
never loaded, and Cancel. Each is judged on the job CUPS kept: where and
how large, no ink elsewhere, the corner mark there — and **no request to
the site** while it printed, and the teacher's clipboard theirs again.
`tools/lab/wonderink-print-check` runs all of this against the installed
command in the lab, and judges every scenario by what CUPS actually
received. `tests/test-classroom-printing.sh` covers the CUPS side at every
build. **Neither is paper.** The first print through the real Elmer is a
job for somebody standing next to it.
### Any other error
An exception escaping any Qt handler makes PyQt6 abort the whole process —
measured: a mistake in one slot and the window vanishes. Wonder Ink now
logs it to the journal and carries on.
## What stayed the same
* **A separate profile from the ordinary browser.** A teacher's Wonder Ink
sign-in and the room's shared YouTube account are different sessions in
different storage that cannot reach each other. The browser is still
Chrome and nothing in this change touches it.
* **Cookies live only as long as the window.** This is what the Chrome
policy's `CookiesSessionOnlyForUrls` did for wonderink.org, and it is
the reason closing Wonder Ink ends the teacher's session rather than
leaving the next person signed in as the last one. The whole profile
rather than one domain now, because this profile only ever goes where a
lesson sends it. If a room ever wants sign-ins to survive, it is the one
line `setPersistentCookiesPolicy` in `classroom-wonderink`.
* **Cookies live only as long as the window**, as Chrome's
`CookiesSessionOnlyForUrls` did for wonderink.org, so closing Wonder Ink
signs the teacher out and the next person is not signed in as the last
one. WonderInk's sign-in is the teacher's password in a cookie
(measured, 0.2.6.4), which is one more reason it never reaches the
disk. What does survive a close, for two hours, is the list of tabs —
see *Temporary workspace restore*, below.
* **The old Chrome profile is left where it is**, at
`~/.local/share/ekklesia-classroom/wonderink`. The new one is
`wonderink-shell` beside it, so rolling back to 0.2.0 finds the old one
intact.
* **One scale for the room.** Chrome has to be handed `UI_SCALE` because
it was measured not to follow the compositor on Wayland. Qt does follow
it — measured at `UI_SCALE=2.0`, where a Qt client on that session
reports `devicePixelRatio 2.0` without being told anything — so Wonder
Ink is deliberately *not* told, or the scale would be applied twice.
* **One scale for the room** — see *How large a lesson is*, below.
## Temporary workspace restore (0.2.6.4)
A teacher who closes Wonder Ink by accident, or whose computer crashes or
restarts, gets their recent tabs back if they open it again within **two
hours of last using it**. After that, Wonder Ink starts fresh and the tabs
are forgotten: the next teacher — overnight, next week — never meets the
last one's. It is not a sign-in and not a memory of tabs.
10:00 using Wonder Ink
10:45 closed by accident 10:50 opened again: the tabs are back
11:00 finished, closed next Sunday: a fresh Wonder Ink
### Why it is tabs and not the sign-in
The first design kept the sign-in. It was measured on the real site
before it was built, and stopped:
| | |
| --- | --- |
| `rrf-auth` | a cookie the site's JavaScript writes: **`Basic ` and the base64 of the teacher's email and password**, sent as a header on every request. Not a token the server issued |
| `rrf-session-id`, `rrf_tsec_tok`, `auth` | session cookies alongside it; none survives the window, which is why closing Wonder Ink signs the teacher out |
| Local Storage `mddui_local_storage`, Session Storage | screen variables and a screen cache |
| the site's start-up | asks the server to authenticate again every time it loads; a reload in the lab ended signed out even with the cookie present |
Keeping the sign-in would have meant keeping the teacher's password, in a
form anyone at the maintenance terminal could reverse, on a shared
computer. It is not done. **Closing Wonder Ink still signs the teacher
out**, exactly as before; the engine writes no cookie to disk for any site.
### What comes back
* **Resource tabs** — a slide, a picture, a worksheet or video the lesson
linked to — exactly as they were. WonderInk's files are public (the
lesson's slide loads with no sign-in, measured), so they need none.
* **WonderInk's own tabs as one.** Every WonderInk page has the same
address (`https://app.wonderink.org/?`) whichever lesson it shows — the
site draws each screen itself — so a restored WonderInk tab opens the
site's start page and asks the teacher to sign in, as it would anyway.
Several of them become one, where the first was, so a teacher meets one
sign-in page, not five.
* **Worksheet tabs**, named and where they were — if the file is still in
`~/Downloads/Wonder Ink` and still a PDF. A worksheet that has gone is
left out, silently. Nothing outside that folder is ever opened from a
kept workspace, whatever the file says.
* **The order, and the tab that was in front.** The tab in front loads at
once; the others load the first time they are clicked. Measured: a PDF
loaded in a tab nobody was looking at was fitted to a window of no size,
and showed at 10% when clicked. Until then a tab is kept by the address
it will load, so closing Wonder Ink again loses nothing.
Measured on the real site, with a real Viewer account, under the lab's
Plasma session: the *Rise and Fall of Saul* lesson, its welcome slide and
its WONDER@HOME PDF, the slide in front; closed and reopened — the three
tabs in that order, the slide in front, the WonderInk tab at its sign-in
page; and again after rebooting the machine with Wonder Ink open. The kept
file held the three addresses and titles, and none of the password, the
Basic-auth value or the email address.
Never: the sign-in, a cookie, a header, a page's storage, a form, the
clipboard, a print preview or any of its temporary files.
### What is kept, and when
`~/.local/share/ekklesia-classroom/wonderink-workspace.json`, readable only
by the classroom account:
format, last_activity, active, attempts,
tabs: [{kind: "web" | "worksheet", where: address or file, title}]
An address is kept only if it is `http` or `https` and carries no
credential — no user and password in it, no query parameter named like a
token, key, password, session or signature. `tests/` writes a workspace
with such addresses and a Basic-auth value and checks the file holds none
of them; the lab signs in on a stand-in whose sign-in is Basic auth, as
WonderInk's is, and checks the same.
It is written a second after the tabs change — opened, closed, dragged
into another order, brought to the front, navigated — and the time of
last use at most every 30 seconds, whole, to a temporary file renamed
into place. A crash or a power cut loses at most half a minute of use and
never leaves half a file.
### What counts as using it
The teacher's input reaching Wonder Ink: a click, a key, the wheel, a
touch — so following a link, scrolling, switching, opening and closing
tabs, opening a worksheet, printing and zooming all count. Nothing a page
does by itself does: timers, animation, background requests, analytics,
advertising, video buffering, service workers — measured with a page
making a request every second and animating for 20 s: not activity. A
print preview left open untouched is not activity either.
**Opening Wonder Ink and having the tabs restored is not using it.** The
time kept is the teacher's last use, carried over, so closing and
reopening cannot keep a workspace alive by itself; the teacher clicking
in the restored tabs is using it, and the two hours start again.
### When it is not restored
At start, and only then: more than two hours since the last use, a file
that cannot be read or is from another version, a clock gone backwards by
more than a minute — Wonder Ink starts fresh, removes the file and says
why in the journal. No question is asked.
A restore that crashes Wonder Ink does not make it unusable. Starting from
a workspace is counted in the file before any tab loads, and cleared once
Wonder Ink has run 20 seconds. After two starts that did not get that
far, the workspace is abandoned and Wonder Ink starts fresh, with a line
in the journal.
Two hours is `IDLE_LIMIT_SECONDS` in
`lib/classroom_wonderink_workspace.py`, the one place it is decided.
**Testing without waiting two hours.** From the maintenance terminal in
the session (Meta+Ctrl+Alt+Shift+K), with Wonder Ink closed:
CLASSROOM_WONDERINK_TEST_WORKSPACE_SECONDS=120 classroom-wonderink
That one Wonder Ink keeps the workspace for two minutes after the last
use, and says `TEST ONLY` in the journal. The variable can only shorten
the time, nothing in the classroom sets it (a test checks), and it lasts
as long as that process.
### What it cannot do
* **Reopen a lesson where it was.** WonderInk's screens have no address of
their own, so its tab comes back at its start page; after signing in
the teacher opens the lesson again. The slides, pictures and worksheets
they had open alongside it are already there.
* **Know who is at the keyboard.** Two hours is two hours of nobody using
Wonder Ink. A teacher who leaves and whose room is used within two hours
leaves their tabs — not their sign-in — to the next person.
`tools/lab/wonderink-workspace-check` runs each of these against the
installed command: restored after 5 and 90 minutes; fresh after two hours
and a minute, the next day and the next week; reopened twice untouched;
activity after a restore; a busy page; an untouched print preview;
pressing Print; a closed tab; tabs reordered; a worksheet, and one whose
file has gone; killed outright; a restore that crashes twice; a corrupt
file, a later format, a half-written file; the privacy of the file; and
restored tabs printing. `tests/test-classroom-wonderink-workspace.sh`
covers the rules at every build.
## How large a lesson is
Teachers found lessons in Wonder Ink too small next to the same site in
the ordinary browser. Measured in the lab (Plasma 6 on KWin, Debian 13) by
asking each page for `window.devicePixelRatio` and its width in CSS pixels,
with the display scale set the way a room sets it (`kscreen-doctor` and
`UI_SCALE`):
| Display scale | Wonder Ink 0.2.6 | Chrome, as `classroom-browser` starts it |
| --- | --- | --- |
| 100% | dpr 1, 1920 px wide | dpr 1, 1920 px wide |
| 150% | dpr 1.5, 1280 px wide | **dpr 2.25**, 854 px wide |
| 200% | dpr 2, 960 px wide | **dpr 4**, 500 px wide |
Wonder Ink was right by the desktop's measure: it follows the display
scale exactly, like the panel and every dialog. **Chrome is scaled twice.**
Under Plasma it follows the compositor's scale itself, and
`classroom-browser` also forces `--force-device-scale-factor=UI_SCALE` — a
flag from the sway session, where Chrome was measured *not* following the
compositor. So in any room above 100%, every page in the browser is
`UI_SCALE` times larger than the desktop around it, and that is the size
teachers read lessons at.
0.2.6.1 does not change the browser — it is the size the rooms know, and
changing it everywhere is its own decision. Wonder Ink's pages start at
the browser's size instead: **page zoom = `UI_SCALE`**, the room's own
clamped number, which is exactly 1.0 in a room at 100%. Wonder Ink's
window, tabs and dialogs are not zoomed; Qt already scales them once,
correctly. Measured after the change: at 150% the lesson reports dpr 2.25
and 853 px, Chrome dpr 2.25 and 854 px.
**The teacher's own size** goes on top: Ctrl+Plus (and Ctrl+=, and the
keypad's), Ctrl+Minus, Ctrl+0 for normal, Ctrl+wheel, or the right-click
menu. Chrome's steps, 50% to 300% of the room's normal size, one level for
every tab including new ones. It is remembered in
`~/.config/ekklesia-classroom/wonderink.conf` as a single `LEVEL=` line —
its own file, nowhere near the engine profile, so remembering a size can
never mean remembering a sign-in — and relative to the room, so an
administrator changing `UI_SCALE` later still gets the right result.
Measured along the way:
* the engine keeps a tab's zoom across every navigation, same site or not,
so it is set once when the tab is made and never re-applied;
* zoom does not change what prints: the same lesson is 3 pages at 100%,
200% and 400%;
* **page zoom does not reach a worksheet tab.** Chromium's PDF viewer
follows the display scale like the window, fits the page to its width,
and has its own − / + zoom in its toolbar, which is what a teacher uses
there;
* two Qt actions on one key are ambiguous and Qt does *neither* — Ctrl+Minus
did nothing while it was bound once by name and again as KDE's standard
zoom-out key. One action per command now carries all its keys, which
also fixed Ctrl+Shift+Tab (bound twice the same way since 0.2.1).
`tools/lab/wonderink-zoom-check` asks the installed command all of this.
The lab's injected keyboard does not reach its nested session, so the
check delivers keys through Qt's own key path to the focused page; a
physical Ctrl+Plus on a classroom keyboard is a hardware check.
## Why lessons load slowly
Measured before anything was changed, and nothing about loading was
changed, because the evidence does not point at Wonder Ink.
In the lab, on the same connection, the same page, timed by each
engine's own Navigation Timing until the sign-in form exists (the site
is a 3.7 KB shell that builds itself in JavaScript, so its load event is
not the moment a teacher can use it):
| Until the sign-in page is usable | Wonder Ink | Chrome |
| --- | --- | --- |
| cold cache, 3 runs | 2.86 – 3.00 s | 2.79 – 3.48 s |
| warm cache, 3 runs | 2.20 – 2.49 s | 1.96 – 2.15 s |
| window on screen after launch | 0.33 s | 0.42 s |
* **The site is the weight:** 119 – 160 requests and 3.2 MB for a sign-in
form, about 430 KB warm. Its console says it loads the Google Maps
JavaScript API *twice*, synchronously. That is Wonder Ink's to fix.
* Wonder Ink is about a quarter of a second slower warm: it revalidates
the page shell (62 – 87 ms to first byte) where Chrome serves it from
its cache. Not the "very long time" teachers describe.
* Memory: about 610 MB for one tab and 115 MB more for each further tab —
eight tabs, 1.4 GB — one renderer each. Idle CPU about 1%: nothing
spins in the background.
* The network: from the lab, 0.14 – 0.22 s to first byte; from the
development host, 0.42 – 0.44 s. Cloudflare in front, uncached.
What the lab **cannot** show is the machines: it draws in software for
both engines. A classroom whose graphics fail in QtWebEngine but work in
Chrome would look exactly like the complaint. So there is a command for a
technician, run from the maintenance terminal inside the teacher's
session (Meta+Ctrl+Alt+Shift+K):
classroom-wonderink --diagnose
It prints the engine's versions, the scale, **what WebGL reports as the
renderer** — a real driver's name, or "none … drawing in software" — and
times the sign-in page from a cold cache in a throwaway profile, beside a
running Wonder Ink without touching it. Compare its time with the lab's
2.9 s, and its renderer with Chrome's on the same machine (open
`chrome://gpu` there).
## Turning it off
One future device is a laptop that will not have Wonder Ink at all.
@@ -362,3 +893,7 @@ For completeness, so nobody re-audits them: the managed policy in
`/etc/opt/chrome/policies/managed/` sets first-run, sign-in, password
and homepage behaviour only. None of it touches media, autoplay,
iframes, fullscreen or site permissions.
## Later
What testing of 0.2.6.1 asked for next is in [backlog.md](backlog.md).
+35
View File
@@ -101,6 +101,19 @@ DVD_ENABLED=true
# in the corner is then one more thing to explain.
PARENT_CALL_ENABLED=true
# Whether this device offers Presentations.
#
# A teacher downloads a session in Chrome and it appears here, ready to
# show on the television: no file manager, no Impress, nothing to
# explain. Off by default, because every existing classroom upgrading
# into this release has never had it and a new button nobody asked for
# is a new thing to support.
#
# It depends on nothing else. A machine may have Presentations with no
# Wonder Ink, no disc drive, no Parent Call, on Wi-Fi or a cable, as a
# laptop or a tower.
PRESENTATIONS_ENABLED=false
# Which network this device is meant to be on.
#
# wired an Ethernet cable is the intended connection.
@@ -212,6 +225,22 @@ DVD_ENABLED=true
# A room full of children needs it. A Bible study does not, and the button
# in the corner is then one more thing to explain.
PARENT_CALL_ENABLED=true
""",
),
(
"PRESENTATIONS_ENABLED",
"""# Whether this device offers Presentations.
#
# A teacher downloads a session in Chrome and it appears here, ready to
# show on the television: no file manager, no Impress, nothing to
# explain. Off by default, because every existing classroom upgrading
# into this release has never had it and a new button nobody asked for
# is a new thing to support.
#
# It depends on nothing else. A machine may have Presentations with no
# Wonder Ink, no disc drive, no Parent Call, on Wi-Fi or a cable, as a
# laptop or a tower.
PRESENTATIONS_ENABLED=false
""",
),
(
@@ -325,6 +354,12 @@ FEATURES = {
"WONDERINK_ENABLED": True,
"DVD_ENABLED": True,
"PARENT_CALL_ENABLED": True,
# The one that defaults to OFF, and the only one that can. Every
# existing classroom has Wonder Ink, a drive and Parent Call, so a
# room upgrading into this release must come up with all three. None
# of them has ever had Presentations, so a room upgrading into this
# release must come up without it.
"PRESENTATIONS_ENABLED": False,
}
+103 -4
View File
@@ -36,10 +36,24 @@ import re
# ifupdown's own spelling. Values may be quoted, and a psk may be a
# 64-character hex key rather than a passphrase.
SETTINGS = ("wpa-ssid", "wpa-psk", "wpa-key-mgmt", "wpa-conf", "wpa-proto")
SETTINGS = ("wpa-ssid", "wpa-psk", "wpa-key-mgmt", "wpa-conf", "wpa-proto",
"wpa-scan-ssid")
# Whether the access point broadcasts its name.
#
# wpa-scan-ssid 1 is ifupdown's spelling of wpa_supplicant's scan_ssid=1:
# probe for this network by name, because it is not in a passive scan.
# NetworkManager spells the same thing 802-11-wireless.hidden, and a
# migration that drops it produces a connection that is correct in every
# visible respect and can never associate — NetworkManager will not
# connect to an SSID it has not seen in a scan, and a hidden one is never
# in a scan. That is what happened to the prayer-room laptop on 0.2.5.
HIDDEN = "wpa-scan-ssid"
_STANZA = re.compile(r"^\s*(iface|auto|allow-hotplug|source|source-directory)\s+(.*)$")
TRUTHY = {"1", "yes", "true", "on"}
def _unquote(value):
value = (value or "").strip()
@@ -63,7 +77,19 @@ def parse(text):
current = None
for number, raw in enumerate(lines):
line = raw.split("#", 1)[0].rstrip()
# A comment is a WHOLE line, and only a whole line.
#
# interfaces(5): "Lines starting with '#' are ignored. Note that
# end-of-line comments are NOT supported." This used to cut every
# line at its first '#', which is a silent corruption of exactly
# one value: a pre-shared key containing a '#' was truncated, the
# migrated connection got a password that was a prefix of the
# real one, and the only symptom is an association that fails the
# way a wrong password fails.
if raw.lstrip().startswith("#"):
continue
line = raw.rstrip()
match = _STANZA.match(line)
if match:
@@ -129,12 +155,57 @@ def credentials(stanza):
network inline returns no SSID: that one is not ours to move, and the
caller must leave it alone rather than guess.
"""
found = profile(stanza)
if found is None:
return None, None
return found["ssid"], found["psk"]
def profile(stanza):
"""Everything needed to recreate this stanza in NetworkManager.
A dict, or None when the stanza is not ours to move. Returning the
whole network rather than a pair is the fix for the 0.2.5 failure:
`wpa-scan-ssid` was sitting in the parsed options the whole time and
the only accessor threw it away, so the hand-over could not have
preserved it however carefully it was written.
`key_mgmt` is "wpa-psk", "none", or None meaning "this file says
something this command does not understand". The caller must refuse
that last one rather than guess: a WPA-Enterprise network recreated
as a PSK network is a migration that cannot work and has already
taken the interface away from ifupdown to find out.
"""
options = stanza.get("options") or {}
if "wpa-conf" in options and "wpa-ssid" not in options:
return None, None
return None
return options.get("wpa-ssid") or None, options.get("wpa-psk") or None
ssid = options.get("wpa-ssid") or None
if not ssid:
return None
psk = options.get("wpa-psk") or None
declared = (options.get("wpa-key-mgmt") or "").strip().upper()
if declared in ("", "WPA-PSK", "WPA-PSK-SHA256", "SAE", "WPA-PSK SAE"):
key_mgmt = "wpa-psk" if psk else "none"
elif declared == "NONE":
key_mgmt = "none"
else:
# WPA-EAP, IEEE8021X and anything else this has never seen.
key_mgmt = None
return {
"ssid": ssid,
"psk": psk,
# A network that has to be probed for by name. See HIDDEN.
"hidden": (options.get(HIDDEN) or "").strip().lower() in TRUTHY,
"key_mgmt": key_mgmt,
}
def comment_out(text, stanza, marker="# ekklesia-classroom: "):
@@ -160,3 +231,31 @@ def comment_out(text, stanza, marker="# ekklesia-classroom: "):
break
return "".join(lines)
def uncomment(text, marker="# ekklesia-classroom: "):
"""Undo comment_out, in the file as it stands now.
Returns (text, count). The rollback uses this rather than copying the
backup over the top, because the backup is kept once and never
refreshed: an administrator who corrected a static Ethernet address
between the migration and the rollback would have had that correction
silently reverted by a whole-file restore. This touches only the
lines this package marked, so everything else in the file — wired
stanzas, source lines, an administrator's own comments — is exactly
as the machine left it.
"""
lines = (text or "").splitlines(keepends=True)
restored = 0
for number, line in enumerate(lines):
stripped = line.lstrip()
if not stripped.startswith(marker):
continue
indent = line[:len(line) - len(stripped)]
lines[number] = indent + stripped[len(marker):]
restored += 1
return "".join(lines), restored
+898
View File
@@ -0,0 +1,898 @@
"""The classroom's presentation library.
What a teacher sees is in classroom-presentations. This is everything
underneath it — where the files live, what is known about each one, and
the two things that have to be right whoever is asking: when a download
has really finished, and when a presentation has really been presented.
WHY THE ORIGINAL LIVES IN THE MANAGED LIBRARY.
A teacher downloads a session into ordinary ~/Downloads. Once the download
is complete, it is moved into this managed library. These locked-down
machines have no general file manager, so leaving another copy in Downloads
only wastes space and makes Remove misleading. The moved original is kept
beside the generated PDF and page images, which means conversion failure is
recoverable and cleanup has one contained directory to remove.
WHY EVERY FORMAT BECOMES A PDF.
Measured in the lab, on Debian 13, before any of this was designed —
docs/presentations.md has the whole table. The short of it:
soffice --show starts a real fullscreen slideshow, and leaves
the Impress editor behind when it ends. It also
wants a first-run dialog dismissed and shows a
splash. A teacher who presses Escape must land
in the library, never in an application that can
open any file on the machine.
okular --presentation opens THREE windows: the
presentation, the ordinary Okular document
window behind it, and an information dialog.
So LibreOffice converts to PDF once, at import, with no window on the
screen at all, and poppler renders that PDF to page images. The classroom
presents the images. Nothing here renders a slide: LibreOffice and
poppler do all of it, which is the same engine Impress would have used
live. This is not a slide renderer — it is a picture viewer over pictures
somebody else drew.
That also makes a PDF export a first-class citizen rather than a special
case: a PDF is already the thing everything else is converted into, so a
session exported from Google Slides as PDF skips a conversion and keeps
the appearance Google gave it.
"""
import json
import os
import re
import shutil
import time
import unicodedata
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
# What a teacher may put in the library.
#
# Three, and deliberately no more. Every one of them is something the
# pastor's material is actually exported as, and each has a conversion
# into PDF that Debian packages.
SUPPORTED = {
".pdf": "pdf",
".pptx": "pptx",
".ppt": "ppt",
}
# A download that is still arriving.
#
# Chrome writes "Session 7.pptx.crdownload" and renames it to
# "Session 7.pptx" when the transfer completes, so the presence of the
# sibling is the authoritative answer to "has this finished". The others
# are what a different browser or a copy in progress leaves.
PARTIAL_SUFFIXES = (".crdownload", ".part", ".partial", ".download", ".tmp")
# How long a file has to stop changing before it counts as finished.
#
# Belt and braces with the .crdownload check above. A file copied onto
# the machine from a USB stick has no .crdownload beside it and is still
# half written, and a teacher watching a presentation appear mid-copy
# would get a library entry that cannot be opened.
SETTLE_SECONDS = 6
# Retention, in days. Neither is a teacher-facing setting in 0.2.6.
NEVER_PRESENTED_DAYS = 180
AFTER_PRESENTING_DAYS = 30
# How wide a rendered slide is, in pixels.
#
# The classroom televisions are 1080p and 4K. 1920 is the 1080p set
# exactly and a 4K set scaled up by two, which on static Bible study
# slides is indistinguishable at classroom distance and is a quarter of
# the pixels to render and store.
SLIDE_WIDTH = 1920
# The library card. Small on purpose: a wall of these has to stay quick.
THUMBNAIL_WIDTH = 480
def data_home():
return Path(os.environ.get("XDG_DATA_HOME")
or Path.home() / ".local" / "share")
def runtime_home():
return Path(os.environ.get("XDG_RUNTIME_DIR") or "/tmp")
def library_root():
return data_home() / "ekklesia-classroom" / "presentations"
def downloads_root():
return Path(os.environ.get("XDG_DOWNLOAD_DIR") or Path.home() / "Downloads")
# Wonder Ink's own downloads, which are not presentations and must never
# be imported, moved or removed by any of this. classroom-wonderink puts
# a lesson's worksheets here; see the DOWNLOADS comment in that file for
# why it is a directory of its own.
WONDERINK_DOWNLOADS = "Wonder Ink"
def presenting_lock():
"""The file that says a presentation is on the screen right now.
In the runtime directory, so it cannot outlive a reboot: a lock left
behind by a crash would otherwise make a presentation permanently
un-removable.
"""
return runtime_home() / "ekklesia-classroom-presenting"
def now():
return datetime.now(timezone.utc)
def stamp(moment=None):
return (moment or now()).strftime("%Y-%m-%dT%H:%M:%SZ")
def parse_stamp(text):
if not text:
return None
try:
return datetime.strptime(text, "%Y-%m-%dT%H:%M:%SZ").replace(
tzinfo=timezone.utc)
except (TypeError, ValueError):
return None
def write_json(path, data):
"""Write a JSON file so that a crash cannot leave half of one.
Everything the library knows is written this way. A presentation whose
metadata is truncated is a card a teacher cannot open and cannot
remove, which is worse than one that was never imported.
"""
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(path.name + ".new")
with open(temporary, "w", encoding="utf-8") as handle:
json.dump(data, handle, indent=2, sort_keys=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
def read_json(path, default=None):
try:
with open(path, "r", encoding="utf-8") as handle:
return json.load(handle)
except (OSError, ValueError):
return default
# ------------------------------------------------------------- titles
# Things a download picks up on its way to the machine, none of which
# mean anything to a teacher.
_NOISE = re.compile(
r"""(
\s*\(\d+\)$ # Chrome's " (1)" for a second copy
| ^\d{8,}[-_\s]+ # a leading timestamp
| [-_\s]*\b[0-9a-f]{8,}\b # a hash or a UUID fragment
| \b(final|v\d+|copy|export|download)\b
)""",
re.IGNORECASE | re.VERBOSE,
)
def readable_title(filename, fallback_text=""):
"""A title a teacher would recognise, from a filename they did not choose.
Google Slides exports as "Session 7 - Romans.pptx" and a browser may
make that "Session_7_-_Romans%20(1).pptx". Neither a UUID nor a
40-character hash is a title, and a teacher scanning a wall of cards
for tonight's session should not have to read one.
"""
stem = Path(filename).stem
# Percent-escapes a download can carry, decoded only when they are
# obviously escapes rather than a literal per cent in a name.
if "%20" in stem or "%28" in stem:
for escape, character in (("%20", " "), ("%28", "("), ("%29", ")"),
("%26", "&"), ("%27", "'")):
stem = stem.replace(escape, character)
stem = unicodedata.normalize("NFC", stem)
stem = stem.replace("_", " ").replace("+", " ")
# A dash between words is a dash; a dash padding one is a space.
stem = re.sub(r"\s*-\s*", " - ", stem)
cleaned = _NOISE.sub(" ", stem)
cleaned = re.sub(r"\s+", " ", cleaned).strip(" -–—")
if len(cleaned) >= 3 and not re.fullmatch(r"[\W\d_]+", cleaned):
# Long enough to be a name and not purely punctuation and digits.
return cleaned[:80]
# The filename told us nothing. The first readable line of the
# document itself is a better answer than "a4f39c2e".
for line in (fallback_text or "").splitlines():
line = re.sub(r"\s+", " ", line).strip()
if len(line) >= 3:
return line[:80]
return "Presentation"
def unique_title(title, taken):
"""A title that is not already on another card.
Two files of the same name are two presentations, never one
overwriting the other, so the second has to be tellable from the
first on a wall of cards.
"""
if title not in taken:
return title
for number in range(2, 100):
candidate = f"{title} ({number})"
if candidate not in taken:
return candidate
return f"{title} ({uuid.uuid4().hex[:6]})"
# -------------------------------------------------------- the library
class Presentation:
"""One entry in the library, and the directory holding it."""
def __init__(self, directory, data):
self.directory = Path(directory)
self.data = data
# -- identity -------------------------------------------------
@property
def id(self):
return self.data.get("id") or self.directory.name
@property
def title(self):
return self.data.get("title") or "Presentation"
@property
def pages(self):
try:
return max(0, int(self.data.get("pages") or 0))
except (TypeError, ValueError):
return 0
@property
def state(self):
return self.data.get("state") or "importing"
@property
def ready(self):
return self.state == "ready" and self.slides.exists()
@property
def failed(self):
return self.state == "failed"
@property
def failure(self):
"""Why it failed, as one word a teacher's card can be chosen by.
"memory": the conversion was killed for want of memory, or was
interrupted too often to keep trying — worth trying again once
other programs are closed. "unreadable": the file itself would not
convert, which trying again will not change.
"""
if self.stalled:
return "memory"
if not self.failed:
return None
return self.data.get("error_kind") or "unreadable"
@property
def stalled(self):
"""Still "getting ready" long after any import could have finished.
The importer's own limits end every attempt within
CONVERT_TIMEOUT + RENDER_TIMEOUT. Past that, with no answer, the
importer is not running at all — and a card that says "Getting
this ready" for ever is the thing a teacher must never be left
looking at.
"""
if self.state not in ("importing", "retry"):
return False
started = parse_stamp(self.data.get("attempt_started_at")
or self.data.get("imported_at"))
if started is None:
return False
return (now() - started).total_seconds() > STALLED_SECONDS
@property
def error(self):
return self.data.get("error") or ""
# -- files ----------------------------------------------------
@property
def slides(self):
"""The PDF everything is presented and rendered from."""
return self.directory / "slides.pdf"
@property
def pages_directory(self):
return self.directory / "pages"
def page_image(self, number):
return self.pages_directory / f"page-{number:03d}.png"
@property
def thumbnail(self):
return self.directory / "thumbnail.png"
@property
def managed_copy(self):
name = self.data.get("managed_copy")
return self.directory / name if name else None
# -- what a teacher chose -------------------------------------
@property
def thumbnail_page(self):
"""Which slide is the card's picture.
Independent of where a presentation starts, and independent of
whether it has ever been presented. Changing it is not a
presentation and does not touch a retention timer — the three are
kept apart on purpose, because the first slide of a Bible study
session is a generic opening shared by every session and the one
that names the session is usually the second.
"""
try:
chosen = int(self.data.get("thumbnail_page") or 1)
except (TypeError, ValueError):
return 1
return min(max(chosen, 1), self.pages or 1)
# -- retention ------------------------------------------------
@property
def imported_at(self):
return parse_stamp(self.data.get("imported_at"))
@property
def last_presented_at(self):
return parse_stamp(self.data.get("last_presented_at"))
@property
def times_presented(self):
try:
return max(0, int(self.data.get("times_presented") or 0))
except (TypeError, ValueError):
return 0
def expires_at(self):
"""When the cleanup may remove this, or None if it cannot say.
Two clocks, and only ever one of them running. A presentation
that has never been shown is kept for six months from the day it
arrived. One that has been shown is kept for a month from the
last time it was actually shown, and every showing starts that
month again.
"""
presented = self.last_presented_at
if presented is not None:
return presented + timedelta(days=AFTER_PRESENTING_DAYS)
imported = self.imported_at
if imported is None:
return None
return imported + timedelta(days=NEVER_PRESENTED_DAYS)
def expired(self, moment=None):
due = self.expires_at()
return due is not None and (moment or now()) >= due
# -- writing --------------------------------------------------
@property
def metadata_path(self):
return self.directory / "presentation.json"
def save(self):
write_json(self.metadata_path, self.data)
def set_thumbnail_page(self, number):
"""Choose the slide the library card shows.
Deliberately writes nothing else. This is not a presentation, it
is not an import, and it must not move a retention timer.
"""
number = min(max(int(number), 1), self.pages or 1)
self.data["thumbnail_page"] = number
self.save()
source = self.page_image(number)
if source.exists():
render_thumbnail(source, self.thumbnail)
return number
def mark_presented(self, moment=None):
"""Record that this was really shown to a room.
Called only after the presenter has a slide on the screen. Quick
Look, opening the library, generating previews and choosing a
thumbnail all deliberately do not call it, and neither does a
viewer that failed to start — a presentation that did not happen
must not buy another thirty days.
"""
self.data["last_presented_at"] = stamp(moment)
self.data["times_presented"] = self.times_presented + 1
self.save()
def load(directory):
directory = Path(directory)
data = read_json(directory / "presentation.json")
if not isinstance(data, dict):
return None
return Presentation(directory, data)
def library(root=None):
"""Every presentation, newest import first.
A directory that is half written — an import interrupted by a reboot,
say — has no readable metadata and is simply not in the list. The
importer clears those up; the library never shows a card it cannot
open.
"""
root = Path(root) if root is not None else library_root()
if not root.is_dir():
return []
found = []
for entry in sorted(root.iterdir()):
if not entry.is_dir():
continue
presentation = load(entry)
if presentation is not None:
found.append(presentation)
found.sort(key=lambda p: (p.data.get("imported_at") or ""), reverse=True)
return found
def request_retry(presentation):
"""A teacher's Try again: the importer takes it up on its next pass.
Only a presentation that failed for want of memory, or stalled; a file
that will not convert is not going to convert on the second try.
"""
if presentation.failure != "memory":
return False
presentation.data["state"] = "retry"
presentation.data["error"] = None
presentation.data["error_kind"] = None
presentation.data["attempts"] = 0
presentation.data["attempt_started_at"] = stamp()
presentation.save()
return True
def remove(presentation):
"""Delete exactly one managed entry, unless it is on screen now."""
try:
showing = presenting_lock().read_text().strip()
except OSError:
showing = ""
if presentation.id == showing:
return False
shutil.rmtree(presentation.directory, ignore_errors=True)
return not presentation.directory.exists()
def titles_in_use(root=None, exclude=None):
"""Every title already on a card, optionally ignoring one presentation.
Excluded BY ID rather than by title, which is not a detail. The import
being named is already in the library — it is written before the
conversion starts so an interruption is recoverable — so its own
provisional title has to come out of the set. Taking it out by title
removed the other presentation's identical title along with it, a set
holding one copy of a repeated string, and two files whose names
differ only in their extension both came out called "Session 7 -
Romans". Found in the lab, importing a PDF and a PPTX of the same
session, which is exactly what tomorrow's test does.
"""
return {
presentation.title
for presentation in library(root)
if exclude is None or presentation.id != exclude
}
# ------------------------------------------------------------ imports
def is_partial(path):
"""Whether this file is still arriving."""
path = Path(path)
if path.name.startswith("."):
return True
if path.suffix.lower() in PARTIAL_SUFFIXES:
return True
# Chrome's in-progress file sits beside the finished name.
for suffix in PARTIAL_SUFFIXES:
if path.with_name(path.name + suffix).exists():
return True
return False
def settled(path, seconds=SETTLE_SECONDS):
"""Whether the file has stopped being written to."""
try:
stat = Path(path).stat()
except OSError:
return False
return (time.time() - stat.st_mtime) >= seconds and stat.st_size > 0
def importable(path):
"""Whether this is a finished file the library should take."""
path = Path(path)
if path.suffix.lower() not in SUPPORTED:
return False
if not path.is_file():
return False
return not is_partial(path) and settled(path)
def within_wonderink(path, downloads=None):
"""Whether this file belongs to Wonder Ink's own download workflow.
Wonder Ink puts a lesson's worksheets in a directory of its own
inside Downloads. They are worksheets, not presentations: they are
not imported, and nothing in the library ever removes one.
"""
downloads = Path(downloads) if downloads is not None else downloads_root()
try:
relative = Path(path).resolve().relative_to(downloads.resolve())
except (OSError, ValueError):
return False
return WONDERINK_DOWNLOADS in relative.parts
def candidates(downloads=None):
"""Finished presentation files sitting in the teacher's Downloads.
The top level only. Nothing recurses into Wonder Ink's directory,
and nothing looks anywhere else on the machine: a library that
imported from the whole home directory would be a file manager.
"""
downloads = Path(downloads) if downloads is not None else downloads_root()
if not downloads.is_dir():
return []
found = []
try:
entries = sorted(downloads.iterdir())
except OSError:
return []
for entry in entries:
if entry.is_dir():
continue
if within_wonderink(entry, downloads):
continue
if importable(entry):
found.append(entry)
return found
def fingerprint(path):
"""What makes this exact download this exact download.
Path, size and modification time. Enough that re-downloading the same
session tomorrow is a new presentation, and that a reboot does not
import everything in Downloads all over again.
"""
try:
stat = Path(path).stat()
except OSError:
return None
return f"{Path(path).resolve()}|{stat.st_size}|{int(stat.st_mtime)}"
def ledger_path(root=None):
root = Path(root) if root is not None else library_root()
return root / "imported.json"
def ledger(root=None):
data = read_json(ledger_path(root), default={})
return data if isinstance(data, dict) else {}
def remember(mark, value, root=None):
"""Record that this download has been dealt with.
The normal successful import moves the source out of Downloads, so it
cannot be found by a later scan. The ledger is still the transaction
record that makes an interrupted same- or cross-filesystem move safe:
startup can tell which half-built directory belongs to which source.
"""
data = ledger(root)
data[mark] = value
write_json(ledger_path(root), data)
# ----------------------------------------------------- rendering
# Everything below shells out. Nothing here draws a slide: LibreOffice
# converts a deck to PDF exactly as Impress would have rendered it live,
# and poppler turns PDF pages into pictures. The classroom's own code
# only ever displays a picture one of them produced.
SOFFICE = "soffice"
PDFTOPPM = "pdftoppm"
PDFINFO = "pdfinfo"
PDFTOTEXT = "pdftotext"
# A large deck on a slow machine. Generous, because the alternative to
# waiting is a half-converted file presented to a room.
CONVERT_TIMEOUT = 300
RENDER_TIMEOUT = 600
# Past this, a presentation still "getting ready" has no importer working
# on it; see Presentation.stalled.
STALLED_SECONDS = CONVERT_TIMEOUT + RENDER_TIMEOUT + 300
# What convert_to_pdf and render_pages say when the kernel killed the
# program doing the work. Measured: a deck of 30 different photographs
# takes LibreOffice 1.4 GB for about twenty seconds, 20 larger ones 2.1 GB,
# and the importer's service is capped at 40% of the machine's memory.
KILLED = "killed for want of memory"
def killed(returncode):
"""Did the kernel end this process? SIGKILL, directly or via a shell."""
return returncode in (-9, 137)
def _run(command, timeout, **kwargs):
import subprocess
try:
return subprocess.run(
command, capture_output=True, text=True, timeout=timeout,
check=False, **kwargs)
except (OSError, subprocess.SubprocessError) as error:
class Failed:
returncode = 127
stdout = ""
stderr = str(error)
return Failed()
def convert_to_pdf(source, destination):
"""Turn a PPT or PPTX into the PDF everything else works from.
Headless, in a profile of its own, with no window on any screen. A
teacher never sees LibreOffice: not its splash, not its first-run
dialog, and above all not its editor. It runs once, at import, and
then never again for that presentation.
A separate -env:UserInstallation is not optional. LibreOffice takes
an exclusive lock on its user profile, so a conversion running while
anything else on the machine uses LibreOffice would fail, and the
failure would arrive as a presentation that never finished importing.
"""
source = Path(source)
destination = Path(destination)
destination.parent.mkdir(parents=True, exist_ok=True)
workspace = destination.parent / "convert"
profile = destination.parent / "loprofile"
if workspace.exists():
shutil.rmtree(workspace, ignore_errors=True)
workspace.mkdir(parents=True, exist_ok=True)
profile.mkdir(parents=True, exist_ok=True)
result = _run(
[
SOFFICE,
f"-env:UserInstallation=file://{profile}",
"--headless", "--norestore", "--nologo", "--nolockcheck",
"--nodefault", "--nofirststartwizard",
"--convert-to", "pdf:impress_pdf_Export",
"--outdir", str(workspace),
str(source),
],
CONVERT_TIMEOUT,
)
produced = sorted(workspace.glob("*.pdf"))
if not produced:
shutil.rmtree(profile, ignore_errors=True)
shutil.rmtree(workspace, ignore_errors=True)
if killed(result.returncode):
return False, KILLED
return False, (result.stderr or result.stdout or
"the file could not be opened").strip()
os.replace(produced[0], destination)
shutil.rmtree(profile, ignore_errors=True)
shutil.rmtree(workspace, ignore_errors=True)
return True, ""
def page_count(pdf):
"""How many slides, according to poppler."""
result = _run([PDFINFO, str(pdf)], 60)
for line in (result.stdout or "").splitlines():
if line.startswith("Pages:"):
try:
return int(line.split(":", 1)[1].strip())
except ValueError:
return 0
return 0
def first_text(pdf, pages=2):
"""A little of the document's own words, for a title of last resort."""
result = _run([PDFTOTEXT, "-f", "1", "-l", str(pages), str(pdf), "-"], 60)
return result.stdout or ""
def render_pages(pdf, directory, width=SLIDE_WIDTH, progress=None):
"""Every slide as a picture, at the size a television shows it.
One pdftoppm run for the whole document rather than one per page:
measured in the lab at 0.03s for ten pages, against a process launch
each time. `progress` is called with the number rendered so far, so a
library window can show a large import filling in rather than freeze.
"""
pdf = Path(pdf)
directory = Path(directory)
directory.mkdir(parents=True, exist_ok=True)
import subprocess
command = [
PDFTOPPM, "-png",
"-scale-to-x", str(width), "-scale-to-y", "-1",
str(pdf), str(directory / "page"),
]
try:
process = subprocess.Popen(
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True)
except OSError as error:
return 0, str(error)
deadline = time.monotonic() + RENDER_TIMEOUT
while process.poll() is None:
if time.monotonic() > deadline:
process.kill()
return 0, "rendering took too long"
if progress is not None:
progress(len(list(directory.glob("page-*.png"))))
time.sleep(0.4)
if process.returncode != 0:
if killed(process.returncode):
return 0, KILLED
return 0, (process.stderr.read() if process.stderr else
"the slides could not be rendered")
# pdftoppm numbers by the page count's width: page-1 for a nine-page
# document, page-01 for a ninety-page one. The library asks for
# page-001, so they are renamed into one shape and nothing that reads
# them has to know how many there were.
rendered = sorted(directory.glob("page-*.png"))
for number, path in enumerate(rendered, start=1):
wanted = directory / f"page-{number:03d}.png"
if path != wanted:
os.replace(path, wanted)
return len(rendered), ""
def render_thumbnail(source, destination, width=THUMBNAIL_WIDTH):
"""The small picture on a library card, from an already rendered page."""
source = Path(source)
destination = Path(destination)
if not source.exists():
return False
try:
import gi
gi.require_version("GdkPixbuf", "2.0")
from gi.repository import GdkPixbuf
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
str(source), width, -1, True)
temporary = destination.with_name(destination.name + ".new")
pixbuf.savev(str(temporary), "png", [], [])
os.replace(temporary, destination)
return True
except Exception: # noqa: BLE001 - a card without a picture is fine
# A thumbnail is a convenience. A library that refused to show a
# presentation because it could not shrink a picture would be
# worse than one showing a card with no picture on it.
return False
+337
View File
@@ -0,0 +1,337 @@
"""Printing a PDF to the room's printer, and showing what will be printed.
Wonder Ink's printing, without Qt's printing in it. Every document Wonder
Ink prints is a PDF by the time it gets here — a worksheet already is
one, and a lesson page is turned into one by the engine — so what is left
is the part the rest of the classroom already trusts: CUPS.
WHY NOT QPRINTER.
Measured in the lab on Debian 13 (QtWebEngine 6.8.2), against a queue
built from Elmer's own PPD with a file in place of the printer:
QWebEngineView.print() a lesson page arrived as 24.6 MB of
300 dpi pictures, no text left in it;
a worksheet PDF tab arrived as ONE BLANK
PAGE; a queue rejecting jobs was reported
as printed successfully; and on 0.2.2 it
crashed, because the printer was deleted
before Qt had finished with it.
printToPdf() + lp the same lesson was an 18 KB PDF with its
text, on Letter, through Elmer's filters
in 0.43 s; a rejected job is an error
with CUPS's own words in it.
The second is what Chrome does, and Chrome is what the rooms already
print from without trouble.
"""
import re
import subprocess
# lp hands the file to the scheduler and returns; it does not wait for
# the printer. A scheduler that has not answered in this long is not
# going to, and a teacher is standing at the dialog.
SUBMIT_TIMEOUT = 30
QUERY_TIMEOUT = 10
# What a worksheet can reasonably be asked for. A spin box that goes to
# 999 is one slip of a finger away from a ream of paper.
MAX_COPIES = 50
# Paper names a driver uses, in the terms Qt's page sizes are named by.
# Anything else is Letter, which is what the church's printer is.
PAPER = {"Letter": "Letter", "A4": "A4", "Legal": "Legal"}
# The driver's own words for "no colour", in the order they are tried.
# Elmer's generic PCL 6 driver ignores the IPP print-color-mode attribute
# altogether — measured, it logs "Unknown option" and prints in colour —
# so black and white has to be asked for in the PPD's terms.
GRAY_CHOICES = ("Grayscale", "Gray", "Mono", "Monochrome", "BlackWhite")
def _run(command, timeout=QUERY_TIMEOUT):
try:
return subprocess.run(
command, capture_output=True, text=True, timeout=timeout
)
except (OSError, subprocess.TimeoutExpired) as error:
return subprocess.CompletedProcess(command, 1, "", str(error))
def printers():
"""(names, default) for every printer CUPS will accept a job for.
lpstat -e is the list a teacher can print to: in a classroom that is
Elmer and nothing else, because discovery is switched off.
"""
names = [
line.strip()
for line in _run(["lpstat", "-e"]).stdout.splitlines()
if line.strip()
]
default = None
match = re.search(
r"system default destination:\s*(\S+)", _run(["lpstat", "-d"]).stdout
)
if match and match.group(1) in names:
default = match.group(1)
elif names:
default = names[0]
return names, default
def options(printer):
"""What this printer's driver offers, parsed from lpoptions -l.
{"paper": "Letter", "gray": ("ColorModel", "Grayscale") or None}
"""
found = {"paper": "Letter", "gray": None}
for line in _run(["lpoptions", "-p", printer, "-l"]).stdout.splitlines():
if ":" not in line:
continue
key, choices = line.split(":", 1)
key = key.split("/", 1)[0].strip()
choices = choices.split()
default = next((c[1:] for c in choices if c.startswith("*")), None)
plain = [c.lstrip("*") for c in choices]
if key == "PageSize" and default in PAPER:
found["paper"] = PAPER[default]
if key in ("ColorModel", "ColorMode", "OutputMode"):
gray = next((c for c in GRAY_CHOICES if c in plain), None)
if gray is not None and found["gray"] is None:
found["gray"] = (key, gray)
return found
def page_count(pdf):
"""How many pages the PDF has, or 0 if it cannot be read."""
result = _run(["pdfinfo", str(pdf)])
match = re.search(r"^Pages:\s+(\d+)", result.stdout, re.MULTILINE)
return int(match.group(1)) if match else 0
def render_page(pdf, number, width):
"""PNG bytes of one page, WIDTH pixels wide, or None.
Rendered into a directory of its own and read back. Measured on
poppler 25.03: asked for "-" as its output, pdftoppm does NOT write to
stdout — it writes a file called "-.png" (or "--1.png") into whatever
directory it was started in, which for Wonder Ink is the teacher's
home, and the preview showed nothing at all.
"""
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory(prefix="wonderink-page-") as directory:
root = Path(directory) / "page"
try:
result = subprocess.run(
[
"pdftoppm", "-f", str(number), "-l", str(number),
"-scale-to-x", str(int(width)), "-scale-to-y", "-1",
"-png", "-singlefile", "--", str(pdf), str(root),
],
capture_output=True,
timeout=QUERY_TIMEOUT,
)
except (OSError, subprocess.TimeoutExpired):
return None
image = root.with_suffix(".png")
if result.returncode != 0 or not image.is_file():
return None
data = image.read_bytes()
return data or None
def parse_pages(text, count):
"""A teacher's page list, as CUPS wants it, or ValueError saying why.
"" or "all" is every page (None). Otherwise numbers and ranges
separated by commas — "2", "1-3", "1, 3, 5-6" — each inside the
document. Returned normalised: "1-3,5".
"""
text = (text or "").strip().lower()
if text in ("", "all"):
return None
parts = []
for piece in re.split(r"\s*,\s*", text):
match = re.fullmatch(r"(\d+)(?:\s*-\s*(\d+))?", piece)
if not match:
raise ValueError(
f"“{piece}” is not a page number. "
"Use numbers like 2 or 1-3, separated by commas."
)
first = int(match.group(1))
last = int(match.group(2) or first)
if first < 1 or last < first:
raise ValueError(f"“{piece}” is not a range of pages.")
if last > count:
raise ValueError(
f"This document has {count} page{'s' if count != 1 else ''}."
)
parts.append(str(first) if first == last else f"{first}-{last}")
return ",".join(parts)
def grayscale_command(source, target):
"""The command that writes SOURCE, in black and white, to TARGET.
Black and white is made here, before the preview, so the preview shows
it and CUPS is given the same file — 0.2.6.2 only told the driver, and
the preview stayed in colour while the paper came out grey. Measured in
the lab (Ghostscript 10.05.1) on a lesson page, a picture page, a
worksheet and a fillable form:
* the result is grey and nothing else, with the same pages, page
sizes and words, and the text still text;
* Ghostscript's own grey rendering of the colour original and of
this file give the same greys — within 1 level in 255 on every
filled area and picture; small text can land a fraction of a
pixel over. Elmer's driver is Ghostscript, so this is what Elmer
would have made of the colour file anyway — poppler's grey is
not, and differs on 7% of a coloured page;
* 0.05 s for a worksheet, 1.3 s for a full-page photograph, 2 s for
42 pages, and 4.4 s for one lesson page with eleven full-size
pictures, which is why the preview does it in the background.
Pages are never turned (pdfwrite otherwise rotates a page by its text)
and annotations — a form field a teacher filled in, a mark on a
worksheet — are drawn into the page as they print, so nothing is left
for a later filter to draw again in colour.
"""
return [
"gs", "-q", "-dSAFER", "-dBATCH", "-dNOPAUSE",
"-sDEVICE=pdfwrite",
"-sColorConversionStrategy=Gray", "-dProcessColorModel=/DeviceGray",
"-dAutoRotatePages=/None",
"-dPreserveAnnots=false", "-dPrinted",
f"-sOutputFile={target}", "--", str(source),
]
def submit(pdf, printer, copies=1, pages=None, gray=None, title="Wonder Ink"):
"""Hand a PDF to CUPS. (True, job id) or (False, what went wrong).
Success means the scheduler has the job and its id — which is the
thing Qt's own print path could not say: it reported a rejected job
as printed.
"""
copies = max(1, min(int(copies), MAX_COPIES))
command = ["lp", "-d", printer, "-n", str(copies), "-t", title]
if pages:
command += ["-P", pages]
if gray:
command += ["-o", f"{gray[0]}={gray[1]}"]
command += ["--", str(pdf)]
result = _run(command, timeout=SUBMIT_TIMEOUT)
match = re.search(r"request id is (\S+)", result.stdout)
if result.returncode == 0 and match:
return True, match.group(1)
message = (result.stderr or result.stdout).strip()
# "lp: The printer or class is not accepting jobs." reads better
# without the program's name in front of it.
message = re.sub(r"^lp:\s*", "", message) or "The printer did not accept the job."
return False, message
# ---------------------------------------------------------------- pictures
#
# A picture a lesson opens in a tab of its own is not a page: the engine
# shows it on a near-black viewer page (body background rgb(14, 14, 14),
# measured) scaled to the window, and printing that page printed the black
# around the picture — the bars teachers saw above and below the artwork.
# So a picture is laid out on the paper here instead, from the picture
# itself, and the teacher chooses how large and where.
# How large, as a share of the largest size that fits the page.
PICTURE_SIZES = (100, 75, 50, 33, 25)
# Where, as (column, row): 0 left or top, 1 centre, 2 right or bottom.
PICTURE_POSITIONS = {
"top left": (0, 0), "top": (1, 0), "top right": (2, 0),
"left": (0, 1), "centre": (1, 1), "right": (2, 1),
"bottom left": (0, 2), "bottom": (1, 2), "bottom right": (2, 2),
}
# Portrait or landscape: "auto" follows the picture's own shape.
ORIENTATIONS = ("auto", "portrait", "landscape")
def picture_landscape(orientation, width, height):
"""Should the page be landscape for this picture?"""
if orientation == "landscape":
return True
if orientation == "portrait":
return False
return width > height
def picture_rect(page, margin, picture, size=100, position="centre", fill=False):
"""Where a picture goes on a page, as (x, y, width, height).
PAGE is (width, height) and MARGIN the blank edge, both in the same
units; PICTURE is the picture's (width, height) in pixels. Never
stretched: the picture keeps its own proportions.
Fit (the default) is the largest size that shows ALL of the picture
inside the margins, times SIZE percent, placed at POSITION. Fill covers
the whole printable area and so cuts off whatever overhangs — which a
teacher asks for by name, never by default; SIZE and POSITION do not
apply to it.
"""
page_width, page_height = page
width, height = picture
if width <= 0 or height <= 0:
raise ValueError("the picture has no size")
area_x, area_y = margin, margin
area_width = max(1.0, page_width - 2 * margin)
area_height = max(1.0, page_height - 2 * margin)
if fill:
scale = max(area_width / width, area_height / height)
drawn_width, drawn_height = width * scale, height * scale
return (area_x + (area_width - drawn_width) / 2,
area_y + (area_height - drawn_height) / 2,
drawn_width, drawn_height)
size = max(1, min(100, int(size)))
scale = min(area_width / width, area_height / height) * size / 100
drawn_width, drawn_height = width * scale, height * scale
column, row = PICTURE_POSITIONS.get(position, (1, 1))
return (area_x + (area_width - drawn_width) * column / 2,
area_y + (area_height - drawn_height) * row / 2,
drawn_width, drawn_height)
+98 -3
View File
@@ -1,9 +1,11 @@
"""The classroom stylesheet, resolved at the room's UI scale.
"""The classroom's look: its measurements and its palette.
The dock and the DVD control bar share this so they cannot drift apart:
one template, one set of measurements, one look.
The DVD control bar, Parent Call and the Presentations library all draw
themselves, and they share this so they cannot drift apart: one set of
measurements, one palette, one look.
"""
import os
from pathlib import Path
from classroom_config import scaled
@@ -34,3 +36,96 @@ def render(scale, template=None):
css = css.replace(f"@{token}@", str(scaled(value, scale)))
return css
# ---------------------------------------------------------- the palette
def load_colours():
"""The session's own palette, read from the colour scheme in use.
There is one set of design tokens in this classroom, and they live in
a Plasma colour scheme. Reading them here rather than repeating them
means this card follows classroom-theme light and dark without
knowing that either exists.
"""
palette = {
"window": "#141c20", "view": "#0f171a", "text": "#e6edef",
"dim": "#93a5aa", "accent": "#4fb3a8", "on_accent": "#0e1518",
"negative": "#e36a6a", "neutral": "#e0b15c", "positive": "#6fcf97",
"line": "#25333a",
}
scheme = "EkklesiaDark"
for path in (
os.path.expanduser("~/.config/kdeglobals"),
"/etc/ekklesia-classroom/xdg/kdeglobals",
"/etc/xdg/kdeglobals",
):
try:
with open(path, "r", encoding="utf-8") as handle:
section = None
for line in handle:
line = line.strip()
if line.startswith("["):
section = line
elif section and section.startswith("[General]") and \
line.startswith("ColorScheme="):
scheme = line.split("=", 1)[1].strip()
break
except OSError:
continue
else:
if scheme:
break
def rgb(value):
parts = [part.strip() for part in value.split(",")]
if len(parts) != 3:
return None
try:
return "#%02x%02x%02x" % tuple(int(part) for part in parts)
except ValueError:
return None
try:
with open(f"/usr/share/color-schemes/{scheme}.colors", "r",
encoding="utf-8") as handle:
group = None
for line in handle:
line = line.strip()
if line.startswith("["):
group = line
continue
if "=" not in line or group is None:
continue
key, value = (part.strip() for part in line.split("=", 1))
colour = rgb(value)
if colour is None:
continue
if group == "[Colors:Window]":
if key == "BackgroundNormal":
palette["window"] = colour
elif key == "ForegroundNormal":
palette["text"] = colour
elif key == "ForegroundInactive":
palette["dim"] = colour
elif key == "ForegroundNegative":
palette["negative"] = colour
elif key == "ForegroundNeutral":
palette["neutral"] = colour
elif key == "ForegroundPositive":
palette["positive"] = colour
elif key == "BackgroundAlternate":
palette["line"] = colour
elif group == "[Colors:View]" and key == "BackgroundNormal":
palette["view"] = colour
elif group == "[Colors:Selection]":
if key == "BackgroundNormal":
palette["accent"] = colour
elif key == "ForegroundNormal":
palette["on_accent"] = colour
except OSError:
pass
return palette
+306
View File
@@ -0,0 +1,306 @@
"""Wonder Ink's recent tabs, kept for two hours: temporary workspace restore.
A teacher who closes Wonder Ink by accident, or whose computer restarts,
gets their tabs back if they open it again soon. A room nobody has used
for two hours starts fresh: the next teacher never sees the last one's
tabs.
It is not a sign-in and not a memory of tabs. Measured on the real site
(docs/wonder-ink.md, *Temporary workspace restore*): WonderInk's sign-in
is the teacher's email and password, Basic-auth encoded in a cookie, so
keeping a sign-in would mean keeping a password. Nothing of the sign-in is
kept here — no cookie, no header, no storage — and a restored tab that
needs the teacher signed in gets WonderInk's own sign-in page.
WHAT IS KEPT, AND ONLY THIS.
format the version of this file
last_activity wall-clock time the teacher last used Wonder Ink
active which tab was in front
tabs for each, in order: its kind ("web" or "worksheet"),
its address or the worksheet's file, and its title
Addresses are http or https and nothing else, and one that carries a
credential — a user and password in it, or a query parameter named like
a token, key, password or signature — is not kept at all.
WHEN IT IS USED.
Only when Wonder Ink starts, and only within IDLE_LIMIT_SECONDS of the
last time the teacher used it. The time is the teacher's last use, not
the last start: opening Wonder Ink and having its tabs restored is not
using it, so closing and reopening cannot keep a workspace alive by
itself. Anything that cannot be read, is from another version, is in the
future by more than a minute (a clock gone backwards), or has failed to
restore twice running is discarded, and Wonder Ink starts fresh.
"""
import json
import os
import time
from pathlib import Path
from urllib.parse import parse_qsl, urlsplit
# Two hours after the teacher last used Wonder Ink. The one place this is
# decided.
IDLE_LIMIT_SECONDS = 2 * 60 * 60
# FOR TESTING ONLY. Checking expiry on a real machine should not take two
# hours, so this variable, in the environment of a Wonder Ink started by
# hand from the maintenance terminal, shortens it. Nothing in the classroom
# sets it; it lasts as long as that one process, and it can only shorten.
TEST_OVERRIDE = "CLASSROOM_WONDERINK_TEST_WORKSPACE_SECONDS"
TEST_SHORTEST = 10
# How far the clock may seem to have gone backwards before a workspace is
# discarded for it.
CLOCK_TOLERANCE_SECONDS = 60
# A workspace that has been started from this many times without Wonder
# Ink then running long enough to say it worked is abandoned: one bad tab
# must not make Wonder Ink unusable.
RESTORE_ATTEMPTS = 2
FORMAT = 1
KINDS = ("web", "worksheet")
# WonderInk's own site. Every page of it has the same address — it is one
# page that draws each screen itself — so however many of its tabs a
# teacher had, one is restored, and that one asks them to sign in.
SITE_DOMAIN = "wonderink.org"
# Query parameters that are, or may be, a credential. A tab whose address
# has one is not kept.
CREDENTIAL_WORDS = (
"password", "passwd", "pwd", "token", "auth", "secret", "key",
"signature", "sig", "credential", "session", "jwt", "x-amz-",
)
TITLE_LENGTH = 120
def idle_limit(environ=None):
"""Seconds after the teacher's last use that a workspace is kept."""
environ = os.environ if environ is None else environ
value = environ.get(TEST_OVERRIDE, "").strip()
if value:
try:
seconds = int(value)
except ValueError:
return IDLE_LIMIT_SECONDS
return max(TEST_SHORTEST, min(seconds, IDLE_LIMIT_SECONDS))
return IDLE_LIMIT_SECONDS
def is_test_limit(environ=None):
return idle_limit(environ) != IDLE_LIMIT_SECONDS
def is_site(host, site=SITE_DOMAIN):
host = (host or "").lstrip(".").lower()
return host == site or host.endswith("." + site)
def keepable_url(url):
"""Is this an address that may be written down? http(s), no credential."""
try:
parts = urlsplit(url)
except ValueError:
return False
if parts.scheme not in ("http", "https") or not parts.hostname:
return False
if parts.username or parts.password:
return False
for name, _value in parse_qsl(parts.query, keep_blank_values=True):
lowered = name.lower()
if any(word in lowered for word in CREDENTIAL_WORDS):
return False
return True
def is_site_start(url, site=SITE_DOMAIN):
"""Is this one of WonderInk's own pages, which all share one address?"""
parts = urlsplit(url)
return is_site(parts.hostname, site) and parts.path in ("", "/")
def tab(kind, where, title=""):
return {"kind": kind, "where": str(where), "title": str(title or "")[:TITLE_LENGTH]}
def plan(tabs, active, downloads, site=SITE_DOMAIN):
"""Which of the saved TABS to open, in order, and which to put in front.
Returns (tabs, active) with every tab one that may be opened:
* a web tab only if its address may be kept (see keepable_url);
* WonderInk's own tabs as one, where the first of them was;
* a worksheet only if it is a file directly inside DOWNLOADS that
still exists and still begins as a PDF — a state file cannot send
Wonder Ink to any other file on the computer;
* the tab in front is the one that was, or the nearest kept before it.
"""
downloads = Path(downloads).resolve()
kept, index_of, site_seen = [], {}, None
for number, item in enumerate(tabs):
if not isinstance(item, dict) or item.get("kind") not in KINDS:
continue
where, title = str(item.get("where", "")), str(item.get("title", ""))[:TITLE_LENGTH]
if item["kind"] == "web":
if not keepable_url(where):
continue
if is_site_start(where, site):
if site_seen is not None:
index_of[number] = site_seen
continue
site_seen = len(kept)
else:
if not openable_worksheet(where, downloads):
continue
index_of[number] = len(kept)
kept.append(tab(item["kind"], where, title))
if not kept:
return [], 0
try:
active = int(active)
except (TypeError, ValueError):
active = 0
for candidate in range(active, -1, -1):
if candidate in index_of:
return kept, index_of[candidate]
return kept, 0
def openable_worksheet(path, downloads):
try:
resolved = Path(path).resolve()
except (OSError, RuntimeError, ValueError):
return False
if resolved.parent != Path(downloads).resolve() or not resolved.is_file():
return False
try:
with open(resolved, "rb") as handle:
return handle.read(5) == b"%PDF-"
except OSError:
return False
def read(path, now=None, limit=None):
"""The workspace to restore, or why there is none.
Returns (data, None) — with "tabs", "active", "last_activity" and
"attempts" — or (None, reason). Anything that is not a workspace to
restore is removed.
"""
now = time.time() if now is None else now
limit = idle_limit() if limit is None else limit
path = Path(path)
try:
data = json.loads(path.read_text())
except FileNotFoundError:
return None, "none kept"
except (OSError, ValueError):
return _discard(path, "unreadable")
try:
if data["format"] != FORMAT:
return _discard(path, "from another version")
last = float(data["last_activity"])
tabs = list(data["tabs"])
active = int(data.get("active", 0))
attempts = int(data.get("attempts", 0))
except (KeyError, TypeError, ValueError):
return _discard(path, "incomplete")
if last > now + CLOCK_TOLERANCE_SECONDS:
return _discard(path, "the clock is behind the last use")
if now - last > limit:
return _discard(path, "expired")
if attempts >= RESTORE_ATTEMPTS:
return _discard(path, f"it failed to restore {attempts} times")
if not tabs:
return _discard(path, "no tabs")
return {"tabs": tabs, "active": active, "last_activity": last, "attempts": attempts}, None
def write(path, tabs, active, last_activity, attempts=0):
"""Keep TABS, or remove the file if there is nothing to keep.
Only what plan() would open again is written. Written whole to a
temporary file, flushed to disk, and renamed over the old one, readable
only by the classroom account: a crash or a power cut leaves the old
file or the new one, never half of either.
"""
path = Path(path)
keepable, front = [], 0
for number, item in enumerate(tabs):
if item.get("kind") == "worksheet" or (
item.get("kind") == "web" and keepable_url(item.get("where", ""))
):
if number <= active:
front = len(keepable)
keepable.append(tab(item["kind"], item["where"], item.get("title", "")))
if not keepable or last_activity is None:
forget(path)
return False
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(path.name + ".new")
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(descriptor, "w") as handle:
json.dump({
"format": FORMAT,
"last_activity": float(last_activity),
"active": front,
"attempts": int(attempts),
"tabs": keepable,
}, handle)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
return True
def forget(path):
"""Remove a kept workspace. Never fails: Wonder Ink must start regardless."""
path = Path(path)
for target in (path, path.with_name(path.name + ".new")):
try:
target.unlink(missing_ok=True)
except OSError:
pass
def _discard(path, reason):
forget(path)
return None, reason
+37 -2
View File
@@ -4,9 +4,9 @@ Section: misc
Priority: optional
Architecture: amd64
Maintainer: Ekklesia
Depends: pkexec, polkit-kde-agent-1, plasma-desktop, plasma-workspace, kwin-wayland, plasma-nm, plasma-pa, plasma-widgets-addons, konsole, sddm, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, pulseaudio-utils, cups, cups-client, cups-filters, foomatic-db-compressed-ppds, python3-gi, python3-gi-cairo, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, libnotify-bin, curl, gnupg, ca-certificates, python3-paho-mqtt, python3-pyqt6.qtwebengine, python3-pyqt6
Depends: pkexec, polkit-kde-agent-1, plasma-desktop, plasma-workspace, kwin-wayland, plasma-nm, plasma-pa, plasma-widgets-addons, konsole, sddm, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, pulseaudio-utils, cups, cups-client, cups-filters, foomatic-db-compressed-ppds, python3-gi, python3-gi-cairo, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, libnotify-bin, curl, gnupg, ca-certificates, python3-paho-mqtt, python3-pyqt6.qtwebengine, python3-pyqt6, libreoffice-impress, libreoffice-core, poppler-utils, ghostscript, fonts-liberation, fonts-liberation-sans-narrow, fonts-croscore, fonts-crosextra-carlito, fonts-crosextra-caladea, fonts-urw-base35, fonts-roboto, fonts-open-sans, fonts-lato, fonts-montserrat, fonts-noto-extra
Recommends: vlc
Conflicts: plasma-welcome, plasma-discover, kdeconnect, kup-backup
Conflicts: plasma-welcome, plasma-discover, kdeconnect, kup-backup, khelpcenter
Description: Ekklesia managed classroom environment
A classroom appliance built on Debian's own Plasma desktop. The room's
panel, window behaviour, notifications, palette and lockdown are
@@ -36,6 +36,41 @@ Description: Ekklesia managed classroom environment
of this build. Chrome stays exactly where it was, for the ordinary
browser.
.
libreoffice-impress and poppler-utils are Presentations, and neither is
ever shown to a teacher. LibreOffice runs once per import, headless,
with no window on any screen, to turn a PPT or PPTX into a PDF; poppler
turns that PDF into one picture per slide; the classroom's own library
shows the pictures. Measured before it was chosen: `soffice --show`
does start a real slideshow and then leaves the Impress editor on
screen when it ends, and `okular --presentation` opens its own document
window and an information dialog behind the slides. Neither is
something a teacher may be left looking at, and converting once at
import costs 0.34s for a ten-slide deck and nothing at all afterwards.
libreoffice-gtk3 is deliberately NOT here: nothing interactive runs.
.
ghostscript is Wonder Ink's black and white. It was already on every
machine, because cups-filters needs it; it is named here because Wonder
Ink now runs it itself, to make the grey copy of a page that the print
preview shows and CUPS is given. Measured: it is the same engine Elmer's
driver prints with, and its grey differs from what Elmer would have
made of the colour page by at most 1 level in 255.
.
The fonts are what a Google Slides export asks for. The metric
compatible families cover the Microsoft names — Liberation for Arial,
Times New Roman and Courier New, Carlito for Calibri, Caladea for
Cambria, Croscore for the same three under their Chrome OS names — and
Roboto, Open Sans, Lato and Montserrat are the web fonts Google Slides
itself offers. Every one is in Debian main and installs without a
question. ttf-mscorefonts-installer is deliberately absent: it is in
contrib, it downloads from a third party at install time and it shows
an EULA prompt, and an unattended classroom upgrade cannot answer one.
.
The Conflicts line is deliberate. A welcome tour, a software centre and
a phone pairing daemon are all things plasma-desktop recommends and a
room a teacher walks into should not have.
.
khelpcenter is there for the same reason, found the hard way: a
settings window's Help button opens it directly, whatever the Kiosk
restrictions say, and it is a browser of the machine's manual pages with
file dialogs of its own. plasma-desktop only recommends it, and nothing
else installed needs it.
+7
View File
@@ -62,6 +62,13 @@ systemctl enable sddm.service >/dev/null 2>&1 || true
systemctl --global enable ekklesia-classroom-audio.service >/dev/null 2>&1 || true
systemctl --global enable ekklesia-classroom-desktop.service >/dev/null 2>&1 || true
# The presentation watcher. Enabled on every machine and started by every
# session, because whether it does anything is device.conf's answer, not
# the installer's: with PRESENTATIONS_ENABLED=false — which is the
# default, and therefore every existing classroom — it exits immediately
# and the room never runs an importer it has no use for.
systemctl --global enable ekklesia-classroom-presentations.service >/dev/null 2>&1 || true
# The panel's disc player is decided before the display manager starts, not
# at the same time as it. Enabled rather than started: this install has
# just run the same command directly, below.
+52 -2
View File
@@ -3,7 +3,7 @@
"""Match the classroom's panel to the machine it is on.
Which applications a device offers is decided here, once, and written into
the three feature lines of the installed layout. The panel is then built
the feature lines of the installed layout. The panel is then built
from that layout at the next login.
Each application is its own question, and the shape of the machine is
@@ -15,6 +15,11 @@ never one of them:
Parent Call PARENT_CALL_ENABLED. Same rule.
Presentations PRESENTATIONS_ENABLED. Same rule again, and it asks
the machine nothing at all: a presentation needs a
screen, which every device has. Alone among these it
defaults to OFF, because no existing room has it.
the disc DVD_ENABLED *and* a drive being present. The setting can
player take the player away from a machine that has a drive;
nothing can put it on a machine that has none, because a
@@ -39,6 +44,7 @@ again at the next login.
"""
import glob
import json
import os
import re
import sys
@@ -61,10 +67,25 @@ FEATURE_LINES = (
("WONDERINK", "WONDERINK_ENABLED"),
("DVD", "DVD_ENABLED"),
("PARENT_CALL", "PARENT_CALL_ENABLED"),
("PRESENTATIONS", "PRESENTATIONS_ENABLED"),
)
MARKER = "/* CLASSROOM-FEATURES-BEGIN */"
# WHAT THE MANUAL IS ALLOWED TO KNOW ABOUT THIS DEVICE.
#
# This command already decides what the device offers, so it is also the
# place to write that answer down where the classroom account can read
# it. A list of setting NAMES, and nothing else: no values, no broker
# password, no Wi-Fi, no update address, none of the rest of device.conf
# — which the manual has no use for and the room should not be handed.
#
# In /run rather than /etc because it is derived, not configured: it is
# rewritten from device.conf at every boot by the same service that
# builds the bar, so it cannot go stale behind a setting somebody
# changed.
MANIFEST = "/run/ekklesia-classroom/features.json"
# The kernel names optical drives sr0, sr1, ... regardless of bus. Both
# views are checked: /dev is what anything opening the drive will use, and
# /sys/block is the kernel's own list, which cannot be left behind by a
@@ -121,6 +142,32 @@ def rewrite(text, variable, answer):
)
def publish(answers, path=MANIFEST):
"""Leave the enabled features where classroom-manual can read them.
Best effort on purpose. A room whose manual shows a page for
something it has not got is a small confusion; a panel that failed to
build because a directory was read-only is a room with no way of
getting anywhere.
"""
enabled = sorted(
setting for variable, setting in FEATURE_LINES if answers.get(variable)
)
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
temporary = f"{path}.new"
with open(temporary, "w", encoding="utf-8") as handle:
json.dump(enabled, handle)
os.replace(temporary, path)
os.chmod(path, 0o644)
say(f"the manual will show {', '.join(enabled) or 'no optional pages'}")
except OSError as error:
say(f"could not write {path}: {error}")
def main():
path = sys.argv[1] if len(sys.argv) > 1 else LAYOUT
@@ -137,8 +184,11 @@ def main():
return 0
after = before
answers = wanted(read_device_config())
for variable, answer in wanted(read_device_config()).items():
publish(answers)
for variable, answer in answers.items():
after = rewrite(after, variable, answer)
if after == before:
+308
View File
@@ -0,0 +1,308 @@
#!/usr/bin/env python3
"""Put the classroom's wallpaper on every desktop the shell has.
Tiny Pebbles came up after the 0.2.2 -> 0.2.6.4 upgrade on Plasma's own
purple wallpaper, with the right panel and the right package installed.
Its desktop was containment 10, with no Wallpaper group at all:
[Containments][10]
plugin=org.kde.plasma.folder
wallpaperplugin=org.kde.image
[Containments][10][General]
positions={"1920x1080":[]}
The layout script writes the wallpaper, but only onto the desktops that
exist while it runs. Plasma makes a desktop for every screen, and every
activity, that appears afterwards — and it makes them without the layout.
0.2.2's updater rebooted at three in the morning with the television
switched off at the set, so the layout ran with no screen and no desktop
at all, and the desktop Plasma made when the television came on was
bare. Reproduced in the lab through a new activity, which takes the same
path through the shell: containment 11, the same four lines, Plasma's
wallpaper.
Two things now close that. The look and feel's default wallpaper is a
real wallpaper package, so a desktop Plasma makes on its own comes up
with the classroom's picture — that is the fix, and it needs nothing to
run. And this, which writes the picture into every desktop the file
already has, so that the configuration says what the screen shows.
WHICH CONTAINMENT IS THE DESKTOP is answered by what it is, never by its
number: a top-level containment whose plugin is one of Plasma's desktop
containments. Tiny Pebbles' desktop was 10 and River Kids' is 1, and the
panel's number is whatever was free when it was built.
ONLY THE DESKTOPS ARE TOUCHED. Every panel, applet and line that is not a
desktop's wallpaper is written back byte for byte, and a file that is
already right is not written at all.
WHEN IT RUNS. Before plasmashell starts, as the shell's own ExecStartPre,
because the shell owns this file: editing it underneath a live shell was
measured to destroy the panel. That is every start — a login, a reboot,
and the restart after a crash — and at a login it usually finds nothing,
because classroom-desktop-reset has just removed the file for the layout
to rebuild. Run by hand, it refuses while the shell is running and says
how to apply it. Live D-Bus scripting is not an option: the classroom
locks the desktop and forbids the scripting console, and Plasma answers
"Widgets are locked" — measured.
classroom-desktop-wallpaper repair, if the shell is stopped
classroom-desktop-wallpaper --check say what is wrong, change nothing
classroom-desktop-wallpaper FILE work on FILE instead
"""
import os
import re
import sys
WALLPAPER = "/usr/share/backgrounds/ekklesia/classroom-calm.png"
WALLPAPER_PLUGIN = "org.kde.image"
# Plasma's two desktop containments: Folder View, which is what the
# classroom's desktops are, and the plain Desktop. Panels, the system tray
# a 0.2.2-era profile may still carry and anything else are not desktops.
DESKTOP_PLUGINS = ("org.kde.plasma.folder", "org.kde.desktopcontainment")
# What the layout writes onto a desktop, so a repaired desktop and a
# freshly built one say the same thing.
WALLPAPER_KEYS = (
("FillMode", "2"),
("Image", f"file://{WALLPAPER}"),
)
APPLETSRC = "plasma-org.kde.plasma.desktop-appletsrc"
CONTAINMENT = re.compile(r"^\[Containments\]\[(\d+)\]$")
# key=value, with KConfig's optional [$i]-style flags or [locale] after
# the key. The flags are kept when a value is replaced.
ENTRY = re.compile(r"^([^=\[\s]+)((?:\[[^\]]*\])*)=(.*)$")
def say(message):
print(f"classroom-desktop-wallpaper: {message}", flush=True)
def groups(lines):
"""[(name, header, end)]: lines[header] is the group's header line, or
-1 for the lines before the first group, and end is exclusive."""
found = []
name, header = None, -1
for index, line in enumerate(lines):
stripped = line.strip()
if stripped.startswith("[") and stripped.endswith("]"):
found.append((name, header, index))
name, header = stripped, index
found.append((name, header, len(lines)))
return found
def find_group(lines, name):
for group, header, end in groups(lines):
if group == name:
return header, end
return None
def entry(line):
match = ENTRY.match(line.rstrip("\n"))
return (match.group(1), match.group(2), match.group(3)) if match else None
def desktops(lines):
"""The ids of the desktop containments, whatever they are numbered."""
ids = []
for name, header, end in groups(lines):
match = CONTAINMENT.match(name or "")
if not match:
continue
for line in lines[header + 1:end]:
parsed = entry(line)
if parsed and parsed[0] == "plugin" and parsed[2] in DESKTOP_PLUGINS:
ids.append(match.group(1))
break
return ids
def set_entry(lines, group, key, value):
"""Make `key=value` true in an existing group. Returns what changed,
or None when it already was."""
header, end = find_group(lines, group)
last = header
for index in range(header + 1, end):
parsed = entry(lines[index])
if lines[index].strip():
last = index
if parsed and parsed[0] == key:
if parsed[2] == value:
return None
lines[index] = f"{key}{parsed[1]}={value}\n"
return f"{key} was [{parsed[2]}]"
lines.insert(last + 1, f"{key}={value}\n")
return f"{key} was missing"
def add_group(lines, containment, group):
"""A new group, placed after the last of its containment's own."""
prefix = f"[Containments][{containment}]"
after = None
for name, header, end in groups(lines):
if name == prefix or (name or "").startswith(prefix + "["):
after = end
# Back over the blank lines that separate it from the next group, so
# the new one sits with its containment and the spacing stays as
# KConfig writes it.
while after > 0 and not lines[after - 1].strip():
after -= 1
if after > 0 and not lines[after - 1].endswith("\n"):
lines[after - 1] += "\n"
block = ["\n", f"{group}\n"]
block += [f"{key}={value}\n" for key, value in WALLPAPER_KEYS]
lines[after:after] = block
def converge(text):
"""The file as it should be, and what was changed to make it so."""
lines = text.splitlines(keepends=True)
changes = []
for containment in desktops(lines):
top = f"[Containments][{containment}]"
change = set_entry(lines, top, "wallpaperplugin", WALLPAPER_PLUGIN)
if change:
changes.append(f"desktop {containment}: {change}")
group = f"{top}[Wallpaper][{WALLPAPER_PLUGIN}][General]"
if find_group(lines, group) is None:
add_group(lines, containment, group)
changes.append(f"desktop {containment}: had no wallpaper configured")
continue
for key, value in WALLPAPER_KEYS:
change = set_entry(lines, group, key, value)
if change:
changes.append(f"desktop {containment}: {change}")
return "".join(lines), changes
def default_path():
home = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
return os.path.join(home, APPLETSRC)
def shell_running(proc="/proc"):
"""plasmashell running as this user. Read from /proc rather than asked
of systemd, so a shell started any way at all counts, and rather than
of pgrep, which nothing guarantees is installed."""
uid = os.getuid()
for pid in os.listdir(proc):
if not pid.isdigit():
continue
try:
with open(os.path.join(proc, pid, "comm"), encoding="utf-8") as handle:
name = handle.read().strip()
if name == "plasmashell" and os.stat(os.path.join(proc, pid)).st_uid == uid:
return True
except OSError:
# Gone while it was being looked at.
continue
return False
def write(path, text):
"""Replace the file whole, so a full disk or a power cut leaves the old
one rather than half of a new one."""
temporary = f"{path}.classroom-new"
mode = os.stat(path).st_mode & 0o7777
with open(temporary, "w", encoding="utf-8") as handle:
handle.write(text)
handle.flush()
os.fsync(handle.fileno())
os.chmod(temporary, mode)
os.replace(temporary, path)
def main(argv):
check = "--check" in argv
paths = [arg for arg in argv if not arg.startswith("--")]
path = paths[0] if paths else default_path()
try:
with open(path, "r", encoding="utf-8") as handle:
before = handle.read()
except FileNotFoundError:
say("no desktop configuration yet; the layout writes the wallpaper")
return 0
except OSError as error:
say(f"cannot read {path}: {error}")
return 0
after, changes = converge(before)
found = desktops(before.splitlines(keepends=True))
if not found:
say("no desktop in the configuration yet; Plasma's default is the "
"classroom's wallpaper package")
return 0
if not changes:
say(f"desktop {', '.join(found)}: the classroom wallpaper already")
return 0
for change in changes:
say(change)
if check:
say("not changed (--check)")
return 1
# The shell owns the live file. Anything else is just a file.
if not paths and shell_running():
say("plasmashell is running, and editing its file underneath it")
say("destroys the panel. Apply it with:")
say(" systemctl --user restart plasma-plasmashell.service")
say("which repairs the file before the shell starts again.")
return 1
try:
write(path, after)
except OSError as error:
say(f"cannot write {path}: {error}")
return 1
say(f"the wallpaper is now configured on desktop {', '.join(found)}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""Open this computer's own manual.
classroom-manual open it
classroom-manual --features say what this device would show
One manual is installed on every machine, carrying every page. What makes
it THIS room's manual is that the pages for features this device does not
have are removed before a teacher ever sees them: a Bible study laptop
opens it and reads about the Browser, Presentations and the volume, with
no mention of Wonder Ink or Parent Call to wonder about.
WHAT REACHES THE CLASSROOM ACCOUNT, AND WHAT DOES NOT.
Only a list of setting NAMES — ["WONDERINK_ENABLED", ...] — and only
those the package already defines as optional applications. No values
from device.conf, no broker address, no broker password, no update URL,
no Wi-Fi anything, no other key an administrator has put in that file.
That list is prepared by classroom-desktop-layout, which is already the
one place that decides what this device offers, and left in the runtime
directory where it is readable and carries nothing worth protecting. If
it is not there, every page is shown — which is exactly the printed
manual, and is never wrong, only less specific.
Shown through Chrome in application mode: no address bar, no tabs, one
window that closes. Chrome is on every classroom machine already, the
manual is a local file, and none of it needs the network.
"""
import argparse
import html
import json
import os
import subprocess
import sys
import tempfile
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
sys.path.insert(
0,
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
)
from classroom_config import FEATURES, feature, read_device_config # noqa: E402
MANUAL = "/usr/local/share/ekklesia-classroom/manual/classroom-manual.html"
# Written by classroom-desktop-layout at boot. See the module docstring
# for what is and is not in it.
MANIFEST = "/run/ekklesia-classroom/features.json"
def say(message):
print(f"classroom-manual: {message}")
def enabled_features():
"""The optional applications this device offers, by setting name."""
try:
with open(MANIFEST, "r", encoding="utf-8") as handle:
listed = json.load(handle)
if isinstance(listed, list):
# Filtered against what this package knows, so a stale or
# hand-edited manifest can only ever name a real feature.
return [name for name in listed if name in FEATURES]
except (OSError, ValueError):
pass
# No manifest. Fall back to asking device.conf directly for the
# feature keys and nothing else — the same question, one step less
# removed. A root-run --features does this; a classroom session
# normally has the manifest.
config = read_device_config()
return [name for name in sorted(FEATURES) if feature(config, name)]
def page(features):
"""The installed manual, with this device's features declared in it."""
with open(MANUAL, "r", encoding="utf-8") as handle:
text = handle.read()
declaration = (
"<script>window.CLASSROOM_FEATURES = "
+ json.dumps(sorted(features))
+ ";</script>"
)
# Before the viewer script, which reads it. Nothing else in the file
# is touched, so what a teacher reads is the file the package
# installed.
return text.replace("<body>", "<body>" + declaration, 1)
def main():
parser = argparse.ArgumentParser(
description="Open this computer's manual.")
parser.add_argument("--features", action="store_true",
help="say what this device would show; opens nothing")
arguments = parser.parse_args()
features = enabled_features()
if arguments.features:
for name in sorted(FEATURES):
print(f" {name:<24} {'shown' if name in features else 'hidden'}")
return 0
if not os.path.exists(MANUAL):
say(f"the manual is not installed at {MANUAL}")
return 1
# Composed into the teacher's own cache rather than beside the
# installed file, which is root's and read-only.
cache = os.path.join(
os.environ.get("XDG_CACHE_HOME")
or os.path.expanduser("~/.cache"),
"ekklesia-classroom")
os.makedirs(cache, exist_ok=True)
try:
composed = os.path.join(cache, "manual.html")
with tempfile.NamedTemporaryFile(
"w", encoding="utf-8", dir=cache, delete=False,
suffix=".html") as handle:
handle.write(page(features))
temporary = handle.name
os.replace(temporary, composed)
except OSError as error:
say(f"could not prepare the manual: {error}")
return 1
scale = "1"
try:
scale = subprocess.run(
["/usr/local/bin/classroom-ui-scale"], capture_output=True,
text=True, timeout=10, check=False).stdout.strip() or "1"
except (OSError, subprocess.SubprocessError):
pass
try:
# Application mode: the manual in a window of its own, with no
# address bar for a teacher to end up somewhere else in. The
# room's ordinary browser profile is deliberately not used, so
# opening Help cannot disturb a lesson that is already open in
# Chrome.
os.execvp("google-chrome", [
"google-chrome",
"--ozone-platform=wayland",
f"--force-device-scale-factor={scale}",
f"--user-data-dir={os.path.join(cache, 'manual-profile')}",
"--no-first-run",
"--no-default-browser-check",
"--password-store=basic",
"--hide-crash-restore-bubble",
f"--app=file://{composed}",
])
except OSError as error:
say(f"could not open the browser: {error}")
say(f"the manual itself is at {composed}")
return 1
if __name__ == "__main__":
sys.exit(main())
+4 -89
View File
@@ -105,95 +105,10 @@ OVERALL_TIMEOUT = 210.0
CLOSE_AFTER_SUCCESS = 4.0
def load_colours():
"""The session's own palette, read from the colour scheme in use.
There is one set of design tokens in this classroom, and they live in
a Plasma colour scheme. Reading them here rather than repeating them
means this card follows classroom-theme light and dark without
knowing that either exists.
"""
palette = {
"window": "#141c20", "view": "#0f171a", "text": "#e6edef",
"dim": "#93a5aa", "accent": "#4fb3a8", "on_accent": "#0e1518",
"negative": "#e36a6a", "neutral": "#e0b15c", "positive": "#6fcf97",
"line": "#25333a",
}
scheme = "EkklesiaDark"
for path in (
os.path.expanduser("~/.config/kdeglobals"),
"/etc/ekklesia-classroom/xdg/kdeglobals",
"/etc/xdg/kdeglobals",
):
try:
with open(path, "r", encoding="utf-8") as handle:
section = None
for line in handle:
line = line.strip()
if line.startswith("["):
section = line
elif section and section.startswith("[General]") and \
line.startswith("ColorScheme="):
scheme = line.split("=", 1)[1].strip()
break
except OSError:
continue
else:
if scheme:
break
def rgb(value):
parts = [part.strip() for part in value.split(",")]
if len(parts) != 3:
return None
try:
return "#%02x%02x%02x" % tuple(int(part) for part in parts)
except ValueError:
return None
try:
with open(f"/usr/share/color-schemes/{scheme}.colors", "r",
encoding="utf-8") as handle:
group = None
for line in handle:
line = line.strip()
if line.startswith("["):
group = line
continue
if "=" not in line or group is None:
continue
key, value = (part.strip() for part in line.split("=", 1))
colour = rgb(value)
if colour is None:
continue
if group == "[Colors:Window]":
if key == "BackgroundNormal":
palette["window"] = colour
elif key == "ForegroundNormal":
palette["text"] = colour
elif key == "ForegroundInactive":
palette["dim"] = colour
elif key == "ForegroundNegative":
palette["negative"] = colour
elif key == "ForegroundNeutral":
palette["neutral"] = colour
elif key == "ForegroundPositive":
palette["positive"] = colour
elif key == "BackgroundAlternate":
palette["line"] = colour
elif group == "[Colors:View]" and key == "BackgroundNormal":
palette["view"] = colour
elif group == "[Colors:Selection]":
if key == "BackgroundNormal":
palette["accent"] = colour
elif key == "ForegroundNormal":
palette["on_accent"] = colour
except OSError:
pass
return palette
# The palette lives in classroom_style, so this card, the Presentations
# library and anything else the classroom draws cannot drift apart over
# what "the accent colour" means.
from classroom_style import load_colours # noqa: E402,F401
STYLE = """
+566
View File
@@ -0,0 +1,566 @@
#!/usr/bin/env python3
"""Watch the teacher's downloads, and keep the library tidy.
A teacher downloads a session in ordinary Chrome and then looks for it in
Presentations. Nothing in between is their job: no file manager, no
import button, no "where did you save it". This is the in between.
classroom-presentation-import run as a service, forever
classroom-presentation-import --once one pass, for a technician
classroom-presentation-import --status what the library holds
WHAT IT WILL NOT DO.
It never leaves a duplicate in Downloads. Once a supported download is
complete, its one original is moved into the managed library. Generated
PDFs and page images live beside it and can always be rebuilt from it.
It never imports a Wonder Ink worksheet. Wonder Ink downloads into a
directory of its own inside Downloads and those are worksheets, not
sessions; the cleanup will not remove one either.
It never imports a file that is still arriving. Chrome writes
`Session 7.pptx.crdownload` and renames it when the transfer is
done, so a finished name with that sibling beside it is a download in
progress, not a presentation.
It never overwrites a presentation with a later one of the same
name. Two downloads called "Bible Study.pdf" are two presentations.
"""
import argparse
import errno
import os
import shutil
import sys
import time
import uuid
from pathlib import Path
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
sys.path.insert(
0,
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
)
import classroom_presentations as library # noqa: E402
from classroom_config import feature, read_device_config # noqa: E402
# How often the downloads directory is looked at.
#
# Polling rather than inotify, deliberately. A teacher waits a few
# seconds either way, this cannot leak a watch descriptor or miss an
# event across a restart, and it is the kind of code somebody can still
# read in two years.
SCAN_SECONDS = 4
# How often expired presentations are cleared out.
CLEANUP_SECONDS = 3600
def say(message):
print(f"classroom-presentation-import: {message}", flush=True)
def new_directory(root):
root.mkdir(parents=True, exist_ok=True)
directory = root / uuid.uuid4().hex
directory.mkdir()
return directory
def sync_directory(directory):
"""Best-effort persistence for a rename or unlink transaction."""
try:
descriptor = os.open(directory, os.O_RDONLY)
except OSError:
return
try:
os.fsync(descriptor)
except OSError:
pass
finally:
os.close(descriptor)
def move_download(source, destination):
"""Move one completed download without risking it on another filesystem.
rename is atomic on one filesystem. Across filesystems, copy to a hidden
temporary file, flush it, publish it atomically, and only then unlink the
source. If unlinking fails, the published copy is removed and the source
remains the one original for the next scan.
"""
source = os.fspath(source)
destination = os.fspath(destination)
temporary = destination + ".moving"
os.makedirs(os.path.dirname(destination), exist_ok=True)
if os.path.exists(destination) or os.path.exists(temporary):
raise FileExistsError(destination)
try:
os.replace(source, destination)
sync_directory(os.path.dirname(destination))
sync_directory(os.path.dirname(source))
return
except OSError as error:
if error.errno != errno.EXDEV:
raise
try:
with open(source, "rb") as incoming, open(temporary, "xb") as outgoing:
shutil.copyfileobj(incoming, outgoing, length=1024 * 1024)
outgoing.flush()
os.fsync(outgoing.fileno())
shutil.copystat(source, temporary, follow_symlinks=True)
os.replace(temporary, destination)
sync_directory(os.path.dirname(destination))
try:
os.unlink(source)
except OSError:
# Roll back to the still-complete source rather than leave two
# originals or make a failed move look successful.
try:
os.unlink(destination)
sync_directory(os.path.dirname(destination))
except OSError:
pass
raise
sync_directory(os.path.dirname(source))
except Exception:
try:
os.unlink(temporary)
except OSError:
pass
raise
def forget_presentation(presentation_id, root):
"""Remove transaction records for one interrupted presentation."""
data = library.ledger(root)
changed = False
for mark, value in list(data.items()):
if value == presentation_id:
data.pop(mark, None)
changed = True
if changed:
library.write_json(library.ledger_path(root), data)
class NeedsMemory(RuntimeError):
"""The kernel killed the converter or renderer for want of memory."""
# An import the service was killed in the middle of is resumed at the next
# start — once. Measured in the lab: a deck that needs more memory than
# the machine has free is killed again every time, and with no limit the
# service converted, died and restarted in a loop while the library said
# "Getting this ready…" for ever.
MAX_ATTEMPTS = 2
def prepare(presentation):
"""Build the derived PDF, pages and cover from a managed original."""
data = presentation.data
managed = presentation.managed_copy
# Counted and written BEFORE the work, so that a kill in the middle of
# it is still counted when the service comes back.
data["attempts"] = int(data.get("attempts") or 0) + 1
data["attempt_started_at"] = library.stamp()
presentation.save()
if managed is None or not managed.is_file():
raise RuntimeError("the managed original is missing")
# A restart may find conversion or rendering output from the interrupted
# attempt. The managed original is the recovery point; everything else is
# derived and is rebuilt cleanly.
if presentation.slides != managed:
try:
presentation.slides.unlink()
except OSError:
pass
shutil.rmtree(presentation.pages_directory, ignore_errors=True)
try:
presentation.thumbnail.unlink()
except OSError:
pass
if data["format"] == "pdf":
shutil.copy2(managed, presentation.slides)
else:
converted, error = library.convert_to_pdf(
managed, presentation.slides)
if not converted:
if error == library.KILLED:
raise NeedsMemory(error)
raise RuntimeError(error or "the file could not be converted")
pages, error = library.render_pages(
presentation.slides, presentation.pages_directory)
if not pages:
if error == library.KILLED:
raise NeedsMemory(error)
raise RuntimeError(error or "the slides could not be rendered")
data["pages"] = pages
data["title"] = library.unique_title(
library.readable_title(
data.get("original_name", ""),
library.first_text(presentation.slides)),
library.titles_in_use(presentation.directory.parent,
exclude=presentation.id),
)
library.render_thumbnail(presentation.page_image(1),
presentation.thumbnail)
data["state"] = "ready"
data["error"] = None
data["error_kind"] = None
presentation.save()
say(f"imported {data['title']!r} ({pages} slides)")
return presentation
def import_one(source, root=None):
"""Take one finished download into the library.
Ordered so that an interruption is always recoverable. Metadata and the
transaction record are written before the move. Startup either retries
from Downloads or resumes from the moved managed original. A presentation
becomes ready only once every derived file it needs is on disk.
"""
root = root if root is not None else library.library_root()
mark = library.fingerprint(source)
if mark is None:
return None
directory = new_directory(root)
managed = directory / "original" / source.name
data = {
"id": directory.name,
"state": "importing",
"title": library.readable_title(source.name),
"original_name": source.name,
"source_path": str(source),
"managed_copy": str(managed.relative_to(directory)),
"format": library.SUPPORTED.get(source.suffix.lower(), ""),
"imported_at": library.stamp(),
"last_presented_at": None,
"times_presented": 0,
"thumbnail_page": 1,
"pages": 0,
"error": None,
}
presentation = library.Presentation(directory, data)
presentation.save()
# Recorded before the work, so that a file which crashes the
# conversion every time cannot be retried in a loop forever. The
# startup sweep is what un-records an import interrupted part way.
library.remember(mark, directory.name, root)
say(f"importing {source.name}")
try:
move_download(source, managed)
except Exception as error: # noqa: BLE001
# A failed move leaves the source in Downloads. Remove the transaction
# and retry on the next scan; there is no half-owned file to show.
forget_presentation(presentation.id, root)
shutil.rmtree(directory, ignore_errors=True)
say(f"could not move {source.name}: {error}")
return None
try:
return prepare(presentation)
except Exception as error: # noqa: BLE001
# Conversion and rendering failures keep the managed original. The
# teacher can remove it, and a technician can recover or inspect it;
# no successfully downloaded file is lost because a derived asset
# could not be made.
fail(presentation, error)
say(f"could not import {source.name}: {error}")
return presentation
def fail(presentation, error, kind=None):
"""Record a failure a teacher's card can say something useful about."""
presentation.data["state"] = "failed"
presentation.data["error"] = str(error)[:300]
presentation.data["error_kind"] = kind or (
"memory" if isinstance(error, NeedsMemory) else "unreadable")
presentation.save()
def attempt(presentation):
"""prepare(), with any failure recorded rather than raised."""
try:
prepare(presentation)
except Exception as error: # noqa: BLE001
fail(presentation, error)
say(f"could not import {presentation.title!r}: {error}")
def retry_requested(root=None):
"""Presentations a teacher pressed Try again on, taken up now."""
root = root if root is not None else library.library_root()
retried = 0
if not root.is_dir():
return 0
for presentation in library.library(root):
if presentation.state != "retry":
continue
managed = presentation.managed_copy
if managed is None or not managed.is_file():
fail(presentation, RuntimeError("the managed original is missing"), "unreadable")
continue
say(f"trying again: {presentation.title!r}")
attempt(presentation)
retried += 1
return retried
def sweep(root=None):
"""Recover imports interrupted during a move, conversion or rendering.
If Downloads still has the source, discard only the incomplete managed
copy and let the next scan retry. If the source has already been moved,
the managed original is the recovery point and its derived assets are
rebuilt in place. This distinction prevents a crash after the move from
deleting the only copy of a successful download.
"""
root = root if root is not None else library.library_root()
if not root.is_dir():
return 0
stale = 0
for presentation in library.library(root):
if presentation.state != "importing":
continue
source_text = presentation.data.get("source_path")
source = Path(source_text) if source_text else None
managed = presentation.managed_copy
if (managed is not None and managed.is_file()
and (source is None or not source.exists())):
attempts = int(presentation.data.get("attempts") or 0)
if attempts >= MAX_ATTEMPTS:
# Killed in the middle of it every time it was tried.
say(f"giving up on {presentation.title!r} after {attempts} "
"interrupted attempts; the teacher can try again")
fail(presentation, NeedsMemory("interrupted every attempt"))
else:
say(f"resuming an interrupted import: {presentation.title!r}")
attempt(presentation)
stale += 1
continue
say(f"retrying an interrupted move: {presentation.title!r}")
forget_presentation(presentation.id, root)
shutil.rmtree(presentation.directory, ignore_errors=True)
stale += 1
return stale
def scan(root=None, downloads=None):
"""One look at the downloads directory."""
root = root if root is not None else library.library_root()
known = library.ledger(root)
imported = 0
for candidate in library.candidates(downloads):
mark = library.fingerprint(candidate)
if mark is None or mark in known:
continue
if import_one(candidate, root) is not None:
imported += 1
known = library.ledger(root)
return imported
def cleanup(root=None, moment=None):
"""Remove presentations whose time is up.
Only ever the selected managed directory, including its one original and
derived assets, and never the presentation on the screen.
"""
root = root if root is not None else library.library_root()
lock = library.presenting_lock()
showing = ""
try:
showing = lock.read_text().strip()
except OSError:
showing = ""
removed = 0
for presentation in library.library(root):
if not presentation.expired(moment):
continue
if presentation.id == showing:
# A retention timer must never interrupt a room. It will
# still be expired in an hour.
say(f"{presentation.title!r} is expired but is being presented")
continue
if presentation.last_presented_at is None:
why = f"never presented, imported {presentation.data['imported_at']}"
else:
why = f"last presented {presentation.data['last_presented_at']}"
say(f"removing {presentation.title!r} — {why}")
if library.remove(presentation):
removed += 1
return removed
def status(root=None):
presentations = library.library(root)
if not presentations:
print("the library is empty")
return 0
for presentation in presentations:
expires = presentation.expires_at()
print(f"{presentation.title}")
print(f" state {presentation.state}"
f"{' — ' + presentation.error if presentation.error else ''}")
print(f" slides {presentation.pages}"
f" (card shows slide {presentation.thumbnail_page})")
print(f" imported {presentation.data.get('imported_at')}")
presented = presentation.data.get("last_presented_at") or "never"
print(f" presented {presented} "
f"({presentation.times_presented} times)")
print(f" expires {library.stamp(expires) if expires else 'unknown'}")
return 0
def hold_lock(root=None):
"""The importer's lock, held for as long as this process lives."""
import fcntl
root = root if root is not None else library.library_root()
root.mkdir(parents=True, exist_ok=True)
handle = open(root / ".importer.lock", "a")
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
handle.close()
return None
return handle
def main():
parser = argparse.ArgumentParser(
description="Bring finished downloads into the Presentations library.")
parser.add_argument("--once", action="store_true",
help="one pass, then stop")
parser.add_argument("--status", action="store_true",
help="what the library holds; changes nothing")
parser.add_argument("--cleanup", action="store_true",
help="apply the retention rules now")
arguments = parser.parse_args()
if arguments.status:
return status()
# A device that does not offer Presentations does not run an importer
# for them. Said out loud for the technician who started it by hand.
if not feature(read_device_config(), "PRESENTATIONS_ENABLED"):
say("PRESENTATIONS_ENABLED is false on this device; not starting")
return 0
if arguments.cleanup:
removed = cleanup()
say(f"removed {removed} expired presentation(s)")
return 0
# One importer at a time, whoever started it. Conversions are the
# memory the machine runs short of, so two at once — the service and a
# technician's --once, say — must not happen.
held = hold_lock()
if held is None:
say("another importer is already running; not starting a second")
return 0
sweep()
retry_requested()
if arguments.once:
imported = scan()
cleanup()
say(f"imported {imported} presentation(s)")
return 0
say(f"watching {library.downloads_root()}")
last_cleanup = 0.0
while True:
try:
retry_requested()
scan()
if time.monotonic() - last_cleanup > CLEANUP_SECONDS:
cleanup()
last_cleanup = time.monotonic()
except Exception as error: # noqa: BLE001
# One bad file must not stop the watcher. A teacher whose
# next download never appears has no way to find out why.
say(f"scan failed: {error}")
time.sleep(SCAN_SECONDS)
if __name__ == "__main__":
sys.exit(main())
+982
View File
@@ -0,0 +1,982 @@
#!/usr/bin/env python3
"""The Presentations library, as a teacher sees it.
Three buttons on a card — Present, Quick Look, Remove — and nothing else.
No storage locations, no file names, no import controls, no settings, no
retention. A teacher who has downloaded a session opens this,
recognises tonight's by its picture, and presses Present.
WHY THE PRESENTER IS IN THIS WINDOW AND NOT A SEPARATE PROGRAM.
The disc player is a separate process, because a disc plays until
somebody stops it and the panel is how you get back to it. A
presentation is the opposite: it is a thing a teacher steps into and
must step out of, back to the library, reliably, with one key they
already know.
Making the presenter a fullscreen window of this same application is the
whole of that guarantee. Escape is handled here, the fullscreen window is
taken down here, and the library is already on the screen underneath it.
There is no second process to fail to start, no window to be left behind,
and no path by which a teacher ends up looking at something that is not
the library.
That is also why nothing here launches Impress, Okular or any other
document application. Every one of them that was measured leaves its own
editor or viewer on the screen when a slideshow ends — see
docs/presentations.md for what each one actually did. The slides are
pictures by the time they reach this window, rendered at import by
LibreOffice and poppler, and a picture cannot open a file dialog.
"""
import os
import sys
import gi
gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "3.0")
gi.require_version("GdkPixbuf", "2.0")
from gi.repository import Gdk, GdkPixbuf, GLib, Gtk # noqa: E402
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
sys.path.insert(
0,
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
)
import classroom_presentations as library # noqa: E402
from classroom_config import feature, read_device_config # noqa: E402
from classroom_style import load_colours # noqa: E402
APP_ID = "ekklesia-presentations"
# How often the library notices a newly imported presentation.
#
# The importer is a separate service, so a session downloaded while this
# window is open appears without the teacher doing anything. Two seconds
# is well under the time it takes to walk back to the machine.
REFRESH_SECONDS = 2
CARD_WIDTH = 320
STYLE = """
window {{ background-color: {window}; }}
.heading {{ color: {text}; font-size: 30px; font-weight: 700; }}
.subtle {{ color: {dim}; font-size: 16px; }}
.card {{
background-color: {view};
border: 1px solid {line};
border-radius: 18px;
padding: 14px;
}}
.card-title {{ color: {text}; font-size: 19px; font-weight: 600; }}
.card-note {{ color: {dim}; font-size: 14px; }}
.thumb {{
background-color: {window};
border: 1px solid {line};
border-radius: 10px;
}}
button {{
color: {text};
background-image: none;
background-color: rgba(255,255,255,0.06);
border: 1px solid {line};
border-radius: 12px;
padding: 10px 14px;
font-size: 16px;
}}
button:hover {{ background-color: rgba(255,255,255,0.12); }}
button.primary {{
color: {on_accent};
background-color: {accent};
border-color: {accent};
font-weight: 700;
}}
button.danger:hover {{ background-color: {negative}; color: {on_accent}; }}
.empty {{ color: {dim}; font-size: 20px; }}
.slide-counter {{
color: {text};
background-color: rgba(0,0,0,0.65);
border-radius: 10px;
padding: 6px 12px;
font-size: 16px;
}}
.current-cover {{
color: {on_accent};
background-color: {accent};
border-radius: 10px;
padding: 6px 10px;
font-size: 14px;
font-weight: 700;
}}
"""
def scaled_pixbuf(path, width, height=-1):
try:
return GdkPixbuf.Pixbuf.new_from_file_at_scale(
str(path), width, height, True)
except (GLib.Error, OSError):
return None
def monitor_workarea(window=None):
"""The usable logical pixels on the monitor, excluding Plasma's panel."""
display = Gdk.Display.get_default()
if display is None:
return Gdk.Rectangle(x=0, y=0, width=1280, height=720)
monitor = None
if window is not None and window.get_window() is not None:
monitor = display.get_monitor_at_window(window.get_window())
if monitor is None:
monitor = display.get_primary_monitor()
if monitor is None and display.get_n_monitors() > 0:
monitor = display.get_monitor(0)
if monitor is None:
return Gdk.Rectangle(x=0, y=0, width=1280, height=720)
return monitor.get_workarea()
def monitor_geometry(window=None):
"""The full logical monitor rectangle, including the panel area."""
display = Gdk.Display.get_default()
if display is None:
return Gdk.Rectangle(x=0, y=0, width=1280, height=720)
monitor = None
if window is not None and window.get_window() is not None:
monitor = display.get_monitor_at_window(window.get_window())
if monitor is None:
monitor = display.get_primary_monitor()
if monitor is None and display.get_n_monitors() > 0:
monitor = display.get_monitor(0)
if monitor is None:
return Gdk.Rectangle(x=0, y=0, width=1280, height=720)
return monitor.get_geometry()
class Presenter(Gtk.Window):
"""The slideshow. A fullscreen window showing one picture at a time.
Everything a presentation remote and a keyboard can reasonably send
is accepted, because a teacher in front of a room does not get to
look up which key this one wants. The clicker in the cupboard sends
Page Up and Page Down; the keyboard sends arrows and space.
Escape is the way out and it is the only way out, which is what makes
this safe to put in front of a teacher: there is nothing else to
press and nowhere else to end up.
"""
def __init__(self, presentation, start_page=1, on_finish=None,
display_geometry=None):
# A stable title gives KWin a presenter-only match. The library and
# Quick Look share this application's Wayland app id, so matching the
# app id alone would fullscreen every window.
super().__init__(title="Presenting")
self.presentation = presentation
self.on_finish = on_finish
self.page = min(max(start_page, 1), presentation.pages or 1)
self.counted = False
self.finished = False
self.display_geometry = display_geometry or monitor_geometry()
# How many times fullscreen has been asked for without it
# sticking. See _state.
self.refused = 0
self.set_decorated(False)
self.set_skip_taskbar_hint(True)
self.set_role("classroom-presenter")
self.set_default_size(
self.display_geometry.width, self.display_geometry.height)
self.set_size_request(
self.display_geometry.width, self.display_geometry.height)
self.fullscreen()
# A slideshow is the only thing on the screen while it is running.
self.set_keep_above(True)
black = Gdk.RGBA()
black.parse("#000000")
self.override_background_color(Gtk.StateFlags.NORMAL, black)
# Paint an explicit full-window layer behind the image. The old
# app-paintable Gtk.Window never drew its own background, so KWin
# correctly made it fullscreen while the panel and desktop remained
# visible through the transparent space around a portrait slide.
self.image = Gtk.Image()
self.image.set_halign(Gtk.Align.CENTER)
self.image.set_valign(Gtk.Align.CENTER)
stage = Gtk.Overlay()
stage.set_size_request(
self.display_geometry.width, self.display_geometry.height)
background = Gtk.DrawingArea()
background.connect("draw", self._paint_black)
stage.add(background)
stage.add_overlay(self.image)
self.add(stage)
self.connect("key-press-event", self._key)
self.connect("delete-event", lambda *_: self.finish())
self.connect("size-allocate", self._resized)
self.connect("window-state-event", self._state)
self.connect("map-event", self._mapped)
self._drawn_at = (0, 0, 0)
def _paint_black(self, _widget, context):
context.set_source_rgb(0, 0, 0)
context.paint()
return False
def _mapped(self, *_):
# GTK can make the first request before the Wayland surface exists.
# Repeat it after mapping; KWin's matching rule is the compositor-side
# guarantee, while this keeps the window correct on other desktops.
self.fullscreen()
GLib.idle_add(self._mapped_fullscreen)
return False
def _mapped_fullscreen(self):
if not self.finished:
self.fullscreen()
return False
def _state(self, _widget, event):
"""Put the slideshow back on the whole screen if it is taken off it.
A compositor may unfullscreen a window without being asked:
measured in the lab, where focusing another window on the same
workspace dropped the slideshow to half the screen with the
library beside it, which is precisely the "teacher is left
looking at something that is not the presentation" this whole
design is built to prevent. KWin has its own occasions —
a notification, a second output appearing.
So fullscreen is re-asserted rather than assumed, for as long as
the presentation is running. It stops the moment Escape is
pressed, so this can never fight the teacher's way out.
And it gives up after a few goes. A compositor that keeps
granting fullscreen and taking it away again would otherwise have
this asking forever, in the middle of a Bible study, which is a
worse failure than the window being the wrong size. After that
the slides are still on the screen and Escape still works — only
the insisting stops.
"""
if self.finished:
return False
if event.new_window_state & Gdk.WindowState.FULLSCREEN:
self.refused = 0
return False
if self.refused < 5:
self.refused += 1
self.fullscreen()
return False
# -- showing a slide ------------------------------------------
def _resized(self, _widget, _allocation):
self.show_page()
def show_page(self):
path = self.presentation.page_image(self.page)
allocation = self.get_allocation()
width = max(allocation.width, self.display_geometry.width)
height = max(allocation.height, self.display_geometry.height)
want = (self.page, width, height)
if want == self._drawn_at:
return
if not path.exists():
return
pixbuf = self._fitted(path, width, height)
if pixbuf is None:
return
self.image.set_from_pixbuf(pixbuf)
self._drawn_at = want
# A SLIDE IS ON THE SCREEN, SO THIS IS A PRESENTATION.
#
# Counted here and nowhere earlier. Pressing Present is an
# intention; a viewer that failed to start, a file that could not
# be read and a window that never drew are all not presentations,
# and none of them may buy another thirty days of retention.
if not self.counted:
self.counted = True
self.presentation.mark_presented()
def _fitted(self, path, width, height):
if width < 2 or height < 2:
return None
try:
original = GdkPixbuf.Pixbuf.new_from_file(str(path))
except (GLib.Error, OSError):
return None
# Letterboxed, never cropped and never stretched. A slide with a
# verse on it cannot lose its edges to fit a television.
ratio = min(width / original.get_width(),
height / original.get_height())
fitted_width = max(1, int(original.get_width() * ratio))
fitted_height = max(1, int(original.get_height() * ratio))
fitted = original.scale_simple(
fitted_width,
fitted_height,
GdkPixbuf.InterpType.BILINEAR,
)
# Make the entire client buffer opaque. On Plasma Wayland, painting a
# container behind a smaller Gtk.Image still left the surface outside
# the image transparent: KWin reported a correct 1920x1080 fullscreen
# window, but the panel and desktop showed through the letterbox. A
# full-size RGB pixbuf makes black part of the presented content.
canvas = GdkPixbuf.Pixbuf.new(
GdkPixbuf.Colorspace.RGB, False, 8, width, height)
canvas.fill(0x000000ff)
fitted.copy_area(
0, 0, fitted_width, fitted_height, canvas,
(width - fitted_width) // 2,
(height - fitted_height) // 2,
)
return canvas
# -- moving about ---------------------------------------------
def go(self, delta):
target = self.page + delta
if target < 1:
return
if target > self.presentation.pages:
# PAST THE LAST SLIDE IS THE END, AND THE END IS THE LIBRARY.
#
# Not a black "click to exit" slide, and certainly not an
# editor. A teacher who clicks once more than there are
# slides is finished, and being finished means being back
# where they started.
self.finish()
return
self.page = target
self.show_page()
def _key(self, _widget, event):
key = event.keyval
forward = (
Gdk.KEY_Right, Gdk.KEY_Down, Gdk.KEY_space, Gdk.KEY_Page_Down,
Gdk.KEY_KP_Page_Down, Gdk.KEY_Return, Gdk.KEY_KP_Enter,
Gdk.KEY_KP_Right, Gdk.KEY_KP_Down, Gdk.KEY_n,
)
backward = (
Gdk.KEY_Left, Gdk.KEY_Up, Gdk.KEY_Page_Up, Gdk.KEY_KP_Page_Up,
Gdk.KEY_BackSpace, Gdk.KEY_KP_Left, Gdk.KEY_KP_Up, Gdk.KEY_p,
)
if key in forward:
self.go(1)
elif key in backward:
self.go(-1)
elif key in (Gdk.KEY_Home, Gdk.KEY_KP_Home):
self.page = 1
self.show_page()
elif key in (Gdk.KEY_End, Gdk.KEY_KP_End):
self.page = self.presentation.pages
self.show_page()
elif key in (Gdk.KEY_Escape, Gdk.KEY_q, Gdk.KEY_F5):
self.finish()
return True
def finish(self):
if self.finished:
return True
self.finished = True
try:
library.presenting_lock().unlink()
except OSError:
pass
on_finish = self.on_finish
self.destroy()
# Map and raise the library only after the fullscreen surface is gone.
# Showing it underneath a still-live fullscreen window could leave the
# nested KWin session with no newly damaged return surface on Escape.
if on_finish is not None:
on_finish()
return True
class QuickLook(Gtk.Window):
"""Look through the slides before showing them to a room.
Three things a teacher can do here, and they are kept apart on
purpose because conflating any two of them would be wrong:
browse which slide is on this screen right now;
Use as cover which slide the library card shows;
Present start the slideshow, from the beginning.
Browsing is not presenting: coming in here, looking through every
slide and leaving again must not mark the session as presented or
move its retention timer by a second. Choosing the picture is not
presenting either, and it does not change where a presentation
starts.
"""
def __init__(self, presentation, parent, on_close=None, on_present=None):
super().__init__(title=presentation.title)
self.presentation = presentation
self.on_close = on_close
self.on_present = on_present
# Open on the familiar card image. Teachers can immediately see what
# "cover" means, while Present still starts independently at slide 1.
self.page = presentation.thumbnail_page
self.set_transient_for(parent)
self.set_modal(True)
workarea = monitor_workarea(parent)
window_width = min(1000, max(560, int(workarea.width * 0.86)))
# Leave enough room for decorations and a panel even on compositors
# that report the full monitor as the work area (the nested Plasma lab
# does). At 1366x768 this remains comfortably usable at larger scale.
window_height = min(760, max(420, int(workarea.height * 0.75)))
self.set_default_size(window_width, window_height)
self.preview_width = max(320, window_width - 32)
self.preview_height = max(180, window_height - 210)
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
outer.set_border_width(16)
self.add(outer)
heading = Gtk.Label(label=presentation.title, xalign=0)
heading.get_style_context().add_class("heading")
heading.set_line_wrap(True)
outer.pack_start(heading, False, False, 0)
self.slide = Gtk.Image()
frame = Gtk.EventBox()
frame.get_style_context().add_class("thumb")
frame.add(self.slide)
outer.pack_start(frame, True, True, 0)
# Browsing.
browse = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
browse.set_halign(Gtk.Align.CENTER)
self.back = Gtk.Button(label="Previous")
self.back.connect("clicked", lambda *_: self.go(-1))
browse.pack_start(self.back, False, False, 0)
self.counter = Gtk.Label()
self.counter.get_style_context().add_class("subtle")
browse.pack_start(self.counter, False, False, 12)
self.forward = Gtk.Button(label="Next")
self.forward.connect("clicked", lambda *_: self.go(1))
browse.pack_start(self.forward, False, False, 0)
outer.pack_start(browse, False, False, 0)
# The three things this window is for.
actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
self.choose = Gtk.Button(label="Use as cover")
self.choose.connect("clicked", self._choose)
self.cover = Gtk.Label(label="Current cover")
self.cover.get_style_context().add_class("current-cover")
self.cover_action = Gtk.Stack()
self.cover_action.add_named(self.choose, "choose")
self.cover_action.add_named(self.cover, "current")
actions.pack_start(self.cover_action, False, False, 0)
actions.pack_start(Gtk.Box(), True, True, 0)
close = Gtk.Button(label="Back to Presentations")
close.connect("clicked", lambda *_: self.close_quietly())
actions.pack_start(close, False, False, 0)
present = Gtk.Button(label="Present")
present.get_style_context().add_class("primary")
present.connect("clicked", self._present)
actions.pack_start(present, False, False, 0)
outer.pack_start(actions, False, False, 0)
self.connect("key-press-event", self._key)
self.connect("delete-event", lambda *_: self.close_quietly())
self.connect("size-allocate", lambda *_: self.show_page(False))
self._drawn_at = (0, 0, 0)
self.show_page()
def show_page(self, force=True):
allocation = self.slide.get_allocation()
width = min(self.preview_width,
max(1, allocation.width or self.preview_width))
height = min(self.preview_height,
max(1, allocation.height or self.preview_height))
want = (self.page, width, height)
if want == self._drawn_at and not force:
return
path = self.presentation.page_image(self.page)
pixbuf = (scaled_pixbuf(path, width, height)
if path.exists() else None)
if pixbuf is not None:
self.slide.set_from_pixbuf(pixbuf)
self._drawn_at = want
total = self.presentation.pages
chosen = self.presentation.thumbnail_page
self.counter.set_text(f"Slide {self.page} of {total}")
self.back.set_sensitive(self.page > 1)
self.forward.set_sensitive(self.page < total)
self.cover_action.set_visible_child_name(
"current" if self.page == chosen else "choose")
def go(self, delta):
target = self.page + delta
if 1 <= target <= self.presentation.pages:
self.page = target
self.show_page()
def _choose(self, *_):
# Writes the chosen slide and nothing else. Not a presentation,
# and not a change to where the slideshow starts.
self.presentation.set_thumbnail_page(self.page)
self.show_page()
if self.on_close is not None:
self.on_close()
def _present(self, *_):
self.destroy()
if self.on_present is not None:
self.on_present(self.presentation)
def close_quietly(self):
"""Leave without having presented anything."""
self.destroy()
if self.on_close is not None:
self.on_close()
return True
def _key(self, _widget, event):
key = event.keyval
if key in (Gdk.KEY_Right, Gdk.KEY_Down, Gdk.KEY_Page_Down,
Gdk.KEY_space):
self.go(1)
elif key in (Gdk.KEY_Left, Gdk.KEY_Up, Gdk.KEY_Page_Up):
self.go(-1)
elif key == Gdk.KEY_Escape:
self.close_quietly()
else:
return False
return True
class Library(Gtk.Window):
"""The window the panel button opens."""
def __init__(self):
super().__init__(title="Presentations")
self.set_default_size(680, 420)
self.presenter = None
self._signature = None
self._apply_style()
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
outer.set_border_width(20)
self.add(outer)
heading = Gtk.Label(label="Presentations", xalign=0)
heading.get_style_context().add_class("heading")
outer.pack_start(heading, False, False, 0)
self.note = Gtk.Label(xalign=0)
self.note.get_style_context().add_class("subtle")
outer.pack_start(self.note, False, False, 0)
scroller = Gtk.ScrolledWindow()
scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
outer.pack_start(scroller, True, True, 8)
self.grid = Gtk.FlowBox()
self.grid.set_valign(Gtk.Align.START)
self.grid.set_max_children_per_line(4)
self.grid.set_min_children_per_line(1)
self.grid.set_selection_mode(Gtk.SelectionMode.NONE)
self.grid.set_row_spacing(16)
self.grid.set_column_spacing(16)
self.grid.set_homogeneous(False)
scroller.add(self.grid)
self.connect("key-press-event", self._key)
self.connect("destroy", lambda *_: Gtk.main_quit())
self.refresh()
GLib.timeout_add_seconds(REFRESH_SECONDS, self._tick)
def _apply_style(self):
css = Gtk.CssProvider()
css.load_from_data(STYLE.format(**load_colours()).encode("utf-8"))
Gtk.StyleContext.add_provider_for_screen(
self.get_screen(), css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
# -- the wall of cards ----------------------------------------
def _tick(self):
self.refresh()
return True
def signature(self, presentations):
"""What the library looks like, so it is only rebuilt when it changes.
Rebuilding a FlowBox under a teacher's pointer every two seconds
would make the buttons unclickable.
"""
return tuple(
(p.id, p.state, p.pages, p.thumbnail_page, p.title, p.failure)
for p in presentations
)
def _fit_window(self, count, tall=False):
"""Size to the cards while staying inside the panel's work area."""
workarea = monitor_workarea(self)
maximum_width = max(420, int(workarea.width * 0.92))
maximum_height = max(360, int(workarea.height * 0.90))
if count == 0:
width = min(maximum_width, 640)
height = min(maximum_height, 300)
else:
# FlowBox includes the card's CSS padding and the three buttons'
# natural widths when deciding whether another child fits. Budget
# that chrome here too, or two cards can wrap into two rows while
# the window is sized for one.
card_with_gap = CARD_WIDTH + 40
available = max(1, (maximum_width - 40) // card_with_gap)
columns = min(count, available, 4)
rows = (count + columns - 1) // columns
width = min(maximum_width, max(420, 40 + columns * card_with_gap))
# A card explaining a memory failure carries three lines of
# words above its buttons, not one; measured, at 340 its Try
# again was cut off below the window's edge.
height = min(maximum_height, 130 + rows * (400 if tall else 340))
self.resize(width, height)
def refresh(self):
presentations = library.library()
signature = self.signature(presentations)
if signature == self._signature:
return
self._signature = signature
for child in self.grid.get_children():
self.grid.remove(child)
if not presentations:
self.note.set_text("")
empty = Gtk.Label(
label="No presentations yet\n\n"
"Download a PowerPoint or PDF to get started.")
empty.get_style_context().add_class("empty")
empty.set_justify(Gtk.Justification.CENTER)
self.grid.add(empty)
else:
# Three states, and a file that could not be opened is not
# the same as one still being got ready. Telling a teacher
# something is "being prepared" when it never will be is how
# somebody ends up waiting through a Bible study for it.
ready = sum(1 for p in presentations if p.ready)
again = sum(1 for p in presentations if p.failure == "memory")
broken = sum(1 for p in presentations
if p.failed and p.failure != "memory")
working = len(presentations) - ready - broken - again
parts = [f"{ready} ready to present"]
if working:
parts.append(f"{working} still being prepared")
if again:
parts.append(f"{again} to try again")
if broken:
parts.append(f"{broken} could not be opened")
self.note.set_text(", ".join(parts) + ".")
for presentation in presentations:
self.grid.add(self.card(presentation))
self.grid.show_all()
self._fit_window(len(presentations),
tall=any(p.failure == "memory" for p in presentations))
def card(self, presentation):
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
box.get_style_context().add_class("card")
box.set_size_request(CARD_WIDTH, -1)
picture_frame = Gtk.EventBox()
picture_frame.get_style_context().add_class("thumb")
picture_frame.set_size_request(CARD_WIDTH - 28, 180)
if presentation.thumbnail.exists():
picture = Gtk.Image()
pixbuf = scaled_pixbuf(
presentation.thumbnail, CARD_WIDTH - 28, 180)
if pixbuf is not None:
picture.set_from_pixbuf(pixbuf)
else:
picture = Gtk.Image()
picture.set_from_icon_name("view-presentation", Gtk.IconSize.DIALOG)
picture.set_pixel_size(96)
picture_frame.add(picture)
box.pack_start(picture_frame, False, False, 0)
title = Gtk.Label(label=presentation.title, xalign=0)
title.get_style_context().add_class("card-title")
title.set_line_wrap(True)
title.set_max_width_chars(32)
title.set_size_request(CARD_WIDTH - 28, -1)
box.pack_start(title, False, False, 0)
note = Gtk.Label(xalign=0)
note.get_style_context().add_class("card-note")
box.pack_start(note, False, False, 0)
buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
if presentation.ready:
note.set_text(f"{presentation.pages} slides")
present = Gtk.Button(label="Present")
present.get_style_context().add_class("primary")
present.connect("clicked",
lambda *_: self.start_presenting(presentation))
buttons.pack_start(present, True, True, 0)
look = Gtk.Button(label="Quick Look")
look.connect("clicked", lambda *_: self.quick_look(presentation))
buttons.pack_start(look, True, True, 0)
elif presentation.failure == "memory":
# The computer ran short of memory getting it ready, or the
# importer stopped part way. Worth another go with less open;
# never a converter's message, a path or a terminal.
note.set_text("This needed more memory than the computer had "
"free. Close other programs, then choose "
"Try again.")
note.set_line_wrap(True)
note.set_max_width_chars(34)
again = Gtk.Button(label="Try again")
again.get_style_context().add_class("primary")
again.connect("clicked", lambda *_: self.try_again(presentation))
buttons.pack_start(again, True, True, 0)
elif presentation.failed:
# SHORT, AND IN WORDS A TEACHER CAN ACT ON.
#
# Never the converter's error text, a path or a terminal.
# The managed original remains recoverable until Remove.
note.set_text("This file could not be opened.")
note.set_line_wrap(True)
else:
note.set_text("Getting this ready…")
remove = Gtk.Button(label="Remove")
remove.get_style_context().add_class("danger")
remove.connect("clicked", lambda *_: self.remove(presentation))
buttons.pack_start(remove, True, True, 0)
box.pack_start(buttons, False, False, 0)
return box
# -- what the buttons do --------------------------------------
def start_presenting(self, presentation, start_page=1):
if not presentation.ready:
return
first = presentation.page_image(start_page)
if not first.exists():
self.trouble(presentation,
"This presentation is not ready yet.")
return
try:
lock = library.presenting_lock()
lock.parent.mkdir(parents=True, exist_ok=True)
lock.write_text(presentation.id)
except OSError:
# A lock that could not be written only costs the cleanup its
# politeness. It must not stop a teacher presenting.
pass
self.presenter = Presenter(
presentation, start_page, on_finish=self._presented,
display_geometry=monitor_geometry(self))
self.hide()
self.presenter.show_all()
self.presenter.present()
self.presenter.fullscreen()
def _presented(self):
self.presenter = None
self._signature = None
self.refresh()
self.present_window()
def present_window(self):
self.show_all()
self.present()
def quick_look(self, presentation):
if not presentation.ready:
return
window = QuickLook(
presentation, self,
on_close=self._looked,
on_present=lambda p: self.start_presenting(p),
)
window.show_all()
def _looked(self):
# Coming back from Quick Look is not a presentation. The only
# thing that may have changed is which slide the card shows.
self._signature = None
self.refresh()
def try_again(self, presentation):
# The importer takes it up within a few seconds; the card says
# "Getting this ready" again at the next refresh.
if library.request_retry(presentation):
self.refresh()
def remove(self, presentation):
dialog = Gtk.MessageDialog(
transient_for=self,
modal=True,
message_type=Gtk.MessageType.QUESTION,
buttons=Gtk.ButtonsType.NONE,
text="Remove this presentation?",
)
dialog.format_secondary_text(
f"“{presentation.title}”\n\n"
"This will delete it from this computer.")
dialog.add_button("Cancel", Gtk.ResponseType.CANCEL)
dialog.add_button("Remove", Gtk.ResponseType.OK)
dialog.set_default_response(Gtk.ResponseType.CANCEL)
answer = dialog.run()
dialog.destroy()
if answer != Gtk.ResponseType.OK:
return
if not library.remove(presentation):
self.trouble(
presentation,
"Finish presenting before removing this presentation.")
return
self._signature = None
self.refresh()
def trouble(self, presentation, message):
dialog = Gtk.MessageDialog(
transient_for=self,
modal=True,
message_type=Gtk.MessageType.INFORMATION,
buttons=Gtk.ButtonsType.OK,
text=presentation.title,
)
dialog.format_secondary_text(message)
dialog.run()
dialog.destroy()
def _key(self, _widget, event):
if event.keyval == Gdk.KEY_Escape:
# Escape in the library closes the library, which is the same
# thing it does everywhere else in this room.
self.destroy()
return True
return False
def main():
GLib.set_prgname(APP_ID)
GLib.set_application_name("Presentations")
# A device that does not offer Presentations does not open them. The
# panel has no button, so nothing should reach here; saying it out
# loud is for the technician who ran the command by hand.
if not feature(read_device_config(), "PRESENTATIONS_ENABLED"):
print("classroom-presentations: PRESENTATIONS_ENABLED is false on "
"this device; not starting", flush=True)
return 0
window = Library()
window.show_all()
Gtk.main()
return 0
if __name__ == "__main__":
sys.exit(main())
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""Reboot after an update, but only once nobody is using the computer.
The nightly update check (ekklesia-classroom-update.timer, 03:00, and
Persistent so that a computer switched off at 03:00 checks when it is next
switched on) used to reboot the moment an update was installed. At 03:00
that is harmless. Switched on in the morning after a night off, it meant a
classroom computer restarting itself half an hour into the day, in front
of a teacher.
So the updater installs, then starts this, and this reboots only when:
nothing has touched any keyboard, mouse, touchpad or touchscreen for
IDLE_SECONDS — seen here as kernel input events, which is what every
one of them produces, whatever the desktop is doing; and
no sound has played for as long — a video to the room has no input for
an hour and is not an idle computer, and the end of it is not the
moment to restart either. Seen as the kernel's own playback state
(/proc/asound/.../status says RUNNING while a stream plays).
At 03:00 that is about 03:30. In the middle of a morning it is half an
hour after the teacher last touched it. If the computer is switched off
first, nothing is lost: the update is installed, and the next start is on
it. Until then the running session carries on as it was.
The time starts when this starts: input from before cannot be seen, so a
computer is only called idle after this has watched it be idle.
classroom-reboot-when-idle [--idle-seconds N] [--reboot-command CMD...]
The options are for the lab; the service runs it with none.
"""
import argparse
import glob
import os
import select
import subprocess
import sys
import time
IDLE_SECONDS = 30 * 60
# How often it looks: input is noticed at once, the rest once a minute is
# plenty for a half-hour decision.
CHECK_SECONDS = 60
INPUT_GLOB = "/dev/input/event*"
ASOUND_ROOT = "/proc/asound"
def log(message):
print(f"[classroom-reboot-when-idle] {message}", flush=True)
def audio_playing(root=ASOUND_ROOT):
"""Is any sound card playing right now?"""
for status in glob.glob(os.path.join(root, "card*", "pcm*p", "sub*", "status")):
try:
with open(status, encoding="utf-8", errors="replace") as handle:
if handle.readline().strip() == "state: RUNNING":
return True
except OSError:
continue
return False
def should_reboot(now, last_input, idle_seconds, playing):
"""The decision: long enough without input, and nothing playing."""
return not playing and now - last_input >= idle_seconds
class Inputs:
"""Every input device the kernel has, reopened as they come and go."""
def __init__(self, pattern=INPUT_GLOB):
self.pattern = pattern
self.open = {}
def rescan(self):
present = set(glob.glob(self.pattern))
for fd, path in list(self.open.items()):
if path not in present:
os.close(fd)
del self.open[fd]
known = set(self.open.values())
for path in sorted(present - known):
try:
self.open[os.open(path, os.O_RDONLY | os.O_NONBLOCK)] = path
except OSError:
continue
def wait(self, seconds):
"""True if anything happened on any device within SECONDS."""
if not self.open:
time.sleep(seconds)
return False
ready, _, _ = select.select(list(self.open), [], [], seconds)
seen = False
for fd in ready:
try:
while os.read(fd, 4096):
seen = True
except BlockingIOError:
pass
except OSError:
# A device that went away mid-read; the next rescan drops it.
pass
return seen
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--idle-seconds", type=float, default=IDLE_SECONDS)
parser.add_argument("--check-seconds", type=float, default=CHECK_SECONDS)
parser.add_argument("--input-glob", default=INPUT_GLOB)
parser.add_argument("--asound-root", default=ASOUND_ROOT)
parser.add_argument("--reboot-command", nargs="+", default=["systemctl", "reboot"])
args = parser.parse_args(argv)
inputs = Inputs(args.input_glob)
inputs.rescan()
last_input = time.monotonic()
log(f"an update is installed; rebooting once nobody has used this computer for "
f"{round(args.idle_seconds / 60)} minutes and nothing is playing "
f"(watching {len(inputs.open)} input devices)")
while True:
deadline = time.monotonic() + args.check_seconds
while time.monotonic() < deadline:
if inputs.wait(max(0.0, min(5.0, deadline - time.monotonic()))):
last_input = time.monotonic()
inputs.rescan()
playing = audio_playing(args.asound_root)
if playing:
# Sound counts as someone there: a 40-minute video ending is
# not the moment to restart, so the half hour starts after it.
last_input = time.monotonic()
if should_reboot(time.monotonic(), last_input, args.idle_seconds, playing):
log("nobody has used it, and nothing is playing: rebooting")
return subprocess.run(args.reboot_command).returncode
if __name__ == "__main__":
sys.exit(main())
+350 -39
View File
@@ -77,6 +77,11 @@ def nmcli(*args, check=True):
result = subprocess.run(
["nmcli"] + list(args), capture_output=True, text=True, timeout=60,
check=False,
# nmcli translates its output and its terse field separator rules
# are the only thing here that is parsed. C is the one locale in
# which what this reads back is what nmcli is documented to
# print, on a machine in any country.
env={**os.environ, "LC_ALL": "C"},
)
if check and result.returncode != 0:
@@ -86,14 +91,57 @@ def nmcli(*args, check=True):
return result
def terse(line):
"""One line of `nmcli -t` output, split into its fields.
nmcli escapes a colon inside a value as `\\:` and a backslash as
`\\\\`, so splitting on ":" is wrong for any network whose name
contains one. Everything here that compares a connection name to a
field goes through this, because getting it wrong does not produce an
error — it produces a migration that decides it failed and rolls a
working network back.
"""
fields = []
current = []
escaped = False
for character in line:
if escaped:
current.append(character)
escaped = False
elif character == "\\":
escaped = True
elif character == ":":
fields.append("".join(current))
current = []
else:
current.append(character)
fields.append("".join(current))
return fields
def connections():
"""Every saved connection, as (name, uuid, type)."""
result = nmcli("-t", "-f", "NAME,UUID,TYPE", "connection", "show",
check=False)
found = []
for line in (result.stdout or "").splitlines():
fields = terse(line)
if len(fields) >= 3:
found.append((fields[0], fields[1], fields[2]))
return found
def listing():
result = nmcli("-t", "-f", "NAME,TYPE,UUID", "connection", "show")
for line in result.stdout.splitlines():
parts = line.split(":")
if len(parts) >= 2 and "wireless" in parts[1]:
print(f" {parts[0]}")
for name, _uuid, kind in connections():
if "wireless" in kind:
print(f" {name}")
return 0
@@ -105,7 +153,7 @@ def forget(name):
return 0
def add(ssid, password, name=None):
def add(ssid, password, name=None, hidden=False):
name = name or ssid
# Deliberately no connection.permissions. That single omission is the
@@ -121,6 +169,15 @@ def add(ssid, password, name=None):
# Stored in the connection file, which is root-only, rather than
# in a login keyring that belongs to whoever is signed in.
"802-11-wireless-security.psk-flags", "0",
# WHETHER THE ACCESS POINT BROADCASTS ITS NAME.
#
# Always passed, in both directions, rather than only when it is
# true. NetworkManager's default is "no", and a default that
# happens to be right is not the same as an answer: this is the
# setting the prayer-room laptop's migration left at the default
# and could therefore never associate. Written down either way so
# that the connection file says what was meant.
"802-11-wireless.hidden", "yes" if hidden else "no",
]
if password:
@@ -131,9 +188,13 @@ def add(ssid, password, name=None):
nmcli(*arguments)
path = os.path.join(CONNECTIONS, f"{name}.nmconnection")
if hidden:
say(f"{name} is a hidden network; NetworkManager will probe for it "
"by name")
if os.path.exists(path):
path = connection_file(name)
if path and os.path.exists(path):
# NetworkManager already writes 0600 root:root. Said out loud and
# enforced anyway, because this is the file the whole arrangement
# rests on.
@@ -148,6 +209,28 @@ def add(ssid, password, name=None):
return 0
def connection_file(name):
"""Where NetworkManager actually put a connection, or None.
Asked rather than constructed. NetworkManager escapes characters in
the filename it derives from a connection name, so
f"{name}.nmconnection" is a guess that happens to be right for a
one-word SSID and wrong for "ROGC Classroom" — which is the network
this command exists to migrate, and the file whose permissions the
whole arrangement rests on.
"""
result = nmcli("-t", "-f", "NAME,FILENAME", "connection", "show",
check=False)
for line in (result.stdout or "").splitlines():
fields = terse(line)
if len(fields) >= 2 and fields[0] == name and fields[-1]:
return fields[-1]
return None
def run(*command):
"""Run a command, tolerating one that is not installed.
@@ -198,16 +281,78 @@ def carrying(name, device):
"--active", check=False)
for line in (result.stdout or "").splitlines():
parts = line.rsplit(":", 1)
fields = terse(line)
if len(parts) == 2 and parts[0] == name and parts[1] == device:
if len(fields) == 2 and fields[0] == name and fields[1] == device:
return True
return False
def device_state(device):
"""NetworkManager's word for what it is doing with this interface."""
result = nmcli("-t", "-f", "DEVICE,STATE", "device", "status", check=False)
for line in (result.stdout or "").splitlines():
fields = terse(line)
if len(fields) >= 2 and fields[0] == device:
return fields[1]
return "unknown"
def unmanaged_by_config(device):
"""Configuration files that name this device as unmanaged.
Separate from the ifupdown plugin, and the reason the prayer-room
laptop still needed `nmcli device set wlp2s0 managed yes` by hand
after its stanza was gone. A keyfile saying `unmanaged-devices` is
not undone by commenting out an interfaces stanza, and a hand-over
that does not notice it hands the interface to nobody.
"""
naming = []
candidates = ["/etc/NetworkManager/NetworkManager.conf"]
for directory in ("/etc/NetworkManager/conf.d",
"/usr/lib/NetworkManager/conf.d",
"/run/NetworkManager/conf.d"):
try:
candidates += sorted(
os.path.join(directory, entry)
for entry in os.listdir(directory)
if entry.endswith(".conf")
)
except OSError:
continue
for path in candidates:
try:
with open(path, "r", encoding="utf-8", errors="replace") as handle:
text = handle.read()
except OSError:
continue
for line in text.splitlines():
stripped = line.strip()
if not stripped.startswith("unmanaged-devices"):
continue
if device in stripped or "except:" in stripped:
naming.append(path)
break
return naming
def find_stanza():
"""The one wireless stanza ifupdown holds, or None, plus the file."""
if not os.path.exists(INTERFACES):
# A machine with no ifupdown configuration at all. Nothing is
# wrong with that and nothing here has anything to do.
return None, ""
try:
with open(INTERFACES, "r", encoding="utf-8") as handle:
text = handle.read()
@@ -244,20 +389,33 @@ def check():
return 0
device = stanza["name"]
ssid, _ = ifupdown.credentials(stanza)
found = ifupdown.profile(stanza)
say(f"NOTE: {device} is configured in {INTERFACES}, so ifupdown owns "
"it and")
say(" NetworkManager will report it unmanaged — the room's panel")
say(" will say there is no connection and will not find networks.")
if ssid:
if found and found["hidden"]:
say(" Its access point does not broadcast its name, which "
"NetworkManager")
say(" has to be told; --adopt carries that across.")
for path in unmanaged_by_config(device):
say(f" {path} also names this device unmanaged; --adopt "
"reports it")
say(" rather than editing it.")
if found is None:
say(" Its credentials are not in that file, so it has to be "
"moved by hand.")
elif found["key_mgmt"] is None:
say(" Its security is not a pre-shared key, so it has to be "
"moved by hand.")
else:
say(" To move it, while watching, over SSH:")
say(" classroom-wifi --adopt --dry-run")
say(" classroom-wifi --adopt")
else:
say(" Its credentials are not in that file, so it has to be "
"moved by hand.")
return 0
@@ -307,17 +465,37 @@ def adopt(dry_run=False):
return 0
device = stanza["name"]
ssid, psk = ifupdown.credentials(stanza)
found = ifupdown.profile(stanza)
if not ssid:
if found is None:
say(f"{device} is configured through a wpa_supplicant.conf rather "
"than named in this file")
say("that one is not ours to move; adopt it by hand")
return 1
if found["key_mgmt"] is None:
say(f"{device} uses a security this command does not recreate "
f"({stanza['options'].get('wpa-key-mgmt')})")
say("refusing to guess; a network moved wrong is a network that "
"cannot come back")
return 1
ssid = found["ssid"]
psk = found["psk"]
# Never printed. The room's password is not diagnostic output.
say(f"{device}: ifupdown holds a wireless network "
f"({'with' if psk else 'without'} a saved password)")
f"({'with' if psk else 'without'} a saved password"
f"{', hidden' if found['hidden'] else ''})")
# A keyfile naming this device unmanaged survives the stanza going
# away, so the hand-over would end with the interface belonging to
# nobody. Said before anything is touched, and not edited: a file an
# administrator wrote is not this command's to rewrite.
for path in unmanaged_by_config(device):
say(f"WARNING: {path} names {device} unmanaged")
say(" NetworkManager will not keep it after a restart "
"until that is changed")
# Refuse to start a hand-over that cannot be undone. ifdown releases
# the interface and ifup is the whole of the rollback; without them
@@ -337,17 +515,32 @@ def adopt(dry_run=False):
"run would refuse")
say(f"would create a NetworkManager system connection for {ssid!r}")
say(f"would comment the {device} stanza out of {INTERFACES}")
say(" 802-11-wireless.hidden: "
f"{'yes' if found['hidden'] else 'no'}")
say(f" 802-11-wireless-security.key-mgmt: {found['key_mgmt']}")
say(f"would run `ifdown {device}` while its stanza is still in "
f"{INTERFACES}")
say(f"would then comment that stanza out of {INTERFACES}")
say("would hand the interface over, and put it back if that failed")
return 0
existing = nmcli("-t", "-f", "NAME", "connection", "show", check=False)
already = ssid in (existing.stdout or "").splitlines()
already = any(name == ssid for name, _uuid, _kind in connections())
if already:
# Reused as it stands. An existing connection of this name may be
# a teacher's own, or one a previous attempt left behind with the
# wrong hidden setting — which is exactly the state the
# prayer-room laptop was in — so it is corrected rather than
# trusted. Every setting the migration decides is written again.
say(f"NetworkManager already has a connection named {ssid!r}")
say("correcting it to match what ifupdown holds")
nmcli("connection", "modify", ssid,
"802-11-wireless.hidden", "yes" if found["hidden"] else "no",
"connection.autoconnect", "yes",
"802-11-wireless-security.psk-flags", "0",
check=False)
else:
if add(ssid, psk) != 0:
if add(ssid, psk, hidden=found["hidden"]) != 0:
return 1
backup = f"{INTERFACES}.before-ekklesia-classroom"
@@ -361,20 +554,51 @@ def adopt(dry_run=False):
# timeout, an interrupt — the file goes back and ifupdown gets the
# interface again. Nothing below may raise past this.
try:
# RELEASE THE INTERFACE BEFORE TAKING ITS STANZA AWAY.
#
# ifdown reads /etc/network/interfaces to find out how to bring
# an interface down. Run after the stanza is commented out it
# reports "Ignoring unknown interface" and does nothing at all —
# so ifupdown's own wpa_supplicant and DHCP client were still
# running, still holding the radio, when NetworkManager was asked
# to associate with it. Two supplicants on one interface is a
# fight NetworkManager loses, and it is the second half of why
# the prayer-room laptop rolled back.
#
# The order is therefore: down first, while the file still says
# how; rewrite the file second. The window in which the machine
# is offline is the same either way.
result = run("ifdown", device)
if result is not None and result.returncode != 0:
# Not fatal. A machine whose interface is already down is a
# perfectly good starting point, and ifdown says so with a
# non-zero status.
say(f"ifdown {device}: "
f"{(result.stderr or result.stdout or '').strip()}")
say(f"released {device} from ifupdown")
with open(INTERFACES, "w", encoding="utf-8") as handle:
handle.write(ifupdown.comment_out(text, stanza))
say(f"took {device} out of ifupdown's hands")
say(f"took {device} out of ifupdown's hands in {INTERFACES}")
# NetworkManager only re-reads which interfaces ifupdown claims
# when its configuration is reloaded. The link is still
# ifupdown's at this point, and NetworkManager does not touch a
# device it considers unmanaged, so this cannot drop it.
# when its configuration is reloaded.
nmcli("general", "reload", "conf", check=False)
run("ifdown", device)
nmcli("device", "set", device, "managed", "yes", check=False)
nmcli("connection", "up", ssid, check=False)
# On the device, by name. `connection up <name>` alone lets
# NetworkManager choose an interface, and a machine with two
# radios could bring the network up on the wrong one — which
# `carrying` would then correctly report as a failure, and the
# migration would roll back a network that was working.
started = nmcli("connection", "up", ssid, "ifname", device,
check=False)
if started.returncode != 0:
say(f"nmcli: {(started.stderr or '').strip()}")
deadline = time.monotonic() + ADOPT_TIMEOUT
@@ -382,12 +606,15 @@ def adopt(dry_run=False):
if carrying(ssid, device) and online():
say(f"{device} is NetworkManager's now, and the room is "
"online")
say(f"NetworkManager reports {device} "
f"{device_state(device)}")
say("the panel will show the connection at the next login")
return 0
time.sleep(3)
say("NetworkManager did not get the network back")
say("NetworkManager did not get the network back "
f"({device} is {device_state(device)})")
except BaseException as error: # noqa: BLE001 - see the comment above
say(f"the migration failed part way through: {error}")
@@ -398,6 +625,48 @@ def adopt(dry_run=False):
return 1
def put_interfaces_back():
"""Undo this command's edit to /etc/network/interfaces.
Un-commenting the marked lines where they are, rather than copying
the backup over the whole file. The backup is written once, on the
first attempt, and never refreshed — so a whole-file restore silently
reverts anything an administrator changed about the WIRED network in
between. The marker names exactly the lines this command commented
out, and nothing else in the file is touched.
The backup is still the fallback, for a file that has been replaced
since rather than edited.
"""
backup = f"{INTERFACES}.before-ekklesia-classroom"
try:
with open(INTERFACES, "r", encoding="utf-8") as handle:
current = handle.read()
except OSError as error:
say(f"cannot read {INTERFACES}: {error}")
current = None
if current is not None:
restored, count = ifupdown.uncomment(current)
if count:
with open(INTERFACES, "w", encoding="utf-8") as handle:
handle.write(restored)
say(f"put {count} line{'' if count == 1 else 's'} back in "
f"{INTERFACES}, and changed nothing else in it")
return True
if os.path.exists(backup):
shutil.copy2(backup, INTERFACES)
say(f"no marked lines to restore; copied {backup} back instead")
return True
say(f"nothing of this command's is in {INTERFACES} to undo")
return False
def restore(device, ssid=None):
"""Put the interface back under ifupdown.
@@ -405,11 +674,7 @@ def restore(device, ssid=None):
decide what that means for an exit code: for a failed migration it is
still a failure, and for `--rollback` it is a success.
"""
backup = f"{INTERFACES}.before-ekklesia-classroom"
if os.path.exists(backup):
shutil.copy2(backup, INTERFACES)
say(f"restored {INTERFACES} from {backup}")
put_interfaces_back()
nmcli("general", "reload", "conf", check=False)
@@ -418,7 +683,12 @@ def restore(device, ssid=None):
if device:
nmcli("device", "set", device, "managed", "no", check=False)
run("ifup", device)
result = run("ifup", device)
if result is not None and result.returncode != 0:
say(f"ifup {device}: "
f"{(result.stderr or result.stdout or '').strip()}")
say(f"ifupdown has {device} again")
for _ in range(10):
@@ -428,6 +698,44 @@ def restore(device, ssid=None):
time.sleep(3)
# THE ADAPTER IS NEVER LEFT BELONGING TO NOBODY.
#
# Above, the device has deliberately been handed back to ifupdown and
# NetworkManager told to leave it alone. That is right while ifupdown
# can actually bring it up. When ifup has failed too, the same two
# lines are a wireless adapter that neither manager owns: no
# connection, and a panel that cannot even scan for one. That is the
# state the prayer-room laptop was found in after 0.2.5, and it is
# the state that made `nmcli device set wlp2s0 managed yes` a manual
# recovery step.
#
# So the last resort is to give it to NetworkManager rather than to
# leave it with neither. A room able to pick its network from the
# panel is recoverable by somebody standing in front of it; an
# unmanaged radio is not.
if device:
say("WARNING: ifupdown did not bring the network back either")
say(f" handing {device} to NetworkManager so the adapter "
"is not left unmanaged")
nmcli("device", "set", device, "managed", "yes", check=False)
if ssid:
nmcli("connection", "up", ssid, "ifname", device, check=False)
for _ in range(5):
if online():
say("the room is on the network, through NetworkManager")
say(f"NOTE: {INTERFACES} still describes {device}, so "
"ifupdown will")
say(" claim it again at the next boot. Decide which "
"one owns it.")
return True
time.sleep(3)
say(f" {device} is {device_state(device)}")
say("WARNING: the machine may be offline. The original "
f"{INTERFACES} is restored; try `ifup {device}` or a reboot")
return False
@@ -444,6 +752,8 @@ def main():
help="remove a saved connection")
parser.add_argument("--open", action="store_true",
help="the network has no password")
parser.add_argument("--hidden", action="store_true",
help="the access point does not broadcast its name")
parser.add_argument("--adopt", action="store_true",
help="move an installer-configured Wi-Fi from "
"ifupdown into NetworkManager")
@@ -489,7 +799,8 @@ def main():
say("no password given")
return 1
return add(arguments.ssid, password, arguments.name)
return add(arguments.ssid, password, arguments.name,
hidden=arguments.hidden)
if __name__ == "__main__":
File diff suppressed because it is too large Load Diff
+101 -5
View File
@@ -5,6 +5,7 @@ import fcntl
import hashlib
import json
import os
import re
import subprocess
import sys
import tempfile
@@ -13,6 +14,22 @@ from urllib.parse import urljoin
CONFIG = "/etc/ekklesia-classroom/device.conf"
LOCKFILE = "/run/ekklesia-classroom-update.lock"
REBOOT_WHEN_IDLE = "ekklesia-classroom-reboot-when-idle.service"
CACHE_DIR = "/var/cache/ekklesia-classroom"
PACKAGE = "ekklesia-classroom"
# What a published version looks like: 0.2.6 for a feature release,
# 0.2.6.1 for a maintenance release of it. Anything else in a manifest is
# a mistake on the server, and a classroom does nothing with it. The same
# pattern is in tools/release-lib.sh; tests/test-release-versions.sh holds
# the two to one list of good and bad versions.
#
# Only the SHAPE is decided here. Which of two versions is newer is always
# dpkg's answer, because dpkg is what installs them.
VERSION_PATTERN = re.compile(
r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\.[1-9][0-9]*)?$"
)
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
def log(message):
@@ -50,13 +67,70 @@ def installed_version():
return result.stdout.strip()
def valid_version(version):
return isinstance(version, str) and bool(VERSION_PATTERN.match(version))
def version_is_newer(candidate, current):
result = subprocess.run(
["dpkg", "--compare-versions", candidate, "gt", current]
["dpkg", "--compare-versions", candidate, "gt", current],
stderr=subprocess.DEVNULL,
)
return result.returncode == 0
def manifest_problem(manifest):
"""Why this manifest must not be acted on, or None if it may be.
A manifest names a version, the package file for exactly that
version, and that file's checksum. The package name is checked
against the version, not merely read, so a manifest that says 0.2.6.1
and points at the 0.2.6 file is refused rather than installed and
then offered again at every check.
"""
if not isinstance(manifest, dict):
return "the manifest is not an object"
version = manifest.get("version")
if not valid_version(version):
return f"the manifest names an invalid version: {version!r}"
package = manifest.get("package")
expected = f"/packages/{PACKAGE}_{version}_amd64.deb"
if package != expected:
return f"the manifest names {package!r} for version {version}"
checksum = manifest.get("sha256")
if not isinstance(checksum, str) or not SHA256_PATTERN.match(checksum.lower()):
return f"the manifest's sha256 is not a checksum: {checksum!r}"
return None
def package_identity(path):
"""(Package, Version) as dpkg reads them out of a downloaded file."""
result = subprocess.run(
["dpkg-deb", "--field", path, "Package", "Version"],
capture_output=True,
text=True,
)
if result.returncode != 0:
return None, None
fields = {}
for line in result.stdout.splitlines():
if ":" in line:
key, value = line.split(":", 1)
fields[key.strip()] = value.strip()
return fields.get("Package"), fields.get("Version")
def fetch_json(url):
request = urllib.request.Request(
url,
@@ -134,6 +208,12 @@ def main():
log(f"Unable to fetch update manifest: {exc}")
return 1
problem = manifest_problem(manifest)
if problem:
log(f"Ignoring the {channel} manifest: {problem}.")
return 1
candidate = manifest["version"]
log(f"Available version: {candidate}")
@@ -149,10 +229,10 @@ def main():
package_url = urljoin(base_url, manifest["package"].lstrip("/"))
expected_hash = manifest["sha256"].lower()
os.makedirs("/var/cache/ekklesia-classroom", exist_ok=True)
os.makedirs(CACHE_DIR, exist_ok=True)
package_path = os.path.join(
"/var/cache/ekklesia-classroom",
CACHE_DIR,
os.path.basename(manifest["package"]),
)
@@ -177,6 +257,18 @@ def main():
log("SHA-256 verification passed.")
# The checksum proves the file is the one the manifest describes;
# this proves the manifest described the right file. A package
# that installs as some other version would be offered again at
# every check, and installed again, and the room rebooted, for
# ever.
name, version = package_identity(temporary_path)
if name != PACKAGE or version != candidate:
log(f"The package is {name} {version}, not {PACKAGE} {candidate}.")
os.unlink(temporary_path)
return 1
os.replace(temporary_path, package_path)
log(f"Installing {candidate}")
@@ -200,8 +292,12 @@ def main():
if args.no_reboot:
log("Update requires reboot, but --no-reboot was specified.")
else:
log("Update requires reboot. Rebooting now.")
subprocess.run(["systemctl", "reboot"])
# Not now. At 03:00 nobody is there; after a missed 03:00,
# checked when the computer is next switched on, somebody
# usually is. classroom-reboot-when-idle waits until nobody
# has touched it for half an hour and nothing is playing.
log("Update requires reboot. Rebooting once nobody is using this computer.")
subprocess.run(["systemctl", "start", "--no-block", REBOOT_WHEN_IDLE])
return 0
@@ -0,0 +1,10 @@
[Unit]
Description=Reboot after an Ekklesia Classroom update, once nobody is using the computer
Documentation=file:///usr/local/bin/classroom-reboot-when-idle
# Started by ekklesia-classroom-update after it installs an update that
# needs a reboot; never at boot. A computer switched off before it
# reboots simply starts on the new version.
[Service]
Type=simple
ExecStart=/usr/local/bin/classroom-reboot-when-idle
@@ -0,0 +1,49 @@
[Unit]
Description=Ekklesia Classroom presentation import
Documentation=file:///usr/local/bin/classroom-presentation-import
# Nothing graphical: it copies, converts and renders files. It is a user
# service rather than a system one because the downloads it watches and
# the library it fills both belong to the classroom account, and a root
# service writing into a teacher's home would have to be told about every
# one of those permissions.
PartOf=graphical-session.target
[Service]
ExecStart=/usr/local/bin/classroom-presentation-import
# It exits 0 immediately on a device with PRESENTATIONS_ENABLED=false, and
# on-failure means such a device is left alone rather than restarted in a
# loop forever. A device that does have Presentations gets the watcher
# back if it ever dies, because a teacher whose download never appears has
# no way at all to find out why.
Restart=on-failure
RestartSec=10
Slice=session.slice
# LibreOffice converting a large deck must never be what a teacher notices
# about the machine. The room's own applications come first; this is the
# only thing here that is allowed to take a while.
Nice=10
IOSchedulingClass=idle
# MEMORY. Measured in the lab, LibreOffice converting one deck: 203 MB
# for ten slides of text, 738 MB for thirty slides sharing twelve photos,
# 1.4 GB for thirty different photos and 2.1 GB for twenty very large
# ones, for twenty seconds or so. A 4 GB classroom running Plasma and
# Wonder Ink does not have 2 GB spare, and in testing the kernel killed
# the whole service mid-conversion — which then restarted and tried the
# same deck again, while the library said "Getting this ready" for ever.
#
# At most 40% of this machine's memory: 1.6 GB on 4 GB, 3.2 GB on 8 GB.
# A deck that needs more is stopped HERE, inside this service, rather
# than pushing the teacher's own windows out of memory.
MemoryMax=40%
# If memory runs short everywhere, this is what goes first: never the
# lesson on the screen, the browser or the panel.
OOMScoreAdjust=800
# And a killed LibreOffice is a failed import, not a dead service. The
# watcher records it — "needed more memory", with Try again — and carries
# on. systemd's default stops the whole unit when any one process in it
# is killed for memory.
OOMPolicy=continue
[Install]
WantedBy=plasma-workspace.target
@@ -16,3 +16,9 @@ StartLimitIntervalSec=0
[Service]
Restart=always
RestartSec=2
# Every desktop the configuration already has gets the classroom's
# wallpaper written into it, in the one moment the shell is not running to
# own the file — at a login, and at every restart after a crash. See
# classroom-desktop-wallpaper. The "-" is because a panel matters more than
# a wallpaper: nothing that script can do is allowed to stop the shell.
ExecStartPre=-/usr/local/bin/classroom-desktop-wallpaper
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""A stand-in for the part of Gitea 1.27's API that tools/gitea-release uses.
Tags, releases and assets in memory. Writes need the token
"not-a-real-token", as Gitea needs a real one. Like Gitea, it takes a
second asset under a name a release already has: refusing that is
tools/gitea-release's job, and the tests check that it does. GET
/test/tamper/TAG/NAME changes an asset's bytes, the way somebody could in
the web UI. Prints the port it listens on, then serves until killed.
"""
import json, re
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs, unquote
TOKEN = "token not-a-real-token"
PREFIX = "/api/v1/repos/church/classroom"
state = {"tags": {"v0.1.5": "1" * 40}, "releases": [], "next": 1}
def find(tag):
return next((r for r in state["releases"] if r["tag_name"] == tag), None)
class Gitea(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def reply(self, status, body=None, raw=None):
data = raw if raw is not None else json.dumps(body).encode() if body is not None else b""
self.send_response(status)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def authorised(self):
if self.headers.get("Authorization") != TOKEN:
self.reply(401, {"message": "token is required"})
return False
return True
def release_json(self, r):
port = self.server.server_port
return {**{k: v for k, v in r.items() if k != "assets"},
"html_url": f"http://127.0.0.1:{port}/church/classroom/releases/tag/{r['tag_name']}"}
def do_GET(self):
path = urlparse(self.path).path
if m := re.fullmatch(PREFIX + r"/releases/tags/(.+)", path):
r = find(unquote(m.group(1)))
return self.reply(200, self.release_json(r)) if r else self.reply(404, {})
if m := re.fullmatch(PREFIX + r"/tags/(.+)", path):
sha = state["tags"].get(unquote(m.group(1)))
return self.reply(200, {"name": m.group(1), "commit": {"sha": sha}}) if sha else self.reply(404, {})
if m := re.fullmatch(PREFIX + r"/releases/(\d+)/assets", path):
r = next((r for r in state["releases"] if r["id"] == int(m.group(1))), None)
if not r:
return self.reply(404, {})
port = self.server.server_port
return self.reply(200, [{"id": i, "name": n, "size": len(d),
"browser_download_url": f"http://127.0.0.1:{port}/download/{r['id']}/{n}"}
for i, (n, d) in enumerate(r["assets"].items())])
if m := re.fullmatch(r"/download/(\d+)/(.+)", path):
r = next((r for r in state["releases"] if r["id"] == int(m.group(1))), None)
data = r and r["assets"].get(unquote(m.group(2)))
return self.reply(200, raw=data) if data is not None else self.reply(404, {})
# For the test: somebody replaces an asset by hand in the web UI.
if m := re.fullmatch(r"/test/tamper/(.+)/(.+)", path):
find(unquote(m.group(1)))["assets"][unquote(m.group(2))] = b"tampered"
return self.reply(200, {})
return self.reply(404, {})
def do_POST(self):
if not self.authorised():
return
url = urlparse(self.path)
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length)
if url.path == PREFIX + "/releases":
data = json.loads(body)
if find(data["tag_name"]):
return self.reply(409, {"message": "release exists"})
state["tags"].setdefault(data["tag_name"], data["target_commitish"])
r = {"id": state["next"], "tag_name": data["tag_name"], "name": data["name"],
"body": data["body"], "prerelease": data["prerelease"], "draft": False, "assets": {}}
state["next"] += 1
state["releases"].append(r)
return self.reply(201, self.release_json(r))
if m := re.fullmatch(PREFIX + r"/releases/(\d+)/assets", url.path):
r = next(r for r in state["releases"] if r["id"] == int(m.group(1)))
name = parse_qs(url.query)["name"][0]
boundary = self.headers["Content-Type"].split("boundary=")[1].encode()
part = body.split(b"--" + boundary)[1]
content = part.split(b"\r\n\r\n", 1)[1].rsplit(b"\r\n", 1)[0]
# Gitea itself happily takes a second asset of the same name.
r["assets"][name] = content
return self.reply(201, {"name": name})
return self.reply(404, {})
def do_PATCH(self):
if not self.authorised():
return
path = urlparse(self.path).path
if m := re.fullmatch(PREFIX + r"/releases/(\d+)", path):
r = next(r for r in state["releases"] if r["id"] == int(m.group(1)))
data = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
r.update({k: v for k, v in data.items() if k in ("body", "prerelease")})
return self.reply(200, self.release_json(r))
return self.reply(404, {})
server = HTTPServer(("127.0.0.1", 0), Gitea)
print(server.server_port, flush=True)
server.serve_forever()
+87 -10
View File
@@ -85,8 +85,16 @@ check("a desktop can have Wonder Ink taken away",
check("and keeps everything else",
desktop_no_ink["DVD"] is True and desktop_no_ink["PARENT_CALL"] is True)
check("an existing room that has never heard of these keeps them all",
all(module.wanted({}).values()), str(module.wanted({})))
unset = module.wanted({})
check("an existing room that has never heard of these keeps all three",
unset["WONDERINK"] and unset["DVD"] and unset["PARENT_CALL"],
str(unset))
# The one that must NOT follow that rule. Every existing classroom has
# Wonder Ink, a drive and Parent Call, so an upgrade has to leave all
# three on. No existing classroom has Presentations, so an upgrade has to
# leave it off — a new button on a bar nobody asked for is a support call.
check("but Presentations is off until a device asks for it",
unset["PRESENTATIONS"] is False, str(unset))
print("3. a drive can only ever take the player away")
@@ -126,13 +134,18 @@ check("and an unconfigured machine keeps every application",
print("5. the bar that comes out, evaluated in node")
STUB = r"""
const fs = require("fs");
const captured = {widgets: [], config: {}};
const captured = {widgets: [], config: {}, launchers: []};
let current = null;
function makeWidget(type) {
const w = {
type: type,
currentConfigGroup: [],
writeConfig: function (k, v) { captured.config[type + "." + k] = String(v); },
writeConfig: function (k, v) {
captured.config[type + "." + k] = String(v);
// Quick Launch widgets share a config key, so the last one
// written would otherwise be the only one anybody could see.
if (k === "launcherUrls") { captured.launchers.push(String(v)); }
},
reloadConfig: function () {}
};
captured.widgets.push(type);
@@ -172,8 +185,15 @@ else:
check("a classroom gets Wonder Ink, the browser and the player, in order",
every and every["config"].get(LAUNCH) == f"{INK},{WEB},{DISC}",
(every or {}).get("config", {}).get(LAUNCH, why))
CALL = "file:///usr/share/applications/ekklesia-parent-call.desktop"
HELP = "file:///usr/share/applications/ekklesia-manual.desktop"
check("and Parent Call in the corner",
every and "org.kde.plasma.quicklaunch" in every["widgets"])
every and CALL in every["launchers"],
str((every or {}).get("launchers")))
check("and the unfinished Help launcher is hidden",
every and HELP not in every["launchers"],
str((every or {}).get("launchers")))
no_ink, why = bar({"WONDERINK_ENABLED": "false"})
check("a Bible study desktop has the browser first and no gap",
@@ -188,8 +208,49 @@ else:
bible and bible["config"].get(LAUNCH) == WEB,
(bible or {}).get("config", {}).get(LAUNCH, why))
check("with no Parent Call button on it",
bible and "org.kde.plasma.quicklaunch" not in bible["widgets"],
str((bible or {}).get("widgets")))
bible and CALL not in bible["launchers"],
str((bible or {}).get("launchers")))
# The source and launcher remain installed, but the unfinished digital
# Help entry point is deliberately absent from the teacher-facing bar.
check("and unfinished Help stays hidden",
bible and HELP not in bible["launchers"],
str((bible or {}).get("launchers")))
SLIDES = "applications:ekklesia-presentations.desktop"
# THE DOCK ORDER IS THE THING NOT TO BREAK.
#
# Every existing room has Wonder Ink, the browser and the player in
# that order, and teachers have been pressing them in that order for
# months. Presentations is appended, so all three keep their place on
# every machine that upgrades into this release.
module.has_optical_drive = lambda: True
slides, why = bar({"PRESENTATIONS_ENABLED": "true"})
check("Presentations goes on the END of the row, after the player",
slides and slides["config"].get(LAUNCH)
== f"{INK},{WEB},{DISC},{SLIDES}",
(slides or {}).get("config", {}).get(LAUNCH, why))
without, why = bar({"PRESENTATIONS_ENABLED": "false"})
check("and is absent entirely when the device does not offer it",
without and SLIDES not in without["config"].get(LAUNCH, ""),
(without or {}).get("config", {}).get(LAUNCH, why))
check("which leaves the other three exactly where they were",
without and without["config"].get(LAUNCH) == f"{INK},{WEB},{DISC}",
(without or {}).get("config", {}).get(LAUNCH, why))
# The prayer-room laptop: the browser and Presentations, nothing else.
module.has_optical_drive = lambda: False
study, why = bar({"PRESENTATIONS_ENABLED": "true",
"WONDERINK_ENABLED": "false",
"PARENT_CALL_ENABLED": "false",
"DEVICE_TYPE": "laptop"})
check("the Bible study laptop gets the browser and Presentations",
study and study["config"].get(LAUNCH) == f"{WEB},{SLIDES}",
(study or {}).get("config", {}).get(LAUNCH, why))
check("and still no Parent Call on it",
study and CALL not in study["launchers"],
str((study or {}).get("launchers")))
check("and the status corner is untouched",
bible and "org.kde.plasma.volume" in bible["widgets"]
and "org.kde.plasma.digitalclock" in bible["widgets"]
@@ -227,12 +288,28 @@ print("7. a disabled application does not start either")
# talked into running it by a stale desktop entry or a technician's
# muscle memory.
for script, setting in (("classroom-wonderink", "WONDERINK_ENABLED"),
("classroom-parent-call", "PARENT_CALL_ENABLED")):
("classroom-parent-call", "PARENT_CALL_ENABLED"),
("classroom-presentations", "PRESENTATIONS_ENABLED"),
("classroom-presentation-import",
"PRESENTATIONS_ENABLED")):
text = (root / "scripts" / script).read_text()
check(f"{script} refuses when {setting} is false",
setting in text and "not starting" in text)
check(f"{script} defaults to starting, so existing rooms are unaffected",
'default=True' in text or 'feature(' in text)
check(f"{script} asks the shared helper rather than reading the file",
"feature(" in text and "read_device_config()" in text)
# And each of them defaults the way its own room needs, which is not the
# same answer for all four. Asserting one rule for all of them would pass
# for the wrong reason on the one that differs.
from classroom_config import feature as _feature # noqa: E402
for setting, default, why in (
("WONDERINK_ENABLED", True, "every existing classroom has it"),
("DVD_ENABLED", True, "every existing classroom has it"),
("PARENT_CALL_ENABLED", True, "every existing classroom has it"),
("PRESENTATIONS_ENABLED", False, "no existing classroom has it")):
check(f"{setting} defaults to {default} — {why}",
_feature({}, setting) is default)
print()
print(f"{passed} passed, {failed} failed")
+408
View File
@@ -0,0 +1,408 @@
#!/bin/bash
# Every desktop shows the classroom's wallpaper, whatever it is numbered.
#
# Tiny Pebbles came through the 0.2.2 upgrade with its desktop as
# containment 10 and no wallpaper configured on it, and showed Plasma's own
# purple one. These run the real repair against that file, against a
# machine that was already right, and against the shapes in between, and
# then check that every place naming the wallpaper names the same one.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
python3 - "$ROOT" <<'PY'
import importlib.util
import json
import os
import re
import sys
import tempfile
from importlib.machinery import SourceFileLoader
from pathlib import Path
root = Path(sys.argv[1])
loader = SourceFileLoader("wallpaper", str(root / "scripts/classroom-desktop-wallpaper"))
spec = importlib.util.spec_from_loader("wallpaper", loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
passed = failed = 0
def check(label, condition, detail=""):
global passed, failed
if condition:
print(f" ok {label}")
passed += 1
else:
print(f" FAIL {label}")
if detail:
print(f" {detail}")
failed += 1
IMAGE = "Image=file:///usr/share/backgrounds/ekklesia/classroom-calm.png"
def group_texts(text):
"""{group name: its lines}, for saying what did and did not change."""
lines = text.splitlines(keepends=True)
return {
name: "".join(lines[header:end]).rstrip("\n")
for name, header, end in module.groups(lines)
}
def containment_ids(text):
return sorted(re.findall(r"^\[Containments\]\[(\d+)\]$", text, re.M))
# A panel built with no screen present takes id 1 and its applets 2 to 9,
# which is how Tiny Pebbles' desktop came to be 10. Its containment is as
# it was read off the machine.
PANEL_1 = """[ActionPlugins][0]
MiddleButton;NoModifier=org.kde.paste
RightButton;NoModifier=org.kde.contextmenu
[ActionPlugins][1]
RightButton;NoModifier=org.kde.contextmenu
[Containments][1]
activityId=
formfactor=2
immutability=1
lastScreen=0
location=4
plugin=org.kde.panel
wallpaperplugin=org.kde.image
[Containments][1][Applets][2]
immutability=1
plugin=org.kde.plasma.quicklaunch
[Containments][1][Applets][2][Configuration][General]
launcherUrls=file:///usr/share/applications/ekklesia-parent-call.desktop
[Containments][1][Applets][3]
immutability=1
plugin=org.kde.plasma.panelspacer
[Containments][1][Applets][4]
immutability=1
plugin=org.kde.plasma.icontasks
[Containments][1][Applets][4][Configuration][General]
launchers=applications:ekklesia-wonderink.desktop,applications:google-chrome.desktop,applications:ekklesia-dvd.desktop
sortingStrategy=1
[Containments][1][Applets][5]
immutability=1
plugin=org.kde.plasma.panelspacer
[Containments][1][Applets][6]
immutability=1
plugin=org.kde.plasma.networkmanagement
[Containments][1][Applets][7]
immutability=1
plugin=org.kde.plasma.volume
[Containments][1][Applets][8]
immutability=1
plugin=org.kde.plasma.notifications
[Containments][1][Applets][9]
immutability=1
plugin=org.kde.plasma.digitalclock
[Containments][1][General]
AppletOrder=2;3;4;5;6;7;8;9
"""
TINY_PEBBLES = PANEL_1 + """
[Containments][10]
activityId=62d3d55d-bd31-4fc8-9a7b-a5b9b2b99b60
formfactor=0
immutability=1
lastScreen=0
location=0
plugin=org.kde.plasma.folder
wallpaperplugin=org.kde.image
[Containments][10][General]
positions={"1920x1080":[]}
[ScreenMapping]
itemsOnDisabledScreens=
"""
# The healthy machine, as the layout builds it with a screen present and
# as the lab's own session wrote it: desktop 1, panel 2, applets 3 to 10.
# The clock is applet 10 — the number that is Tiny Pebbles' desktop.
RIVER_KIDS = """[ActionPlugins][0]
MiddleButton;NoModifier=org.kde.paste
RightButton;NoModifier=org.kde.contextmenu
[Containments][1]
ItemGeometries-1920x1080=
ItemGeometriesHorizontal=
activityId=a9db02f6-926f-4650-b05f-de3a9f0b73fe
formfactor=0
immutability=1
lastScreen=0
location=0
plugin=org.kde.plasma.folder
wallpaperplugin=org.kde.image
[Containments][1][Wallpaper][org.kde.image][General]
FillMode=2
Image=file:///usr/share/backgrounds/ekklesia/classroom-calm.png
SlidePaths=
[Containments][2]
activityId=
formfactor=2
immutability=1
lastScreen=0
location=4
plugin=org.kde.panel
wallpaperplugin=org.kde.image
[Containments][2][Applets][10]
immutability=1
plugin=org.kde.plasma.digitalclock
[Containments][2][Applets][10][Configuration][Appearance]
showDate=true
[Containments][2][Applets][3]
immutability=1
plugin=org.kde.plasma.quicklaunch
[Containments][2][Applets][5]
immutability=1
plugin=org.kde.plasma.icontasks
[Containments][2][General]
AppletOrder=3;4;5;6;7;8;9;10
[ScreenMapping]
itemsOnDisabledScreens=
"""
# What the lab produced when Plasma made a desktop mid-session: the good
# one kept its picture, and the new one, 11, had none.
LATE_DESKTOP = RIVER_KIDS.replace("[ScreenMapping]", """[Containments][11]
activityId=f992593c-2347-4e8d-be82-f80d02e71158
formfactor=0
immutability=1
lastScreen=0
location=0
plugin=org.kde.plasma.folder
wallpaperplugin=org.kde.image
[Containments][11][General]
positions={"1920x1080":[]}
[ScreenMapping]""")
print("1. Tiny Pebbles: a desktop numbered 10, with no wallpaper at all")
check("the desktop is found by what it is, not its number",
module.desktops(TINY_PEBBLES.splitlines(True)) == ["10"],
str(module.desktops(TINY_PEBBLES.splitlines(True))))
after, changes = module.converge(TINY_PEBBLES)
groups_before, groups_after = group_texts(TINY_PEBBLES), group_texts(after)
wallpaper = groups_after.get("[Containments][10][Wallpaper][org.kde.image][General]", "")
check("it gets the classroom's image", IMAGE in wallpaper, wallpaper)
check("scaled and cropped, as the layout writes it", "FillMode=2" in wallpaper)
check("and it says what it did", changes == ["desktop 10: had no wallpaper configured"],
str(changes))
check("the wallpaper plugin is still the image one",
"wallpaperplugin=org.kde.image" in groups_after["[Containments][10]"])
check("one group added and nothing else changed",
set(groups_after) - set(groups_before)
== {"[Containments][10][Wallpaper][org.kde.image][General]"}
and all(groups_after[name] == text for name, text in groups_before.items()))
check("every containment keeps its number", containment_ids(after) == ["1", "10"])
check("the panel is still containment 1, applets 2 to 9, in order",
groups_after["[Containments][1][General]"].endswith("AppletOrder=2;3;4;5;6;7;8;9")
and groups_after["[Containments][1]"] == groups_before["[Containments][1]"])
check("the new group sits with its own containment",
after.index("[Containments][10][General]")
< after.index("[Containments][10][Wallpaper]")
< after.index("[ScreenMapping]"))
again, changes_again = module.converge(after)
check("a second run changes nothing", again == after and changes_again == [], str(changes_again))
print("2. River Kids: already right")
after, changes = module.converge(RIVER_KIDS)
check("the file comes back byte for byte", after == RIVER_KIDS)
check("and nothing is reported as changed", changes == [], str(changes))
check("applet 10 on the panel is not mistaken for a desktop",
module.desktops(RIVER_KIDS.splitlines(True)) == ["1"])
print("3. two desktops, one bare, as the lab made them")
after, changes = module.converge(LATE_DESKTOP)
groups_before, groups_after = group_texts(LATE_DESKTOP), group_texts(after)
check("only the bare one is written",
changes == ["desktop 11: had no wallpaper configured"], str(changes))
check("the good one is untouched",
groups_after["[Containments][1][Wallpaper][org.kde.image][General]"]
== groups_before["[Containments][1][Wallpaper][org.kde.image][General]"])
check("and the panel, numbered 2, is untouched",
all(groups_after[name] == text for name, text in groups_before.items()
if name and name.startswith("[Containments][2]")))
check("the new one has the image",
IMAGE in groups_after.get("[Containments][11][Wallpaper][org.kde.image][General]", ""))
print("4. a desktop some other profile left with another picture")
OTHER = """[Containments][4]
formfactor=0
location=0
plugin=org.kde.desktopcontainment
wallpaperplugin=org.kde.color
[Containments][4][Wallpaper][org.kde.image][General]
Image[$e]=file:///usr/share/wallpapers/Next/contents/images/1920x1080.png
SlidePaths=/usr/share/wallpapers/
[Containments][7]
formfactor=2
location=4
plugin=org.kde.plasma.private.systemtray
[Containments][9]
formfactor=2
location=4
plugin=org.kde.panel
"""
after, changes = module.converge(OTHER)
groups_after = group_texts(after)
check("the plain Desktop containment counts as a desktop",
module.desktops(OTHER.splitlines(True)) == ["4"])
check("its plugin becomes the image one",
"wallpaperplugin=org.kde.image" in groups_after["[Containments][4]"])
wallpaper = groups_after["[Containments][4][Wallpaper][org.kde.image][General]"]
check("its image becomes the classroom's, keeping the key's flags",
"Image[$e]=file:///usr/share/backgrounds/ekklesia/classroom-calm.png" in wallpaper,
wallpaper)
check("a missing key is added to the existing group", "FillMode=2" in wallpaper)
check("a key the classroom does not set is left alone",
"SlidePaths=/usr/share/wallpapers/" in wallpaper)
check("a system tray is not a desktop",
groups_after["[Containments][7]"] == group_texts(OTHER)["[Containments][7]"])
check("nor is a panel",
groups_after["[Containments][9]"] == group_texts(OTHER)["[Containments][9]"])
check("and it settles", module.converge(after) == (after, []))
print("5. the command, on files")
work = Path(tempfile.mkdtemp())
def run(*args):
return module.main([str(arg) for arg in args])
target = work / "tiny-pebbles"
target.write_text(TINY_PEBBLES)
os.chmod(target, 0o600)
check("--check says it is wrong", run("--check", target) == 1)
check("and changes nothing", target.read_text() == TINY_PEBBLES)
check("the repair succeeds", run(target) == 0)
check("the file is repaired", IMAGE in target.read_text())
check("the file keeps its permissions", (target.stat().st_mode & 0o777) == 0o600)
check("no temporary file is left behind", sorted(p.name for p in work.iterdir()) == ["tiny-pebbles"])
stamp = (target.stat().st_ino, target.stat().st_mtime_ns)
check("a second run succeeds", run(target) == 0)
check("and does not write the file at all",
(target.stat().st_ino, target.stat().st_mtime_ns) == stamp)
check("--check then says it is right", run("--check", target) == 0)
healthy = work / "river-kids"
healthy.write_text(RIVER_KIDS)
stamp = (healthy.stat().st_ino, healthy.stat().st_mtime_ns)
check("a machine that is right is left alone", run(healthy) == 0
and (healthy.stat().st_ino, healthy.stat().st_mtime_ns) == stamp)
check("no file at all is not an error", run(work / "absent") == 0)
check("and none is created", not (work / "absent").exists())
empty = work / "no-desktop"
empty.write_text(PANEL_1)
check("a file with only a panel is not an error", run(empty) == 0)
check("and is not changed", empty.read_text() == PANEL_1)
print("6. never underneath a running shell")
config_home = work / "config"
config_home.mkdir()
live = config_home / module.APPLETSRC
live.write_text(TINY_PEBBLES)
os.environ["XDG_CONFIG_HOME"] = str(config_home)
check("the default file is the one in XDG_CONFIG_HOME", module.default_path() == str(live))
proc = work / "proc"
for pid, name in (("1", "systemd"), ("812", "kwin_wayland"), ("self", "python3")):
(proc / pid).mkdir(parents=True)
(proc / pid / "comm").write_text(name + "\n")
check("no plasmashell in the process table is no shell",
module.shell_running(str(proc)) is False)
(proc / "1044").mkdir()
(proc / "1044" / "comm").write_text("plasmashell\n")
check("this user's plasmashell is found", module.shell_running(str(proc)) is True)
check("and the real process table can be read",
module.shell_running() in (True, False))
module.shell_running = lambda: True
check("with plasmashell running it refuses", run() == 1)
check("and the shell's file is untouched", live.read_text() == TINY_PEBBLES)
check("--check still answers while it runs", run("--check") == 1)
module.shell_running = lambda: False
check("with the shell stopped it repairs", run() == 0 and IMAGE in live.read_text())
print("7. one wallpaper, named the same everywhere")
layout = (root / "config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js").read_text()
defaults = (root / "config/plasma/lookandfeel/contents/defaults").read_text()
build = (root / "tools/build-release.sh").read_text()
dropin = (root / "systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf").read_text()
package = root / "config/wallpapers/EkklesiaClassroom"
metadata = json.loads((package / "metadata.json").read_text())
check("the layout and the repair name the same file",
f'var WALLPAPER = "{module.WALLPAPER}";' in layout)
check("and the same fill mode", 'desktop.writeConfig("FillMode", 2);' in layout
and ("FillMode", "2") in module.WALLPAPER_KEYS)
check("the packaged file is where they say", f'"$STAGE{module.WALLPAPER}"' in build)
default = re.search(r"^\[Wallpaper\]\nImage=(.*)$", defaults, re.M)
check("the look and feel's default is a package name, not a path",
default is not None and "/" not in default.group(1),
default.group(1) if default else "no [Wallpaper] Image")
check("the package it names is the classroom's",
default is not None and default.group(1) == metadata["KPlugin"]["Id"] == package.name)
check("the build installs that package's metadata",
f'"$ROOT/config/wallpapers/{package.name}/metadata.json"' in build
and f'WALLPAPER_PACKAGE="$STAGE/usr/share/wallpapers/{package.name}"' in build)
check("its image is a link to the one file, not a copy",
f"ln -s {module.WALLPAPER}" in build)
check("the repair is packaged",
'"$STAGE/usr/local/bin/classroom-desktop-wallpaper"' in build)
check("it runs before every shell start",
"ExecStartPre=-/usr/local/bin/classroom-desktop-wallpaper" in dropin)
check("and a failure in it cannot stop the shell",
not re.search(r"^ExecStartPre=/", dropin, re.M))
check("the layout still writes the wallpaper for a fresh desktop",
'desktop.writeConfig("Image", "file://" + WALLPAPER);' in layout)
print()
print(f"{passed} passed, {failed} failed")
sys.exit(1 if failed else 0)
PY
+263
View File
@@ -0,0 +1,263 @@
#!/bin/bash
# The teacher's manual: one source, two versions, and no drift.
#
# Two halves, because the manual has two failure modes and they are not
# alike. The source can be wrong — a page for an optional feature with
# no conditional sentence makes the PRINTED manual state it
# unconditionally, which is wrong in every room that does not have it.
# And the built page can be wrong — it can show a teacher a feature
# their computer does not have, or hide one it does, or carry something
# out of device.conf that the classroom account has no business seeing.
#
# The second half runs the manual's own viewer script in node against a
# stub DOM, the same way test-classroom-network.sh evaluates the polkit
# rule: it is the actual shipped code, not a description of it.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
echo "A. the source, and the release gate"
PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY'
import subprocess
import sys
from pathlib import Path
root = Path(sys.argv[1])
sys.path.insert(0, str(root / "lib"))
from classroom_config import FEATURES
passed = failed = 0
def check(label, condition, detail=""):
global passed, failed
if condition:
print(f" ok {label}")
passed += 1
else:
print(f" FAIL {label}")
if detail:
print(f" {detail}")
failed += 1
source = root / "docs" / "manual"
build = source / "build"
pages = sorted(p for p in source.glob("*.md") if p.name != "README.md")
check("there is a manual source directory", source.is_dir())
check("with pages in it", len(pages) >= 5, str(len(pages)))
headers = {}
for path in pages:
text = path.read_text(encoding="utf-8")
header = {}
if text.startswith("---\n"):
end = text.index("\n---\n", 3)
for line in text[4:end].splitlines():
if ":" in line:
key, value = line.split(":", 1)
header[key.strip()] = value.strip()
headers[path.name] = header
for name, header in headers.items():
check(f"{name} has a title", bool(header.get("title")))
# THE RULE THAT KEEPS THE PRINTED MANUAL TRUE.
for name, header in headers.items():
if header.get("feature"):
check(f"{name} says when it applies, for the printed manual",
bool(header.get("printed-if")),
"without it the printed manual states an optional feature "
"as though every room had it")
check(f"{name} names a real device.conf feature",
header["feature"] in FEATURES,
f"{header.get('feature')} is not one of "
f"{', '.join(sorted(FEATURES))}")
documented = {h.get("feature") for h in headers.values() if h.get("feature")}
for name in FEATURES:
check(f"{name} has a manual page", name in documented,
"a room would get a button the manual never mentions")
orders = [h.get("order") for h in headers.values()]
check("no two pages claim the same place", len(orders) == len(set(orders)),
str(sorted(orders)))
print(" -- the builder itself")
builder = root / "tools" / "build-manual"
check("it exists and is runnable", builder.exists()
and builder.stat().st_mode & 0o111)
result = subprocess.run([str(builder)], capture_output=True, text=True,
timeout=120)
check("it builds without complaint", result.returncode == 0,
result.stderr.strip())
check("it produced the digital manual",
(build / "classroom-manual.html").exists())
check("and the printable one", (build / "classroom-manual.pdf").exists())
pdf = (build / "classroom-manual.pdf").read_bytes()
check("the PDF is a real PDF", pdf.startswith(b"%PDF-")
and pdf.rstrip().endswith(b"%%EOF"), str(pdf[:8]))
check("with more than one page", pdf.count(b"/Type /Page ") > 1,
str(pdf.count(b"/Type /Page ")))
check("and a revision date on it, so an old printout can be spotted",
b"Revision" in pdf)
# Every conditional sentence has to actually reach the printed manual.
for name, header in headers.items():
if header.get("printed-if"):
words = header["printed-if"].encode("latin-1", "replace")
check(f"{name}'s conditional sentence is in the PDF",
words in pdf, header["printed-if"])
check("the check mode passes on what was just built",
subprocess.run([str(builder), "--check"], capture_output=True,
timeout=120).returncode == 0)
html = (build / "classroom-manual.html").read_text(encoding="utf-8")
for name, header in headers.items():
if header.get("feature"):
check(f"{name} is marked in the HTML with its feature",
f'data-feature="{header["feature"]}"' in html)
check("the digital manual needs nothing from the network",
"http://" not in html and "https://" not in html,
"it has to work in a building with no internet")
print()
print(f"{passed} passed, {failed} failed")
raise SystemExit(1 if failed else 0)
PY
status=$?
[[ $status -ne 0 ]] && exit $status
echo
echo "B. what a device actually shows"
if ! command -v node >/dev/null 2>&1; then
echo " SKIP node is not installed; the viewer script was not evaluated"
exit 0
fi
node - "$ROOT" <<'JS'
const fs = require("fs");
const path = require("path");
const root = process.argv[2];
let passed = 0, failed = 0;
function check(label, condition, detail) {
if (condition) { console.log(" ok " + label); passed++; }
else { console.log(" FAIL " + label); if (detail) console.log(" " + detail); failed++; }
}
const html = fs.readFileSync(
path.join(root, "docs/manual/build/classroom-manual.html"), "utf8");
// The shipped viewer, pulled out of the built page and run for real.
const script = html.slice(html.lastIndexOf("<script>") + 8,
html.lastIndexOf("</script>"));
// Enough DOM for it. Sections carry the feature they need, exactly as
// the builder wrote them.
function sectionsOf(text) {
const found = [];
const re = /<section id="([^"]+)"(?: data-feature="([^"]+)")?>/g;
let m;
while ((m = re.exec(text)) !== null) {
found.push({id: m[1], feature: m[2] || null});
}
return found;
}
function show(features) {
const sections = sectionsOf(html).map(s => ({
id: s.id,
removed: false,
getAttribute: function (name) {
return name === "data-feature" ? s.feature : null;
},
remove: function () { this.removed = true; },
querySelector: function () { return {textContent: s.id}; },
}));
const contents = {children: [], appendChild: function (c) { this.children.push(c); }};
const window = {CLASSROOM_FEATURES: features};
const document = {
querySelectorAll: () => sections,
getElementById: () => contents,
createElement: () => ({}),
};
new Function("window", "document", script)(window, document);
return sections.filter(s => !s.removed).map(s => s.id);
}
const all = sectionsOf(html).map(s => s.id);
const optional = sectionsOf(html).filter(s => s.feature);
check("the built manual carries every page",
all.length >= 5, all.join(", "));
check("and some of them are optional", optional.length >= 3,
optional.map(s => s.feature).join(", "));
console.log(" -- the Bible study laptop: Presentations, and nothing else optional");
const laptop = show(["PRESENTATIONS_ENABLED"]);
check("the Presentations page is shown",
laptop.some(id => id.includes("presentations")), laptop.join(", "));
check("the Browser page is shown, because every room has one",
laptop.some(id => id.includes("browser")));
check("sound and volume are shown", laptop.some(id => id.includes("sound")));
check("Wonder Ink is NOT shown",
!laptop.some(id => id.includes("wonderink")), laptop.join(", "));
check("Parent Call is NOT shown",
!laptop.some(id => id.includes("parent-call")), laptop.join(", "));
check("the DVD page is NOT shown",
!laptop.some(id => id.includes("dvd")), laptop.join(", "));
console.log(" -- a children's classroom: everything but Presentations");
const classroom = show(["WONDERINK_ENABLED", "PARENT_CALL_ENABLED", "DVD_ENABLED"]);
check("Wonder Ink is shown", classroom.some(id => id.includes("wonderink")));
check("Parent Call is shown", classroom.some(id => id.includes("parent-call")));
check("the DVD page is shown", classroom.some(id => id.includes("dvd")));
check("Presentations is NOT shown",
!classroom.some(id => id.includes("presentations")), classroom.join(", "));
console.log(" -- a device that could not say what it has");
// No manifest: show everything, which is the printed manual and is never
// wrong, only less specific.
const unknown = show(null);
check("every page is shown rather than none",
unknown.length === all.length, unknown.length + " of " + all.length);
console.log(" -- nothing optional at all");
const bare = show([]);
check("only the common pages remain",
bare.length === all.length - optional.length,
bare.join(", "));
console.log(" -- what the page is allowed to know about the device");
const manual = fs.readFileSync(path.join(root, "scripts/classroom-manual"), "utf8");
check("the manual is handed a list of setting NAMES, not device.conf",
/window\.CLASSROOM_FEATURES/.test(manual)
&& /json\.dumps\(sorted\(features\)\)/.test(manual));
check("and the names are filtered against the features the package knows",
/name for name in listed if name in FEATURES/.test(manual),
"a stale manifest must not be able to name anything else");
const layout = fs.readFileSync(path.join(root, "scripts/classroom-desktop-layout"), "utf8");
check("the manifest is written from the enabled features alone",
/setting for variable, setting in FEATURE_LINES if answers\.get\(variable\)/.test(layout));
check("no value from device.conf is written into it",
!/read_device_config\(\)\[/.test(layout));
check("it lives in the runtime directory, so it cannot go stale",
/\/run\/ekklesia-classroom\/features\.json/.test(layout));
console.log();
console.log(passed + " passed, " + failed + " failed");
process.exit(failed ? 1 : 0);
JS
+9 -1
View File
@@ -166,7 +166,15 @@ check("it refuses to start when it could not undo itself",
"shutil.which" in wifi
and "leaving the network exactly as it is" in wifi)
print("9. the upgrade does not do any of this on its own")
print("9. a machine with no ifupdown configuration at all")
# A container, and any machine installed without ifupdown. Found by the
# release validator: --check said nothing at all, because a missing file
# was being treated as a read error.
check("a missing interfaces file is not an error, it is just nothing to do",
"not os.path.exists(INTERFACES)" in wifi
and 'return None, ""' in wifi)
print("10. the upgrade does not do any of this on its own")
# Moving a machine's only link during an unattended upgrade is fine until
# the once it is not, and the machine it would strand is a laptop in a
# locked room.
+529
View File
@@ -0,0 +1,529 @@
#!/bin/bash
# The Presentations library: what it imports, what it refuses, what it
# keeps and what it is never allowed to delete.
#
# No LibreOffice and no poppler here. Those are exercised by the lab and
# by the release validator against a real Debian; this is the part that
# has to be right whatever is installed — when a download counts as
# finished, whose files may be touched, what a teacher's choice of
# thumbnail does and does not change, and which of the two retention
# clocks is running.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY'
import os
import errno
import importlib.util
import shutil
import sys
import tempfile
import time
from datetime import timedelta
from importlib.machinery import SourceFileLoader
from pathlib import Path
root = Path(sys.argv[1])
sys.path.insert(0, str(root / "lib"))
import classroom_presentations as library
loader = SourceFileLoader(
"classroom_presentation_import",
str(root / "scripts" / "classroom-presentation-import"),
)
spec = importlib.util.spec_from_loader(loader.name, loader)
importer = importlib.util.module_from_spec(spec)
loader.exec_module(importer)
passed = failed = 0
def check(label, condition, detail=""):
global passed, failed
if condition:
print(f" ok {label}")
passed += 1
else:
print(f" FAIL {label}")
if detail:
print(f" {detail}")
failed += 1
work = Path(tempfile.mkdtemp(prefix="classroom-presentations-"))
downloads = work / "Downloads"
wonderink = downloads / "Wonder Ink"
shelf = work / "library"
wonderink.mkdir(parents=True)
shelf.mkdir(parents=True)
def download(name, contents="x" * 400, age=60):
"""A file sitting in Downloads, finished `age` seconds ago."""
path = downloads / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(contents)
when = time.time() - age
os.utime(path, (when, when))
return path
def entry(**data):
"""A presentation in the library, without running an import."""
directory = shelf / data.get("id", "one")
directory.mkdir(parents=True, exist_ok=True)
base = {
"id": directory.name,
"state": "ready",
"title": "A session",
"pages": 10,
"thumbnail_page": 1,
"imported_at": library.stamp(),
"last_presented_at": None,
"times_presented": 0,
}
base.update(data)
presentation = library.Presentation(directory, base)
presentation.save()
(directory / "slides.pdf").write_text("%PDF-1.4")
return presentation
print("1. a download that has finished, and ones that have not")
done = download("Session 7 - Romans.pptx")
check("a settled PowerPoint is importable", library.importable(done))
arriving = download("Still arriving.pptx")
partial = downloads / "Still arriving.pptx.crdownload"
partial.write_text("half")
check("one with a .crdownload beside it is not",
not library.importable(arriving),
"Chrome renames the .crdownload away when the transfer completes")
check("and the .crdownload itself is never a candidate",
not library.importable(partial))
fresh = download("Just landed.pdf", age=0)
check("one still being written is not",
not library.importable(fresh),
"a file copied from a USB stick has no .crdownload to go by")
check("something that is not a presentation is not",
not library.importable(download("notes.txt")))
empty = download("Nothing.pdf", contents="", age=60)
check("a nought-byte file is not", not library.importable(empty))
print("2. Wonder Ink's worksheets are not presentations")
worksheet = wonderink / "Colouring page.pdf"
worksheet.write_text("%PDF-1.4" + "x" * 400)
os.utime(worksheet, (time.time() - 60, time.time() - 60))
check("a file in the Wonder Ink folder is recognised as Wonder Ink's",
library.within_wonderink(worksheet, downloads))
check("and one beside it in Downloads is not",
not library.within_wonderink(done, downloads))
names = [p.name for p in library.candidates(downloads)]
check("so it is not offered for import", "Colouring page.pdf" not in names,
str(names))
check("the finished session is", "Session 7 - Romans.pptx" in names, str(names))
check("and nothing unfinished is",
"Still arriving.pptx" not in names and "Just landed.pdf" not in names,
str(names))
print("3. titles a teacher would recognise")
check("an ordinary name is kept",
library.readable_title("Session 7 - Romans.pptx") == "Session 7 - Romans",
library.readable_title("Session 7 - Romans.pptx"))
check("underscores become spaces",
library.readable_title("Session_7_Romans.pdf") == "Session 7 Romans",
library.readable_title("Session_7_Romans.pdf"))
check("a browser's duplicate counter goes",
library.readable_title("Bible Study (1).pdf") == "Bible Study",
library.readable_title("Bible Study (1).pdf"))
check("a percent-escaped space is decoded",
library.readable_title("Session%207.pdf") == "Session 7",
library.readable_title("Session%207.pdf"))
hashed = library.readable_title(
"a4f39c2e91bb4d7e.pdf", "Session 7 of the autumn study\nmore")
check("a hash falls back to the document's own first words",
hashed == "Session 7 of the autumn study", hashed)
check("and something with no name at all still gets one",
library.readable_title("...pdf", "") == "Presentation",
library.readable_title("...pdf", ""))
print("4. two downloads of the same name are two presentations")
first = entry(id="first", title="Session 7")
second = entry(id="second", title="Session 7")
# The bug this replaces: excluding the import being named BY TITLE also
# removed the other presentation's identical title from the set, so a
# PDF and a PPTX of one session both came out called "Session 7".
taken = library.titles_in_use(shelf, exclude="second")
check("the other presentation's title is still in the way",
"Session 7" in taken, str(taken))
check("so the second gets a title of its own",
library.unique_title("Session 7", taken) == "Session 7 (2)",
library.unique_title("Session 7", taken))
check("and a name nothing else has is left alone",
library.unique_title("Session 8", taken) == "Session 8")
shutil.rmtree(shelf / "first")
shutil.rmtree(shelf / "second")
print("5. the thumbnail is the teacher's choice, and only that")
# The first slide of a Bible study session is a generic opening shared by
# every session; the one that names the session is usually the second.
p = entry(id="thumb", pages=12)
before = dict(p.data)
p.set_thumbnail_page(2)
after = library.load(shelf / "thumb")
check("the chosen slide is remembered", after.thumbnail_page == 2,
str(after.thumbnail_page))
check("it survives being read back from disk, so it survives a restart",
library.load(shelf / "thumb").thumbnail_page == 2)
check("choosing it did not mark the session as presented",
after.last_presented_at is None and after.times_presented == 0)
check("and did not move the retention clock",
after.data["imported_at"] == before["imported_at"]
and after.data.get("last_presented_at") is None)
check("a slide past the end is clamped rather than stored",
entry(id="thumb2", pages=5, thumbnail_page=99).thumbnail_page == 5)
check("and a nonsense one does not crash the card",
entry(id="thumb3", pages=5, thumbnail_page="banana").thumbnail_page == 1)
print("6. the two retention clocks")
old = library.now() - timedelta(days=181)
recent = library.now() - timedelta(days=100)
never = entry(id="never", imported_at=library.stamp(old))
check("never presented, imported over 180 days ago, is expired",
never.expired(), str(never.expires_at()))
young = entry(id="young", imported_at=library.stamp(recent))
check("never presented but only 100 days old is not",
not young.expired(), str(young.expires_at()))
shown = entry(id="shown",
imported_at=library.stamp(old),
last_presented_at=library.stamp(library.now() - timedelta(days=2)),
times_presented=3)
check("presented two days ago is kept, however old the import is",
not shown.expired(),
"presenting has to start the 30-day clock and stop the 180-day one")
stale = entry(id="stale",
imported_at=library.stamp(old),
last_presented_at=library.stamp(library.now() - timedelta(days=31)),
times_presented=1)
check("presented 31 days ago is expired", stale.expired())
print("7. presenting is the only thing that resets the clock")
p = entry(id="counted", pages=4)
check("a fresh import has never been presented",
p.last_presented_at is None and p.times_presented == 0)
p.mark_presented()
reread = library.load(shelf / "counted")
check("presenting is recorded", reread.last_presented_at is not None)
check("and counted", reread.times_presented == 1)
first_due = reread.expires_at()
time.sleep(1.1)
reread.mark_presented()
again = library.load(shelf / "counted")
check("presenting again starts the thirty days over",
again.expires_at() > first_due, f"{first_due} -> {again.expires_at()}")
check("and counts again", again.times_presented == 2)
print("8. an import that was interrupted is not shown to a teacher")
half = entry(id="half", state="importing")
check("it is in the library's own list",
any(x.id == "half" for x in library.library(shelf)))
check("but it is not ready, so no card offers Present",
not library.load(shelf / "half").ready)
broken = shelf / "broken"
broken.mkdir()
(broken / "presentation.json").write_text("{ this is not json")
check("a truncated metadata file is skipped rather than crashing the library",
all(x.id != "broken" for x in library.library(shelf)),
"a card a teacher cannot open is worse than no card")
print("9. the presenting lock cannot outlive a reboot")
check("it lives in the runtime directory",
"/run" in str(library.presenting_lock())
or str(library.presenting_lock()).startswith("/tmp"),
str(library.presenting_lock()))
print("10. the ledger identifies a download transaction")
mark = library.fingerprint(done)
library.remember(mark, "some-id", shelf)
check("a fingerprint is path, size and time",
mark is not None and str(done.resolve()) in mark, str(mark))
check("it is written down", library.ledger(shelf).get(mark) == "some-id")
check("so an interrupted move can be matched after a restart",
mark in library.ledger(shelf))
touched = download("Session 7 - Romans.pptx", contents="y" * 900)
check("but re-downloading the same session is a new presentation",
library.fingerprint(touched) != mark,
"a different size or time is a different download")
print("11. a completed download is moved into the managed library")
original_render_pages = library.render_pages
original_render_thumbnail = library.render_thumbnail
original_first_text = library.first_text
original_convert = library.convert_to_pdf
def fake_render_pages(_pdf, directory, width=library.SLIDE_WIDTH,
progress=None):
directory.mkdir(parents=True, exist_ok=True)
(directory / "page-001.png").write_bytes(b"png")
return 1, ""
def fake_render_thumbnail(_source, destination,
width=library.THUMBNAIL_WIDTH):
destination.write_bytes(b"thumbnail")
return True
library.render_pages = fake_render_pages
library.render_thumbnail = fake_render_thumbnail
library.first_text = lambda _pdf, pages=2: ""
moved = download("Move me.pdf", contents="%PDF-1.4 moved")
moved_contents = moved.read_bytes()
managed = importer.import_one(moved, shelf)
check("the source leaves Downloads after import", not moved.exists())
check("the one original is kept in the managed entry",
managed is not None and managed.managed_copy.read_bytes() == moved_contents,
str(managed.managed_copy if managed else None))
check("its PDF and page images are derived assets",
managed is not None and managed.ready and managed.slides.exists()
and managed.page_image(1).exists())
print("12. conversion failure does not lose the downloaded original")
library.convert_to_pdf = lambda _source, _destination: (False, "broken deck")
broken_source = download("Broken deck.pptx", contents="not really a deck")
failed_import = importer.import_one(broken_source, shelf)
check("the failed source was still moved out of Downloads",
not broken_source.exists())
check("a failed card retains the managed original",
failed_import is not None and failed_import.failed
and failed_import.managed_copy.read_text() == "not really a deck")
check("the failure did not create a second original",
failed_import is not None
and list((failed_import.directory / "original").iterdir())
== [failed_import.managed_copy])
print("13. a cross-filesystem move publishes before deleting")
cross_source = download("Across filesystems.pdf", contents="cross-device")
cross_destination = work / "other-device" / cross_source.name
real_replace = importer.os.replace
source_text = str(cross_source)
destination_text = str(cross_destination)
def cross_replace(source, destination):
if os.fspath(source) == source_text and os.fspath(destination) == destination_text:
raise OSError(errno.EXDEV, "different filesystems")
return real_replace(source, destination)
importer.os.replace = cross_replace
try:
importer.move_download(cross_source, cross_destination)
finally:
importer.os.replace = real_replace
check("the cross-filesystem destination is complete",
cross_destination.read_text() == "cross-device")
check("only the destination remains after it is durable",
not cross_source.exists()
and not cross_destination.with_name(cross_destination.name + ".moving").exists())
print("14. restart recovery never deletes the only moved original")
resume_dir = shelf / "resume"
resume_original = resume_dir / "original" / "Resume.pdf"
resume_original.parent.mkdir(parents=True)
resume_original.write_text("%PDF-1.4 resume")
resume_data = {
"id": "resume",
"state": "importing",
"title": "Resume",
"original_name": "Resume.pdf",
"source_path": str(downloads / "Resume.pdf"),
"managed_copy": "original/Resume.pdf",
"format": "pdf",
"imported_at": library.stamp(),
"last_presented_at": None,
"times_presented": 0,
"thumbnail_page": 1,
"pages": 0,
"error": None,
}
library.Presentation(resume_dir, resume_data).save()
importer.sweep(shelf)
resumed = library.load(resume_dir)
check("an import interrupted after the move resumes in place",
resumed is not None and resumed.ready)
check("and its original survives recovery", resume_original.exists())
print("15. removal is isolated and refuses an active presentation")
remove_one = entry(id="remove-one", title="Similar title")
keep_one = entry(id="keep-one", title="Similar title (2)")
(remove_one.directory / "original").mkdir()
(remove_one.directory / "original" / "one.pdf").write_text("one")
(keep_one.directory / "original").mkdir()
(keep_one.directory / "original" / "two.pdf").write_text("two")
check("removing one managed entry succeeds", library.remove(remove_one))
check("a similarly named presentation and its files are untouched",
keep_one.directory.exists()
and (keep_one.directory / "original" / "two.pdf").exists())
runtime = work / "runtime"
runtime.mkdir()
old_runtime = os.environ.get("XDG_RUNTIME_DIR")
os.environ["XDG_RUNTIME_DIR"] = str(runtime)
library.presenting_lock().write_text(keep_one.id)
check("an actively presented entry cannot be removed",
not library.remove(keep_one) and keep_one.directory.exists())
library.presenting_lock().unlink()
if old_runtime is None:
os.environ.pop("XDG_RUNTIME_DIR", None)
else:
os.environ["XDG_RUNTIME_DIR"] = old_runtime
library.render_pages = original_render_pages
library.render_thumbnail = original_render_thumbnail
library.first_text = original_first_text
library.convert_to_pdf = original_convert
print("16. teacher-facing wording and compositor policy")
ui = (root / "scripts" / "classroom-presentations").read_text()
layout = (root / "config" / "plasma" / "lookandfeel" / "contents"
/ "layouts" / "org.kde.plasma.desktop-layout.js").read_text()
kwin = (root / "config" / "plasma" / "xdg" / "kwinrc").read_text()
rules = (root / "config" / "plasma" / "xdg" / "kwinrulesrc").read_text()
check("the empty state has one concise browser-neutral instruction",
'label="No presentations yet\\n\\n"' in ui
and "Download a PowerPoint or PDF to get started." in ui
and "Download a presentation in Chrome" not in ui)
check("cover selection uses the requested plain wording",
'label="Use as cover"' in ui and 'label="Current cover"' in ui
and "this is the library picture" not in ui)
check("Remove no longer promises a copy in Chrome",
"This will delete it from this computer." in ui
and "Your download in Chrome" not in ui)
check("the presenter has a compositor-enforced fullscreen rule",
"title=Presenting" in rules and "fullscreen=true" in rules
and "fullscreenrule=2" in rules)
check("normal title bars contain only Close",
"ButtonsOnRight=X" in kwin and "ButtonsOnRight=FX" not in kwin)
check("the unfinished Help launcher is absent from the panel",
"ekklesia-manual.desktop" not in layout)
print("17. running out of memory is a state a teacher can act on, never a loop")
library.render_pages = fake_render_pages
library.render_thumbnail = fake_render_thumbnail
library.first_text = lambda _pdf, pages=2: ""
# The kernel killing LibreOffice: SIGKILL directly, or 137 through the
# soffice shell wrapper.
check("a SIGKILL is recognised either way",
library.killed(-9) and library.killed(137)
and not library.killed(1) and not library.killed(0))
library.convert_to_pdf = lambda _source, _destination: (False, library.KILLED)
heavy = download("Heavy deck.pptx", contents="photographs")
starved = importer.import_one(heavy, shelf)
check("a conversion killed for memory is a failed import",
starved is not None and starved.failed, starved.data if starved else None)
check("of the kind a teacher can try again",
starved.failure == "memory" and starved.data["error_kind"] == "memory")
check("and the downloaded original is kept",
starved.managed_copy.read_text() == "photographs")
check("the attempt was counted before the work began",
starved.data.get("attempts") == 1 and starved.data.get("attempt_started_at"))
check("a file that simply will not convert is a different failure",
failed_import.failure == "unreadable")
check("and cannot be tried again", not library.request_retry(failed_import))
# A service killed in the middle of an import, found by the next start.
library.convert_to_pdf = original_convert
def interrupted(name, attempts):
directory = shelf / name
(directory / "original").mkdir(parents=True)
(directory / "original" / f"{name}.pdf").write_text("%PDF-1.4 " + name)
library.Presentation(directory, {
"id": name, "state": "importing", "title": name,
"original_name": f"{name}.pdf", "source_path": str(downloads / f"{name}.pdf"),
"managed_copy": f"original/{name}.pdf", "format": "pdf",
"imported_at": library.stamp(), "last_presented_at": None,
"times_presented": 0, "thumbnail_page": 1, "pages": 0, "error": None,
"attempts": attempts,
}).save()
return directory
once = interrupted("once", 1)
twice = interrupted("twice", 2)
importer.sweep(shelf)
check("an import interrupted once is resumed",
library.load(once).ready)
gave_up = library.load(twice)
check("an import interrupted twice is not tried a third time",
gave_up.failed and gave_up.failure == "memory", gave_up.data)
check("and its original is still there to try again from",
(twice / "original" / "twice.pdf").exists())
check("Try again puts it back in the importer's hands",
library.request_retry(gave_up) and library.load(twice).state == "retry")
check("which counts afresh", library.load(twice).data.get("attempts") == 0)
check("and the importer takes it up on its next pass",
importer.retry_requested(shelf) == 1 and library.load(twice).ready)
# The importer not running at all: nothing will ever finish this one.
stuck = interrupted("stuck", 0)
entry_data = library.load(stuck).data
long_ago = library.now() - timedelta(seconds=library.STALLED_SECONDS + 60)
entry_data["attempt_started_at"] = library.stamp(long_ago)
library.Presentation(stuck, entry_data).save()
stuck_card = library.load(stuck)
check("an import nobody is working on stops saying Getting this ready",
stuck_card.stalled and stuck_card.failure == "memory")
check("and offers Try again", library.request_retry(stuck_card))
check("a fresh import is not mistaken for a stalled one",
not library.load(once).stalled and library.load(once).failure is None)
held = importer.hold_lock(shelf)
check("one importer holds the lock", held is not None)
check("a second importer is refused rather than run beside it",
importer.hold_lock(shelf) is None)
held.close()
check("and it is free again once the first has gone",
importer.hold_lock(shelf) is not None)
check("the lock file is not a presentation",
all(p.id != ".importer.lock" for p in library.library(shelf)))
unit = (root / "systemd" / "user" / "ekklesia-classroom-presentations.service").read_text()
check("the service may use at most 40% of the machine's memory",
"MemoryMax=40%" in unit)
check("a killed converter does not take the watcher with it",
"OOMPolicy=continue" in unit)
check("and under pressure it goes before the teacher's windows",
"OOMScoreAdjust=800" in unit)
check("the card says what to do, in a teacher's words",
"Close other programs, then choose" in ui and 'label="Try again"' in ui)
library.render_pages = original_render_pages
library.render_thumbnail = original_render_thumbnail
library.first_text = original_first_text
library.convert_to_pdf = original_convert
shutil.rmtree(work, ignore_errors=True)
print()
print(f"{passed} passed, {failed} failed")
raise SystemExit(1 if failed else 0)
PY
+319
View File
@@ -0,0 +1,319 @@
#!/bin/bash
# Wonder Ink's printing: which printers, which options, what is sent.
#
# CUPS is not on the development host, so lpstat, lpoptions, lp and
# pdfinfo are stand-ins that answer with the output the real ones gave in
# the lab — Elmer's own PPD options included. Whether a job really goes
# through Elmer's filters is tools/lab/wonderink-print-check's question.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$WORK/bin"
cat > "$WORK/bin/lpstat" <<EOF
#!/bin/bash
[[ -f "$WORK/cups-down" ]] && { echo "lpstat: Scheduler is not running." >&2; exit 1; }
case "\$1" in
-e) cat "$WORK/printers" 2>/dev/null ;;
-d) cat "$WORK/default" 2>/dev/null || echo "no system default destination" ;;
esac
EOF
# lpoptions -p NAME -l, as the real one printed it for Elmer's PPD.
cat > "$WORK/bin/lpoptions" <<EOF
#!/bin/bash
cat "$WORK/options-\$2" 2>/dev/null
EOF
cat > "$WORK/bin/lp" <<EOF
#!/bin/bash
printf '%s\n' "\$@" > "$WORK/lp-args"
if [[ -f "$WORK/lp-refuses" ]]; then
echo 'lp: Destination "Elmer" is not accepting jobs.' >&2
exit 1
fi
echo "request id is Elmer-42 (1 file(s))"
EOF
cat > "$WORK/bin/pdfinfo" <<'EOF'
#!/bin/bash
[[ "$(head -c 5 "$1" 2>/dev/null)" == "%PDF-" ]] || { echo "Syntax Error" >&2; exit 1; }
echo "Producer: test"
echo "Pages: $(grep -c '^page' "$1")"
EOF
chmod +x "$WORK"/bin/*
printf 'Elmer\n' > "$WORK/printers"
echo "system default destination: Elmer" > "$WORK/default"
cat > "$WORK/options-Elmer" <<'EOF'
PrintoutMode/Print Quality: Draft Draft.Gray *Normal Normal.Gray High High.Gray
PageSize/Page Size: Custom.WIDTHxHEIGHT *Letter A4 11x17 A3 A5 B5 Env10 EnvC5 EnvDL EnvISOB5 EnvMonarch Executive Legal
Duplex/Double-Sided Printing: DuplexNoTumble DuplexTumble *None
ColorModel/Color Mode: FromPrintoutMode *Color Grayscale
PrinterResolution/Resolution: *FromPrintoutMode 300x300dpi 600x600dpi 1200x600dpi 1200x1200dpi
EOF
cat > "$WORK/options-Office" <<'EOF'
PageSize/Media Size: Letter *A4 Legal
EOF
PATH="$WORK/bin:$PATH" WORK="$WORK" PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY'
import os
import re
import sys
from pathlib import Path
import classroom_printing as printing
root = Path(sys.argv[1])
work = Path(os.environ["WORK"])
passed = failed = 0
def check(label, condition, detail=""):
global passed, failed
if condition:
print(f" ok {label}")
passed += 1
else:
print(f" FAIL {label}")
if detail:
print(f" {detail}")
failed += 1
def lp_args():
return (work / "lp-args").read_text().splitlines()
print("1. which printers a teacher is offered")
check("Elmer, and Elmer is the default", printing.printers() == (["Elmer"], "Elmer"))
(work / "printers").write_text("Elmer\nOffice\n")
(work / "default").write_text("system default destination: Office\n")
check("the system default is preselected, whichever it is",
printing.printers() == (["Elmer", "Office"], "Office"))
(work / "default").write_text("no system default destination\n")
check("with no default, the first printer",
printing.printers() == (["Elmer", "Office"], "Elmer"))
(work / "default").write_text("system default destination: Gone\n")
check("a default that is not in the list is not offered",
printing.printers() == (["Elmer", "Office"], "Elmer"))
(work / "printers").write_text("")
check("no printers at all is an empty list, not an error",
printing.printers() == ([], None))
(work / "cups-down").touch()
check("and so is CUPS not running", printing.printers() == ([], None))
(work / "cups-down").unlink()
(work / "printers").write_text("Elmer\n")
(work / "default").write_text("system default destination: Elmer\n")
print()
print("2. what each printer's driver offers")
elmer = printing.options("Elmer")
check("Elmer's paper is Letter", elmer["paper"] == "Letter", elmer)
# Measured: this driver ignores print-color-mode and prints in colour.
check("black and white is asked for in the driver's own terms",
elmer["gray"] == ("ColorModel", "Grayscale"), elmer)
office = printing.options("Office")
check("an A4 printer is A4", office["paper"] == "A4", office)
check("a printer with no grey choice offers none", office["gray"] is None, office)
unknown = printing.options("Nobody")
check("a printer that says nothing is Letter, colour only",
unknown == {"paper": "Letter", "gray": None}, unknown)
print()
print("3. a teacher's page list")
for text, expected in (("", None), ("all", None), (" ", None), ("2", "2"),
("1-3", "1-3"), ("1, 3, 5-6", "1,3,5-6"), ("3-3", "3"),
("1 - 2", "1-2"), ("6", "6")):
try:
got = printing.parse_pages(text, 6)
except ValueError as error:
got = f"refused: {error}"
check(f"{text!r} is {expected!r}", got == expected, got)
for text in ("7", "0", "3-1", "1-9", "one", "1;lp -d x", "-2", "1--2", "2,", "1.5"):
try:
printing.parse_pages(text, 6)
check(f"{text!r} is refused", False)
except ValueError as error:
check(f"{text!r} is refused, and says why: {error}", bool(str(error)))
print()
print("4. what is handed to CUPS")
pdf = work / "Colour the Ark.pdf"
pdf.write_text("%PDF-1.4\npage\npage\n")
check("a PDF with two pages has two", printing.page_count(pdf) == 2)
check("something that is not a PDF has none",
printing.page_count(work / "printers") == 0)
ok, answer = printing.submit(pdf, "Elmer")
args = lp_args()
check("a job is accepted, with its id", (ok, answer) == (True, "Elmer-42"), answer)
check("to Elmer, one copy, named for the lesson",
args[:6] == ["-d", "Elmer", "-n", "1", "-t", "Wonder Ink"], args)
check("the file comes after --, so no name can be read as an option",
args[-2:] == ["--", str(pdf)], args)
check("and nothing else is asked for", "-P" not in args and "-o" not in args, args)
printing.submit(pdf, "Elmer", copies=3, pages="2-3", gray=("ColorModel", "Grayscale"),
title="Noah")
args = lp_args()
check("copies, pages and black and white arrive as lp options",
args[args.index("-n") + 1] == "3"
and args[args.index("-P") + 1] == "2-3"
and args[args.index("-o") + 1] == "ColorModel=Grayscale"
and args[args.index("-t") + 1] == "Noah", args)
printing.submit(pdf, "Elmer", copies=500)
check(f"copies are capped at {printing.MAX_COPIES}",
lp_args()[lp_args().index("-n") + 1] == str(printing.MAX_COPIES))
printing.submit(pdf, "Elmer", copies=0)
check("and never fewer than one", lp_args()[lp_args().index("-n") + 1] == "1")
(work / "lp-refuses").touch()
ok, answer = printing.submit(pdf, "Elmer")
check("a refused job is a failure, in CUPS's own words",
not ok and answer == 'Destination "Elmer" is not accepting jobs.', answer)
print()
print("4b. where a picture goes on the paper")
LETTER, MARGIN = (612, 792), 28.35
area = (MARGIN, MARGIN, 612 - 2 * MARGIN, 792 - 2 * MARGIN)
close = lambda a, b: all(abs(x - y) < 0.01 for x, y in zip(a, b))
x, y, w, h = printing.picture_rect(LETTER, MARGIN, (1600, 1000))
check("a wide picture fits the width and keeps its shape",
abs(w - area[2]) < 0.01 and abs(w / h - 1.6) < 0.001, (x, y, w, h))
check("and is centred on the page", abs(y + h / 2 - 396) < 0.01)
check("nothing of it falls outside the margins",
x >= MARGIN - 0.01 and y >= MARGIN - 0.01
and x + w <= 612 - MARGIN + 0.01 and y + h <= 792 - MARGIN + 0.01)
x, y, w, h = printing.picture_rect(LETTER, MARGIN, (1600, 1000), 50, "top left")
check("50% in the top left is half the fitted size, at the margin",
close((x, y, w), (MARGIN, MARGIN, area[2] / 2)), (x, y, w, h))
x, y, w, h = printing.picture_rect(LETTER, MARGIN, (1600, 1000), 25, "bottom right")
check("25% in the bottom right ends at the margin",
close((x + w, y + h), (612 - MARGIN, 792 - MARGIN)), (x, y, w, h))
x, y, w, h = printing.picture_rect(LETTER, MARGIN, (900, 1400), 100, "right")
check("a tall picture fits the height and sits at the right",
abs(h - area[3]) < 0.01 and abs(x + w - (612 - MARGIN)) < 0.01, (x, y, w, h))
x, y, w, h = printing.picture_rect(LETTER, MARGIN, (1600, 1000), fill=True)
check("fill covers the whole printable area",
x <= MARGIN and y <= MARGIN + 0.01 and x + w >= 612 - MARGIN and y + h >= 792 - MARGIN - 0.01)
check("and keeps the picture's shape, cutting off what overhangs",
abs(w / h - 1.6) < 0.001 and w > area[2])
check("fill ignores size and position",
printing.picture_rect(LETTER, MARGIN, (1600, 1000), 25, "top left", True)
== printing.picture_rect(LETTER, MARGIN, (1600, 1000), fill=True))
check("an unknown position is the centre",
printing.picture_rect(LETTER, MARGIN, (1600, 1000), 50, "upside down")
== printing.picture_rect(LETTER, MARGIN, (1600, 1000), 50, "centre"))
try:
printing.picture_rect(LETTER, MARGIN, (0, 10))
check("a picture with no size is refused", False)
except ValueError:
check("a picture with no size is refused", True)
check("automatic paper is landscape for a wide picture",
printing.picture_landscape("auto", 1600, 1000))
check("and portrait for a tall or square one",
not printing.picture_landscape("auto", 900, 1400)
and not printing.picture_landscape("auto", 1000, 1000))
check("and the teacher's choice overrides it",
not printing.picture_landscape("portrait", 1600, 1000)
and printing.picture_landscape("landscape", 900, 1400))
check("every position is on a three by three grid",
len(printing.PICTURE_POSITIONS) == 9
and sorted(printing.PICTURE_POSITIONS.values()) == [(c, r) for c in range(3) for r in range(3)])
print()
print("4c. black and white, made before the preview")
command = printing.grayscale_command(work / "in.pdf", work / "out gray.pdf")
check("Ghostscript writes a PDF", command[0] == "gs" and "-sDEVICE=pdfwrite" in command, command)
check("in grey and nothing else",
"-sColorConversionStrategy=Gray" in command and "-dProcessColorModel=/DeviceGray" in command, command)
# pdfwrite otherwise turns a page to match its text; a landscape picture
# on a portrait sheet would come back turned.
check("never turning a page", "-dAutoRotatePages=/None" in command, command)
check("annotations drawn in as they print, not left for a filter to draw in colour",
"-dPreserveAnnots=false" in command and "-dPrinted" in command, command)
check("safely, and without stopping to ask",
"-dSAFER" in command and "-dBATCH" in command and "-dNOPAUSE" in command, command)
check("to the file asked for, even with a space in its name",
f"-sOutputFile={work / 'out gray.pdf'}" in command, command)
check("and the source after --, so no name can be read as an option",
command[-2:] == ["--", str(work / "in.pdf")], command)
print()
print("5. Wonder Ink prints through this, and only this")
ink = (root / "scripts/classroom-wonderink").read_text()
code = "\n".join(line for line in ink.splitlines() if not line.lstrip().startswith("#"))
# The 0.2.2 crash, and the blank worksheet, both came from Qt's print path.
check("no QPrinter", "QPrinter(" not in code and "QPrintDialog" not in code)
check("no QWebEngineView.print()", not re.search(r"\bview\.print\(", code))
check("a lesson page becomes a PDF by printToPdf", "printToPdf(" in code)
check("which is given up on if the engine never answers",
"def _wait(self, message, too_long, timeout=PREPARE_TIMEOUT_MS):" in code
and "self._timer.start(timeout)" in code)
check("black and white has longer, since a page of photographs is slow, not stuck",
"GRAY_TIMEOUT_MS = 60_000" in code and " GRAY_TIMEOUT_MS,\n" in code)
check("the preview submits with classroom_printing.submit",
"classroom_printing.submit(" in code)
check("there is no file chooser in it", "QFileDialog" not in code)
check("a worksheet tab is printed from its file",
"self._pdf_views[view] = resolved" in code)
check("an inline PDF is fetched as a file first", "view.page().download(view.url())" in code)
check("a page's background is not printed, as in Chrome",
"PrintElementBackgrounds, False" in code)
check("a picture on its own is laid out, not printed as the viewer page",
"dialog.show_picture(image)" in code and "document.contentType" in ink)
preview_code = code[code.index("class PrintPreview"):code.index("\ndef notify")]
check("the print interface says Color, not Colour",
'"Color"' in preview_code and '"Colour"' not in preview_code)
check("an exception in a handler is logged, not fatal",
"sys.excepthook = keep_going" in code)
# 0.2.6.3: black and white is shown as it prints.
check("black and white is made by classroom_printing.grayscale_command, in the background",
"classroom_printing.grayscale_command(" in preview_code and "QProcess(" in preview_code)
check("choosing Color or Black and white lays the preview out again",
"self._colour.currentIndexChanged.connect" in preview_code)
check("a failed conversion is judged by its exit and its pages, never printed in colour",
"process.exitCode() != 0" in preview_code
and "page_count(target) != expected" in preview_code)
check("the driver is still told ColorModel=Grayscale for the grey file",
'gray = self._options[printer]["gray"]' in preview_code)
check("and nothing prints that is not what is shown",
"(self._gray is not None and self._pdf == self._gray[1]) != (gray is not None)" in preview_code)
# 0.2.6.3: right-click says what it prints.
menu_code = code[code.index(" def contextMenuEvent"):code.index("\nclass WonderInk")]
check("right-click offers Print page always",
'"Print page…"' in menu_code and "print_view(self)" in menu_code)
check("and Print image only where the engine's hit test found a picture or a canvas",
'"Print image…"' in menu_code and "MediaTypeImage" in menu_code
and "MediaTypeCanvas" in menu_code and "print_image(self)" in menu_code)
check("the old single Print… is gone from the menu", '"Print…"' not in menu_code)
take_code = code[code.index(" def _take_picture"):code.index(" def _show_preview")]
check("the picture is the engine's own copy, not fetched again",
"WebAction.CopyImageToClipboard" in take_code
and "download(" not in take_code and "QNetwork" not in take_code)
check("what was on the clipboard is kept and put back",
"keep_clipboard(clipboard)" in take_code and "put_back()" in take_code)
check("a picture that never arrives is said so, not printed as the page",
"TAKE_PICTURE_MS" in take_code and "dialog.fail(" in take_code
and "print_view" not in take_code)
check("and it goes to the picture layout, on white",
"dialog.show_picture(printable_picture(image))" in take_code
and "Qt.GlobalColor.white" in code)
check("the module is packaged",
"lib/classroom_printing.py" in (root / "tools/build-release.sh").read_text())
print()
print(f"{passed} passed, {failed} failed")
sys.exit(1 if failed else 0)
PY

Some files were not shown because too many files have changed in this diff Show More