37 Commits
Author SHA1 Message Date
Zayin_Admin 412eb9e086 Merge pull request 'docs: a CLAUDE.md for agents working in this repository' (#7) from docs/claude-md into master
CI / checks (push) Successful in 2m20s
CI / package and container gate (push) Failing after 7m17s
Reviewed-on: #7
2026-10-14 04:50:39 +00:00
ekklesiaandClaude Opus 5.5 d5b1fdf119 docs: a CLAUDE.md for agents working in this repository
CI / checks (push) Successful in 2m19s
CI / checks (pull_request) Successful in 2m18s
CI / package and container gate (push) Failing after 8m16s
CI / package and container gate (pull_request) Failing after 8m30s
What the repository is and where things are, the release rules agents
must never bend (a version is used once, nothing is rebuilt, a person
runs every release step), branches, the checks that exist and what each
proves, and the working conventions, including filing an issue for what
is found outside the task at hand.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 23:34:50 -05:00
ekklesia 719c0f7718 test: isolate release CI from Gitea Actions environment
CI / checks (push) Successful in 2m59s
CI / package and container gate (push) Canceled after 6m22s
2026-09-30 23:53:15 -05:00
ekklesia 0bc88519c2 test: isolate release CI from actions environment
CI / checks (push) Failing after 2m33s
CI / package and container gate (push) Skipped
2026-09-30 23:44:36 -05:00
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
80 changed files with 9178 additions and 293 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"
+86
View File
@@ -0,0 +1,86 @@
# CLAUDE.md
Guidance for Claude Code (and other coding agents) working in this repository. It points at the
authoritative sources rather than restating them, because restated details drift. Inspect the actual
source, tests, scripts, and workflows before relying on anything here or on memory of a prior
session.
## What this repository is
**Ekklesia Classroom**: the `ekklesia-classroom` Debian package (amd64, Debian 13 "trixie") that turns
a laptop or desktop into a locked-down KDE Plasma (Wayland) classroom machine for a church: a
teacher's launcher, the classroom browser and Wonder Ink, Presentations, DVD playback, printing,
Parent Call messaging, networking and Wi-Fi adoption, a display-size setting, and an updater that
installs new releases unattended. Machines receive it over the air from the update server
(`https://updates.riverofglorymn.org`), on the **stable** or **canary** ("Preview") channel.
It is a separate product from the Ekklesia platform repository (`Zayin/Ekklesia`) and its modules,
and shares no code with them. The platform roadmap (`../ROADMAP.md`, section *Classroom*) plans
Classroom as a future first-party module whose scope is deliberately **not locked yet**. Do not build
platform integration (modules, platform devices, accounts) here speculatively; that starts from a
scope decision, not from code.
Where things are:
| Path | What |
| --- | --- |
| `scripts/` | Everything installed as a command: the session, launcher, browser, updater, admin tools |
| `lib/` | The Python modules those commands share (`classroom_config.py`, printing, presentations, …) |
| `config/`, `systemd/` | Plasma, SDDM, polkit, D-Bus, udev, and systemd units the package installs |
| `packaging/debian/` | `control.template` and `postinst` |
| `tests/test-*.sh` | Every automated test; each runs with no screen and no container |
| `tools/` | `check.sh`, `build-release.sh`, `release-gate.sh`, the release tooling, and `lab/` |
| `docs/` | How each feature works and why. `releasing.md` and `testing.md` are the process |
## Releases are never rebuilt, and a person makes every decision
Read `docs/releasing.md` before touching anything release-shaped. Its rules are not negotiable:
- **A version is used once.** Once a file of that name has been on the update server, it is never
rebuilt or replaced. Tags (`v` + version) are never moved.
- **The file the gate passed is the file every room installs**, byte for byte. Nothing is rebuilt
after Release 1.
- **A person runs every release step**: the Release workflows, `update_canary.sh`,
`update_stable.sh`, and the physical checks in `docs/testing.md`. Never run them, publish to the
update server, reach a classroom machine or the church server (`10.40.0.2`), or create or push a
tag. Tests use stand-in servers; keep it that way.
## Branches
`master` is the next feature release; `release/X.Y` is a maintenance line (fixes only). A fix made on
`release/X.Y` is merged **forward** into `master`, never cherry-picked backwards. `experiment/*` is
never built as a release. See `docs/releasing.md` → *Branches*.
## Verification
`tools/check.sh` is the one list of checks, run by CI and by every release build:
```bash
tools/check.sh # sources, the manual, every tests/test-*.sh
tools/check.sh --base <ref> # and the change since <ref> is clean (git diff --check, no build output)
```
Beyond that: `tools/build-release.sh VERSION` builds the package (after `check.sh`);
`tools/release-gate.sh` installs it on a clean Debian container, fresh and as an upgrade (needs
Docker); `tools/lab/` exercises the real Plasma session and needs the nested session on the
development VM; and real hardware is `docs/testing.md`. Unit checks passing is not evidence that a
desktop behaviour works; say which of these you ran.
CI is `.gitea/workflows/ci.yml` (checks, then package and container gate) on the `ekklesia-ci`
runner. Read its open issues before trusting a red or green run.
## Working conventions
- Canonical remote: `ssh://[email protected]:2222/Zayin/Classroom_Desktop.git` (web and API:
`https://git.zayin.net`). The default branch is `master`. Branch from an up-to-date `origin/master`
(or `origin/release/X.Y` for a maintenance fix), one branch per pull request. Never push to
`master` or `release/*`, never merge your own pull request.
- Every defect fix gets a test in `tests/`; `check.sh` picks it up with nothing else to edit.
- Plain language in anything a teacher sees.
- Report what you find. A defect, flaky test, documentation conflict, or CI problem outside the task
at hand gets an issue in the repository it belongs to, as soon as you find it. Search for an
existing issue first; something fixed in the same pull request goes in its description instead. Be
descriptive: what you saw, the evidence (run and job links, commit, file and line), how to
reproduce it, the impact, and options, and never a secret. Label it `found-by-claude` (an agent
that cannot apply labels ends the body with a line saying so), leave it unassigned, and list what
you filed when you report back.
+1 -1
View File
@@ -1 +1 @@
0.2.6.1 0.2.6.6
+11 -1
View File
@@ -24,8 +24,18 @@ cursorTheme=breeze_cursors
library=org.kde.breeze library=org.kde.breeze
theme=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] [Wallpaper]
Image=/usr/share/backgrounds/ekklesia/classroom-calm.png Image=EkklesiaClassroom
[KSplash] [KSplash]
Theme=None Theme=None
@@ -37,6 +37,13 @@ var WALLPAPER = "/usr/share/backgrounds/ekklesia/classroom-calm.png";
var PANEL_HEIGHT = 76; var PANEL_HEIGHT = 76;
// The idle desktop: the wallpaper, and nothing on it. // 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(); var screens = desktops();
for (var i = 0; i < screens.length; i++) { for (var i = 0; i < screens.length; i++) {
var desktop = screens[i]; var desktop = screens[i];
+115
View File
@@ -26,6 +26,12 @@ editable_desktop_icons=false
editable_bookmarks=false editable_bookmarks=false
movable_toolbars=false movable_toolbars=false
plasma/plasmashell/unlockedDesktop=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 plasma-desktop/scripting_console=false
# Nothing in the classroom should offer to file a bug or open a manual. # Nothing in the classroom should offer to file a bug or open a manual.
action/help_report_bug=false action/help_report_bug=false
@@ -47,6 +53,115 @@ action/help_contents=false
# is not shown doors that will not open. # is not shown doors that will not open.
[KDE Control Module Restrictions][$i] [KDE Control Module Restrictions][$i]
kcm_networkmanagement=false 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 # Not immutable. Marking this group [$i] froze the colour scheme so hard
# that `classroom-theme light` changed the running session and then lost # that `classroom-theme light` changed the running session and then lost
@@ -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 To put the session screen back, remove the D-Bus shadow and the
`kglobalshortcutsrc` entries; both are package files, so an update will `kglobalshortcutsrc` entries; both are package files, so an update will
restore them, which is the intended direction. 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.
+36 -5
View File
@@ -32,11 +32,28 @@ The size applies to every tab and is remembered next time.
## Printing ## Printing
Press **Ctrl** and **P**, click **Print** at the top right, or right-click Press **Ctrl** and **P**, click **Print** at the top right, or right-click
and choose **Print…**. You will see the pages exactly as they will print. 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`. * Choose **Only some pages** to print, for example, `2` or `1-3`.
* Set **Copies** for the number of children. * Set **Copies** for the number of children.
* Choose **Black and white** to save colour ink. * 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 Click **Print**. Wonder Ink says when the printer has it and goes back to
the lesson. **Cancel** prints nothing. the lesson. **Cancel** prints nothing.
@@ -44,14 +61,28 @@ the lesson. **Cancel** prints nothing.
## Worksheets and colouring pages ## Worksheets and colouring pages
Click the worksheet link in the lesson. It opens in a new tab, ready to Click the worksheet link in the lesson. It opens in a new tab, ready to
read or print. A worksheet's own **−** and **+** at the top of the page read or print. Any other link in a lesson — a slide, a picture, a video —
make it bigger or smaller. 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 Worksheets you download are kept separately from everything else on the
computer, so they never get mixed up with anything. 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 ## When you have finished
**Close Wonder Ink at the end of your session.** Closing it signs you **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 out, so the next teacher in this room starts with their own account and
not yours. not yours. Your recent tabs are kept for two hours in case you need them
again, but never your sign-in.
+11 -5
View File
@@ -43,7 +43,7 @@ figcaption { color: #93a5aa; font-size: .9rem; margin-top: .4rem; }
<body> <body>
<div class="sheet"> <div class="sheet">
<h1>Classroom manual</h1> <h1>Classroom manual</h1>
<p class="revision">River of Glory &middot; Revision 23 September 2026</p> <p class="revision">River of Glory &middot; Revision 29 September 2026</p>
<nav id='contents'></nav> <nav id='contents'></nav>
<section id="00-welcome"> <section id="00-welcome">
<h2>Your classroom computer</h2> <h2>Your classroom computer</h2>
@@ -135,14 +135,20 @@ figcaption { color: #93a5aa; font-size: .9rem; margin-top: .4rem; }
<p>Press <strong>Ctrl</strong> and <strong>+</strong> to make the lesson bigger, <strong>Ctrl</strong> and <strong>−</strong> to make it smaller, and <strong>Ctrl</strong> and <strong>0</strong> to go back to normal. You can also right-click the lesson and choose <strong>Make the lesson bigger</strong>. When several tabs are open, the <strong>− 100% +</strong> buttons at the top right do the same.</p> <p>Press <strong>Ctrl</strong> and <strong>+</strong> to make the lesson bigger, <strong>Ctrl</strong> and <strong>−</strong> to make it smaller, and <strong>Ctrl</strong> and <strong>0</strong> to go back to normal. You can also right-click the lesson and choose <strong>Make the lesson bigger</strong>. When several tabs are open, the <strong>− 100% +</strong> buttons at the top right do the same.</p>
<p>The size applies to every tab and is remembered next time.</p> <p>The size applies to every tab and is remembered next time.</p>
<h3>Printing</h3> <h3>Printing</h3>
<p>Press <strong>Ctrl</strong> and <strong>P</strong>, click <strong>Print</strong> at the top right, or right-click and choose <strong>Print…</strong>. You will see the pages exactly as they will print.</p> <p>Press <strong>Ctrl</strong> and <strong>P</strong>, click <strong>Print</strong> at the top right, or right-click and choose <strong>Print page…</strong>. You will see the pages exactly as they will print.</p>
<ul><li>Choose <strong>Only some pages</strong> to print, for example, `2` or `1-3`.</li><li>Set <strong>Copies</strong> for the number of children.</li><li>Choose <strong>Black and white</strong> to save colour ink.</li></ul> <ul><li>Choose <strong>Only some pages</strong> to print, for example, `2` or `1-3`.</li><li>Set <strong>Copies</strong> for the number of children.</li><li>Choose <strong>Black and white</strong> to save color ink. The preview turns grey, so you see what will come out; choose <strong>Color</strong> to go back.</li></ul>
<p><strong>Printing a picture from the lesson.</strong> Right-click the picture and choose <strong>Print image…</strong>. Only that picture prints, not the page around it. This works for pictures you cannot open by themselves.</p>
<p>In the preview for a picture, also under <strong>Picture</strong>: choose its <strong>Size</strong> (for example 50%), click one of the nine <strong>Position</strong> squares to put it in a corner or the middle, and choose <strong>Paper</strong> upright or sideways. The preview changes as you choose, and shows exactly what will print. <strong>Fill the whole page</strong> makes it cover the paper but cuts off the edges. A picture that is open in a tab by itself prints the same way.</p>
<p>If Wonder Ink says a picture *could not be taken from the page*, it had not finished loading: close the preview and try again, or choose <strong>Print page…</strong> to print the whole page instead.</p>
<p>Click <strong>Print</strong>. Wonder Ink says when the printer has it and goes back to the lesson. <strong>Cancel</strong> prints nothing.</p> <p>Click <strong>Print</strong>. Wonder Ink says when the printer has it and goes back to the lesson. <strong>Cancel</strong> prints nothing.</p>
<h3>Worksheets and colouring pages</h3> <h3>Worksheets and colouring pages</h3>
<p>Click the worksheet link in the lesson. It opens in a new tab, ready to read or print. A worksheet&#x27;s own <strong>−</strong> and <strong>+</strong> at the top of the page make it bigger or smaller.</p> <p>Click the worksheet link in the lesson. It opens in a new tab, ready to read or print. Any other link in a lesson — a slide, a picture, a video — also opens in a new tab with one click, and Wonder Ink shows it straight away; the lesson stays open in its own tab. A worksheet&#x27;s own <strong>−</strong> and <strong>+</strong> at the top of the page make it bigger or smaller.</p>
<p>Worksheets you download are kept separately from everything else on the computer, so they never get mixed up with anything.</p> <p>Worksheets you download are kept separately from everything else on the computer, so they never get mixed up with anything.</p>
<h3>If Wonder Ink closes by accident</h3>
<p>Open it again. If you used it in the last two hours, <strong>your recent tabs come back</strong> — the slides, pictures and worksheets you had open, in the same order. Wonder Ink&#x27;s own lesson tab comes back at the sign-in page: sign in again and open your lesson.</p>
<p>Your password is never kept. After two hours that nobody has used Wonder Ink, it forgets the tabs and the next person starts with a fresh Wonder Ink.</p>
<h3>When you have finished</h3> <h3>When you have finished</h3>
<p><strong>Close Wonder Ink at the end of your session.</strong> Closing it signs you out, so the next teacher in this room starts with their own account and not yours.</p> <p><strong>Close Wonder Ink at the end of your session.</strong> Closing it signs you out, so the next teacher in this room starts with their own account and not yours. Your recent tabs are kept for two hours in case you need them again, but never your sign-in.</p>
<p><a class="top" href="#contents">Back to the top</a></p> <p><a class="top" href="#contents">Back to the top</a></p>
</section> </section>
<section id="60-parent-call" data-feature="PARENT_CALL_ENABLED"> <section id="60-parent-call" data-feature="PARENT_CALL_ENABLED">
+95 -66
View File
@@ -3,47 +3,50 @@
<< /Type /Catalog /Pages 2 0 R >> << /Type /Catalog /Pages 2 0 R >>
endobj endobj
2 0 obj 2 0 obj
<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R] /Count 11 >> << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] /Count 12 >>
endobj endobj
3 0 obj 3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 14 0 R >> << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 15 0 R >>
endobj endobj
4 0 obj 4 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 15 0 R >> << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 16 0 R >>
endobj endobj
5 0 obj 5 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 16 0 R >> << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 17 0 R >>
endobj endobj
6 0 obj 6 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 17 0 R >> << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 18 0 R >>
endobj endobj
7 0 obj 7 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 18 0 R >> << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 19 0 R >>
endobj endobj
8 0 obj 8 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 19 0 R >> << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 20 0 R >>
endobj endobj
9 0 obj 9 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 20 0 R >> << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 21 0 R >>
endobj endobj
10 0 obj 10 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 21 0 R >> << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 22 0 R >>
endobj endobj
11 0 obj 11 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 22 0 R >> << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 23 0 R >>
endobj endobj
12 0 obj 12 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 23 0 R >> << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 24 0 R >>
endobj endobj
13 0 obj 13 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 25 0 R /F2 26 0 R >> >> /Contents 24 0 R >> << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 25 0 R >>
endobj endobj
14 0 obj 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 >> << /Length 765 >>
stream stream
BT /F2 26 Tf 1 0 0 1 62.0 720.0 Tm (Classroom manual) Tj ET 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 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 23 September 2026) Tj ET BT /F1 10.5 Tf 1 0 0 1 62.0 656.6 Tm (Revision 29 September 2026) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 623.7 Tm (This manual covers every classroom computer in the church. Not every room has every button, so) Tj ET BT /F1 10.5 Tf 1 0 0 1 62.0 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 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 593.9 Tm (that page is not for your room.) Tj ET
@@ -51,7 +54,7 @@ BT /F1 10.5 Tf 1 0 0 1 62.0 569.0 Tm (Keep this copy in the room. If the date ab
BT /F1 10.5 Tf 1 0 0 1 62.0 554.1 Tm (the office for a new one.) Tj ET BT /F1 10.5 Tf 1 0 0 1 62.0 554.1 Tm (the office for a new one.) Tj ET
endstream endstream
endobj endobj
15 0 obj 16 0 obj
<< /Length 1250 >> << /Length 1250 >>
stream stream
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Your classroom computer) Tj ET BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Your classroom computer) Tj ET
@@ -68,7 +71,7 @@ BT /F1 10.5 Tf 1 0 0 1 62.0 580.7 Tm (The right-hand end shows the network, the
BT /F1 10.5 Tf 1 0 0 1 62.0 560.8 Tm (To open something, click its button once. To come back to it later, click the same button again.) Tj ET 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 endstream
endobj endobj
16 0 obj 17 0 obj
<< /Length 1408 >> << /Length 1408 >>
stream stream
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (The Browser) Tj ET BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (The Browser) Tj ET
@@ -89,7 +92,7 @@ BT /F1 10.5 Tf 1 0 0 1 62.0 518.2 Tm (Anything you sign in to yourself - a perso
BT /F1 10.5 Tf 1 0 0 1 62.0 503.3 Tm (The computer is shared.) Tj ET BT /F1 10.5 Tf 1 0 0 1 62.0 503.3 Tm (The computer is shared.) Tj ET
endstream endstream
endobj endobj
17 0 obj 18 0 obj
<< /Length 1694 >> << /Length 1694 >>
stream stream
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Sound and volume) Tj ET BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Sound and volume) Tj ET
@@ -113,7 +116,7 @@ BT /F1 10.5 Tf 1 0 0 1 94.0 512.3 Tm (across.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 492.4 Tm (If none of that works, tell the office. Do not unplug anything.) Tj ET 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 endstream
endobj endobj
18 0 obj 19 0 obj
<< /Length 4084 >> << /Length 4084 >>
stream stream
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Presentations) Tj ET BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Presentations) Tj ET
@@ -164,7 +167,7 @@ BT /F1 10.5 Tf 1 0 0 1 94.0 113.3 Tm (Click Use this slide as the picture.) Tj E
BT /F1 10.5 Tf 1 0 0 1 62.0 93.4 Tm (The card changes straight away, and stays that way - after you close the library, and after the) Tj ET 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 endstream
endobj endobj
19 0 obj 20 0 obj
<< /Length 3089 >> << /Length 3089 >>
stream 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 780.0 Tm (computer is switched off and on again.) Tj ET
@@ -205,8 +208,8 @@ BT /F1 10.5 Tf 1 0 0 1 62.0 223.3 Tm (Its card says so, and offers only Remove.
BT /F1 10.5 Tf 1 0 0 1 62.0 203.4 Tm (Download it again. Your original download is still there either way.) Tj ET 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 endstream
endobj endobj
20 0 obj 21 0 obj
<< /Length 3431 >> << /Length 4396 >>
stream stream
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Wonder Ink) Tj ET 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 0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
@@ -227,27 +230,51 @@ BT /F1 10.5 Tf 1 0 0 1 62.0 516.2 Tm (back to normal. You can also right-click t
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 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 /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 /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?. You will see) 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 (the pages exactly as they will print.) 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 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 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 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 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 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 colour ink.) 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 62.0 348.1 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 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 (nothing.) 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 /F2 12.5 Tf 1 0 0 1 62.0 305.3 Tm (Worksheets and colouring pages) 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 284.6 Tm (Click the worksheet link in the lesson. It opens in a new tab, ready to read or print. A) 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 269.7 Tm (worksheet's own ? and + at the top of the page make it bigger or smaller.) 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 249.8 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 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 234.8 Tm (get mixed up with anything.) 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 /F2 12.5 Tf 1 0 0 1 62.0 206.9 Tm (When you have finished) 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 186.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 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 171.3 Tm (this room starts with their own account and not yours.) 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 endstream
endobj endobj
21 0 obj 22 0 obj
<< /Length 1446 >>
stream
BT /F1 10.5 Tf 1 0 0 1 62.0 780.0 Tm (top of the page make it bigger or smaller.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 760.1 Tm (Worksheets you download are kept separately from everything else on the computer, so they never) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 745.2 Tm (get mixed up with anything.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 717.3 Tm (If Wonder Ink closes by accident) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 696.5 Tm (Open it again. If you used it in the last two hours, your recent tabs come back - the slides,) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 681.6 Tm (pictures and worksheets you had open, in the same order. Wonder Ink's own lesson tab comes back at) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 666.7 Tm (the sign-in page: sign in again and open your lesson.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 646.8 Tm (Your password is never kept. After two hours that nobody has used Wonder Ink, it forgets the) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 631.9 Tm (tabs and the next person starts with a fresh Wonder Ink.) Tj ET
BT /F2 12.5 Tf 1 0 0 1 62.0 604.0 Tm (When you have finished) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 583.2 Tm (Close Wonder Ink at the end of your session. Closing it signs you out, so the next teacher in) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 568.3 Tm (this room starts with their own account and not yours. Your recent tabs are kept for two hours in) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 553.4 Tm (case you need them again, but never your sign-in.) Tj ET
endstream
endobj
23 0 obj
<< /Length 1475 >> << /Length 1475 >>
stream stream
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Parent Call) Tj ET BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Parent Call) Tj ET
@@ -270,7 +297,7 @@ BT /F1 10.5 Tf 1 0 0 1 62.0 516.2 Tm (when it did not.) Tj ET
BT /F1 10.5 Tf 1 0 0 1 62.0 496.3 Tm (If that happens, send somebody.) Tj ET BT /F1 10.5 Tf 1 0 0 1 62.0 496.3 Tm (If that happens, send somebody.) Tj ET
endstream endstream
endobj endobj
22 0 obj 24 0 obj
<< /Length 1500 >> << /Length 1500 >>
stream stream
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Playing a DVD) Tj ET BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Playing a DVD) Tj ET
@@ -294,7 +321,7 @@ BT /F1 10.5 Tf 1 0 0 1 62.0 487.4 Tm (Click X on that row to stop, or eject to s
BT /F1 10.5 Tf 1 0 0 1 62.0 467.5 Tm (Take the disc with you.) Tj ET BT /F1 10.5 Tf 1 0 0 1 62.0 467.5 Tm (Take the disc with you.) Tj ET
endstream endstream
endobj endobj
23 0 obj 25 0 obj
<< /Length 1166 >> << /Length 1166 >>
stream stream
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Printing) Tj ET BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Printing) Tj ET
@@ -313,7 +340,7 @@ BT /F1 10.5 Tf 1 0 0 1 62.0 591.8 Tm (in the queue and prints when the printer i
BT /F1 10.5 Tf 1 0 0 1 62.0 571.9 Tm (Sending it again two or three times is how ten copies of the same worksheet appear later.) Tj ET 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 endstream
endobj endobj
24 0 obj 26 0 obj
<< /Length 1766 >> << /Length 1766 >>
stream stream
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (If something goes wrong) Tj ET BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (If something goes wrong) Tj ET
@@ -337,43 +364,45 @@ BT /F1 10.5 Tf 1 0 0 1 62.0 411.0 Tm (Tell the office, and say which room and wh
BT /F1 10.5 Tf 1 0 0 1 62.0 396.1 Tm (the one that saves everybody time.) Tj ET BT /F1 10.5 Tf 1 0 0 1 62.0 396.1 Tm (the one that saves everybody time.) Tj ET
endstream endstream
endobj endobj
25 0 obj 27 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>
endobj endobj
26 0 obj 28 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >> << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>
endobj endobj
xref xref
0 27 0 29
0000000000 65535 f 0000000000 65535 f
0000000009 00000 n 0000000009 00000 n
0000000058 00000 n 0000000058 00000 n
0000000180 00000 n 0000000187 00000 n
0000000319 00000 n 0000000326 00000 n
0000000458 00000 n 0000000465 00000 n
0000000597 00000 n 0000000604 00000 n
0000000736 00000 n 0000000743 00000 n
0000000875 00000 n 0000000882 00000 n
0000001014 00000 n 0000001021 00000 n
0000001153 00000 n 0000001160 00000 n
0000001293 00000 n 0000001300 00000 n
0000001433 00000 n 0000001440 00000 n
0000001573 00000 n 0000001580 00000 n
0000001713 00000 n 0000001720 00000 n
0000002530 00000 n 0000001860 00000 n
0000003833 00000 n 0000002677 00000 n
0000005294 00000 n 0000003980 00000 n
0000007041 00000 n 0000005441 00000 n
0000011178 00000 n 0000007188 00000 n
0000014320 00000 n 0000011325 00000 n
0000017804 00000 n 0000014467 00000 n
0000019332 00000 n 0000018916 00000 n
0000020885 00000 n 0000020415 00000 n
0000022104 00000 n 0000021943 00000 n
0000023923 00000 n 0000023496 00000 n
0000024021 00000 n 0000024715 00000 n
0000026534 00000 n
0000026632 00000 n
trailer trailer
<< /Size 27 /Root 1 0 R >> << /Size 29 /Root 1 0 R >>
startxref startxref
24124 26735
%%EOF %%EOF
+58
View File
@@ -128,6 +128,60 @@ A file that cannot be converted gets a card saying so, offering only
**Remove**. Its managed original remains recoverable even when PDF or page **Remove**. Its managed original remains recoverable even when PDF or page
generation fails. 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 ## The three things kept apart
This is the part most worth not breaking. This is the part most worth not breaking.
@@ -226,3 +280,7 @@ classroom-manual --features which manual pages this device shows
`--status` prints titles, slide counts, the chosen thumbnail slide, both `--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 timers and the expiry date. It is the fastest way to answer "why has this
gone" and "why has this not appeared". 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).
+60 -5
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 never touches a repository it did not add. Classroom software updates
are the OTA channel's job, not this. 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 ## Permanent download addresses
``` ```
@@ -155,11 +199,15 @@ version newer than its own. So now:
A publish that fails after the local copy into `repo/packages` has spent A publish that fails after the local copy into `repo/packages` has spent
that number. Use the next one. that number. Use the next one.
### Canary ### 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.1 # a maintenance release ./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 # the next feature release ./update_canary.sh 0.2.7 # build, gate and publish
``` ```
The version is always given. With four parts there are two possible next The version is always given. With four parts there are two possible next
@@ -167,17 +215,24 @@ 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 argument it prints the current canary and both candidates, and builds
nothing. 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 ### Stable
``` ```
./update_stable.sh 0.2.6.1 ./update_stable.sh 0.2.6.6 <sha256>
``` ```
Only with the owner's written approval for that release. Stable is never 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, 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 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 current one or whose package the server does not hold with the same
checksum. checksum. With the sha256 — always give it — it refuses any file but the
one that was approved.
### Checking a channel without changing it ### Checking a channel without changing it
+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 | | `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 | | `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 ## Failures, kept
| | | | | |
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: 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: 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: 281 KiB

+184 -12
View File
@@ -3,6 +3,36 @@
Run against the exact artifact published to canary, on the real endpoint, Run against the exact artifact published to canary, on the real endpoint,
before promoting to stable with `./update_stable.sh`. 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 ## Boot
- [ ] Cold boot reaches the classroom shell with no terminal or window junk - [ ] 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 - [ ] Dock appears, centred
- [ ] Clock appears, bottom right - [ ] 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 ## Provisioning
- [ ] `journalctl -u ekklesia-classroom-provision` shows a clean run - [ ] `journalctl -u ekklesia-classroom-provision` shows a clean run
@@ -466,7 +529,15 @@ With `LIVE_ENABLED=true`:
- [ ] `./update_canary.sh` publishes package first, manifest last - [ ] `./update_canary.sh` publishes package first, manifest last
- [ ] The endpoint on the canary channel picks the update up - [ ] The endpoint on the canary channel picks the update up
- [ ] SHA-256 verification passes - [ ] 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 - [ ] Everything above still works after the update
- [ ] Upgrade from 0.1.36 preserves every existing `device.conf` value - [ ] Upgrade from 0.1.36 preserves every existing `device.conf` value
- [ ] `LIVE_ENABLED=false` is appended exactly once on upgrade and remains - [ ] `LIVE_ENABLED=false` is appended exactly once on upgrade and remains
@@ -575,14 +646,14 @@ ifupdown stanza is already gone. **Do not run `--adopt` on it.**
still reports `yes` still reports `yes`
- [ ] The laptop still reconnects after a reboot - [ ] The laptop still reconnects after a reboot
## Wonder Ink — 0.2.6.1, before it goes to stable ## 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, 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. 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. Those are these checks. Stable stays on 0.2.2 until they pass.
Before starting, on each machine: `dpkg-query -W ekklesia-classroom` Before starting, on each machine: `dpkg-query -W ekklesia-classroom`
must say `0.2.6.1`. The desktops only get it once stable is moved, so on 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: a desktop install it by hand for the test:
``` ```
@@ -600,20 +671,70 @@ after testing, either promote or reinstall 0.2.2 the same way.
- [ ] Open Wonder Ink, sign in, open a lesson with a worksheet - [ ] 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 - [ ] Click the worksheet; it opens as a tab named after the file, with a
document icon document icon
- [ ] Ctrl+P (or right-click → Print…): the preview shows the worksheet's - [ ] Ctrl+P (or right-click → Print page…): the preview shows the worksheet's
own pages, page count right, ‹ › turn them own pages, page count right, ‹ › turn them
- [ ] Printer shows **Elmer**, preselected - [ ] Printer shows **Elmer**, preselected
- [ ] Print → a notification "Sent to the printer — … — Elmer", Wonder Ink - [ ] Print → a notification "Sent to the printer — … — Elmer", Wonder Ink
stays open on the lesson stays open on the lesson
- [ ] **The worksheet comes out of Elmer**, every page, right size, in colour - [ ] **The worksheet comes out of Elmer**, every page, right size, in colour
- [ ] Pages `2`, Copies `2`, Black and white → exactly two grey copies of - [ ] Pages `2`, Copies `2`, Black and white → the preview turns grey,
page 2 come out and exactly two grey copies of page 2 come out
- [ ] Print a normal lesson page the same way; it comes out, Letter - [ ] Print a normal lesson page the same way; it comes out, Letter
- [ ] Open the preview and press Cancel → nothing prints - [ ] Open the preview and press Cancel → nothing prints
- [ ] Print three times in a row → three printouts, no crash - [ ] Print three times in a row → three printouts, no crash
- [ ] `lpstat -W completed -o` shows each job; `journalctl --user -b | grep - [ ] `lpstat -W completed -o` shows each job; `journalctl --user -b | grep
"printed '"` shows Wonder Ink's line for each "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 ### Size and zoom
- [ ] A lesson in Wonder Ink is the same size as the same page in the - [ ] A lesson in Wonder Ink is the same size as the same page in the
@@ -642,12 +763,63 @@ classroom-wonderink --diagnose
with a driver named above points at the network with a driver named above points at the network
- [ ] `free -m`: Wonder Ink needs about 610 MB plus 115 MB per extra tab - [ ] `free -m`: Wonder Ink needs about 610 MB plus 115 MB per extra tab
### Signing out ### Temporary workspace restore (0.2.6.4) — Little Waders
- [ ] Sign in, close Wonder Ink, open it again → **signed out**. (Cookies Use a **Viewer** account. An Admin sees lessons in WonderInk's editor,
are session-only and measured so; the site's Local Storage is not where a click on a link shows its Edit/Unlink bubble — WonderInk's design,
cleared, which is the one way this could fail and has never been not Wonder Ink's; Ctrl+click opens it. See wonder-ink.md, *Links in a
tested with a real account) 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 ### After a reboot, and the other rooms
@@ -660,4 +832,4 @@ classroom-wonderink --diagnose
says `false`) says `false`)
If anything here fails, leave the desktops on stable 0.2.2 and fix it in If anything here fails, leave the desktops on stable 0.2.2 and fix it in
0.2.6.2 on canary. the next maintenance release on canary.
+63
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 path used by the packaged Plasma layout. The layout is rebuilt at login, so
upgraded classrooms receive the package wallpaper without per-room copying. 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 ## What the one number reaches
`classroom-ui-scale` prints it, clamped, and everything that cannot read `classroom-ui-scale` prints it, clamped, and everything that cannot read
+369 -10
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 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 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, 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, 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. Ctrl+Tab switches. Ctrl+T, Ctrl+N and Ctrl+L are deliberately unbound.
@@ -232,8 +276,9 @@ One dialog, for lessons and worksheets alike:
* **Pages** — all, or some: `2`, `1-3`, `1, 3, 5-6`, checked against the * **Pages** — all, or some: `2`, `1-3`, `1, 3, 5-6`, checked against the
document with the reason given if wrong; document with the reason given if wrong;
* **Copies** — 1 to 50; * **Copies** — 1 to 50;
* **Colour** — colour or black and white, only where the printer's driver * **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; 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 * **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 the job, and a notification says where it went. A refused job keeps the
preview open with CUPS's reason. preview open with CUPS's reason.
@@ -244,9 +289,176 @@ 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 does a page calling `window.print()` meanwhile. The PDF viewer's own print
button opens the same preview. 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 Temporary PDFs live in the teacher's `$XDG_RUNTIME_DIR` and are removed
when the preview closes. 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 `tools/lab/wonderink-print-check` runs all of this against the installed
command in the lab, and judges every scenario by what CUPS actually command in the lab, and judges every scenario by what CUPS actually
received. `tests/test-classroom-printing.sh` covers the CUPS side at every received. `tests/test-classroom-printing.sh` covers the CUPS side at every
@@ -265,19 +477,162 @@ logs it to the journal and carries on.
sign-in and the room's shared YouTube account are different sessions in sign-in and the room's shared YouTube account are different sessions in
different storage that cannot reach each other. The browser is still different storage that cannot reach each other. The browser is still
Chrome and nothing in this change touches it. Chrome and nothing in this change touches it.
* **Cookies live only as long as the window.** This is what the Chrome * **Cookies live only as long as the window**, as Chrome's
policy's `CookiesSessionOnlyForUrls` did for wonderink.org, and it is `CookiesSessionOnlyForUrls` did for wonderink.org, so closing Wonder Ink
the reason closing Wonder Ink ends the teacher's session rather than signs the teacher out and the next person is not signed in as the last
leaving the next person signed in as the last one. The whole profile one. WonderInk's sign-in is the teacher's password in a cookie
rather than one domain now, because this profile only ever goes where a (measured, 0.2.6.4), which is one more reason it never reaches the
lesson sends it. If a room ever wants sign-ins to survive, it is the one disk. What does survive a close, for two hours, is the list of tabs —
line `setPersistentCookiesPolicy` in `classroom-wonderink`. see *Temporary workspace restore*, below.
* **The old Chrome profile is left where it is**, at * **The old Chrome profile is left where it is**, at
`~/.local/share/ekklesia-classroom/wonderink`. The new one is `~/.local/share/ekklesia-classroom/wonderink`. The new one is
`wonderink-shell` beside it, so rolling back to 0.2.0 finds the old one `wonderink-shell` beside it, so rolling back to 0.2.0 finds the old one
intact. intact.
* **One scale for the room** — see *How large a lesson is*, below. * **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 ## How large a lesson is
Teachers found lessons in Wonder Ink too small next to the same site in Teachers found lessons in Wonder Ink too small next to the same site in
@@ -538,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 `/etc/opt/chrome/policies/managed/` sets first-run, sign-in, password
and homepage behaviour only. None of it touches media, autoplay, and homepage behaviour only. None of it touches media, autoplay,
iframes, fullscreen or site permissions. iframes, fullscreen or site permissions.
## Later
What testing of 0.2.6.1 asked for next is in [backlog.md](backlog.md).
+77
View File
@@ -288,6 +288,44 @@ class Presentation:
def failed(self): def failed(self):
return self.state == "failed" 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 @property
def error(self): def error(self):
return self.data.get("error") or "" return self.data.get("error") or ""
@@ -458,6 +496,24 @@ def library(root=None):
return found 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): def remove(presentation):
"""Delete exactly one managed entry, unless it is on screen now.""" """Delete exactly one managed entry, unless it is on screen now."""
try: try:
@@ -642,6 +698,21 @@ PDFTOTEXT = "pdftotext"
CONVERT_TIMEOUT = 300 CONVERT_TIMEOUT = 300
RENDER_TIMEOUT = 600 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): def _run(command, timeout, **kwargs):
import subprocess import subprocess
@@ -704,6 +775,9 @@ def convert_to_pdf(source, destination):
shutil.rmtree(profile, ignore_errors=True) shutil.rmtree(profile, ignore_errors=True)
shutil.rmtree(workspace, ignore_errors=True) shutil.rmtree(workspace, ignore_errors=True)
if killed(result.returncode):
return False, KILLED
return False, (result.stderr or result.stdout or return False, (result.stderr or result.stdout or
"the file could not be opened").strip() "the file could not be opened").strip()
@@ -775,6 +849,9 @@ def render_pages(pdf, directory, width=SLIDE_WIDTH, progress=None):
time.sleep(0.4) time.sleep(0.4)
if process.returncode != 0: if process.returncode != 0:
if killed(process.returncode):
return 0, KILLED
return 0, (process.stderr.read() if process.stderr else return 0, (process.stderr.read() if process.stderr else
"the slides could not be rendered") "the slides could not be rendered")
+110
View File
@@ -197,6 +197,42 @@ def parse_pages(text, count):
return ",".join(parts) 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"): 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). """Hand a PDF to CUPS. (True, job id) or (False, what went wrong).
@@ -225,3 +261,77 @@ def submit(pdf, printer, copies=1, pages=None, gray=None, title="Wonder Ink"):
# without the program's name in front of it. # without the program's name in front of it.
message = re.sub(r"^lp:\s*", "", message) or "The printer did not accept the job." message = re.sub(r"^lp:\s*", "", message) or "The printer did not accept the job."
return False, message 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)
+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
+15 -2
View File
@@ -4,9 +4,9 @@ Section: misc
Priority: optional Priority: optional
Architecture: amd64 Architecture: amd64
Maintainer: Ekklesia Maintainer: Ekklesia
Depends: pkexec, polkit-kde-agent-1, plasma-desktop, plasma-workspace, kwin-wayland, plasma-nm, plasma-pa, plasma-widgets-addons, konsole, sddm, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, pulseaudio-utils, cups, cups-client, cups-filters, foomatic-db-compressed-ppds, python3-gi, python3-gi-cairo, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, libnotify-bin, curl, gnupg, ca-certificates, python3-paho-mqtt, python3-pyqt6.qtwebengine, python3-pyqt6, libreoffice-impress, libreoffice-core, poppler-utils, fonts-liberation, fonts-liberation-sans-narrow, fonts-croscore, fonts-crosextra-carlito, fonts-crosextra-caladea, fonts-urw-base35, fonts-roboto, fonts-open-sans, fonts-lato, fonts-montserrat, fonts-noto-extra Depends: pkexec, polkit-kde-agent-1, plasma-desktop, plasma-workspace, kwin-wayland, plasma-nm, plasma-pa, plasma-widgets-addons, konsole, sddm, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, pulseaudio-utils, cups, cups-client, cups-filters, foomatic-db-compressed-ppds, python3-gi, python3-gi-cairo, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, libnotify-bin, curl, gnupg, ca-certificates, python3-paho-mqtt, python3-pyqt6.qtwebengine, python3-pyqt6, libreoffice-impress, libreoffice-core, poppler-utils, ghostscript, fonts-liberation, fonts-liberation-sans-narrow, fonts-croscore, fonts-crosextra-carlito, fonts-crosextra-caladea, fonts-urw-base35, fonts-roboto, fonts-open-sans, fonts-lato, fonts-montserrat, fonts-noto-extra
Recommends: vlc Recommends: vlc
Conflicts: plasma-welcome, plasma-discover, kdeconnect, kup-backup Conflicts: plasma-welcome, plasma-discover, kdeconnect, kup-backup, khelpcenter
Description: Ekklesia managed classroom environment Description: Ekklesia managed classroom environment
A classroom appliance built on Debian's own Plasma desktop. The room's A classroom appliance built on Debian's own Plasma desktop. The room's
panel, window behaviour, notifications, palette and lockdown are panel, window behaviour, notifications, palette and lockdown are
@@ -48,6 +48,13 @@ Description: Ekklesia managed classroom environment
import costs 0.34s for a ten-slide deck and nothing at all afterwards. import costs 0.34s for a ten-slide deck and nothing at all afterwards.
libreoffice-gtk3 is deliberately NOT here: nothing interactive runs. 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 The fonts are what a Google Slides export asks for. The metric
compatible families cover the Microsoft names — Liberation for Arial, compatible families cover the Microsoft names — Liberation for Arial,
Times New Roman and Courier New, Carlito for Calibri, Caladea for Times New Roman and Courier New, Carlito for Calibri, Caladea for
@@ -61,3 +68,9 @@ Description: Ekklesia managed classroom environment
The Conflicts line is deliberate. A welcome tour, a software centre and The Conflicts line is deliberate. A welcome tour, a software centre and
a phone pairing daemon are all things plasma-desktop recommends and a a phone pairing daemon are all things plasma-desktop recommends and a
room a teacher walks into should not have. room a teacher walks into should not have.
.
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.
+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:]))
+108 -12
View File
@@ -158,11 +158,29 @@ def forget_presentation(presentation_id, root):
library.write_json(library.ledger_path(root), data) 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): def prepare(presentation):
"""Build the derived PDF, pages and cover from a managed original.""" """Build the derived PDF, pages and cover from a managed original."""
data = presentation.data data = presentation.data
managed = presentation.managed_copy 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(): if managed is None or not managed.is_file():
raise RuntimeError("the managed original is missing") raise RuntimeError("the managed original is missing")
@@ -189,12 +207,18 @@ def prepare(presentation):
managed, presentation.slides) managed, presentation.slides)
if not converted: if not converted:
if error == library.KILLED:
raise NeedsMemory(error)
raise RuntimeError(error or "the file could not be converted") raise RuntimeError(error or "the file could not be converted")
pages, error = library.render_pages( pages, error = library.render_pages(
presentation.slides, presentation.pages_directory) presentation.slides, presentation.pages_directory)
if not pages: if not pages:
if error == library.KILLED:
raise NeedsMemory(error)
raise RuntimeError(error or "the slides could not be rendered") raise RuntimeError(error or "the slides could not be rendered")
data["pages"] = pages data["pages"] = pages
@@ -211,6 +235,7 @@ def prepare(presentation):
data["state"] = "ready" data["state"] = "ready"
data["error"] = None data["error"] = None
data["error_kind"] = None
presentation.save() presentation.save()
say(f"imported {data['title']!r} ({pages} slides)") say(f"imported {data['title']!r} ({pages} slides)")
@@ -278,15 +303,55 @@ def import_one(source, root=None):
# teacher can remove it, and a technician can recover or inspect it; # teacher can remove it, and a technician can recover or inspect it;
# no successfully downloaded file is lost because a derived asset # no successfully downloaded file is lost because a derived asset
# could not be made. # could not be made.
data["state"] = "failed" fail(presentation, error)
data["error"] = str(error)[:300]
presentation.save()
say(f"could not import {source.name}: {error}") say(f"could not import {source.name}: {error}")
return presentation 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): def sweep(root=None):
"""Recover imports interrupted during a move, conversion or rendering. """Recover imports interrupted during a move, conversion or rendering.
@@ -313,14 +378,17 @@ def sweep(root=None):
if (managed is not None and managed.is_file() if (managed is not None and managed.is_file()
and (source is None or not source.exists())): and (source is None or not source.exists())):
say(f"resuming an interrupted import: {presentation.title!r}") attempts = int(presentation.data.get("attempts") or 0)
try:
prepare(presentation) if attempts >= MAX_ATTEMPTS:
except Exception as error: # noqa: BLE001 # Killed in the middle of it every time it was tried.
presentation.data["state"] = "failed" say(f"giving up on {presentation.title!r} after {attempts} "
presentation.data["error"] = str(error)[:300] "interrupted attempts; the teacher can try again")
presentation.save() fail(presentation, NeedsMemory("interrupted every attempt"))
say(f"could not import {presentation.title!r}: {error}") else:
say(f"resuming an interrupted import: {presentation.title!r}")
attempt(presentation)
stale += 1 stale += 1
continue continue
@@ -414,6 +482,23 @@ def status(root=None):
return 0 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(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Bring finished downloads into the Presentations library.") description="Bring finished downloads into the Presentations library.")
@@ -439,7 +524,17 @@ def main():
say(f"removed {removed} expired presentation(s)") say(f"removed {removed} expired presentation(s)")
return 0 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() sweep()
retry_requested()
if arguments.once: if arguments.once:
imported = scan() imported = scan()
@@ -453,6 +548,7 @@ def main():
while True: while True:
try: try:
retry_requested()
scan() scan()
if time.monotonic() - last_cleanup > CLEANUP_SECONDS: if time.monotonic() - last_cleanup > CLEANUP_SECONDS:
+35 -6
View File
@@ -682,11 +682,11 @@ class Library(Gtk.Window):
would make the buttons unclickable. would make the buttons unclickable.
""" """
return tuple( return tuple(
(p.id, p.state, p.pages, p.thumbnail_page, p.title) (p.id, p.state, p.pages, p.thumbnail_page, p.title, p.failure)
for p in presentations for p in presentations
) )
def _fit_window(self, count): def _fit_window(self, count, tall=False):
"""Size to the cards while staying inside the panel's work area.""" """Size to the cards while staying inside the panel's work area."""
workarea = monitor_workarea(self) workarea = monitor_workarea(self)
maximum_width = max(420, int(workarea.width * 0.92)) maximum_width = max(420, int(workarea.width * 0.92))
@@ -705,7 +705,10 @@ class Library(Gtk.Window):
columns = min(count, available, 4) columns = min(count, available, 4)
rows = (count + columns - 1) // columns rows = (count + columns - 1) // columns
width = min(maximum_width, max(420, 40 + columns * card_with_gap)) width = min(maximum_width, max(420, 40 + columns * card_with_gap))
height = min(maximum_height, 130 + rows * 340) # 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) self.resize(width, height)
@@ -735,14 +738,19 @@ class Library(Gtk.Window):
# something is "being prepared" when it never will be is how # something is "being prepared" when it never will be is how
# somebody ends up waiting through a Bible study for it. # somebody ends up waiting through a Bible study for it.
ready = sum(1 for p in presentations if p.ready) ready = sum(1 for p in presentations if p.ready)
broken = sum(1 for p in presentations if p.failed) again = sum(1 for p in presentations if p.failure == "memory")
working = len(presentations) - ready - broken 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"] parts = [f"{ready} ready to present"]
if working: if working:
parts.append(f"{working} still being prepared") parts.append(f"{working} still being prepared")
if again:
parts.append(f"{again} to try again")
if broken: if broken:
parts.append(f"{broken} could not be opened") parts.append(f"{broken} could not be opened")
@@ -752,7 +760,8 @@ class Library(Gtk.Window):
self.grid.add(self.card(presentation)) self.grid.add(self.card(presentation))
self.grid.show_all() self.grid.show_all()
self._fit_window(len(presentations)) self._fit_window(len(presentations),
tall=any(p.failure == "memory" for p in presentations))
def card(self, presentation): def card(self, presentation):
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
@@ -802,6 +811,20 @@ class Library(Gtk.Window):
look = Gtk.Button(label="Quick Look") look = Gtk.Button(label="Quick Look")
look.connect("clicked", lambda *_: self.quick_look(presentation)) look.connect("clicked", lambda *_: self.quick_look(presentation))
buttons.pack_start(look, True, True, 0) 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: elif presentation.failed:
# SHORT, AND IN WORDS A TEACHER CAN ACT ON. # SHORT, AND IN WORDS A TEACHER CAN ACT ON.
# #
@@ -878,6 +901,12 @@ class Library(Gtk.Window):
self._signature = None self._signature = None
self.refresh() 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): def remove(self, presentation):
dialog = Gtk.MessageDialog( dialog = Gtk.MessageDialog(
transient_for=self, transient_for=self,
+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())
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -14,6 +14,7 @@ from urllib.parse import urljoin
CONFIG = "/etc/ekklesia-classroom/device.conf" CONFIG = "/etc/ekklesia-classroom/device.conf"
LOCKFILE = "/run/ekklesia-classroom-update.lock" LOCKFILE = "/run/ekklesia-classroom-update.lock"
REBOOT_WHEN_IDLE = "ekklesia-classroom-reboot-when-idle.service"
CACHE_DIR = "/var/cache/ekklesia-classroom" CACHE_DIR = "/var/cache/ekklesia-classroom"
PACKAGE = "ekklesia-classroom" PACKAGE = "ekklesia-classroom"
@@ -291,8 +292,12 @@ def main():
if args.no_reboot: if args.no_reboot:
log("Update requires reboot, but --no-reboot was specified.") log("Update requires reboot, but --no-reboot was specified.")
else: else:
log("Update requires reboot. Rebooting now.") # Not now. At 03:00 nobody is there; after a missed 03:00,
subprocess.run(["systemctl", "reboot"]) # 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 return 0
@@ -0,0 +1,10 @@
[Unit]
Description=Reboot after an Ekklesia Classroom update, once nobody is using the computer
Documentation=file:///usr/local/bin/classroom-reboot-when-idle
# Started by ekklesia-classroom-update after it installs an update that
# needs a reboot; never at boot. A computer switched off before it
# reboots simply starts on the new version.
[Service]
Type=simple
ExecStart=/usr/local/bin/classroom-reboot-when-idle
@@ -24,5 +24,26 @@ Slice=session.slice
Nice=10 Nice=10
IOSchedulingClass=idle 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] [Install]
WantedBy=plasma-workspace.target WantedBy=plasma-workspace.target
@@ -16,3 +16,9 @@ StartLimitIntervalSec=0
[Service] [Service]
Restart=always Restart=always
RestartSec=2 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()
+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
+99
View File
@@ -422,6 +422,105 @@ check("normal title bars contain only Close",
check("the unfinished Help launcher is absent from the panel", check("the unfinished Help launcher is absent from the panel",
"ekklesia-manual.desktop" not in layout) "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) shutil.rmtree(work, ignore_errors=True)
print() print()
+116 -1
View File
@@ -177,6 +177,77 @@ ok, answer = printing.submit(pdf, "Elmer")
check("a refused job is a failure, in CUPS's own words", check("a refused job is a failure, in CUPS's own words",
not ok and answer == 'Destination "Elmer" is not accepting jobs.', answer) 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()
print("5. Wonder Ink prints through this, and only this") print("5. Wonder Ink prints through this, and only this")
ink = (root / "scripts/classroom-wonderink").read_text() ink = (root / "scripts/classroom-wonderink").read_text()
@@ -186,15 +257,59 @@ check("no QPrinter", "QPrinter(" not in code and "QPrintDialog" not in code)
check("no QWebEngineView.print()", not re.search(r"\bview\.print\(", 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("a lesson page becomes a PDF by printToPdf", "printToPdf(" in code)
check("which is given up on if the engine never answers", check("which is given up on if the engine never answers",
"PREPARE_TIMEOUT_MS" in code and "self._timer.start(PREPARE_TIMEOUT_MS)" in code) "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", check("the preview submits with classroom_printing.submit",
"classroom_printing.submit(" in code) "classroom_printing.submit(" in code)
check("there is no file chooser in it", "QFileDialog" not in code) check("there is no file chooser in it", "QFileDialog" not in code)
check("a worksheet tab is printed from its file", check("a worksheet tab is printed from its file",
"self._pdf_views[view] = resolved" in code) "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("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", check("an exception in a handler is logged, not fatal",
"sys.excepthook = keep_going" in code) "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", check("the module is packaged",
"lib/classroom_printing.py" in (root / "tools/build-release.sh").read_text()) "lib/classroom_printing.py" in (root / "tools/build-release.sh").read_text())
+182
View File
@@ -0,0 +1,182 @@
#!/bin/bash
# When a classroom checks for updates, and when it reboots after one.
#
# The check: daily at 03:00, caught up once after a computer that was off
# at 03:00 is switched on. The reboot: only once nobody has used the
# computer for half an hour and nothing is playing. The timer itself is
# measured in the lab (tools/lab/update-schedule-check); here are its
# settings, and classroom-reboot-when-idle run for real with a pipe
# standing in for a keyboard and files standing in for sound cards.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
WORK="$WORK" python3 - "$ROOT" <<'PY'
import configparser
import importlib.machinery
import importlib.util
import os
import sys
import threading
import time
from pathlib import Path
root = Path(sys.argv[1])
work = Path(os.environ["WORK"])
passed = failed = 0
def check(label, condition, detail=""):
global passed, failed
if condition:
print(f" ok {label}")
passed += 1
else:
print(f" FAIL {label}")
if detail:
print(f" {detail}")
failed += 1
loader = importlib.machinery.SourceFileLoader("idle", str(root / "scripts/classroom-reboot-when-idle"))
spec = importlib.util.spec_from_loader("idle", loader)
idle = importlib.util.module_from_spec(spec)
loader.exec_module(idle)
def unit(path):
parser = configparser.ConfigParser(strict=False, interpolation=None)
parser.optionxform = str
parser.read(path)
return parser
print("1. the check: daily at 03:00, caught up once after being off")
timer = unit(root / "systemd/updater/ekklesia-classroom-update.timer")
check("every day at 03:00", timer["Timer"].get("OnCalendar") == "*-*-* 03:00:00", dict(timer["Timer"]))
check("a computer off at 03:00 checks when it is next on (Persistent)", timer["Timer"].get("Persistent") == "true")
check("spread over half an hour, so rooms do not all ask at once", timer["Timer"].get("RandomizedDelaySec") == "30m")
check("and nothing else starts it: no boot or repeating trigger to loop on",
not any(k in timer["Timer"] for k in ("OnBootSec", "OnStartupSec", "OnUnitActiveSec", "OnUnitInactiveSec", "OnActiveSec")))
check("enabled by the package on every install and upgrade",
"systemctl enable ekklesia-classroom-update.timer" in (root / "packaging/debian/postinst").read_text())
check("and installed as a package file, so an upgrade always brings the packaged schedule",
"systemd/updater/ekklesia-classroom-update.timer" in (root / "tools/build-release.sh").read_text()
and not (root / "packaging/debian/conffiles").exists())
print()
print("2. the updater: the same checks, and no reboot of its own")
updater = (root / "scripts/ekklesia-classroom-update").read_text()
code = "\n".join(l for l in updater.splitlines() if not l.lstrip().startswith("#"))
check("only the device's own channel", 'f"{channel}/manifest.json"' in code and 'channel = config["CHANNEL"]' in code)
check("only a newer version", "version_is_newer(candidate, current)" in code)
check("only a manifest that makes sense", "manifest_problem(manifest)" in code)
check("only a file with the manifest's SHA-256", "actual_hash = sha256(temporary_path)" in code)
check("only the package and version the manifest names", "if name != PACKAGE or version != candidate:" in code)
check("never reboots by itself any more", '["systemctl", "reboot"]' not in code)
check("but hands the reboot to classroom-reboot-when-idle, without waiting for it",
'"systemctl", "start", "--no-block", REBOOT_WHEN_IDLE' in code
and 'REBOOT_WHEN_IDLE = "ekklesia-classroom-reboot-when-idle.service"' in code)
service = unit(root / "systemd/updater/ekklesia-classroom-reboot-when-idle.service")
check("which is never started at boot (no [Install])", "Install" not in service.sections())
check("and is not enabled by the package",
"enable ekklesia-classroom-reboot-when-idle" not in (root / "packaging/debian/postinst").read_text())
build = (root / "tools/build-release.sh").read_text()
check("both are packaged", "scripts/classroom-reboot-when-idle" in build
and "systemd/updater/ekklesia-classroom-reboot-when-idle.service" in build)
print()
print("3. is anything playing?")
def cards(states):
base = work / f"asound-{len(list(work.glob('asound-*')))}"
for n, state in enumerate(states):
status = base / f"card{n}" / "pcm0p" / "sub0" / "status"
status.parent.mkdir(parents=True)
status.write_text(state + "\n")
return base
check("nothing: no cards at all", not idle.audio_playing(work / "none"))
check("nothing: a card that is closed", not idle.audio_playing(cards(["closed"])))
check("nothing: a card that is open but not running", not idle.audio_playing(cards(["state: SETUP"])))
check("playing: a card that is running", idle.audio_playing(cards(["state: RUNNING\nowner_pid : 1"])))
check("playing: one of two cards running (the television's)", idle.audio_playing(cards(["closed", "state: RUNNING"])))
capture = work / "asound-capture" / "card0" / "pcm0c" / "sub0" / "status"
capture.parent.mkdir(parents=True)
capture.write_text("state: RUNNING\n")
check("a microphone recording is not playing", not idle.audio_playing(work / "asound-capture"))
print()
print("4. the decision")
check("half an hour untouched, nothing playing: reboot", idle.should_reboot(2000, 100, 1800, False))
check("29 minutes: not yet", not idle.should_reboot(1840, 100, 1800, False))
check("an hour untouched but a video playing: not while it plays", not idle.should_reboot(4000, 100, 1800, True))
check("half an hour by default", idle.IDLE_SECONDS == 30 * 60)
print()
print("5. run for real: a keyboard that is used, then left")
keyboard = work / "input" / "event0"
keyboard.parent.mkdir()
os.mkfifo(keyboard)
os.mkfifo(work / "input" / "event1") # a second device, never used
marker = work / "rebooted"
asound = cards(["closed"])
holder = os.open(keyboard, os.O_RDWR | os.O_NONBLOCK) # keeps the pipe open, as a device is
holder1 = os.open(work / "input" / "event1", os.O_RDWR | os.O_NONBLOCK)
result = {}
def run():
result["code"] = idle.main(["--idle-seconds", "2", "--check-seconds", "0.2",
"--input-glob", str(work / "input" / "event*"),
"--asound-root", str(asound),
"--reboot-command", "touch", str(marker)])
thread = threading.Thread(target=run, daemon=True)
started = time.monotonic()
thread.start()
for _ in range(10): # typing every 0.4 s for 4 s
os.write(holder, b"k" * 24)
time.sleep(0.4)
check("while the keyboard is used, no reboot (4 s against a 2 s limit)", not marker.exists())
thread.join(8)
waited = time.monotonic() - started
check(f"left alone, it reboots once the limit has passed ({waited:.1f} s)",
marker.exists() and 5.5 <= waited <= 8, result)
marker.unlink(missing_ok=True)
playing = cards(["state: RUNNING"])
result.clear()
def run_playing():
result["code"] = idle.main(["--idle-seconds", "1", "--check-seconds", "0.2",
"--input-glob", str(work / "input" / "event*"),
"--asound-root", str(playing),
"--reboot-command", "touch", str(marker)])
thread = threading.Thread(target=run_playing, daemon=True)
thread.start()
time.sleep(3)
check("untouched but playing for 3 s against a 1 s limit: no reboot", not marker.exists())
((playing / "card0" / "pcm0p" / "sub0" / "status")).write_text("closed\n")
stopped = time.monotonic()
thread.join(6)
after = time.monotonic() - stopped
# Sound is seen at each check, so the limit runs from the last check that
# heard it: at most one check (0.2 s here, a minute in a classroom) early.
check(f"the sound stops: it waits the limit again, less at most one check, before rebooting ({after:.1f} s)",
marker.exists() and 0.75 <= after <= 3, result)
os.close(holder)
os.close(holder1)
print()
print(f"{passed} passed, {failed} failed")
sys.exit(1 if failed else 0)
PY
+258
View File
@@ -0,0 +1,258 @@
#!/bin/bash
# Wonder Ink's temporary workspace restore: which tabs come back, and when.
#
# lib/classroom_wonderink_workspace.py decides, from a file and the clock,
# whether a teacher's recent tabs are opened again, and which of them may
# be. Every way that could go wrong is either the next teacher meeting the
# last one's tabs, a credential written to disk, or a file Wonder Ink was
# never meant to open — so each is tested here with a clock the test
# controls. Whether the engine really restores them is
# tools/lab/wonderink-workspace-check's question.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
WORK="$WORK" PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY'
import base64
import json
import os
import stat
import sys
from pathlib import Path
import classroom_wonderink_workspace as ws
root = Path(sys.argv[1])
work = Path(os.environ["WORK"])
passed = failed = 0
def check(label, condition, detail=""):
global passed, failed
if condition:
print(f" ok {label}")
passed += 1
else:
print(f" FAIL {label}")
if detail:
print(f" {detail}")
failed += 1
HOUR = 60 * 60
NOW = 1_790_000_000.0
downloads = work / "Downloads" / "Wonder Ink"
downloads.mkdir(parents=True)
sheet = downloads / "Colour the Ark.pdf"
sheet.write_bytes(b"%PDF-1.4 worksheet")
path = work / "wonderink-workspace.json"
SITE = "https://app.wonderink.org/?"
SLIDE = "https://dcc-sbbd-user-content.s3.amazonaws.com/5ee6/69fd-SaulWelcomeQuestionSlide.png"
VIDEO = "https://player.vimeo.com/video/1144568454"
THREE = [ws.tab("web", SITE, "Welcome Question"), ws.tab("web", SLIDE, "Slide"), ws.tab("web", VIDEO, "Video")]
print("1. how long")
check("two hours by default", ws.idle_limit({}) == 2 * HOUR)
check("the test override shortens it", ws.idle_limit({ws.TEST_OVERRIDE: "120"}) == 120)
check("but never below ten seconds", ws.idle_limit({ws.TEST_OVERRIDE: "1"}) == ws.TEST_SHORTEST)
check("and never lengthens it", ws.idle_limit({ws.TEST_OVERRIDE: "999999"}) == 2 * HOUR)
check("nonsense in it is the default", ws.idle_limit({ws.TEST_OVERRIDE: "soon"}) == 2 * HOUR)
check("and it says it is one", ws.is_test_limit({ws.TEST_OVERRIDE: "120"}) and not ws.is_test_limit({}))
print()
print("2. A. restore within the window")
def keep(tabs=THREE, active=1, last=NOW, attempts=0):
ws.write(path, tabs, active, last, attempts)
for label, minutes in (("five minutes", 5), ("ninety minutes", 90), ("a second before two hours", 120 - 1 / 60)):
keep()
data, why = ws.read(path, now=NOW + minutes * 60)
check(f"{label} later: restored", data is not None and len(data["tabs"]) == 3, why)
check("with the time of the last use, not the time it was opened again", data["last_activity"] == NOW)
print()
print("3. B. expiration")
for label, seconds in (("two hours and a minute", 2 * HOUR + 60), ("the next day", 24 * HOUR),
("the next week", 7 * 24 * HOUR)):
keep()
data, why = ws.read(path, now=NOW + seconds)
check(f"{label} later: fresh ({why})", data is None and why == "expired")
check("and the file is gone", not path.exists())
keep(last=NOW)
data, why = ws.read(path, now=NOW + 30, limit=20)
check("the test override applies to a kept workspace", data is None and why == "expired", why)
keep(last=NOW)
data, why = ws.read(path, now=NOW - 2 * HOUR)
check("a clock gone back two hours: fresh, not a workspace made young", data is None and "clock" in why, why)
keep(last=NOW)
data, why = ws.read(path, now=NOW - 30)
check("a clock corrected by half a minute: still restored", data is not None, why)
print()
print("4. D. which tabs, in which order, which in front")
keep()
data, _ = ws.read(path, now=NOW + 60)
check("the order is kept", [t["where"] for t in data["tabs"]] == [SITE, SLIDE, VIDEO])
check("and the tab in front", data["active"] == 1)
check("and readable titles", [t["title"] for t in data["tabs"]] == ["Welcome Question", "Slide", "Video"])
tabs, active = ws.plan(THREE, 1, downloads)
check("all three can be opened, the middle one in front", len(tabs) == 3 and active == 1)
five_site = [ws.tab("web", SITE, f"Lesson {n}") for n in range(3)] + [ws.tab("web", SLIDE, "Slide"),
ws.tab("web", "https://app.wonderink.org/", "Home")]
tabs, active = ws.plan(five_site, 4, downloads)
check("WonderInk's own tabs, which share one address, become one sign-in page, not four",
[t["where"] for t in tabs] == [SITE, SLIDE], tabs)
check("and a WonderInk tab in front brings its one tab to the front", active == 0)
tabs, active = ws.plan(five_site, 3, downloads)
check("the slide in front stays in front", active == 1)
with_sheet = [ws.tab("web", SITE, "Lesson"), ws.tab("worksheet", str(sheet), "Colour the Ark"), ws.tab("web", SLIDE, "Slide")]
tabs, active = ws.plan(with_sheet, 1, downloads)
check("a worksheet that still exists comes back, where it was and named", tabs[1] == ws.tab("worksheet", str(sheet), "Colour the Ark")
and active == 1, tabs)
gone = downloads / "Gone.pdf"
tabs, active = ws.plan([ws.tab("web", SITE), ws.tab("worksheet", str(gone), "Gone"), ws.tab("web", SLIDE)], 1, downloads)
check("a worksheet whose file has gone is left out, silently", [t["where"] for t in tabs] == [SITE, SLIDE], tabs)
check("and the tab in front becomes the one before it", active == 0)
print()
print("5. only files Wonder Ink downloaded, and only PDFs")
elsewhere = work / "secret.pdf"
elsewhere.write_bytes(b"%PDF-1.4 not a worksheet")
(downloads / "sub").mkdir()
(downloads / "sub" / "deep.pdf").write_bytes(b"%PDF-1.4")
notpdf = downloads / "notes.pdf"
notpdf.write_text("<html>an error page</html>")
link = downloads / "link.pdf"
link.symlink_to(elsewhere)
for label, target in (("a file outside Downloads/Wonder Ink", elsewhere), ("a system file", Path("/etc/passwd")),
("a file in a folder inside it", downloads / "sub" / "deep.pdf"),
("a file that is not a PDF", notpdf), ("a link out of it", link),
("a path climbing out of it", downloads / ".." / ".." / "secret.pdf")):
tabs, _ = ws.plan([ws.tab("worksheet", str(target))], 0, downloads)
check(f"{label}: never opened", tabs == [], tabs)
print()
print("6. only http and https, and no credentials")
for label, url in (("a file: address", "file:///etc/passwd"), ("javascript:", "javascript:alert(1)"),
("data:", "data:text/html,hi"), ("blob:", "blob:https://app.wonderink.org/abc"),
("chrome:", "chrome://settings"), ("about:blank", "about:blank"),
("a user and password in it", "https://teacher:[email protected]/"),
("a token", "https://example.org/v?token=abc"), ("an access_token", "https://example.org/v?access_token=abc"),
("a password", "https://example.org/?password=abc"), ("an S3 signature", "https://b.s3.amazonaws.com/x.png?X-Amz-Signature=abc"),
("a key", "https://example.org/?api_key=abc"), ("a session", "https://example.org/?sessionid=abc")):
check(f"{label}: not kept", not ws.keepable_url(url))
check("an ordinary lesson resource is", ws.keepable_url(SLIDE) and ws.keepable_url(VIDEO + "?h=91f1770ec7"))
print()
print("7. F. privacy: what is on disk")
basic = "Basic " + base64.b64encode(b"[email protected]:hunter2hunter2").decode()
risky = THREE + [ws.tab("web", "https://teacher:[email protected]/", "creds"),
ws.tab("web", "https://example.org/?token=" + basic, "token")]
ws.write(path, risky, 0, NOW)
text = path.read_text()
data = json.loads(text)
check("only what may be opened again is written", len(data["tabs"]) == 3, data["tabs"])
check("no password, Basic-auth credential or token in the file",
"hunter2" not in text and "Basic " not in text and "token" not in text.lower() and "@" not in text, text)
check("no cookie, header or storage of any kind",
not any(k in text.lower() for k in ("cookie", "rrf-auth", "x-rrf", "authorization", "localstorage", "sessionstorage")))
check("exactly these fields, and no others",
set(data) == {"format", "last_activity", "active", "attempts", "tabs"}
and all(set(t) == {"kind", "where", "title"} for t in data["tabs"]), data)
check("readable by the classroom account only", stat.S_IMODE(path.stat().st_mode) == 0o600)
check("no half-written file is left beside it", not path.with_name(path.name + ".new").exists())
check("a workspace nobody has used (no time of last use) is not written",
not ws.write(path, THREE, 0, None) and not path.exists())
check("nor one with nothing to open", not ws.write(path, [ws.tab("web", "about:blank")], 0, NOW) and not path.exists())
print()
print("8. E. failure: always a fresh start, never a crash")
for label, content in (("an empty file", ""), ("not JSON", "{not json"), ("JSON of the wrong shape", "[1, 2]"),
("no time of last use", json.dumps({"format": 1, "tabs": THREE, "active": 0})),
("a time that is not a number", json.dumps({"format": 1, "last_activity": "soon", "tabs": THREE})),
("no tabs", json.dumps({"format": 1, "last_activity": NOW, "active": 0, "tabs": []})),
("a future format", json.dumps({"format": 2, "last_activity": NOW, "active": 0, "tabs": THREE})),
("tabs that are not a list", json.dumps({"format": 1, "last_activity": NOW, "tabs": 7}))):
path.write_text(content)
data, why = ws.read(path, now=NOW + 5)
check(f"{label}: fresh ({why}), and removed", data is None and not path.exists(), why)
tabs, _ = ws.plan([{"kind": "web"}, "not a tab", {"kind": "program", "where": "/bin/sh"}, ws.tab("web", SLIDE)], 0, downloads)
check("tabs that make no sense are skipped, the rest kept", [t["where"] for t in tabs] == [SLIDE], tabs)
path.mkdir()
data, why = ws.read(path, now=NOW)
check("a directory where the file should be: fresh, and no crash", data is None, why)
path.rmdir()
(path.with_name(path.name + ".new")).write_text('{"format": 1, "last_act')
keep()
data, why = ws.read(path, now=NOW + 5)
check("a write cut off half way (the .new file) leaves the last whole one", data is not None, why)
print()
print("9. E. a restore that crashes Wonder Ink")
keep(attempts=1)
data, why = ws.read(path, now=NOW + 60)
check("started from once without settling: tried once more", data is not None and data["attempts"] == 1, why)
keep(attempts=2)
data, why = ws.read(path, now=NOW + 60)
check("twice: abandoned, and Wonder Ink starts fresh", data is None and "failed to restore" in why, why)
check("and the workspace is gone", not path.exists())
print()
print("10. Wonder Ink keeps it, and only as this says")
ink = (root / "scripts/classroom-wonderink").read_text()
code = "\n".join(line for line in ink.splitlines() if not line.lstrip().startswith("#"))
keeper = code[code.index("class WorkspaceKeeper"):code.index("class LessonPage")]
check("the engine still keeps no cookie", "PersistentCookiesPolicy.NoPersistentCookies" in code)
check("the keeper never touches a cookie, a header or a page's storage",
not any(w in keeper for w in ("cookieStore", "QNetworkCookie", "setHttpHeader", "localStorage", "runJavaScript")))
check("nothing else in Wonder Ink writes a cookie down", "toRawForm" not in code and "cookieStore()" not in code)
check("activity is the teacher's input: button, key, wheel, touch — not the pointer moving",
all(f"QEvent.Type.{t}" in keeper for t in ("MouseButtonPress", "KeyPress", "Wheel", "TouchBegin"))
and "MouseMove" not in keeper)
check("a restore carries the old time of last use over, not now",
'self._last_wall = kept["last_activity"]' in keeper)
check("a restore is counted in the file before any tab loads",
keeper.index("self._attempts = kept[\"attempts\"] + 1") < keeper.index("for item in tabs:")
and "workspace.write(self._path, tabs, active, self._last_wall, self._attempts)" in keeper)
check("a restored tab loads when it is first shown, the one in front at once (a hidden PDF opened at 10%)",
"self._window._pending[view] = (lambda v=view, u=where: v.load(u))" in keeper
and "pending = self._pending.pop(view, None)" in code)
check("and until then it is kept by its saved address, not dropped as an empty tab",
"if view in self._window._pending:\n tabs.append(self._pending_tabs[view])" in keeper)
check("and the count cleared once Wonder Ink has settled", "QTimer.singleShot(self.SETTLED_MS, self._settled)" in keeper)
check("the tabs are written when they change, not only at the end",
"view.urlChanged.connect(lambda *_: self._workspace_changed())" in code
and "tabMoved.connect(lambda *_: self._workspace_changed())" in code
and "currentChanged.connect(lambda *_: self._workspace_changed())" in code)
check("a page changing its own tab is not activity", "urlChanged.connect(lambda *_: self._workspace_changed())" in code
and "touched()" not in code[code.index("def _workspace_changed"):code.index("def _workspace_changed") + 200])
check("at quit it keeps the tabs, then stops filtering events",
"aboutToQuit.connect(self.shut_down)" in keeper and "removeEventFilter(self)" in keeper)
check("worksheet downloads are connected before the workspace opens",
code.index("profile.downloadRequested.connect(window.on_download)\n open_workspace(window)") > 0)
setters = [str(p.relative_to(root)) for top in ("config", "packaging", "systemd", "scripts")
for p in (root / top).rglob("*") if p.is_file() and not p.is_symlink()
and not str(p).startswith(str(root / "packaging" / "root"))
and ws.TEST_OVERRIDE in p.read_text(errors="ignore")]
check("nothing the classroom installs sets the test-only override", setters == [], setters)
check("and Wonder Ink says so in the journal when it is set", "TEST ONLY" in code and "workspace.is_test_limit()" in code)
check("the module is packaged", "lib/classroom_wonderink_workspace.py" in (root / "tools/build-release.sh").read_text())
print()
print(f"{passed} passed, {failed} failed")
sys.exit(1 if failed else 0)
PY
+47 -6
View File
@@ -197,6 +197,16 @@ for restriction in ("run_command=false", "shell_access=false",
"action/lock_screen=false", "action/lock_screen=false",
"plasma/plasmashell/unlockedDesktop=false"): "plasma/plasmashell/unlockedDesktop=false"):
check(f"{restriction}", restriction in restrictions) check(f"{restriction}", restriction in restrictions)
check("a notification's Configure button opens nothing",
"kcm_notifications=false" in restrictions)
check("and neither does any other settings module a teacher might reach",
all(f"{m}=false" in restrictions for m in (
"kcm_pulseaudio", "kcm_clock", "kcm_kscreen", "kcm_users",
"kcm_sddm", "kcm_keys", "kcm_bluetooth", "kcm_powerdevilprofilesconfig")))
check("a locked panel offers no widget settings",
"plasma/allow_configure_when_locked=false" in restrictions)
check("and the help center a settings window's Help button opens is not installed",
"khelpcenter" in re.search(r"^Conflicts: (.*)$", control, re.MULTILINE).group(1))
check("the restrictions are immutable", "[KDE Action Restrictions][$i]" in restrictions) check("the restrictions are immutable", "[KDE Action Restrictions][$i]" in restrictions)
print() print()
@@ -234,20 +244,51 @@ check("container service startup is suppressed", "/usr/sbin/policy-rc.d" in vali
print() print()
print("6. canary release gate") print("6. canary release gate")
validation = canary.index('"$ROOT/tools/validate-trixie-release.sh"') validation = canary.index('"$ROOT/tools/release-gate.sh"')
package_copy = canary.index('cp -n "$package_path"') package_copy = canary.index('cp -n "$package_path"')
first_upload = canary.index("release_upload_package") first_upload = canary.index("release_upload_package")
check("Trixie validation runs before the repository copy", validation < package_copy) check("the release gate runs before the repository copy", validation < package_copy)
check("Trixie validation runs before any upload", validation < first_upload) check("the release gate runs before any upload", validation < first_upload)
# A version is checked before anything is built for it, and again after # A version is checked before anything is built for it, and again after
# the long gate, immediately before the first upload. # the long gate, immediately before the first upload.
check("a spent version is refused before the build", check("a spent version is refused before the build",
canary.index("release_check_new_version") < canary.index("build-release.sh")) canary.index("release_check_new_version") < canary.index("build-release.sh"))
check("and again after the gate", check("and again after the gate",
validation < canary.rindex("release_check_new_version") < first_upload) validation < canary.rindex("release_check_new_version") < first_upload)
check("the stable rooms' upgrade is rehearsed on the same file before publishing", # A package handed in is the one published: a second build is a second,
"VALIDATION_CHANNEL=stable" in canary # untested file.
and canary.index("VALIDATION_CHANNEL=stable") < first_upload) build_at = canary.index('"$ROOT/tools/build-release.sh"')
check("a package handed in is not rebuilt",
canary.rindex('if [[ -n "$prebuilt" ]]', 0, build_at) < build_at
and "else" in canary[canary.rindex('if [[ -n "$prebuilt" ]]', 0, build_at):build_at])
check("and must say it is the version being published",
'release_package_version "$package_path")" != "$version"' in canary)
check("the file is hashed before the gate and checked after it",
canary.index('hash="$(sha256sum "$package_path"') < validation
< canary.index("changed while it was being gated") < first_upload)
check("the repository copy is proven to be the gated file",
package_copy < canary.index("is not the file that passed the gate") < first_upload)
gate = (root / "tools/release-gate.sh").read_text()
gate_code = "\n".join(line for line in gate.splitlines()
if not line.lstrip().startswith("#"))
check("the gate runs a fresh install, as the stable room a new machine is",
'env VALIDATION_CHANNEL=stable \\\n "$ROOT/tools/validate-trixie-release.sh" "$candidate"\n'
in gate_code)
check("which is what a fresh device.conf says",
"CHANNEL=stable" in (root / "lib/classroom_config.py").read_text())
check("and the upgrade from what canary and stable serve, as rooms of each",
"for channel in canary stable" in gate_code
and 'VALIDATION_CHANNEL="$channel"' in gate_code)
check("the stable rooms' older releases can be rehearsed too",
"VALIDATION_CHANNEL=stable" in gate_code and "RELEASE_GATE_FROM" in canary)
check("what it upgrades from is checked against its manifest's checksum",
'if [[ "$got" != "$from_hash" ]]' in gate_code)
check("the gate needs no credential and writes nothing to the server",
"release_remote" not in gate_code and "ssh" not in gate_code
and "rsync" not in gate_code)
check("the gate stops at the first failure",
"set -euo pipefail" in gate and "inherit_errexit" in gate)
check("the published channel is read back afterwards", check("the published channel is read back afterwards",
canary.index("release_verify_channel canary") > canary.index("release_publish_manifest canary")) canary.index("release_verify_channel canary") > canary.index("release_publish_manifest canary"))
# The gate checks convergence, not just that the package installs: an # The gate checks convergence, not just that the package installs: an
+128
View File
@@ -0,0 +1,128 @@
#!/bin/bash
# The Gitea release record, against a stand-in for Gitea's API.
#
# Nothing here reaches the real Gitea. The stand-in keeps tags, releases
# and assets in memory, answers the handful of calls tools/gitea-release
# makes the way Gitea 1.27 answers them, and refuses any write that does
# not carry the token. The questions are the ones that matter for a
# permanent record: a tag is never moved, an asset's bytes are never
# replaced, and the record only ever grows.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORK="$(mktemp -d)"
SERVER_PID=""
cleanup() {
[[ -z "$SERVER_PID" ]] || kill "$SERVER_PID" 2>/dev/null
rm -rf "$WORK"
}
trap cleanup EXIT
passed=0
failed=0
check() {
local label="$1"
shift
if "$@" >/dev/null 2>&1; then
echo " ok $label"
passed=$((passed + 1))
else
echo " FAIL $label"
failed=$((failed + 1))
fi
}
refuses() {
! "$@"
}
python3 "$ROOT/tests/fake-gitea.py" > "$WORK/port" 2>"$WORK/gitea.log" &
SERVER_PID=$!
for _ in $(seq 1 50); do
[[ -s "$WORK/port" ]] && break
sleep 0.1
done
export GITEA_URL="http://127.0.0.1:$(cat "$WORK/port")"
export GITEA_REPO=church/classroom
export GITEA_TOKEN=not-a-real-token
RELEASE="$ROOT/tools/gitea-release"
A=$(printf 'a%.0s' {1..40})
B=$(printf 'b%.0s' {1..40})
printf 'the package\n' > "$WORK/ekklesia-classroom_0.9.1_amd64.deb"
printf '# 0.9.1\n\nNotes.\n' > "$WORK/notes.md"
echo "1. a release, created once, at one commit"
check "no release yet is its own answer" \
bash -c "'$RELEASE' show v0.9.1; [[ \$? -eq 3 ]]"
check "it is created at the commit named" \
"$RELEASE" create v0.9.1 "$A" "0.9.1" "$WORK/notes.md" --prerelease
check "and its tag names that commit" \
bash -c "[[ \$(curl -fsS '$GITEA_URL/api/v1/repos/church/classroom/tags/v0.9.1' | python3 -c 'import json,sys; print(json.load(sys.stdin)[\"commit\"][\"sha\"])') == $A ]]"
check "it is a pre-release until it is promoted" \
bash -c "api() { curl -fsS '$GITEA_URL/api/v1/repos/church/classroom'\$1; }; api /releases/tags/v0.9.1 | grep -q '\"prerelease\": true'"
check "creating it again at the same commit changes nothing" \
"$RELEASE" create v0.9.1 "$A" "0.9.1" "$WORK/notes.md" --prerelease
check "a tag is never moved to another commit" \
refuses "$RELEASE" create v0.9.1 "$B" "0.9.1" "$WORK/notes.md"
check "nor is a tag that already exists taken over" \
refuses "$RELEASE" create v0.1.5 "$B" "0.1.5" "$WORK/notes.md"
echo "2. assets: attached once, never replaced"
check "the package is attached" \
"$RELEASE" upload v0.9.1 "$WORK/ekklesia-classroom_0.9.1_amd64.deb"
check "and reads back as the same bytes" \
bash -c "'$RELEASE' download v0.9.1 ekklesia-classroom_0.9.1_amd64.deb '$WORK/back.deb' \
&& cmp -s '$WORK/back.deb' '$WORK/ekklesia-classroom_0.9.1_amd64.deb'"
check "the same file again is left alone, and is not an error" \
bash -c "'$RELEASE' upload v0.9.1 '$WORK/ekklesia-classroom_0.9.1_amd64.deb' | grep -q 'left alone'"
mkdir -p "$WORK/rebuilt"
printf 'a different build\n' > "$WORK/rebuilt/ekklesia-classroom_0.9.1_amd64.deb"
check "different bytes under the same name are refused" \
refuses "$RELEASE" upload v0.9.1 "$WORK/rebuilt/ekklesia-classroom_0.9.1_amd64.deb"
check "and the published bytes are still the first ones" \
bash -c "'$RELEASE' download v0.9.1 ekklesia-classroom_0.9.1_amd64.deb '$WORK/back2.deb' \
&& cmp -s '$WORK/back2.deb' '$WORK/ekklesia-classroom_0.9.1_amd64.deb'"
check "an asset the release does not have is refused, not invented" \
refuses "$RELEASE" download v0.9.1 nothing.deb "$WORK/nothing"
curl -fsS "$GITEA_URL/test/tamper/v0.9.1/ekklesia-classroom_0.9.1_amd64.deb" >/dev/null
check "an asset changed by hand is caught at the next upload" \
refuses "$RELEASE" upload v0.9.1 "$WORK/ekklesia-classroom_0.9.1_amd64.deb"
echo "3. the record only grows"
check "a line is recorded" \
"$RELEASE" record v0.9.1 "published to Preview (canary)"
check "and another" \
"$RELEASE" record v0.9.1 "promoted to Stable"
check "the notes, the creation line and both records are all there, in order" \
bash -c "curl -fsS '$GITEA_URL/api/v1/repos/church/classroom/releases/tags/v0.9.1' \
| python3 -c 'import json,sys
b = json.load(sys.stdin)[\"body\"]
order = [b.index(t) for t in (\"Notes.\", \"## Record\", \"release created at\", \"Preview (canary)\", \"promoted to Stable\")]
assert order == sorted(order), order
assert b.count(\"## Record\") == 1'"
check "the notes and record read back as text" \
bash -c "'$RELEASE' notes v0.9.1 | grep -q 'Preview (canary)'"
check "and asking for a release there is none of is refused" \
refuses "$RELEASE" notes v0.0.0
check "promoted, it is no longer a pre-release" \
bash -c "'$RELEASE' stable v0.9.1 && curl -fsS '$GITEA_URL/api/v1/repos/church/classroom/releases/tags/v0.9.1' | grep -q '\"prerelease\": false'"
echo "4. credentials"
check "a write without the token is refused by Gitea, and reported" \
refuses env GITEA_TOKEN= "$RELEASE" record v0.9.1 "nobody"
check "the token never appears in anything it prints" \
bash -c "! { '$RELEASE' show v0.9.1; '$RELEASE' record v0.9.1 x; '$RELEASE' upload v0.9.1 '$WORK/rebuilt/ekklesia-classroom_0.9.1_amd64.deb'; } 2>&1 | grep -q not-a-real-token"
check "nothing works without knowing which Gitea" \
refuses env -u GITEA_URL -u GITHUB_SERVER_URL "$RELEASE" show v0.9.1
echo
echo "$passed passed, $failed failed"
[[ $failed -eq 0 ]]
+307
View File
@@ -0,0 +1,307 @@
#!/bin/bash
# Preview, Stable and import, end to end, as the Gitea workflows run them.
#
# Nothing here reaches the real update server or the real Gitea. The
# update server is a directory served over HTTP on the loopback, with ssh
# and rsync stand-ins that act on it (as in test-release-versions.sh);
# Gitea is tests/fake-gitea.py. What runs is the real thing:
# tools/release-ci.sh, update_canary.sh, update_stable.sh,
# tools/release-gate.sh --still-current and tools/gitea-release.
#
# The container gate itself needs Docker and is not run here: a gate
# report stands in for it, and the question is whether a report that
# must not be relied on ever is.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORK="$(mktemp -d)"
PIDS=()
cleanup() {
for pid in "${PIDS[@]}"; do kill "$pid" 2>/dev/null; done
rm -rf "$WORK"
}
trap cleanup EXIT
passed=0
failed=0
check() {
local label="$1"
shift
if "$@" >"$WORK/last.log" 2>&1; then
echo " ok $label"
passed=$((passed + 1))
else
echo " FAIL $label"
sed 's/^/ /' "$WORK/last.log" | tail -n 8
failed=$((failed + 1))
fi
}
refuses() {
! "$@"
}
# Refused, and for the reason given: a refusal for some other reason
# would pass a test that means nothing.
refuses_saying() {
local reason="$1" out
shift
out="$("$@" 2>&1)" && return 1
grep -qF -- "$reason" <<< "$out" || { printf '%s\n' "$out"; return 1; }
}
fake_deb() {
local version="$1" out="$2" stage="$WORK/stage-$version-$RANDOM"
mkdir -p "$stage/DEBIAN"
printf 'Package: ekklesia-classroom\nVersion: %s\nArchitecture: amd64\nMaintainer: test\nDescription: test %s\n' \
"$version" "${3:-}" > "$stage/DEBIAN/control"
dpkg-deb --root-owner-group --build "$stage" "$out" >/dev/null
}
sha() { sha256sum "$1" | awk '{print $1}'; }
# ---------------------------------------------------------------------------
# The update server: stable on 0.2.6.4, canary on 0.2.6.5.
SERVER="$WORK/server"
mkdir -p "$SERVER/packages" "$SERVER/stable" "$SERVER/canary" "$WORK/bin"
for version in 0.2.2 0.2.6.4 0.2.6.5; do
fake_deb "$version" "$SERVER/packages/ekklesia-classroom_${version}_amd64.deb"
done
point() {
local channel="$1" version="$2" name="ekklesia-classroom_$2_amd64.deb"
printf '{\n "version": "%s",\n "package": "/packages/%s",\n "sha256": "%s",\n "requires_reboot": true\n}\n' \
"$version" "$name" "$(sha "$SERVER/packages/$name")" > "$SERVER/$channel/manifest.json"
ln -sfn "../packages/$name" "$SERVER/$channel/latest.deb"
}
point stable 0.2.6.4
point canary 0.2.6.5
cat > "$WORK/bin/ssh" <<'EOF'
#!/bin/bash
# ssh [-F CONFIG] HOST COMMAND: run COMMAND here, and note which
# configuration it was given ("-" for the machine's own).
config=-
if [[ "$1" == -F ]]; then config="$2"; shift 2; fi
echo "ssh $config" >> "${SSH_LOG:-/dev/null}"
shift
exec bash -c "$*"
EOF
cat > "$WORK/bin/rsync" <<'EOF'
#!/bin/bash
keep=0
args=()
while [[ $# -gt 0 ]]; do
case "$1" in
--ignore-existing) keep=1 ;;
-e) echo "rsync $2" >> "${SSH_LOG:-/dev/null}"; shift ;;
-*) ;;
*) args+=("$1") ;;
esac
shift
done
source="${args[0]}"
dest="${args[1]#*:}"
[[ -d "$dest" ]] && dest="$dest/$(basename "$source")"
[[ "$keep" == 1 && -e "$dest" ]] && exit 0
cp "$source" "$dest"
EOF
chmod +x "$WORK/bin/ssh" "$WORK/bin/rsync"
port() { python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])'; }
HTTP_PORT="$(port)"
python3 -m http.server --bind 127.0.0.1 --directory "$SERVER" "$HTTP_PORT" >/dev/null 2>&1 &
PIDS+=($!)
python3 "$ROOT/tests/fake-gitea.py" > "$WORK/gitea-port" 2>/dev/null &
PIDS+=($!)
for _ in $(seq 1 50); do
[[ -s "$WORK/gitea-port" ]] && curl -fsS "http://127.0.0.1:$HTTP_PORT/stable/manifest.json" >/dev/null 2>&1 && break
sleep 0.1
done
export PATH="$WORK/bin:$PATH"
export RELEASE_HOST=stand-in RELEASE_ROOT="$SERVER" RELEASE_URL="http://127.0.0.1:$HTTP_PORT"
export GITEA_URL="http://127.0.0.1:$(cat "$WORK/gitea-port")" GITEA_REPO=church/classroom GITEA_TOKEN=not-a-real-token
unset GITHUB_ACTIONS GITEA_ACTIONS GITHUB_RUN_NUMBER GITHUB_REF GITHUB_OUTPUT RELEASE_SSH_KEY
CI="$ROOT/tools/release-ci.sh"
GITEA="$ROOT/tools/gitea-release"
COMMIT="$(git -C "$ROOT" rev-parse HEAD)"
ci() { RELEASE_CI_WORK="$(mktemp -d -p "$WORK")" "$CI" "$@"; }
notes() { "$GITEA" notes "$1"; }
serves() { python3 -c 'import json, sys; print(json.load(open(sys.argv[1]))["version"])' "$SERVER/$1/manifest.json"; }
packages() { (cd "$SERVER/packages" && sha256sum -- *.deb); }
# A release as "Release 1 - build" leaves it: the package and its
# SHA256SUMS, in a Gitea pre-release.
make_release() {
local version="$1" dir="$WORK/built-$1" name="ekklesia-classroom_$1_amd64.deb"
mkdir -p "$dir"
fake_deb "$version" "$dir/$name"
(cd "$dir" && sha256sum "$name" > SHA256SUMS)
printf 'notes\n' > "$dir/notes.md"
"$GITEA" create "v$version" "$COMMIT" "Ekklesia Classroom $version" "$dir/notes.md" --prerelease >/dev/null
"$GITEA" upload "v$version" "$dir/$name" "$dir/SHA256SUMS" >/dev/null
sha "$dir/$name"
}
# A gate report as tools/release-gate.sh writes one.
report() {
local out="$1" version="$2" hash="$3" canary="$4" stable="$5" mode="${6:-release}"
local name="ekklesia-classroom_${version}_amd64.deb"
{
echo "release gate for $name"
echo "sha256 $hash"
echo "mode $mode"
echo "started 2026-09-29T00:00:00Z"
echo "PASS fresh install of $version (a new machine, on stable) (5 min)"
echo "PASS upgrade $canary -> $version on a canary room (canary serves $canary) (5 min)"
echo "PASS upgrade $stable -> $version on a stable room (stable serves $stable) (5 min)"
echo "finished 2026-09-29T00:20:00Z: $name passed the release gate"
} > "$out"
}
H6="$(make_release 0.2.6.6)"
PKG6="$WORK/built-0.2.6.6/ekklesia-classroom_0.2.6.6_amd64.deb"
mkdir -p "$WORK/reports"
report "$WORK/reports/gate-report-preview-good.txt" 0.2.6.6 "$H6" 0.2.6.5 0.2.6.4
report "$WORK/reports/gate-report-preview-other.txt" 0.2.6.6 "$(printf 'f%.0s' {1..64})" 0.2.6.5 0.2.6.4
report "$WORK/reports/gate-report-preview-stale.txt" 0.2.6.6 "$H6" 0.2.6.3 0.2.6.4
report "$WORK/reports/gate-report-preview-ci.txt" 0.2.6.6 "$H6" 0.2.6.5 0.2.6.4 ci
head -n -1 "$WORK/reports/gate-report-preview-good.txt" > "$WORK/reports/gate-report-preview-unfinished.txt"
"$GITEA" upload v0.2.6.6 "$WORK"/reports/*.txt >/dev/null
before="$(packages)"
echo "1. a gate report is only relied on when it is about these bytes, now"
GATE="$ROOT/tools/release-gate.sh"
check "a passing release report for this file, from what the channels serve, is current" \
"$GATE" --still-current "$WORK/reports/gate-report-preview-good.txt" "$PKG6"
check "one about other bytes is not" \
refuses_saying "it is about other bytes" "$GATE" --still-current "$WORK/reports/gate-report-preview-other.txt" "$PKG6"
check "nor one made before canary moved" \
refuses_saying "canary serves 0.2.6.5 now" "$GATE" --still-current "$WORK/reports/gate-report-preview-stale.txt" "$PKG6"
check "nor a CI gate's report" \
refuses_saying "not a release gate's report" "$GATE" --still-current "$WORK/reports/gate-report-preview-ci.txt" "$PKG6"
check "nor one that never finished" \
refuses_saying "does not end in a pass" "$GATE" --still-current "$WORK/reports/gate-report-preview-unfinished.txt" "$PKG6"
echo "2. Preview: the release's file, to canary, on a current report"
check "a checksum that is not the release's is refused" \
refuses_saying "is not sha256 eeee" ci preview 0.2.6.6 "$(printf 'e%.0s' {1..64})" gate-report-preview-good.txt
check "a stale report is refused" \
refuses_saying "canary serves 0.2.6.5 now" ci preview 0.2.6.6 "$H6" gate-report-preview-stale.txt
check "a report for other bytes is refused" \
refuses_saying "it is about other bytes" ci preview 0.2.6.6 "$H6" gate-report-preview-other.txt
check "and after all of that, canary is where it was and no package was added" \
bash -c "[[ \"\$(python3 -c 'import json; print(json.load(open(\"$SERVER/canary/manifest.json\"))[\"version\"])')\" == 0.2.6.5 ]] \
&& [[ \"\$(cd '$SERVER/packages' && sha256sum -- *.deb)\" == \"$before\" ]]"
check "the release's file is published to canary" \
ci preview 0.2.6.6 "$H6" gate-report-preview-good.txt
check "canary serves it, and latest.deb is that file" \
bash -c "[[ \$(python3 -c 'import json; m=json.load(open(\"$SERVER/canary/manifest.json\")); print(m[\"version\"], m[\"sha256\"])') == '0.2.6.6 $H6' ]] \
&& cmp -s '$SERVER/canary/latest.deb' '$PKG6'"
check "exactly one package was added, and no other changed" \
bash -c "diff <(printf '%s\n' \"$before\") <(cd '$SERVER/packages' && sha256sum -- *.deb) | grep -c '^[<>]' | grep -qx 1 \
&& ! diff <(printf '%s\n' \"$before\") <(cd '$SERVER/packages' && sha256sum -- *.deb) | grep -q '^<'"
check "stable was not touched" test "$(serves stable)" = 0.2.6.4
check "the Gitea release records it, with the sha256" \
bash -c "'$GITEA' notes v0.2.6.6 | grep -qF 'published to Preview (canary), sha256 \`$H6\`'"
check "publishing it again is refused" \
refuses_saying "0.2.6.6 has already been published" ci preview 0.2.6.6 "$H6" gate-report-preview-good.txt
echo "3. Stable: only the file Preview ran, only with an approval"
H7="$(make_release 0.2.6.7)"
check "a release that never went to Preview is refused" \
refuses_saying "has no record of $H7 going to Preview" ci stable 0.2.6.7 "$H7" "approved"
check "a checksum other than the one Preview ran is refused" \
refuses_saying "has no record of $H7 going to Preview" ci stable 0.2.6.6 "$H7" "approved"
check "a promotion with no approval is refused" \
refuses_saying "names who approved it" ci stable 0.2.6.6 "$H6" ""
check "stable is still 0.2.6.4 after all of that" test "$(serves stable)" = 0.2.6.4
before="$(packages)"
check "the approved file is promoted" \
ci stable 0.2.6.6 "$H6" "physical checks passed on the laptop"
check "stable serves exactly the file canary does" \
bash -c "[[ \$(python3 -c 'import json; m=json.load(open(\"$SERVER/stable/manifest.json\")); print(m[\"version\"], m[\"sha256\"])') == '0.2.6.6 $H6' ]] \
&& cmp -s '$SERVER/stable/latest.deb' '$SERVER/canary/latest.deb'"
check "no package was added or changed by promoting" test "$(packages)" = "$before"
check "the record has the promotion and its approval" \
bash -c "'$GITEA' notes v0.2.6.6 | grep -qF 'Approval: physical checks passed on the laptop'"
check "and the release is no longer a pre-release" \
bash -c "curl -fsS '$GITEA_URL/api/v1/repos/church/classroom/releases/tags/v0.2.6.6' | grep -q '\"prerelease\": false'"
check "the record reads in order: created, Preview, Stable" \
bash -c "'$GITEA' notes v0.2.6.6 | python3 -c 'import sys
t = sys.stdin.read()
order = [t.index(s) for s in (\"release created at\", \"published to Preview\", \"promoted to Stable\")]
assert order == sorted(order), order'"
echo "4. import: a release from before the record, as the server has it"
H5="$(sha "$SERVER/packages/ekklesia-classroom_0.2.6.5_amd64.deb")"
check "a checksum the server's file does not have is refused" \
refuses_saying "is not sha256 $H7" ci import 0.2.6.5 "$H7" "$COMMIT"
check "a commit that does not exist is refused" \
refuses_saying "is not a commit here" ci import 0.2.6.5 "$H5" "$(printf '0%.0s' {1..40})"
check "0.2.6.5 is recorded, with the server's own bytes" \
bash -c "RELEASE_CI_WORK=\$(mktemp -d -p '$WORK') '$CI' import 0.2.6.5 '$H5' '$COMMIT' \
&& '$GITEA' download v0.2.6.5 ekklesia-classroom_0.2.6.5_amd64.deb '$WORK/imported.deb' \
&& cmp -s '$WORK/imported.deb' '$SERVER/packages/ekklesia-classroom_0.2.6.5_amd64.deb'"
check "no channel serves it now, so the record claims neither" \
bash -c "! '$GITEA' notes v0.2.6.5 | grep -qE 'published to Preview|promoted to Stable'"
# A room of another era: stable serving 0.2.6.4, as it did before 0.2.6.6.
point stable 0.2.6.4
H4="$(sha "$SERVER/packages/ekklesia-classroom_0.2.6.4_amd64.deb")"
check "a release a channel serves is recorded as on it, read off the server" \
bash -c "RELEASE_CI_WORK=\$(mktemp -d -p '$WORK') '$CI' import 0.2.6.4 '$H4' '$COMMIT' \
&& '$GITEA' notes v0.2.6.4 | grep -qF 'promoted to Stable, sha256 \`$H4\`' \
&& ! '$GITEA' notes v0.2.6.4 | grep -q 'published to Preview' \
&& curl -fsS '$GITEA_URL/api/v1/repos/church/classroom/releases/tags/v0.2.6.4' | grep -q '\"prerelease\": false'"
check "a release that already has a record is not imported over it" \
refuses_saying "its record is kept as it is" ci import 0.2.6.6 "$H6" "$COMMIT"
check "a Gitea that cannot be asked is never taken as having no release" \
refuses_saying "could not ask Gitea whether v0.2.6.9 exists" \
env GITEA_URL=http://127.0.0.1:9 bash -c "RELEASE_CI_WORK=\$(mktemp -d -p '$WORK') '$CI' import 0.2.6.9 '$H5' '$COMMIT'"
echo "5. the release runner uses the key it is given, and only that"
H8="$(make_release 0.2.6.8)"
report "$WORK/reports/gate-report-preview-0.2.6.8.txt" 0.2.6.8 "$H8" "$(serves canary)" "$(serves stable)"
"$GITEA" upload v0.2.6.8 "$WORK/reports/gate-report-preview-0.2.6.8.txt" >/dev/null
before="$(packages)"
check "under Actions, a job with no key stops before anything is published" \
refuses_saying "A job never uses the runner's own ssh keys" \
env GITHUB_ACTIONS=true bash -c "RELEASE_CI_WORK=\$(mktemp -d -p '$WORK') '$CI' preview 0.2.6.8 '$H8' gate-report-preview-0.2.6.8.txt"
check "and nothing was" test "$(packages)" = "$before"
job="$(mktemp -d -p "$WORK")"
export SSH_LOG="$WORK/ssh.log"
: > "$SSH_LOG"
check "with a key, it publishes" \
env GITHUB_ACTIONS=true RELEASE_CI_WORK="$job" RELEASE_SSH_KEY="not a real key" \
RELEASE_SSH_KNOWN_HOSTS="10.0.0.1 ssh-ed25519 AAAA" RELEASE_SSH_ADDRESS=10.0.0.1 \
RELEASE_SSH_USER=classroom-publish "$CI" preview 0.2.6.8 "$H8" gate-report-preview-0.2.6.8.txt
check "every ssh and rsync it made used the job's own configuration" \
bash -c "[[ -s '$SSH_LOG' ]] && ! grep -v -e 'ssh $job/ssh/config' -e 'rsync ssh -F $job/ssh/config' '$SSH_LOG'"
check "which names the key, the host key and the account it was given" \
bash -c "grep -qx ' IdentityFile $job/ssh/key' '$job/ssh/config' \
&& grep -qx ' UserKnownHostsFile $job/ssh/known_hosts' '$job/ssh/config' \
&& grep -qx ' User classroom-publish' '$job/ssh/config' \
&& grep -qx ' StrictHostKeyChecking yes' '$job/ssh/config'"
check "and the key is gone when the job ends" test ! -e "$job/ssh/key"
unset SSH_LOG
echo "6. build: only from master or a maintenance branch"
check "a feature branch is refused before anything is built" \
bash -c "GITHUB_REF=refs/heads/feat/something RELEASE_CI_WORK=\$(mktemp -d -p '$WORK') '$CI' build 0.2.6.8 2>&1 \
| grep -q 'releases are built from master or release/\\*, not refs/heads/feat/something'"
echo
echo "$passed passed, $failed failed"
[[ $failed -eq 0 ]]
+35 -3
View File
@@ -86,6 +86,11 @@ cp "$SERVER/canary/manifest.json" "$LOCAL/canary/"
# to one host, and it is this directory. # to one host, and it is this directory.
cat > "$WORK/bin/ssh" <<'EOF' cat > "$WORK/bin/ssh" <<'EOF'
#!/bin/bash #!/bin/bash
# ssh [-F CONFIG] HOST COMMAND: run COMMAND here, and note which
# configuration it was given ("-" for the machine's own).
config=-
if [[ "$1" == -F ]]; then config="$2"; shift 2; fi
echo "ssh $config" >> "${SSH_LOG:-/dev/null}"
shift shift
exec bash -c "$*" exec bash -c "$*"
EOF EOF
@@ -98,7 +103,7 @@ args=()
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--ignore-existing) keep=1 ;; --ignore-existing) keep=1 ;;
-e) shift ;; -e) echo "rsync $2" >> "${SSH_LOG:-/dev/null}"; shift ;;
-*) ;; -*) ;;
*) args+=("$1") ;; *) args+=("$1") ;;
esac esac
@@ -274,8 +279,21 @@ fake_deb 0.2.9 "$LOCAL/packages/ekklesia-classroom_0.2.9_amd64.deb"
check "refuses a package that was built but never uploaded" \ check "refuses a package that was built but never uploaded" \
refuses "$ROOT/update_stable.sh" 0.2.9 refuses "$ROOT/update_stable.sh" 0.2.9
rm "$LOCAL/packages/ekklesia-classroom_0.2.9_amd64.deb" rm "$LOCAL/packages/ekklesia-classroom_0.2.9_amd64.deb"
check "refuses a checksum that is not one" \
refuses "$ROOT/update_stable.sh" 0.2.6.1 not-a-checksum
check "refuses a file that is not the one approved" \
refuses "$ROOT/update_stable.sh" 0.2.6.1 "$(printf '0%.0s' {1..64})"
check "stable is still 0.2.2 after all of that" \ check "stable is still 0.2.2 after all of that" \
test "$(cat "$SERVER/stable/manifest.json")" = "$stable_before" test "$(cat "$SERVER/stable/manifest.json")" = "$stable_before"
approved="$(sha256sum "$LOCAL/packages/ekklesia-classroom_0.2.6.1_amd64.deb" | awk '{print $1}')"
check "promotes the approved file, named by its checksum" \
"$ROOT/update_stable.sh" 0.2.6.1 "$approved"
check "and stable now serves exactly that file" \
bash -c "grep -q '\"sha256\": \"$approved\"' '$SERVER/stable/manifest.json' \
&& cmp -s '$SERVER/stable/latest.deb' '$LOCAL/packages/ekklesia-classroom_0.2.6.1_amd64.deb'"
# Back to 0.2.2 for the updater's cases below.
printf '%s\n' "$stable_before" > "$SERVER/stable/manifest.json"
ln -sfn ../packages/ekklesia-classroom_0.2.2_amd64.deb "$SERVER/stable/latest.deb"
echo echo
echo "7. update_canary.sh" echo "7. update_canary.sh"
@@ -286,6 +304,17 @@ check "without a version it builds nothing and names both choices" \
check "a published version is refused before anything is built" \ check "a published version is refused before anything is built" \
bash -c "! '$ROOT/update_canary.sh' 0.2.6 2>&1 | grep -q 'Validating sources'" bash -c "! '$ROOT/update_canary.sh' 0.2.6 2>&1 | grep -q 'Validating sources'"
check "a malformed version is refused" refuses "$ROOT/update_canary.sh" 0.2.6.1.1 check "a malformed version is refused" refuses "$ROOT/update_canary.sh" 0.2.6.1.1
# A package handed in is gated and published as it is, so it has to be
# the release named, before anything is gated at all.
fake_deb 0.2.6.3 "$WORK/ekklesia-classroom_0.2.6.2_amd64.deb"
check "a package handed in that says it is another version is refused" \
bash -c "out=\$('$ROOT/update_canary.sh' 0.2.6.2 '$WORK/ekklesia-classroom_0.2.6.2_amd64.deb' 2>&1); \
[[ \$? -ne 0 ]] && grep -q 'says it is 0.2.6.3' <<< \"\$out\" && ! grep -q 'release gate' <<< \"\$out\""
fake_deb 0.2.6.2 "$WORK/renamed.deb"
check "and so is one that is not named as the release" \
bash -c "out=\$('$ROOT/update_canary.sh' 0.2.6.2 '$WORK/renamed.deb' 2>&1); \
[[ \$? -ne 0 ]] && grep -q 'is not named' <<< \"\$out\" && ! grep -q 'release gate' <<< \"\$out\""
rm -f "$WORK/ekklesia-classroom_0.2.6.2_amd64.deb" "$WORK/renamed.deb"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
echo echo
@@ -441,8 +470,11 @@ publish("canary", "0.2.7")
status, out = run() status, out = run()
check("a device on 0.2.6.1 recognises 0.2.7 as newer and installs it", check("a device on 0.2.6.1 recognises 0.2.7 as newer and installs it",
status == 0 and installed() == "0.2.7", out) status == 0 and installed() == "0.2.7", out)
check("and reboots, because the manifest asks it to", # Since 0.2.6.5 not there and then: the reboot is handed to a service that
(work / "systemctl.log").read_text().strip() == "reboot") # waits until nobody is using the computer (classroom-reboot-when-idle).
check("and asks for the reboot the manifest wants, once nobody is using the computer",
(work / "systemctl.log").read_text().strip()
== "start --no-block ekklesia-classroom-reboot-when-idle.service")
publish("canary", "0.2.6.1") publish("canary", "0.2.6.1")
device("canary", "0.2.7") device("canary", "0.2.7")
+145
View File
@@ -0,0 +1,145 @@
#!/bin/bash
# The Gitea workflows keep the rules docs/releasing.md makes of them.
#
# Read as text, job by job, because the runner that runs this has no YAML
# library and must not need one. The rules are the ones that matter if a
# workflow is edited carelessly later: what a pull request can reach,
# what publishes and when, and that the work stays in the scripts.
# Whether Gitea itself parses and plans them was measured with act_runner
# (docs/releasing.md, "What was measured").
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
python3 - "$ROOT" <<'PY'
import re
import sys
from pathlib import Path
root = Path(sys.argv[1])
flows = root / ".gitea/workflows"
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 read(name):
return (flows / name).read_text()
def triggers(text):
block = re.search(r"^on:\n((?: .*\n|\n)+)", text, re.M)
return set(re.findall(r"^ ([a-z_]+):", block.group(1), re.M)) if block else set()
def jobs(text):
"""{job id: its text}, from the jobs: block."""
body = text[text.index("\njobs:\n") + 7:]
found = {}
for match in re.finditer(r"^ ([a-z_]+):\n((?: .*\n|\n)*)", body, re.M):
found[match.group(1)] = match.group(2)
return found
def runs_on(job):
match = re.search(r"^ runs-on: (\S+)$", job, re.M)
return match.group(1) if match else None
names = sorted(p.name for p in flows.glob("*.yml"))
check("the workflows are the ones documented", names == [
"ci.yml", "release-build.yml", "release-import.yml",
"release-preview.yml", "release-stable.yml"], str(names))
releasing = (root / "docs/releasing.md").read_text()
print("1. CI: every change, no secrets, nothing kept that could be installed")
ci = read("ci.yml")
check("it runs on push and on pull requests", triggers(ci) == {"push", "pull_request"}, str(triggers(ci)))
check("it reads no secret and no token", "secrets." not in ci and "github.token" not in ci)
check("every job runs on the CI runner",
{runs_on(job) for job in jobs(ci).values()} == {"ekklesia-ci"})
check("the checks are tools/check.sh, the same list a release build runs",
"tools/check.sh --base" in ci
and '"$ROOT/tools/check.sh"' in (root / "tools/build-release.sh").read_text())
check("the container gate is the release gate, in its CI mode", "tools/release-gate.sh --ci" in ci)
check("the package is inspected, not kept",
"dist/" not in re.sub(r"(?s)^.*?Keep the package's contents", "", ci)
.split("path:", 1)[-1])
check("building proves the version and changes nothing else in the tree",
'dpkg-deb --field "$deb" Version)" = "$version"' in ci
and "git status --porcelain --untracked-files=no" in ci)
print("2. releases: only by hand, and only through the scripts")
release_flows = [n for n in names if n.startswith("release-")]
for name in release_flows:
text = read(name)
check(f"{name} only runs when a person runs it", triggers(text) == {"workflow_dispatch"},
str(triggers(text)))
# An input pasted into a script is a script anyone who can run the
# workflow can write. Inputs reach the shell as environment only.
runs = "\n".join(re.findall(r"run: \|?\n?((?: .*\n)+|.*\n)", text))
check(f"{name} never pastes an input into a command",
"${{" not in runs, re.findall(r"\$\{\{[^}]*\}\}", runs)[:3])
check(f"{name}'s work is tools/release-ci.sh", "tools/release-ci.sh " in text)
print("3. who holds what")
where = {}
for name in names:
for job_id, job in jobs(read(name)).items():
where[f"{name}:{job_id}"] = (runs_on(job), job)
key_jobs = {k for k, (_, job) in where.items() if "CLASSROOM_UPDATES_SSH_KEY" in job}
check("the update server's key is given to exactly Preview's publish and Stable's promote",
key_jobs == {"release-preview.yml:publish", "release-stable.yml:promote"}, str(key_jobs))
check("and those run only on the release runner",
all(where[k][0] == "ekklesia-release" for k in key_jobs))
check("nothing on the release runner builds or gates (it has no Docker)",
all("release-ci.sh build" not in job and "release-ci.sh gate" not in job
and "release-gate.sh" not in job
for runner, job in where.values() if runner == "ekklesia-release"))
check("nothing on the CI runner is given the key",
all("CLASSROOM_UPDATES_SSH_KEY" not in job
for runner, job in where.values() if runner == "ekklesia-ci"))
check("the key is a secret; where it goes is a variable",
all("secrets.CLASSROOM_UPDATES_SSH_KEY" in where[k][1]
and "vars.CLASSROOM_UPDATES_KNOWN_HOSTS" in where[k][1] for k in key_jobs))
print("4. Preview and Stable")
preview = jobs(read("release-preview.yml"))
check("Preview gates on the CI runner, then publishes on the release runner",
runs_on(preview["gate"]) == "ekklesia-ci" and runs_on(preview["publish"]) == "ekklesia-release"
and "needs: gate" in preview["publish"])
check("the publish job relies on that gate's report, by name",
"needs.gate.outputs.report" in preview["publish"])
check("Preview asks for the sha256 of the file", "sha256:" in read("release-preview.yml"))
stable = read("release-stable.yml")
check("Stable asks for the sha256, an approval and a typed confirmation",
all(f" {field}:" in stable for field in ("sha256", "approval", "confirm"))
and 'PROMOTE $VERSION TO STABLE' in stable)
check("Stable never builds or gates", "build" not in re.sub(r"#.*", "", stable).replace("rebuild", "")
and "release-gate" not in stable)
print("5. what the measurements found")
check("artifacts are uploaded with v3, which Gitea 1.27 takes (v4 is refused)",
all("upload-artifact@v4" not in read(n) for n in names)
and "upload-artifact@v3" in ci)
check("the release token falls back to Gitea's own, which was measured to work",
all("secrets.RELEASE_API_TOKEN || github.token" in read(n) for n in release_flows))
check("the docs say labels in a runner's config file win over --labels",
"OVERRIDE --labels" in releasing)
print()
print(f"{passed} passed, {failed} failed")
sys.exit(1 if failed else 0)
PY
+9 -2
View File
@@ -37,9 +37,10 @@ import argparse
import base64 import base64
import html import html
import mimetypes import mimetypes
import os
import re import re
import sys import sys
from datetime import date from datetime import date, datetime
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
@@ -704,7 +705,13 @@ def main():
if problems: if problems:
return 1 return 1
revision = f"Revision {date.today():%d %B %Y}" # The day the source was committed, when the build says so, rather than
# the day it was built: a release built from a commit a day later must
# still ship the manual that commit holds, or the build changes the
# tree it was built from. SOURCE_DATE_EPOCH is the usual name for it.
epoch = os.environ.get("SOURCE_DATE_EPOCH")
day = datetime.fromtimestamp(int(epoch)).date() if epoch else date.today()
revision = f"Revision {day:%d %B %Y}"
if arguments.check: if arguments.check:
for name in ("classroom-manual.html", "classroom-manual.pdf"): for name in ("classroom-manual.html", "classroom-manual.pdf"):
+50 -51
View File
@@ -19,57 +19,12 @@ release_require_version "$VERSION" || exit 1
STAGE="$ROOT/packaging/root" STAGE="$ROOT/packaging/root"
OUTPUT="$ROOT/dist/ekklesia-classroom_${VERSION}_amd64.deb" OUTPUT="$ROOT/dist/ekklesia-classroom_${VERSION}_amd64.deb"
# Syntax check everything the package ships, by shebang, so this cannot # Sources, the teacher's manual and every test, before anything is built.
# drift out of date as classroom commands are added. # The same list CI runs on every change; see tools/check.sh.
echo "Validating sources" "$ROOT/tools/check.sh" || {
echo "Checks failed; nothing was built."
for script in "$ROOT/packaging/debian/postinst" "$ROOT"/scripts/* "$ROOT"/tests/*; do exit 1
[[ -f "$script" ]] || continue }
case "$(head -n 1 "$script")" in
*python3*)
python3 -m py_compile "$script"
;;
*bash*|*bin/sh*)
bash -n "$script"
;;
esac
done
for module in "$ROOT"/lib/*.py; do
[[ -f "$module" ]] || continue
python3 -m py_compile "$module"
done
for policy in "$ROOT"/config/chrome/policies/*/*.json; do
[[ -f "$policy" ]] || continue
python3 -c 'import json, sys; json.load(open(sys.argv[1]))' "$policy"
done
find "$ROOT/scripts" "$ROOT/lib" "$ROOT/tests" \
-name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true
# THE MANUAL IS PART OF THE RELEASE, NOT A JOB FOR AFTERWARDS.
#
# Built from docs/manual before the tests run, so that the test which
# checks the digital and printed versions against each other is checking
# what this package is about to ship rather than whatever was built last.
# A page that would make the printed manual wrong fails the build here.
echo "Building the teacher's manual"
"$ROOT/tools/build-manual"
for test in "$ROOT"/tests/test-*.sh; do
[[ -x "$test" ]] || continue
echo "Running $(basename "$test")"
"$test" > /dev/null || {
echo "Tests failed. Run it directly to see why:"
echo " $test"
exit 1
}
done
echo "Building Ekklesia Classroom ${VERSION}" echo "Building Ekklesia Classroom ${VERSION}"
@@ -176,6 +131,32 @@ install -Dm644 \
"$ROOT/config/wallpapers/classroom-calm.png" \ "$ROOT/config/wallpapers/classroom-calm.png" \
"$STAGE/usr/share/backgrounds/ekklesia/classroom-calm.png" "$STAGE/usr/share/backgrounds/ekklesia/classroom-calm.png"
# THE SAME PICTURE, AS A WALLPAPER PACKAGE.
#
# The look and feel names this as the default for any desktop Plasma makes
# without the layout, and Plasma only accepts a package there, never a
# file. Its one image is a link to the file above rather than a copy, so
# there is still exactly one picture, and every desktop the layout wrote
# keeps pointing at the path it always has. The name says the image's
# size, because that is how Plasma chooses between a package's images.
WALLPAPER_PACKAGE="$STAGE/usr/share/wallpapers/EkklesiaClassroom"
install -Dm644 \
"$ROOT/config/wallpapers/EkklesiaClassroom/metadata.json" \
"$WALLPAPER_PACKAGE/metadata.json"
wallpaper_size="$(python3 -c '
import struct, sys
with open(sys.argv[1], "rb") as png:
header = png.read(24)
assert header[:8] == b"\x89PNG\r\n\x1a\n" and header[12:16] == b"IHDR"
print("%dx%d" % struct.unpack(">II", header[16:24]))
' "$ROOT/config/wallpapers/classroom-calm.png")"
install -d -m755 "$WALLPAPER_PACKAGE/contents" "$WALLPAPER_PACKAGE/contents/images"
ln -s /usr/share/backgrounds/ekklesia/classroom-calm.png \
"$WALLPAPER_PACKAGE/contents/images/${wallpaper_size}.png"
@@ -193,6 +174,10 @@ install -Dm755 \
"$ROOT/scripts/ekklesia-classroom-update" \ "$ROOT/scripts/ekklesia-classroom-update" \
"$STAGE/usr/local/bin/ekklesia-classroom-update" "$STAGE/usr/local/bin/ekklesia-classroom-update"
install -Dm755 \
"$ROOT/scripts/classroom-reboot-when-idle" \
"$STAGE/usr/local/bin/classroom-reboot-when-idle"
# System configuration. # System configuration.
install -Dm644 \ install -Dm644 \
"$ROOT/config/sddm/ekklesia-classroom.conf" \ "$ROOT/config/sddm/ekklesia-classroom.conf" \
@@ -206,6 +191,11 @@ install -Dm644 \
"$ROOT/systemd/updater/ekklesia-classroom-update.timer" \ "$ROOT/systemd/updater/ekklesia-classroom-update.timer" \
"$STAGE/etc/systemd/system/ekklesia-classroom-update.timer" "$STAGE/etc/systemd/system/ekklesia-classroom-update.timer"
# After an update: the reboot, once nobody is using the computer.
install -Dm644 \
"$ROOT/systemd/updater/ekklesia-classroom-reboot-when-idle.service" \
"$STAGE/etc/systemd/system/ekklesia-classroom-reboot-when-idle.service"
install -Dm644 \ install -Dm644 \
"$ROOT/systemd/provision/ekklesia-classroom-provision.service" \ "$ROOT/systemd/provision/ekklesia-classroom-provision.service" \
"$STAGE/etc/systemd/system/ekklesia-classroom-provision.service" "$STAGE/etc/systemd/system/ekklesia-classroom-provision.service"
@@ -388,6 +378,11 @@ install -Dm644 \
"$ROOT/lib/classroom_printing.py" \ "$ROOT/lib/classroom_printing.py" \
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_printing.py" "$STAGE/usr/local/lib/ekklesia-classroom/classroom_printing.py"
# Wonder Ink's recent tabs, restored within two hours of their last use.
install -Dm644 \
"$ROOT/lib/classroom_wonderink_workspace.py" \
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_wonderink_workspace.py"
# PRESENTATIONS. # PRESENTATIONS.
# #
# The library a teacher sees, and the watcher that fills it from their # The library a teacher sees, and the watcher that fills it from their
@@ -452,6 +447,10 @@ install -Dm755 \
"$ROOT/scripts/classroom-desktop-reset" \ "$ROOT/scripts/classroom-desktop-reset" \
"$STAGE/usr/local/bin/classroom-desktop-reset" "$STAGE/usr/local/bin/classroom-desktop-reset"
install -Dm755 \
"$ROOT/scripts/classroom-desktop-wallpaper" \
"$STAGE/usr/local/bin/classroom-desktop-wallpaper"
install -Dm755 \ install -Dm755 \
"$ROOT/scripts/classroom-theme" \ "$ROOT/scripts/classroom-theme" \
"$STAGE/usr/local/bin/classroom-theme" "$STAGE/usr/local/bin/classroom-theme"
Executable
+141
View File
@@ -0,0 +1,141 @@
#!/bin/bash
# EVERY CHECK THE REPOSITORY HAS THAT NEEDS NO CONTAINER AND NO SCREEN.
#
# tools/check.sh sources, the manual, every test
# tools/check.sh --base REF and the change since REF is clean
# CHECK_LOG_DIR=DIR tools/check.sh each test's output in DIR, and a
# summary.md for a CI run to show
#
# The one list. tools/build-release.sh runs it before it builds anything,
# and CI runs it on every push and pull request, so a test added to tests/
# is a test both of them run, with nothing else to edit.
#
# Every test runs even after one has failed, so a report names all of
# them; the exit status is 1 if any did.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
base=""
while [[ $# -gt 0 ]]; do
case "$1" in
--base) base="$2"; shift 2 ;;
*) echo "Usage: $0 [--base REF]" >&2; exit 2 ;;
esac
done
logs="${CHECK_LOG_DIR:-$(mktemp -d)}"
mkdir -p "$logs"
summary="$logs/summary.md"
failed=0
{
echo "| Check | Result | Seconds |"
echo "| --- | --- | --- |"
} > "$summary"
# check NAME COMMAND...: run it, keep its output, say how it went.
check() {
local name="$1" started status log
shift
started=$SECONDS
log="$logs/$name.log"
"$@" > "$log" 2>&1
status=$?
if [[ $status -eq 0 ]]; then
printf ' ok %s\n' "$name"
echo "| $name | pass | $(( SECONDS - started )) |" >> "$summary"
else
printf ' FAIL %s\n' "$name"
tail -n 25 "$log" | sed 's/^/ /'
echo "| $name | **FAIL** | $(( SECONDS - started )) |" >> "$summary"
failed=1
fi
}
# Syntax check everything the package ships, by shebang, so this cannot
# drift out of date as classroom commands are added.
validate_sources() {
local script module policy
for script in "$ROOT/packaging/debian/postinst" "$ROOT"/scripts/* "$ROOT"/tests/* \
"$ROOT"/tools/* "$ROOT"/tools/lab/* "$ROOT"/update_*.sh; do
[[ -f "$script" ]] || continue
case "$(head -n 1 "$script")" in
*python3*)
python3 -m py_compile "$script" || return 1
;;
*bash*|*bin/sh*)
bash -n "$script" || return 1
;;
*)
# Sourced, so no #! line: tools/release-lib.sh.
if [[ "$script" == *.sh ]]; then
bash -n "$script" || return 1
fi
;;
esac
done
for module in "$ROOT"/lib/*.py; do
[[ -f "$module" ]] || continue
python3 -m py_compile "$module" || return 1
done
for policy in "$ROOT"/config/chrome/policies/*/*.json "$ROOT"/config/wallpapers/*/metadata.json; do
[[ -f "$policy" ]] || continue
python3 -c 'import json, sys; json.load(open(sys.argv[1]))' "$policy" || return 1
done
find "$ROOT/scripts" "$ROOT/lib" "$ROOT/tests" "$ROOT/tools" \
-name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true
}
# The change itself: no whitespace damage, and nothing built or published
# committed by mistake. Only with --base, because it is a question about a
# change, not about a tree.
hygiene() {
git diff --check "$base" HEAD || return 1
if git ls-files | grep -E '\.deb$|^(dist|repo|packaging/root)/'; then
echo "built or published files are committed (above)" >&2
return 1
fi
# A committed release version is one a release could be built from.
source "$ROOT/tools/release-lib.sh"
release_require_version "$(cat "$ROOT/VERSION")"
}
echo "Checking $(git rev-parse --short HEAD 2>/dev/null || echo 'the tree')"
check sources validate_sources
[[ -z "$base" ]] || check hygiene hygiene
# THE MANUAL IS PART OF THE RELEASE, NOT A JOB FOR AFTERWARDS.
#
# Built from docs/manual before the tests run, so that the test which
# checks the digital and printed versions against each other is checking
# what this package is about to ship rather than whatever was built last.
# A page that would make the printed manual wrong fails here.
check manual "$ROOT/tools/build-manual"
for test in "$ROOT"/tests/test-*.sh; do
[[ -x "$test" ]] || continue
check "$(basename "$test" .sh)" "$test"
done
echo
if [[ $failed -eq 0 ]]; then
echo "Every check passed. Logs: $logs"
else
echo "Checks FAILED. Each one's full output is in $logs"
fi
exit "$failed"
+304
View File
@@ -0,0 +1,304 @@
#!/usr/bin/env python3
"""The Gitea release that is the permanent record of one version.
A Gitea release is where a version's package, its checksum, how it was
built and what happened to it afterwards are kept for good: which commit,
which gate, when it went to Preview (canary), when it was promoted to
Stable and on whose approval. The update server is where classrooms fetch
it; this is where a person months from now finds out why.
PUBLISHED BYTES ARE NEVER REPLACED. Gitea lets anyone with write access
delete an asset and upload another under the same name, and nothing in
Gitea stops a workflow doing it either, so this does: an asset is only
ever uploaded under a name the release does not have yet. An asset that
is already there with exactly the same bytes is left alone and reported;
one with different bytes fails, loudly, and nothing is changed. The same
rule the update server has had since 0.2.6 was republished over itself
(tools/release-lib.sh).
A tag, likewise, is never moved: a release is only created for a tag that
does not exist, or that already names the commit being released.
The record is append-only. `record` adds a dated line under "## Record";
nothing here rewrites what an earlier line says.
gitea-release show TAG the release, or exit 3 if none
gitea-release notes TAG its notes and record, as text
gitea-release create TAG COMMIT TITLE NOTES_FILE [--prerelease]
gitea-release upload TAG FILE... each only if not there yet
gitea-release download TAG NAME DEST an asset, by name
gitea-release record TAG LINE append to the record
gitea-release stable TAG no longer a pre-release
Environment:
GITEA_URL https://git.riverofglorymn.org (GITHUB_SERVER_URL in CI)
GITEA_REPO River_of_Glory_Church/Ekklesia_Classroom (GITHUB_REPOSITORY)
GITEA_TOKEN a token that may write releases; never printed
"""
import hashlib
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
import uuid
from datetime import datetime, timezone
RECORD = "## Record"
class Refused(Exception):
"""Something that must not happen, said in words."""
def say(message):
print(f"gitea-release: {message}", flush=True)
def config():
url = os.environ.get("GITEA_URL") or os.environ.get("GITHUB_SERVER_URL")
repo = os.environ.get("GITEA_REPO") or os.environ.get("GITHUB_REPOSITORY")
token = os.environ.get("GITEA_TOKEN", "")
if not url or not repo:
raise Refused("GITEA_URL and GITEA_REPO must be set")
return url.rstrip("/"), repo, token
def request(method, path, body=None, headers=None, raw=False):
"""(status, parsed body). 404 is an answer, not an error."""
url, repo, token = config()
target = path if path.startswith("http") else f"{url}/api/v1/repos/{repo}{path}"
headers = dict(headers or {})
if token:
headers["Authorization"] = f"token {token}"
if isinstance(body, (dict, list)):
body = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
call = urllib.request.Request(target, data=body, method=method, headers=headers)
try:
with urllib.request.urlopen(call, timeout=120) as response:
data = response.read()
status = response.status
except urllib.error.HTTPError as error:
data = error.read()
status = error.code
if status == 404:
return 404, None
if status >= 400:
detail = data[:300].decode("utf-8", "replace")
raise Refused(f"{method} {path} answered {status}: {detail}")
if raw:
return status, data
return status, json.loads(data) if data else None
def release(tag):
status, found = request("GET", f"/releases/tags/{urllib.parse.quote(tag)}")
return None if status == 404 else found
def need_release(tag):
found = release(tag)
if found is None:
raise Refused(f"there is no release {tag}")
return found
def tag_commit(tag):
status, found = request("GET", f"/tags/{urllib.parse.quote(tag)}")
if status == 404:
return None
return found["commit"]["sha"]
def sha256(data):
return hashlib.sha256(data).hexdigest()
def now():
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
def assets(found):
status, listed = request("GET", f"/releases/{found['id']}/assets")
return {} if status == 404 else {asset["name"]: asset for asset in listed or []}
def fetch(asset):
_status, data = request("GET", asset["browser_download_url"], raw=True)
return data
# ---------------------------------------------------------------------------
def show(tag):
found = release(tag)
if found is None:
say(f"no release {tag}")
return 3
print(json.dumps({
"tag": found["tag_name"],
"commit": tag_commit(tag),
"prerelease": found.get("prerelease"),
"assets": sorted(assets(found)),
"url": found.get("html_url"),
}, indent=2))
return 0
def notes(tag):
print(need_release(tag).get("body") or "")
return 0
def create(tag, commit, title, notes_file, prerelease):
notes = open(notes_file, encoding="utf-8").read()
at = tag_commit(tag)
if at is not None and at != commit:
raise Refused(f"tag {tag} already names {at}, not {commit}. "
"A tag is never moved; release a new version.")
found = release(tag)
if found is not None:
say(f"release {tag} already exists at {commit}; nothing created")
return 0
request("POST", "/releases", {
"tag_name": tag,
"target_commitish": commit,
"name": title,
"body": notes.rstrip() + f"\n\n{RECORD}\n\n- {now()}: release created at `{commit}`\n",
"draft": False,
"prerelease": prerelease,
})
if tag_commit(tag) != commit:
raise Refused(f"release {tag} was created, but its tag does not name {commit}")
say(f"created release {tag} at {commit}")
return 0
def upload(tag, files):
found = need_release(tag)
present = assets(found)
for path in files:
name = os.path.basename(path)
data = open(path, "rb").read()
digest = sha256(data)
if name in present:
there = sha256(fetch(present[name]))
if there == digest:
say(f"{name} is already attached, byte for byte; left alone")
continue
raise Refused(f"{name} is already attached to {tag} with sha256 {there}; "
f"this file is {digest}. A published asset is never replaced.")
boundary = uuid.uuid4().hex
body = (f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="attachment"; filename="{name}"\r\n'
f"Content-Type: application/octet-stream\r\n\r\n").encode() \
+ data + f"\r\n--{boundary}--\r\n".encode()
request("POST", f"/releases/{found['id']}/assets?name={urllib.parse.quote(name)}",
body, {"Content-Type": f"multipart/form-data; boundary={boundary}"})
# Read back: what Gitea serves is what was sent.
there = assets(need_release(tag)).get(name)
if there is None or sha256(fetch(there)) != digest:
raise Refused(f"{name} was uploaded but does not read back as sha256 {digest}")
say(f"attached {name}, sha256 {digest}")
return 0
def download(tag, name, dest):
present = assets(need_release(tag))
if name not in present:
raise Refused(f"{tag} has no asset {name}")
data = fetch(present[name])
with open(dest, "wb") as handle:
handle.write(data)
say(f"{name} -> {dest}, sha256 {sha256(data)}")
return 0
def record(tag, line):
found = need_release(tag)
body = found.get("body") or ""
if RECORD not in body:
body = body.rstrip() + f"\n\n{RECORD}\n"
body = body.rstrip() + f"\n- {now()}: {line}\n"
request("PATCH", f"/releases/{found['id']}", {"body": body})
say(f"recorded on {tag}: {line}")
return 0
def stable(tag):
found = need_release(tag)
request("PATCH", f"/releases/{found['id']}", {"prerelease": False})
say(f"{tag} is no longer marked as a pre-release")
return 0
def main(argv):
try:
match argv:
case ["show", tag]:
return show(tag)
case ["notes", tag]:
return notes(tag)
case ["create", tag, commit, title, notes_file, *rest] if rest in ([], ["--prerelease"]):
return create(tag, commit, title, notes_file, rest == ["--prerelease"])
case ["upload", tag, *files] if files:
return upload(tag, files)
case ["download", tag, name, dest]:
return download(tag, name, dest)
case ["record", tag, line]:
return record(tag, line)
case ["stable", tag]:
return stable(tag)
case _:
print(__doc__, file=sys.stderr)
return 2
except Refused as refusal:
say(f"REFUSED: {refusal}")
return 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+26
View File
@@ -182,6 +182,12 @@ check "no discover or system settings" 0 \
"$(in_lab "grep -cE 'discover.desktop|systemsettings.desktop' $APPLETS")" "$(in_lab "grep -cE 'discover.desktop|systemsettings.desktop' $APPLETS")"
check "the packaged wallpaper" 1 \ check "the packaged wallpaper" 1 \
"$(in_lab "grep -c 'Image=file:///usr/share/backgrounds/ekklesia/classroom-calm.png' $APPLETS")" "$(in_lab "grep -c 'Image=file:///usr/share/backgrounds/ekklesia/classroom-calm.png' $APPLETS")"
# And the one a desktop gets when nothing wrote it, which is what Tiny
# Pebbles showed: a package name, and the package there to be found.
check "a desktop nobody configured defaults to it" EkklesiaClassroom \
"$(in_lab 'sed -n "/^\[Wallpaper\]/,/^\[/s/^Image=//p" /usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/defaults')"
check "and that package is the same picture" /usr/share/backgrounds/ekklesia/classroom-calm.png \
"$(in_lab 'readlink -f /usr/share/wallpapers/EkklesiaClassroom/contents/images/*.png')"
section "it will not strand a teacher" section "it will not strand a teacher"
check "no automatic lock" false "$(session_config --file kscreenlockerrc --group Daemon --key Autolock)" check "no automatic lock" false "$(session_config --file kscreenlockerrc --group Daemon --key Autolock)"
@@ -370,6 +376,26 @@ check "device.conf carries the setting" 1 \
"$(in_lab 'grep -c "^WONDERINK_ENABLED=" /etc/ekklesia-classroom/device.conf || true')" "$(in_lab 'grep -c "^WONDERINK_ENABLED=" /etc/ekklesia-classroom/device.conf || true')"
check "and it defaults to on" true \ check "and it defaults to on" true \
"$(in_lab 'sed -n "s/^WONDERINK_ENABLED=//p" /etc/ekklesia-classroom/device.conf')" "$(in_lab 'sed -n "s/^WONDERINK_ENABLED=//p" /etc/ekklesia-classroom/device.conf')"
# THE TEACHER CANNOT OPEN SYSTEM SETTINGS, AND THE MAINTENANCE TERMINAL CAN.
# Every settings module this machine has must be refused in the classroom's
# cascade — a notification's Configure button opens whichever one it names —
# and none may be refused in the cascade the maintenance terminal uses.
teacher_cascade() { in_lab "XDG_CONFIG_DIRS=/etc/ekklesia-classroom/xdg:/etc/xdg kreadconfig6 --file kdeglobals --group 'KDE Control Module Restrictions' --key $1 --default true 2>/dev/null"; }
modules=$(in_lab 'find /usr/lib/x86_64-linux-gnu/qt6/plugins -path "*kcms*" -name "*.so" | sed "s#.*/##; s#\.so\$##" | sort')
open_modules=$(for m in $modules; do [[ "$(teacher_cascade "$m")" == false ]] || echo "$m"; done | tr '\n' ' ')
check "every settings module is refused to the teacher (unrestricted: ${open_modules:-none})" 0 \
"$(wc -w <<< "$open_modules")"
check "the notification settings the Configure button opens are among them" false \
"$(teacher_cascade kcm_notifications)"
check "but not to the maintenance terminal" true \
"$(in_lab "XDG_CONFIG_DIRS=/etc/xdg kreadconfig6 --file kdeglobals --group 'KDE Control Module Restrictions' --key kcm_notifications --default true 2>/dev/null")"
check "which still starts outside the classroom's configuration" 1 \
"$(in_lab 'grep -c "XDG_CONFIG_DIRS=/etc/xdg konsole" /usr/local/bin/classroom-admin-terminal || true')"
check "a locked panel offers no widget settings" false \
"$(in_lab "XDG_CONFIG_DIRS=/etc/ekklesia-classroom/xdg:/etc/xdg kreadconfig6 --file kdeglobals --group 'KDE Action Restrictions' --key plasma/allow_configure_when_locked --default true 2>/dev/null")"
check "and no help center is installed to open from a Help button" 0 \
"$(in_lab 'dpkg -l khelpcenter 2>/dev/null | grep -c "^ii" || true')"
# One line the setting switches, since 0.2.3's one-setting-per-application # One line the setting switches, since 0.2.3's one-setting-per-application
# layout: var WONDERINK = true|false. This asked for the older marker # layout: var WONDERINK = true|false. This asked for the older marker
# until 0.2.6.1 and so failed on every lab run in between. # until 0.2.6.1 and so failed on every lab run in between.
+39 -9
View File
@@ -13,6 +13,8 @@
# tools/lab/dirty-upgrade [NEW.deb] [OLD.deb] # tools/lab/dirty-upgrade [NEW.deb] [OLD.deb]
# CHECK_ARGS=--restart tools/lab/dirty-upgrade ask twice, either side # CHECK_ARGS=--restart tools/lab/dirty-upgrade ask twice, either side
# of a logout # of a logout
# tools/lab/dirty-upgrade NEW.deb dist/ekklesia-classroom_0.2.2_amd64.deb
# the day the desktops move from 0.2.2
# #
# The container is restarted between the install and the session, because # The container is restarted between the install and the session, because
# a room takes its upgrade and reboots. # a room takes its upgrade and reboots.
@@ -38,13 +40,30 @@ docker exec "$NAME" sh -c '
# backports for Hyprland. The lab is not testing that. # backports for Hyprland. The lab is not testing that.
systemctl mask ekklesia-classroom-provision.service >/dev/null 2>&1' systemctl mask ekklesia-classroom-provision.service >/dev/null 2>&1'
# Which version is installed, fully configured, or the rehearsal stops.
# Every step below is a question about one particular release, and a
# step that quietly left the machine on another one answers for the
# wrong release — which is how the 0.2.2 rehearsal once passed on 0.2.2.
require_installed() {
local want got
want=$(dpkg-deb --field "$1" Version)
got=$(docker exec "$NAME" dpkg-query -W -f='${Version} ${db:Status-Abbrev}' ekklesia-classroom 2>/dev/null || true)
echo " installed: $got"
if [[ "$got" != "$want ii " ]]; then
echo " $(basename "$1") DID NOT INSTALL: [$got], wanted [$want ii]" >&2
echo " Nothing after this would be a question about $want." >&2
exit 1
fi
}
step "the old classroom: $(basename "$OLD")" step "the old classroom: $(basename "$OLD")"
# With apt, the way a room installs anything, so an old package's own
# dependencies are resolved rather than forced.
docker exec "$NAME" sh -c " docker exec "$NAME" sh -c "
DEBIAN_FRONTEND=noninteractive dpkg -i '/srv/classroom/${OLD#$ROOT/}' >/dev/null 2>&1 DEBIAN_FRONTEND=noninteractive apt-get install -y -qq '/srv/classroom/${OLD#$ROOT/}' >/dev/null 2>&1
DEBIAN_FRONTEND=noninteractive apt-get -y -qq -f install >/dev/null 2>&1
DEBIAN_FRONTEND=noninteractive dpkg --configure ekklesia-classroom >/dev/null 2>&1
true" || true true" || true
docker exec "$NAME" dpkg-query -W -f=' installed: ${Version}\n' ekklesia-classroom require_installed "$OLD"
step "and everything a used machine accumulates" step "and everything a used machine accumulates"
docker exec -i "$NAME" bash <<'DIRT' docker exec -i "$NAME" bash <<'DIRT'
@@ -164,14 +183,16 @@ DIRT
step "the new classroom: $(basename "$NEW")" step "the new classroom: $(basename "$NEW")"
docker exec "$NAME" sh -c ' docker exec "$NAME" sh -c '
systemctl unmask ekklesia-classroom-provision.service >/dev/null 2>&1 || true' systemctl unmask ekklesia-classroom-provision.service >/dev/null 2>&1 || true'
# The way ekklesia-classroom-update installs it: apt, not dpkg. The
# package conflicts with khelpcenter since 0.2.6.2, and dpkg -i refuses a
# conflict where apt resolves it — so from 0.2.2 this step used to leave
# the machine on 0.2.2 and every check below was asked of the old release.
docker exec "$NAME" sh -c " docker exec "$NAME" sh -c "
DEBIAN_FRONTEND=noninteractive dpkg -i '/srv/classroom/${NEW#$ROOT/}' 2>&1 \ DEBIAN_FRONTEND=noninteractive apt-get install -y '/srv/classroom/${NEW#$ROOT/}' 2>&1 \
| grep -E '^\[classroom-migrate\]' || true | grep -E '^\[classroom-migrate\]' || true
DEBIAN_FRONTEND=noninteractive apt-get -y -qq -f install >/dev/null 2>&1
DEBIAN_FRONTEND=noninteractive dpkg --configure ekklesia-classroom >/dev/null 2>&1
rm -f /usr/sbin/policy-rc.d rm -f /usr/sbin/policy-rc.d
true" || true true" || true
docker exec "$NAME" dpkg-query -W -f=' installed: ${Version} ${db:Status-Abbrev}\n' ekklesia-classroom require_installed "$NEW"
step "and the room reboots" step "and the room reboots"
"$LAB" reboot "$LAB" reboot
@@ -188,8 +209,9 @@ step "and again, installed a second time over itself"
# An update that changes nothing must change nothing. The migration runs # An update that changes nothing must change nothing. The migration runs
# again here, over a machine that is already converged. # again here, over a machine that is already converged.
docker exec "$NAME" sh -c " docker exec "$NAME" sh -c "
DEBIAN_FRONTEND=noninteractive dpkg -i '/srv/classroom/${NEW#$ROOT/}' >/dev/null 2>&1 DEBIAN_FRONTEND=noninteractive apt-get install --reinstall -y -qq '/srv/classroom/${NEW#$ROOT/}' >/dev/null 2>&1
true" || true true" || true
require_installed "$NEW"
before=$(docker exec "$NAME" sh -c 'cat /etc/ekklesia-classroom/device.conf | sha256sum') before=$(docker exec "$NAME" sh -c 'cat /etc/ekklesia-classroom/device.conf | sha256sum')
"$LAB" plasma-down >/dev/null "$LAB" plasma-down >/dev/null
"$LAB" plasma-up >/dev/null "$LAB" plasma-up >/dev/null
@@ -230,6 +252,14 @@ fi
theme dark >/dev/null theme dark >/dev/null
echo " back to $(theme show)" echo " back to $(theme show)"
step "and every desktop has the classroom's wallpaper, however it was made"
# Tiny Pebbles, 0.2.2 -> 0.2.6.4: a desktop Plasma made after the layout
# had run came up with Plasma's own wallpaper, and nothing here noticed,
# because every session this rehearsal started had its screen from the
# beginning. This makes Plasma build one by itself, and puts Tiny
# Pebbles' own file in front of a shell restart.
"$ROOT/tools/lab/wallpaper-check" || converged=1
step "and it still recovers" step "and it still recovers"
"$ROOT/tools/lab/recovery-check" || converged=1 "$ROOT/tools/lab/recovery-check" || converged=1
+8 -4
View File
@@ -17,11 +17,15 @@ docker exec "$NAME" sh -c '
chmod +x /usr/sbin/policy-rc.d chmod +x /usr/sbin/policy-rc.d
apt-get update -qq >/dev/null 2>&1' apt-get update -qq >/dev/null 2>&1'
# apt-get install on the file, exactly as ekklesia-classroom-update does.
# Not dpkg -i and then apt-get -f: when the package conflicts with
# something installed (khelpcenter, from 0.2.6.2) dpkg refuses, and -f then
# "fixes" it by dropping the classroom package rather than the conflict —
# measured, and a lab that installs differently from a classroom tests the
# wrong thing.
docker exec "$NAME" sh -c " docker exec "$NAME" sh -c "
DEBIAN_FRONTEND=noninteractive dpkg -i '/srv/classroom/${DEB#$ROOT/}' \ DEBIAN_FRONTEND=noninteractive apt-get install -y -qq '/srv/classroom/${DEB#$ROOT/}' \
>/dev/null 2>&1 || true >/tmp/from-package-install.log 2>&1 || cat /tmp/from-package-install.log
DEBIAN_FRONTEND=noninteractive apt-get -y -qq -f install >/dev/null 2>&1
DEBIAN_FRONTEND=noninteractive dpkg --configure ekklesia-classroom >/dev/null 2>&1
rm -f /usr/sbin/policy-rc.d" rm -f /usr/sbin/policy-rc.d"
state=$(docker exec "$NAME" dpkg-query -W -f='${db:Status-Abbrev}' ekklesia-classroom) state=$(docker exec "$NAME" dpkg-query -W -f='${db:Status-Abbrev}' ekklesia-classroom)
+18
View File
@@ -135,5 +135,23 @@ check("and the window will open exactly that path", window.may_open(spaces))
print() print()
print(f"{passed} passed, {failed} failed") print(f"{passed} passed, {failed} failed")
# Pages go before their profile. Left to Python's own teardown the order
# is arbitrary, and a profile released while a page still uses it is a
# segfault on the way out — measured, intermittently, AFTER every check
# had passed, which made a passing run report failure. The real Wonder
# Ink was closed six times in the same lab and exited 0 each time.
from PyQt6.QtCore import QCoreApplication, QEvent
for index in range(window._tabs.count()):
view = window._tabs.widget(index)
view.page().deleteLater()
view.deleteLater()
window.close()
window.deleteLater()
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete.value)
profile.deleteLater()
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete.value)
sys.stdout.flush()
sys.exit(1 if failed else 0) sys.exit(1 if failed else 0)
PY PY
+162
View File
@@ -0,0 +1,162 @@
#!/bin/bash
# When a classroom checks for updates, measured on the lab's own systemd.
#
# switched on at the time the timer fires once at its time, and not
# again that day
# switched off at the time the lab machine is really stopped across the
# time, then started: the missed check runs
# once after it boots, and does not loop
#
# A pair of test units stands in for ekklesia-classroom-update: the same
# [Timer] settings with the time moved to a minute or two from now (the
# real one would update the lab for real). The real timer's own settings,
# after installing the package, are the release gate's to check.
#
# And classroom-reboot-when-idle against real kernel input: a mouse made
# with /dev/uinput, moved and then left alone, with a harmless command in
# place of the reboot.
#
# tools/lab/update-schedule-check
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
NAME="${LAB_NAME:-classroom-v2-lab}"
LAB="$ROOT/tools/lab/lab"
IDLE="${IDLE_COMMAND:-/usr/local/bin/classroom-reboot-when-idle}"
LOG=/var/tmp/ekk-schedule-test.log
lab() { docker exec -i "$NAME" bash -c "$1"; }
passed=0
failed=0
judge() {
if eval "$2"; then
printf ' \033[32mPASS\033[0m %s\n' "$1"
passed=$((passed + 1))
else
printf ' \033[31mFAIL\033[0m %s\n' "$1"
failed=$((failed + 1))
fi
}
# A timer like the real one, due at HH:MM:SS local time, with DELAY spread.
install_test_timer() { # WHEN DELAY
lab "cat > /etc/systemd/system/ekk-schedule-test.service <<'UNIT'
[Unit]
Description=Stand-in for the update check
[Service]
Type=oneshot
ExecStart=/bin/sh -c 'echo \"fired \$(date +%%s) boot \$(cut -d. -f1 /proc/uptime)\" >> $LOG'
UNIT
cat > /etc/systemd/system/ekk-schedule-test.timer <<UNIT
[Unit]
Description=Stand-in for the update check
[Timer]
OnCalendar=*-*-* $1
RandomizedDelaySec=$2
Persistent=true
[Install]
WantedBy=timers.target
UNIT
rm -f $LOG /var/lib/systemd/timers/stamp-ekk-schedule-test.timer
systemctl daemon-reload
systemctl enable --now ekk-schedule-test.timer >/dev/null 2>&1"
}
fired() { lab "grep -c fired $LOG 2>/dev/null || echo 0" | tail -1; }
local_in() { lab "date -d '+$1 seconds' +%H:%M:%S"; } # local time SECONDS from now
echo "switched on at the time"
due=$(local_in 60)
install_test_timer "$due" 0
judge "the stand-in is waiting for $due, local time" \
"lab 'systemctl list-timers ekk-schedule-test.timer --no-legend | grep -q ekk-schedule-test'"
sleep 80
judge "at $due it checked, once" '[[ $(fired) -eq 1 ]]'
sleep 60
judge "and not again (a minute later, still once)" '[[ $(fired) -eq 1 ]]'
next=$(lab "systemctl show ekk-schedule-test.timer -p NextElapseUSecRealtime --value")
tomorrow=$(lab "date -d tomorrow +%Y-%m-%d")
judge "its next check is tomorrow at $due ($next)" '[[ "$next" == *"$tomorrow $due"* ]]'
echo "switched off at the time"
due=$(local_in 90)
install_test_timer "$due" 60
lab "systemctl start ekk-schedule-test.timer; sync"
# A stamp from a previous check, as every classroom has one: last checked
# yesterday. The catch-up is decided against it.
lab "touch -d yesterday /var/lib/systemd/timers/stamp-ekk-schedule-test.timer"
echo " powering the lab off before $due"
docker stop -t 20 "$NAME" >/dev/null
sleep 150
echo " and on again, after it"
"$LAB" reboot >/dev/null 2>&1
# When the lab machine really started (inside a container /proc/uptime is
# the host's, so this is asked of docker).
booted=$(date -d "$(docker inspect -f '{{.State.StartedAt}}' "$NAME")" +%s)
judge "while it was off, nothing ran" '[[ $(lab "grep -c fired $LOG 2>/dev/null || echo 0" | tail -1) -le 1 ]]'
for _ in $(seq 1 24); do [[ $(fired) -ge 1 ]] && break; sleep 5; done
judge "switched on after $due: the missed check ran after it booted ($(lab "tail -1 $LOG"))" '[[ $(fired) -eq 1 ]]'
after=$(( $(lab "awk '{print \$2}' $LOG | head -1") - booted ))
judge "$after s after it started: once it was up, within the minute of spread" '(( after >= 0 && after <= 150 ))'
sleep 90
judge "and only once: no loop after booting (90 s later, still once)" '[[ $(fired) -eq 1 ]]'
lab "systemctl disable --now ekk-schedule-test.timer >/dev/null 2>&1; rm -f /etc/systemd/system/ekk-schedule-test.* $LOG /var/lib/systemd/timers/stamp-ekk-schedule-test.timer; systemctl daemon-reload"
echo "reboot only when nobody is using it"
docker cp "$ROOT/scripts/classroom-reboot-when-idle" "$NAME:/opt/classroom-reboot-when-idle" >/dev/null
lab "chmod 755 /opt/classroom-reboot-when-idle; rm -f /tmp/rebooted"
[[ "$IDLE" == /usr/local/bin/* ]] && lab "test -x $IDLE" || IDLE=/opt/classroom-reboot-when-idle
cat > /tmp/update-schedule-mouse.py <<'PY'
"""A mouse made with /dev/uinput: real kernel input events. Moves for N s, then stays."""
import fcntl, os, struct, sys, time
UI_SET_EVBIT, UI_SET_RELBIT, UI_SET_KEYBIT = 0x40045564, 0x40045566, 0x40045565
UI_DEV_CREATE, UI_DEV_DESTROY = 0x5501, 0x5502
EV_SYN, EV_KEY, EV_REL, REL_X, BTN_LEFT = 0, 1, 2, 0, 0x110
fd = os.open("/dev/uinput", os.O_WRONLY | os.O_NONBLOCK)
for ioctl, value in ((UI_SET_EVBIT, EV_KEY), (UI_SET_KEYBIT, BTN_LEFT), (UI_SET_EVBIT, EV_REL), (UI_SET_RELBIT, REL_X)):
fcntl.ioctl(fd, ioctl, value)
os.write(fd, struct.pack("80sHHHHi", b"lab-classroom-mouse", 3, 1, 1, 1, 0) + b"\0" * (4 * 64 * 4))
fcntl.ioctl(fd, UI_DEV_CREATE)
time.sleep(1)
def event(kind, code, value):
now = time.time()
os.write(fd, struct.pack("llHHi", int(now), int(now % 1 * 1e6), kind, code, value))
end = time.monotonic() + float(sys.argv[1])
while time.monotonic() < end:
event(EV_REL, REL_X, 1); event(EV_SYN, 0, 0)
time.sleep(2)
print("mouse left alone", flush=True)
time.sleep(float(sys.argv[2]))
fcntl.ioctl(fd, UI_DEV_DESTROY)
PY
docker cp /tmp/update-schedule-mouse.py "$NAME:/opt/update-schedule-mouse.py" >/dev/null
rm -f /tmp/update-schedule-mouse.py
docker exec -d "$NAME" python3 /opt/update-schedule-mouse.py 40 90
sleep 2
# The kernel makes the device; on a classroom machine devtmpfs gives it a
# node in /dev/input. The lab's /dev is a plain tmpfs, so the node is made
# here, from what sysfs says, as devtmpfs would.
lab 'for e in /sys/class/input/event*; do
[[ "$(cat $e/device/name)" == lab-classroom-mouse ]] || continue
IFS=: read -r major minor < $e/dev
mknod /dev/input/$(basename $e) c $major $minor 2>/dev/null
echo $(basename $e) > /tmp/lab-mouse-node
done'
sleep 1
docker exec -d "$NAME" sh -c "$IDLE --idle-seconds 25 --check-seconds 2 --reboot-command touch /tmp/rebooted > /tmp/idle.log 2>&1"
sleep 35
judge "the mouse moved every 2 s for 40 s: no reboot, against a 25 s limit" "! lab 'test -e /tmp/rebooted'"
judge "it was watching real devices ($(lab 'grep -o "watching [0-9]* input devices" /tmp/idle.log'))" \
"lab 'grep -qE \"watching [1-9][0-9]* input devices\" /tmp/idle.log'"
sleep 40
judge "left alone for longer than the limit: it rebooted" "lab 'test -e /tmp/rebooted'"
lab "pkill -f '[u]pdate-schedule-mouse'; rm -f /dev/input/\$(cat /tmp/lab-mouse-node 2>/dev/null) /tmp/lab-mouse-node /tmp/rebooted /opt/update-schedule-mouse.py /opt/classroom-reboot-when-idle"
echo
echo "$passed passed, $failed failed"
[[ "$failed" -eq 0 ]]
+238
View File
@@ -0,0 +1,238 @@
#!/bin/bash
# Does every desktop show the classroom's wallpaper — including one Plasma
# makes by itself after the layout has run?
#
# Tiny Pebbles came through the 0.2.2 upgrade with its desktop numbered 10
# and nothing configured on it, and showed Plasma's own purple wallpaper.
# The layout only reaches the desktops that exist while it runs, and a
# room whose updater reboots it at three in the morning with the
# television off has none then. The lab cannot switch a television on, so
# it makes Plasma create a desktop the other way it does, through a new
# activity: the same code in the shell, and measured to give the same
# bare containment.
#
# tools/lab/wallpaper-check against the running lab session
#
# Each answer is read off the screen, not out of a file: a configuration
# can say anything, and the question is what the room sees. The sky at the
# top of the classroom's picture is pale blue; Plasma's is purple.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
LAB="$ROOT/tools/lab/lab"
NAME="${LAB_NAME:-classroom-v2-lab}"
APPLETS=/home/classroom/.config/plasma-org.kde.plasma.desktop-appletsrc
PASS=0
FAIL=0
check() {
if [[ "$2" == "$3" ]]; then
printf ' \033[32mPASS\033[0m %-58s %s\n' "$1" "$3"
PASS=$((PASS + 1))
else
printf ' \033[31mFAIL\033[0m %-58s got [%s] want [%s]\n' "$1" "$3" "$2"
FAIL=$((FAIL + 1))
fi
}
section() { printf '\n%s\n' "$1"; }
in_lab() { docker exec "$NAME" bash -c "$1" 2>/dev/null; }
# The same, with this script's input handed through — for a program on a
# here-document. Without -i docker gives it nothing to read, and it runs
# on nothing and says nothing.
in_lab_fed() { docker exec -i "$NAME" bash -c "$1"; }
user() { "$LAB" run "$@" 2>/dev/null; }
activities() {
user busctl --user call org.kde.ActivityManager /ActivityManager/Activities \
org.kde.ActivityManager.Activities "$@"
}
# What is on the screen at the top middle, where the classroom's picture
# is sky and Plasma's is purple: "classroom", "plasma", or the colour.
screen() {
"$LAB" outer grim -t ppm - 2>/dev/null | python3 -c '
import sys
data = sys.stdin.buffer.read()
fields, at = [], 0
while len(fields) < 4:
while data[at:at + 1].isspace():
at += 1
end = at
while not data[end:end + 1].isspace():
end += 1
fields.append(data[at:end])
at = end
width, height = int(fields[1]), int(fields[2])
pixels = data[at + 1:]
total = [0, 0, 0]
count = 0
for y in range(20, 140, 4):
for x in range(width * 2 // 5, width * 3 // 5, 8):
offset = (y * width + x) * 3
for channel in range(3):
total[channel] += pixels[offset + channel]
count += 1
r, g, b = (value / count for value in total)
if b > r + 25 and g > r + 10:
print("classroom")
elif r > g + 25 and b > g + 25:
print("plasma")
else:
print(f"rgb({r:.0f},{g:.0f},{b:.0f})")
'
}
# The shell writes its file a few seconds after a change, not at once.
settle() { sleep "${1:-35}"; }
# The shell's file, read as groups rather than grepped, so nothing here
# can pick a line out of the wrong containment.
#
# applets panels each panel's number, its applets' numbers and plugins
# and their order. Not the panel's text: the shell adds
# keys of its own to applets as soon as it runs (popup
# sizes, the volume's migration), and that is Plasma,
# not the repair. tests/ checks the repair's own
# output byte for byte.
# applets desktops the desktop containments as "id:image" or "id:none",
# found by plugin and never by number
applets() {
in_lab_fed "python3 - $1 $APPLETS" <<'PY'
import re, sys
mode, path = sys.argv[1], sys.argv[2]
groups, name = {}, None
for line in open(path).read().splitlines():
if line.startswith("[") and line.endswith("]"):
name = line
groups[name] = []
elif name and line:
groups[name].append(line)
def top(cid):
return groups.get(f"[Containments][{cid}]", [])
ids = [m.group(1) for g in groups for m in [re.match(r"^\[Containments\]\[(\d+)\]$", g)] if m]
if mode == "panels":
for c in ids:
if "plugin=org.kde.panel" not in top(c):
continue
applets = sorted(
(int(m.group(1)), line[len("plugin=org.kde.plasma."):])
for g in groups
for m in [re.match(r"^\[Containments\]\[%s\]\[Applets\]\[(\d+)\]$" % c, g)] if m
for line in groups[g] if line.startswith("plugin=")
)
order = [l[len("AppletOrder="):] for l in groups.get(f"[Containments][{c}][General]", [])
if l.startswith("AppletOrder=")]
print(f"panel {c}:", " ".join(f"{n}={name}" for n, name in applets), "order", *order)
else:
image = "Image=file:///usr/share/backgrounds/ekklesia/classroom-calm.png"
out = []
for c in ids:
if {"plugin=org.kde.plasma.folder", "plugin=org.kde.desktopcontainment"} & set(top(c)):
wallpaper = groups.get(f"[Containments][{c}][Wallpaper][org.kde.image][General]", [])
out.append(f"{c}:{'image' if image in wallpaper else 'none'}")
print(" ".join(out))
PY
}
panel_groups() { applets panels; }
desktops() { applets desktops; }
last_repair() {
user journalctl --user -u plasma-plasmashell.service --no-pager -o cat \
| grep '^classroom-desktop-wallpaper:' | tail -1
}
restart_shell() {
user systemctl --user restart plasma-plasmashell.service
for _ in $(seq 1 30); do
in_lab 'pgrep -x plasmashell >/dev/null' && break
sleep 1
done
sleep 10
}
section "the package"
check "the look and feel's default names a package" EkklesiaClassroom \
"$(in_lab 'sed -n "/^\[Wallpaper\]/,/^\[/s/^Image=//p" /usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/defaults')"
check "which is installed, and is the classroom's picture" /usr/share/backgrounds/ekklesia/classroom-calm.png \
"$(in_lab 'readlink -f /usr/share/wallpapers/EkklesiaClassroom/contents/images/*.png')"
check "the repair runs before every shell start" 1 \
"$(in_lab 'grep -c "^ExecStartPre=-/usr/local/bin/classroom-desktop-wallpaper" /etc/systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf')"
section "the session as it came up"
check "the room shows the classroom's picture" classroom "$(screen)"
original=$(activities CurrentActivity | cut -d'"' -f2)
section "a desktop Plasma makes by itself (Tiny Pebbles' television)"
panels_before=$(panel_groups)
late=$(activities AddActivity s "Late screen" | cut -d'"' -f2)
activities SetCurrentActivity s "$late" >/dev/null
settle
now=$(desktops)
check "Plasma made a second desktop" 2 "$(wc -w <<< "$now")"
check "and wrote no wallpaper into it, as on Tiny Pebbles" 1 "$(grep -o ':none' <<< "$now" | wc -l)"
check "the room still shows the classroom's picture" classroom "$(screen)"
section "the shell restarted, without a login"
restart_shell
check "the repair wrote it in" "classroom-desktop-wallpaper: the wallpaper is now configured on desktop $(tr ' ' '\n' <<< "$now" | cut -d: -f1 | paste -sd, | sed 's/,/, /g')" \
"$(last_repair)"
check "every desktop now names the picture" 0 "$(desktops | grep -o ':none' | wc -l)"
check "the panel keeps its numbers, applets and order" "$panels_before" "$(panel_groups)"
check "the room shows the classroom's picture" classroom "$(screen)"
restart_shell
check "a second restart finds nothing to do" \
"classroom-desktop-wallpaper: desktop $(desktops | tr ' ' '\n' | cut -d: -f1 | paste -sd, | sed 's/,/, /g'): the classroom wallpaper already" \
"$(last_repair)"
check "and still does" "$panels_before" "$(panel_groups)"
activities SetCurrentActivity s "$original" >/dev/null
activities RemoveActivity s "$late" >/dev/null
sleep 3
section "Tiny Pebbles' own file: desktop 10, panel 1, no wallpaper"
user systemctl --user stop plasma-plasmashell.service
in_lab_fed "python3 - '$original' $APPLETS" <<'PY'
import sys
activity, path = sys.argv[1], sys.argv[2]
applets = ["quicklaunch", "panelspacer", "icontasks", "panelspacer",
"networkmanagement", "volume", "notifications", "digitalclock"]
out = ["[Containments][1]", "activityId=", "formfactor=2", "immutability=1",
"lastScreen=0", "location=4", "plugin=org.kde.panel",
"wallpaperplugin=org.kde.image", ""]
for number, applet in enumerate(applets, start=2):
out += [f"[Containments][1][Applets][{number}]", "immutability=1",
f"plugin=org.kde.plasma.{applet}", ""]
out += ["[Containments][1][Applets][4][Configuration][General]",
"launchers=applications:ekklesia-wonderink.desktop,applications:google-chrome.desktop,applications:ekklesia-dvd.desktop",
"sortingStrategy=1", "",
"[Containments][1][General]", "AppletOrder=2;3;4;5;6;7;8;9", "",
"[Containments][10]", f"activityId={activity}", "formfactor=0",
"immutability=1", "lastScreen=0", "location=0",
"plugin=org.kde.plasma.folder", "wallpaperplugin=org.kde.image", "",
"[Containments][10][General]", 'positions={"1920x1080":[]}', ""]
open(path, "w").write("\n".join(out))
PY
in_lab "chown classroom:classroom $APPLETS"
panels_before=$(panel_groups)
user systemctl --user start plasma-plasmashell.service
sleep 12
check "the repair found desktop 10 and wrote it" \
"classroom-desktop-wallpaper: the wallpaper is now configured on desktop 10" "$(last_repair)"
check "desktop 10 names the picture" "10:image" "$(desktops)"
check "the panel is still containment 1, applets 2 to 9, in order" "$panels_before" "$(panel_groups)"
check "the room shows the classroom's picture" classroom "$(screen)"
section "and at the next login"
"$LAB" plasma-down >/dev/null
"$LAB" plasma-up >/dev/null
settle 10
check "the reset left the repair nothing to do" \
"classroom-desktop-wallpaper: no desktop configuration yet; the layout writes the wallpaper" \
"$(last_repair)"
check "the layout rebuilt one desktop with the picture" 1 "$(desktops | grep -o ':image' | wc -l)"
check "the room shows the classroom's picture" classroom "$(screen)"
printf '\n%s passed, %s failed\n' "$PASS" "$FAIL"
[[ "$FAIL" -eq 0 ]]
+195
View File
@@ -0,0 +1,195 @@
"""A signed-in lesson with pictures in it, for right-click printing.
wonderink-lesson-site.py PICTURES PORT OTHER_PORT LOG
Run inside the lab by tools/lab/wonderink-print-check. Stands in for
Wonder Ink's own site, which the lab has no account for, with every kind
of picture a lesson might put under a teacher's pointer:
/login sets a session cookie (HttpOnly, SameSite=Lax), as a
sign-in does, and goes on to /lesson
/lesson the page; its pictures are all behind that cookie
/private/NAME a picture, 403 without the cookie
/once/N/NAME a picture that can be fetched exactly once — so a
print that fetches it again gets a 404, not a picture
OTHER_PORT the same pictures from another origin, with no CORS
and a stand-in for WonderInk's own start page and sign-in, built the way
the real site's is (read from its code, and measured with a real
account): the sign-in is cookies its JavaScript writes, and rrf-auth is
"Basic " and the base64 of the email and password — so a check can prove
that nothing Wonder Ink writes down holds one. Sign out calls
/rrframework/dataStore/signout and then sets them to "".
/app "Signed in as …" if rrf-auth is set, else a sign-in form
/app?busy=1 the same, plus what a site does by itself: a request
every second and an animation, and no input at all
/links every kind of link a lesson has, for following them
Every request is logged, one JSON line each, so a check can say what was
fetched while it printed. What was measured with this, and why Print
image fetches nothing, is in classroom-wonderink's _take_picture.
"""
import http.server
import json
import re
import sys
import threading
import time
import uuid
from pathlib import Path
from urllib.parse import urlparse
PICTURES = Path(sys.argv[1])
PORT, OTHER = int(sys.argv[2]), int(sys.argv[3])
LOG = Path(sys.argv[4])
TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".webp": "image/webp", ".pdf": "application/pdf"}
fetched_once = set()
lock = threading.Lock()
LESSON = """<html><head><title>Picture lesson</title><style>
body { margin: 8px; font: 15px sans-serif; }
img, canvas { width: 180px; height: 120px; object-fit: contain; border: 1px solid #ccc; margin: 4px; }
</style></head><body>
<h1 style="color:#c00">Noah's Ark</h1>
<p id="words">Noah built an ark, and the animals came two by two. Colour the pictures below.</p>
<img id="private-jpg" src="/private/ark-wide.jpg">
<img id="private-png" src="/private/ark-tall.png">
<img id="clear" src="/private/ark-clear.png">
<img id="webp" src="/private/ark-wide.webp">
<img id="once" src="/once/NONCE/ark-wide.png">
<img id="cross" src="http://localhost:OTHER/img/ark-tall.png">
<img id="blob">
<canvas id="canvas" width="1600" height="1000"></canvas>
<img id="broken" src="/private/not-there.png">
<a href="/lesson#more"><img id="linked" src="/private/ark-tall.png"></a>
<p><a id="text-link" href="/links" target="_blank">More links for this lesson</a></p>
<script>
var waiting = 2;
function one() { if (--waiting === 0) window.__ready = true; }
fetch('/private/ark-tall.png', {credentials: 'include'})
.then(function (r) { return r.blob(); })
.then(function (b) { var i = document.getElementById('blob'); i.onload = one; i.src = URL.createObjectURL(b); });
var art = new Image();
art.onload = function () { document.getElementById('canvas').getContext('2d').drawImage(art, 0, 0); one(); };
art.src = '/private/ark-wide.png';
</script></body></html>"""
APP = """<html><head><title>App</title></head><body>
<div id="state"></div>
<input id="email" type="text"><input id="password" type="password">
<button id="signin">Sign In</button> <button id="signout">Sign Out</button>
<div id="spinner" style="width:20px;height:20px;background:#c00"></div>
<script>
function cookie(name) { var m = document.cookie.match('(^|; )' + name + '=([^;]*)'); return m ? m[2] : null; }
function show() { var t = cookie('rrf-auth');
document.getElementById('state').textContent = t ? 'Signed in as teacher' : 'Signed out'; document.title = t ? 'Signed in' : 'Sign-in'; }
document.getElementById('signin').onclick = function () {
// As the real site: the "token" is Basic auth — the email and password —
// in a cookie its JavaScript writes. Analytics cookies alongside, as there.
document.cookie = 'rrf-auth=' + encodeURIComponent('Basic ' + btoa('[email protected]:hunter2hunter2')) + '; path=/';
document.cookie = 'rrf-session-id=SID; path=/'; document.cookie = 'rrf_tsec_tok=T2; path=/';
document.cookie = '_ga=GA1.1.analytics; path=/'; document.cookie = '_fbp=fb.1.tracking; path=/'; show(); };
function server() { var x = new XMLHttpRequest(); x.open('GET', '/whoami', false); x.send(); return x.status; }
document.getElementById('signout').onclick = function () {
fetch('/rrframework/dataStore/signout').then(function () {
document.cookie = 'rrf-auth=; path=/'; document.cookie = 'rrf-session-id=; path=/'; document.cookie = 'rrf_tsec_tok=; path=/'; show(); }); };
show();
if (location.search.indexOf('busy=1') >= 0) {
setInterval(function () { fetch('/beacon?' + Date.now()); }, 1000);
var a = 0; (function spin() { a += 3; document.getElementById('spinner').style.transform = 'rotate(' + a + 'deg)'; requestAnimationFrame(spin); })();
}
</script></body></html>"""
LINKS = """<html><head><title>Links</title></head><body style="font: 16px sans-serif">
<h1 id="top">Links in a lesson</h1>
<p><a id="blank" target="_blank" rel="noopener noreferrer" href="/private/ark-wide.png">Saul Welcome Question Slide</a></p>
<p><a id="same" href="/lesson">The lesson, no target</a></p>
<p><a id="external" target="_blank" href="http://localhost:OTHER/img/ark-tall.png">A picture on another site</a></p>
<p><a id="pdf" target="_blank" href="/private/ark.pdf">The worksheet (PDF)</a></p>
<p><a id="linked-picture" target="_blank" href="/private/ark-tall.png"><img src="/private/ark-tall.png" width="120"></a></p>
<p><button id="button" onclick="this.textContent = 'Pressed'">A lesson button</button></p>
<p><a id="anchor" href="#bottom">Go to the bottom</a></p>
<p><button id="popup" onclick="window.open('/private/ark-clear.png', '_blank')">Open the picture</button></p>
<div style="height: 1500px"></div><p id="bottom">The bottom</p>
</body></html>"""
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *_):
pass
def answer(self, code, body=b"", kind="text/plain", headers=()):
self.send_response(code)
self.send_header("Content-Type", kind)
self.send_header("Content-Length", str(len(body)))
for name, value in headers:
self.send_header(name, value)
self.end_headers()
self.wfile.write(body)
def picture(self, name):
path = PICTURES / Path(name).name
if not path.is_file() or path.suffix not in TYPES:
return self.answer(404, b"no such picture")
return self.answer(200, path.read_bytes(), TYPES[path.suffix],
[("Cache-Control", "private, max-age=600")])
def do_GET(self):
path = urlparse(self.path).path
signed_in = "session=ok" in self.headers.get("Cookie", "")
with lock, open(LOG, "a") as log:
log.write(json.dumps({"t": time.time(), "port": self.server.server_port,
"path": path, "signed_in": signed_in}) + "\n")
if self.server.server_port == OTHER:
if path.startswith("/img/"):
return self.picture(path[5:])
return self.answer(404)
if path == "/app":
return self.answer(200, APP.encode(), "text/html", [("Cache-Control", "no-store")])
if path == "/whoami":
token = re.search(r"(?:^|; )rrf-auth=([^;]+)", self.headers.get("Cookie", ""))
return self.answer(200 if token else 403, b"{}", "application/json")
if path in ("/rrframework/dataStore/signout", "/beacon"):
return self.answer(200, b"{}", "application/json")
if path == "/login":
return self.answer(302, headers=[
("Set-Cookie", "session=ok; Path=/; HttpOnly; SameSite=Lax"),
("Location", "/lesson")])
if not signed_in:
return self.answer(403, b"sign in first")
if path == "/links":
return self.answer(200, LINKS.replace("OTHER", str(OTHER)).encode(), "text/html")
if path == "/lesson":
page = LESSON.replace("NONCE", uuid.uuid4().hex).replace("OTHER", str(OTHER))
return self.answer(200, page.encode(), "text/html")
if path.startswith("/private/"):
return self.picture(path[9:])
if path.startswith("/once/"):
with lock:
if path in fetched_once:
return self.answer(404, b"this address has been used")
fetched_once.add(path)
return self.picture(path.rsplit("/", 1)[1])
return self.answer(404)
for port in (OTHER, PORT):
server = http.server.ThreadingHTTPServer(("127.0.0.1", port), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
threading.Event().wait()
+207
View File
@@ -0,0 +1,207 @@
"""Make the test pictures, and measure where a printed picture really is.
wonderink-picture-measure.py make DIRECTORY
wonderink-picture-measure.py measure PDF WIDTHxHEIGHT SIZE POSITION PAPER FILL
wonderink-picture-measure.py darkness PDF
wonderink-picture-measure.py colour PDF
Run inside the lab by tools/lab/wonderink-print-check. The pictures are
white with a black border drawn on their own edges and a pink mark near
one corner, so the ink on a printed page says exactly where the picture
landed — and any other dark ink on the page is something that was added,
which is what the black bars were.
The page is rendered at 72 dpi, so one pixel is one PostScript point, and
compared against classroom_printing.picture_rect: the same arithmetic
Wonder Ink lays the page out with, asked independently of the preview.
"""
import json
import subprocess
import sys
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
import classroom_printing # noqa: E402
MARGIN_POINTS = 10 / 25.4 * 72
def make(directory):
from PyQt6.QtCore import QRectF, Qt
from PyQt6.QtGui import QColor, QFont, QImage, QPainter, QPen
from PyQt6.QtWidgets import QApplication
app = QApplication(sys.argv[:1]) # noqa: F841 — kept, or Qt has no application
def art(width, height, name, label, transparent=False):
image = QImage(width, height, QImage.Format.Format_ARGB32)
image.fill(QColor(0, 0, 0, 0) if transparent else QColor("white"))
painter = QPainter(image)
painter.setPen(QPen(QColor("black"), 8))
painter.drawRect(QRectF(4, 4, width - 8, height - 8))
painter.setPen(QPen(QColor("black"), 6))
painter.drawEllipse(QRectF(width * 0.2, height * 0.25, width * 0.6, height * 0.5))
painter.setFont(QFont("DejaVu Sans", int(min(width, height) * 0.05)))
painter.drawText(QRectF(0, height * 0.05, width, height * 0.15),
Qt.AlignmentFlag.AlignCenter, label)
painter.fillRect(QRectF(width * 0.05, height * 0.8, width * 0.1, height * 0.1),
QColor("#e0006c"))
painter.end()
image.save(f"{directory}/{name}")
art(1600, 1000, "ark-wide.png", "Colour the Ark")
art(900, 1400, "ark-tall.png", "Noah")
art(1600, 1000, "ark-wide.jpg", "Colour the Ark")
# Transparent around the drawing: on paper that has to be white, not
# black, which is what a careless composite would make of it.
art(1200, 1200, "ark-clear.png", "Clear", transparent=True)
webp(directory)
def webp(directory):
"""ark-wide.png as WebP. Qt cannot write WebP, or read it; Chromium does both."""
import base64
from PyQt6.QtCore import QTimer, QUrl
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWidgets import QApplication
app = QApplication.instance() or QApplication(sys.argv[:1])
source = base64.b64encode(open(f"{directory}/ark-wide.png", "rb").read()).decode()
view = QWebEngineView()
def made(answer):
if isinstance(answer, str) and answer.startswith("data:image/webp;base64,"):
with open(f"{directory}/ark-wide.webp", "wb") as handle:
handle.write(base64.b64decode(answer.split(",", 1)[1]))
app.quit()
def loaded(_ok):
view.page().runJavaScript("""var i = new Image(); i.onload = function(){
var c = document.createElement('canvas'); c.width = i.width; c.height = i.height;
c.getContext('2d').drawImage(i, 0, 0); window.__webp = c.toDataURL('image/webp', 0.95); };
i.src = 'data:image/png;base64,%s'; 0""" % source)
QTimer.singleShot(1500, lambda: view.page().runJavaScript("window.__webp", made))
view.loadFinished.connect(loaded)
view.setHtml("<html></html>")
QTimer.singleShot(20000, app.quit)
app.exec()
def page_pixels(pdf, page=1):
ppm = subprocess.run(["pdftoppm", "-r", "72", "-f", str(page), "-l", str(page), "-singlefile", pdf],
capture_output=True, check=True).stdout
# P6 header: magic, width, height, maxval, then raw RGB.
fields, index = [], 0
while len(fields) < 4:
while ppm[index:index + 1].isspace():
index += 1
start = index
while not ppm[index:index + 1].isspace():
index += 1
fields.append(ppm[start:index])
width, height = int(fields[1]), int(fields[2])
return width, height, ppm[index + 1:]
def measure(pdf, picture, size, position, paper, fill):
width, height, rgb = page_pixels(pdf)
picture_w, picture_h = (int(n) for n in picture.split("x"))
landscape = classroom_printing.picture_landscape(paper, picture_w, picture_h)
x, y, w, h = classroom_printing.picture_rect(
(width, height), MARGIN_POINTS, (picture_w, picture_h),
int(size), position.replace("-", " "), fill == "fill")
# The expected ink, clipped to the printable area as the page is.
left = max(x, MARGIN_POINTS)
top = max(y, MARGIN_POINTS)
right = min(x + w, width - MARGIN_POINTS)
bottom = min(y + h, height - MARGIN_POINTS)
xs, ys, outside, pink = [], [], 0, 0
for row in range(height):
for column in range(width):
i = (row * width + column) * 3
r, g, b = rgb[i], rgb[i + 1], rgb[i + 2]
dark = r + g + b < 3 * 110
magenta = r > 150 and g < 90 and b > 60
if dark or magenta:
xs.append(column)
ys.append(row)
if not (left - 2 <= column <= right + 2 and top - 2 <= row <= bottom + 2):
outside += 1
if magenta:
pink += 1
# The corner mark, wherever it landed and whatever colour it came out:
# pink on a colour page, grey on a black-and-white one. Inked if most
# of the middle of where it should be is not paper.
mark_left = max(x + w * 0.07, MARGIN_POINTS)
mark_right = min(x + w * 0.13, width - MARGIN_POINTS)
mark_top = max(y + h * 0.82, MARGIN_POINTS)
mark_bottom = min(y + h * 0.88, height - MARGIN_POINTS)
mark = [
sum(rgb[(row * width + column) * 3:(row * width + column) * 3 + 3]) < 3 * 200
for row in range(int(mark_top), int(mark_bottom))
for column in range(int(mark_left), int(mark_right))
]
found = (min(xs), min(ys), max(xs) + 1, max(ys) + 1) if xs else None
expected = (round(left), round(top), round(right), round(bottom))
off = max(abs(a - b) for a, b in zip(found, expected)) if found else None
print(json.dumps({
"page": [width, height],
"page_landscape": width > height,
"wanted_landscape": landscape,
"found": found,
"expected": expected,
"off_by_points": off,
"ink_outside_picture": outside,
"pink_pixels": pink,
"mark_inked": bool(mark) and sum(mark) > 0.8 * len(mark),
"colour_spread": spread(rgb),
}))
def spread(rgb):
"""The most any pixel's red, green and blue differ: 0 on a grey page."""
return max((max(rgb[i:i + 3]) - min(rgb[i:i + 3]) for i in range(0, len(rgb) - 2, 3)), default=0)
def colour(pdf):
"""Is every page grey? The colour spread of each page, and how many."""
pages = int(subprocess.run(["pdfinfo", pdf], capture_output=True, text=True).stdout
.split("Pages:")[1].split()[0])
spreads = [spread(page_pixels(pdf, n)[2]) for n in range(1, pages + 1)]
print(json.dumps({"pages": pages, "spreads": spreads, "gray": max(spreads) == 0}))
def darkness(pdf):
"""How much of the first page is dark, 0 to 1."""
width, height, rgb = page_pixels(pdf)
sums = [rgb[i] + rgb[i + 1] + rgb[i + 2] for i in range(0, len(rgb) - 2, 3)]
dark = sum(1 for total in sums if total < 3 * 110)
# Anything printed at all. Blink darkens light text when a page's
# background is not printed, so white-on-black lesson text comes out
# grey on white rather than vanishing; this is what says it did.
ink = sum(1 for total in sums if total < 3 * 200)
print(json.dumps({"dark_fraction": round(dark / (width * height), 4),
"ink_fraction": round(ink / (width * height), 4)}))
if __name__ == "__main__":
if sys.argv[1] == "make":
make(sys.argv[2])
elif sys.argv[1] == "darkness":
darkness(sys.argv[2])
elif sys.argv[1] == "colour":
colour(sys.argv[2])
else:
measure(*sys.argv[2:])
+260 -1
View File
@@ -25,6 +25,11 @@ lab() { docker exec -i "$NAME" bash -c "$1"; }
RIG=/opt/wonderink-check/wonderink-print-rig.py RIG=/opt/wonderink-check/wonderink-print-rig.py
lab 'mkdir -p /opt/wonderink-check' lab 'mkdir -p /opt/wonderink-check'
docker cp "$ROOT/tools/lab/wonderink-print-rig.py" "$NAME:$RIG" >/dev/null docker cp "$ROOT/tools/lab/wonderink-print-rig.py" "$NAME:$RIG" >/dev/null
MEASURE=/opt/wonderink-check/wonderink-picture-measure.py
docker cp "$ROOT/tools/lab/wonderink-picture-measure.py" "$NAME:$MEASURE" >/dev/null
SITE=/opt/wonderink-check/wonderink-lesson-site.py
SITE_LOG=/opt/wonderink-check/site.log
docker cp "$ROOT/tools/lab/wonderink-lesson-site.py" "$NAME:$SITE" >/dev/null
lab "chmod -R a+rX /opt/wonderink-check && test -r $RIG" || { lab "chmod -R a+rX /opt/wonderink-check && test -r $RIG" || {
echo "could not put the rig in the lab" >&2 echo "could not put the rig in the lab" >&2
exit 1 exit 1
@@ -63,13 +68,33 @@ install -d -o teacher -m 700 /tmp/teacher-rt
# An inline PDF, served the way a site shows one rather than sends it. Its # An inline PDF, served the way a site shows one rather than sends it. Its
# own port and directory: a lab often has another test server on 8088. # own port and directory: a lab often has another test server on 8088.
mkdir -p /opt/wonderink-check/www mkdir -p /opt/wonderink-check/www
# CUPS keeps what it was given, so a check can compare the submitted
# file with the one the preview showed, byte for byte.
cupsctl PreserveJobFiles=Yes
' || { echo "could not prepare the lab's CUPS" >&2; exit 1; } ' || { echo "could not prepare the lab's CUPS" >&2; exit 1; }
lab "test -f /opt/wonderink-check/www/ark-wide.webp || \
QT_QPA_PLATFORM=offscreen QTWEBENGINE_CHROMIUM_FLAGS='--no-sandbox --disable-gpu' \
python3 $MEASURE make /opt/wonderink-check/www; \
chmod -R a+r /opt/wonderink-check/www; test -f /opt/wonderink-check/www/ark-wide.webp" || {
echo "could not make the test pictures" >&2
exit 1
}
# Started detached. A background process started inside a docker exec # Started detached. A background process started inside a docker exec
# dies with it in the Plasma lab, whose PID 1 is systemd. # dies with it in the Plasma lab, whose PID 1 is systemd.
lab 'pgrep -f "[h]ttp.server 8089" >/dev/null' || \ lab 'pgrep -f "[h]ttp.server 8089" >/dev/null' || \
docker exec -d -w /opt/wonderink-check/www "$NAME" python3 -m http.server 8089 docker exec -d -w /opt/wonderink-check/www "$NAME" python3 -m http.server 8089
# The signed-in lesson with pictures in it, restarted each run so that its
# one-time addresses start unused.
lab "pkill -f '[w]onderink-lesson-site.py' ; : > $SITE_LOG; chmod a+rw $SITE_LOG"
docker exec -d "$NAME" python3 "$SITE" /opt/wonderink-check/www 8094 8095 "$SITE_LOG"
lab 'for _ in $(seq 20); do curl -s -o /dev/null http://127.0.0.1:8094/login && exit 0; sleep 0.3; done; exit 1' || {
echo "the lesson site did not start" >&2
exit 1
}
passed=0 passed=0
failed=0 failed=0
@@ -97,12 +122,97 @@ run() {
} }
count() { wc -w <<< "$jobs"; } count() { wc -w <<< "$jobs"; }
# The file CUPS was handed for a job, kept by PreserveJobFiles.
spool_file() { local id="${1%% *}"; printf '/var/spool/cups/d%05d-001' "${id##*-}"; }
said() { grep -q -- "$1" <<< "$out"; } said() { grep -q -- "$1" <<< "$out"; }
# Every line of the rig's output that starts with WORD.
lines() { grep -o -- "$1.*" <<< "$out"; }
# Is every page of a PDF in the lab grey? {"pages", "spreads", "gray"}
colour_of() { lab "python3 $MEASURE colour $1"; }
is_gray() { python3 -c 'import json,sys; sys.exit(0 if json.loads(sys.argv[1])["gray"] else 1)' "$1"; }
# What the lesson site was asked for after the right-click.
fetched_since() {
local since
since=$(grep -o 'since=[0-9.]*' <<< "$out" | head -1 | cut -d= -f2)
lab "python3 -c 'import json,sys; print(sum(1 for l in open(\"$SITE_LOG\") if json.loads(l)[\"t\"] > float(sys.argv[1])))' ${since:-0}"
}
teacher_text_kept() { said "CLIPBOARD \"TEACHER'S TEXT, John 3:16\""; }
# picture layout, measured on the page CUPS kept: judges its own lines.
judge_layout() { # FILE_DIMS SIZE POSITION PAPER FILL COLOUR
local dims=$1 size=$2 position=$3 paper=$4 fill=$5 color=$6 spool result
spool=$(spool_file "$jobs")
judge "CUPS was given exactly the page the preview showed" \
"[[ -n '$spool' ]] && lab 'cmp -s /tmp/rig-out/preview.pdf $spool'"
result=$(lab "python3 $MEASURE measure $spool $dims $size $position $paper $fill")
field() { python3 -c "import json,sys; print(json.loads(sys.argv[1])[sys.argv[2]])" "$result" "$1"; }
judge "the paper is turned the way asked ($paper): $(field page)" \
'[[ "$(field page_landscape)" == "$(field wanted_landscape)" ]]'
judge "the picture is where and as large as asked, within 3 points (off by $(field off_by_points))" \
'[[ "$(field off_by_points)" != None ]] && (( $(field off_by_points) <= 3 ))'
judge "no ink anywhere else on the page — no bars ($(field ink_outside_picture) pixels)" \
'[[ "$(field ink_outside_picture)" == 0 ]]'
if [[ "$fill" == fill ]]; then
# The mark is in the picture's left fifth, which filling a
# portrait page with a wide picture cuts off. That is what the
# control says it does; the fit scenarios prove it happens only
# when asked.
judge "Fill cut the edges off, as it says it will" '(( $(field pink_pixels) == 0 ))'
elif [[ "$color" == gray ]]; then
judge "the corner mark is on the paper, in grey — nothing cut off" '[[ "$(field mark_inked)" == True ]]'
judge "and the page is grey, as the preview showed (colour spread $(field colour_spread))" \
'(( $(field colour_spread) == 0 ))'
judge "and it went in black and white, by the driver's own option" \
'[[ "$(job_options $jobs)" == *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlmono* ]]'
else
judge "the pink corner mark is on the paper — nothing cut off" '(( $(field pink_pixels) > 0 ))'
fi
}
exited_cleanly() { said 'exit=0' && ! said 'Traceback' && ! said 'Segmentation' && ! said 'Aborted'; } exited_cleanly() { said 'exit=0' && ! said 'Traceback' && ! said 'Segmentation' && ! said 'Aborted'; }
# Where a picture goes, measured on the page CUPS was given:
# picture:FILE:SIZE:POSITION:PAPER:FILL[:gray]
PICTURES=(
picture:ark-wide.png:100:centre:auto:fit
picture:ark-wide.png:100:centre:portrait:fit
picture:ark-wide.png:50:top-left:auto:fit
picture:ark-tall.png:100:centre:auto:fit
picture:ark-tall.png:33:bottom-right:portrait:fit
picture:ark-wide.png:100:centre:portrait:fill
picture:ark-wide.jpg:75:right:auto:fit:gray
picture:ark-clear.png:50:top:auto:fit
picture:ark-wide.png:50:top-left:auto:fit:gray
)
# Black and white shown as it prints, and Color again (0.2.6.3).
COLOURS=(bw-web bw-pdf bw-colour-again bw-cancel)
# Links in a signed-in lesson, followed with one click (0.2.6.4).
LINKS=(links:follow links:pdf links:image links:image-rclick links:tabs-print)
# Right-click in a signed-in lesson (0.2.6.3):
# rclick-image:ELEMENT:SIZE:POSITION[:gray], the elements in
# wonderink-lesson-site.py.
RIGHT_CLICKS=(
rclick-page
rclick-page-gray
rclick-image:private-jpg:50:top-left
rclick-image:private-png:100:centre
rclick-image:clear:50:top-left
rclick-image:once:100:centre
rclick-image:cross:75:right
rclick-image:blob:100:centre
rclick-image:webp:100:centre
rclick-image:canvas:100:centre
rclick-image:linked:33:bottom-right
rclick-image:private-jpg:50:top-left:gray
rclick-broken
rclick-cancel
)
scenarios=("$@") scenarios=("$@")
[[ ${#scenarios[@]} -gt 0 ]] || scenarios=(web pdf pdf-options pdf-viewer-button inline [[ ${#scenarios[@]} -gt 0 ]] || scenarios=(web pdf pdf-options pdf-viewer-button inline
options bad-range broken cancel page-closes-itself close-window page-prints-again repeat) options bad-range broken cancel page-closes-itself close-window page-prints-again repeat
dark-page "${PICTURES[@]}" "${COLOURS[@]}" "${RIGHT_CLICKS[@]}" "${LINKS[@]}")
# The inline scenario needs a PDF to serve; the worksheet scenario writes one. # The inline scenario needs a PDF to serve; the worksheet scenario writes one.
lab 'test -f /opt/wonderink-check/www/ark.pdf' || { run pdf >/dev/null; lab 'cp "/home/teacher/Downloads/Wonder Ink/Colour the Ark.pdf" /opt/wonderink-check/www/ark.pdf; chmod a+r /opt/wonderink-check/www/ark.pdf'; } lab 'test -f /opt/wonderink-check/www/ark.pdf' || { run pdf >/dev/null; lab 'cp "/home/teacher/Downloads/Wonder Ink/Colour the Ark.pdf" /opt/wonderink-check/www/ark.pdf; chmod a+r /opt/wonderink-check/www/ark.pdf'; }
@@ -162,6 +272,155 @@ for scenario in "${scenarios[@]}"; do
judge "one job" '[[ $(count) -eq 1 ]]' ;; judge "one job" '[[ $(count) -eq 1 ]]' ;;
repeat) repeat)
judge "five prints in a row, five jobs" '[[ $(count) -eq 5 ]]' ;; judge "five prints in a row, five jobs" '[[ $(count) -eq 5 ]]' ;;
dark-page)
judge "one job" '[[ $(count) -eq 1 ]]'
spool=$(spool_file "$jobs")
answer=$(lab "python3 $MEASURE darkness $spool")
dark=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["dark_fraction"])' "$answer")
ink=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["ink_fraction"])' "$answer")
judge "a black page background does not print (dark: $dark of the page)" \
"python3 -c 'import sys; sys.exit(0 if $dark < 0.05 else 1)'"
judge "and its white text still does, darkened ($ink of the page)" \
"python3 -c 'import sys; sys.exit(0 if $ink > 0.005 else 1)'" ;;
picture:*)
IFS=: read -r _ file size position paper fill color <<< "$scenario"
judge "the picture controls appeared, and none for a page" "said 'PICTURE picture_box=True'"
judge "the print interface says Color" "said \"labels='Color','Color'\""
if [[ "$color" == gray ]]; then
judge "the preview shows it in black and white" "said 'shown_gray=True'"
fi
judge "one job reached CUPS" '[[ $(count) -eq 1 ]]'
dims=$(lab "python3 -c 'from PyQt6.QtGui import QImage; i=QImage(\"/opt/wonderink-check/www/$file\"); print(f\"{i.width()}x{i.height()}\")'" 2>/dev/null)
judge_layout "$dims" "$size" "$position" "$paper" "$fill" "$color" ;;
bw-*)
judge "the preview starts in colour" "said 'COLOUR FIRST shown_gray=False'"
if [[ "$scenario" != bw-colour-again ]]; then
judge "Black and white shows a grey page, every time it is chosen" \
'! lines "want=gray" | grep -qv "shown_gray=True" && lines "want=gray" | grep -q .'
fi
if lines "want=colour" | grep -q .; then
judge "Color shows the colour page again, every time" '! lines "want=colour" | grep -qv "shown_gray=False"'
fi
case "$scenario" in
bw-web)
judge "turned over and over, it stays on page 2 of 3" '! lines TOGGLE | grep -qv "page=2/3"'
judge "black and white is made once, and the same file shown each time" \
'[[ $(lines "want=gray" | grep -o "pdf=[^ ]*" | sort -u | wc -l) -eq 1 ]]'
judge "and shown again at once (after the first: $(lines 'want=gray' | tail -n +2 | grep -o 'took=[0-9.]*' | tr '\n' ' '))" \
'! lines "want=gray" | tail -n +2 | grep -o "took=[0-9.]*" | cut -d= -f2 | grep -qv "^0\.[0-4]"'
judge "one job" '[[ $(count) -eq 1 ]]'
judge "page 2 only, two copies" \
'[[ "$(job_options $jobs)" == *page-ranges=2* && "$(job_options $jobs)" == *"argv[4]=\"2\""* ]]'
judge "in black and white, by the driver's own option" \
'[[ "$(job_options $jobs)" == *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlmono* ]]' ;;
bw-pdf)
judge "one job" '[[ $(count) -eq 1 ]]'
judge "in black and white, by the driver's own option" \
'[[ "$(job_options $jobs)" == *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlmono* ]]' ;;
bw-colour-again)
judge "one job" '[[ $(count) -eq 1 ]]'
judge "in colour, with no grey option" \
'[[ "$(job_options $jobs)" != *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlcolor* ]]' ;;
bw-cancel)
judge "Cancel sends nothing" '[[ $(count) -eq 0 ]]'
judge "and nothing was left behind" "said 'scratch_left=\[\]'" ;;
esac
if [[ $(count) -eq 1 ]]; then
spool=$(spool_file "$jobs")
judge "CUPS was given exactly the file the preview showed" "lab 'cmp -s /tmp/rig-out/preview.pdf $spool'"
answer=$(colour_of "$spool")
if [[ "$scenario" == bw-colour-again ]]; then
judge "and it is in colour ($answer)" '! is_gray "$answer"'
else
judge "and every page of it is grey ($answer)" 'is_gray "$answer"'
fi
fi ;;
rclick-page)
judge "right-click on the words offers Print page, and not Print image" \
"said \"MENU .*'Print page…'\" && ! said \"'Print image…'\""
judge "which prints the lesson page, not a picture" "said 'PREVIEW picture=False'"
judge "one job" '[[ $(count) -eq 1 ]]'
judge "CUPS was given exactly the page the preview showed" \
"lab 'cmp -s /tmp/rig-out/preview.pdf $(spool_file "$jobs")'"
judge "the teacher's clipboard is untouched" teacher_text_kept ;;
links:follow)
# link_is WHICH EXPECTED — the rig's line for that link contains EXPECTED.
link_is() { grep -o -- "LINK $1 .*" <<< "$out" | head -1 | grep -qF -- "$2"; }
judge "a text link to a picture opens it in a new tab, and Wonder Ink shows that tab" \
"link_is '#blank' \"tabs=2 current='/private/ark-wide.png' current_is_new=True\""
judge "another site's link: the same" \
"link_is '#external' \"tabs=3 current='http://localhost:8095/img/ark-tall.png' current_is_new=True\""
judge "a linked picture: the same" \
"link_is '#linked-picture' \"tabs=4 current='/private/ark-tall.png' current_is_new=True\""
judge "a worksheet PDF: a new tab, in the PDF viewer" \
"link_is '#pdf' \"tabs=5 current='/private/ark.pdf' current_is_new=True\""
judge "a lesson button that opens a window: a new tab, shown" \
"link_is '#popup' \"tabs=6 current='/private/ark-clear.png' current_is_new=True\""
judge "a lesson button that is not a link: no tab, no navigation" \
"link_is '#button' \"tabs=6 current='/links' current_is_new=False\""
judge "an in-page anchor: stays on the page, scrolled" \
"link_is '#anchor' \"tabs=6 current='/links#bottom' current_is_new=False\""
judge "a link with no target: followed in the same tab, as a browser does" \
"link_is '#same' \"tabs=6 current='/lesson' current_is_new=False\""
judge "and Back returns to the links" "link_is 'after Back' \"current='/links\""
judge "closing every tab the links opened leaves the lesson's own" \
"link_is 'after closing' \"tabs=1 current='/links\""
judge "every page and picture the links reached came through the signed-in session" \
"! lab \"grep '\\\"signed_in\\\": false' $SITE_LOG | grep -qE '\\\"path\\\": \\\"/(links|lesson|private)'\"" ;;
links:pdf)
judge "a worksheet reached by a link opens as a worksheet and prints" \
"said 'pages=2' && [[ \$(count) -eq 1 ]]"
judge "CUPS was given exactly the file the preview showed" \
"lab 'cmp -s /tmp/rig-out/preview.pdf $(spool_file "$jobs")'" ;;
links:image|links:image-rclick)
if [[ "$scenario" == links:image-rclick ]]; then
judge "right-click on the picture a link opened offers Print image" "said \"MENU .*'Print image…'\""
fi
judge "the picture a link opened gets the picture layout" "said 'PICTURE picture_box=True image=1600x1000'"
judge "one job" '[[ $(count) -eq 1 ]]'
judge "CUPS was given exactly the page the preview showed" \
"lab 'cmp -s /tmp/rig-out/preview.pdf $(spool_file "$jobs")'" ;;
links:tabs-print)
judge "three links opened and two closed again: two tabs left, back on the links" \
"said \"LINK before printing tabs=2 current='/links'\""
judge "the page they came from prints, one job" '[[ $(count) -eq 1 ]] && said "PREVIEW picture=False"'
judge "CUPS was given exactly the page the preview showed" \
"lab 'cmp -s /tmp/rig-out/preview.pdf $(spool_file "$jobs")'" ;;
rclick-page-gray)
judge "a lesson page full of pictures turns grey in the preview ($(lines 'GRAY PAGE' | grep -o 'took=[0-9.]*'))" \
"said 'GRAY PAGE .*shown_gray=True'"
judge "one job" '[[ $(count) -eq 1 ]]'
spool=$(spool_file "$jobs")
judge "CUPS was given exactly the file the preview showed" "lab 'cmp -s /tmp/rig-out/preview.pdf $spool'"
answer=$(colour_of "$spool")
judge "and every page of it is grey ($answer)" 'is_gray "$answer"'
judge "in black and white, by the driver's own option" \
'[[ "$(job_options $jobs)" == *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlmono* ]]' ;;
rclick-image:*)
IFS=: read -r _ element size position color <<< "$scenario"
judge "right-click on the picture offers Print image and Print page" \
"said \"MENU .*'Print image…', 'Print page…'\""
judge "Print image opens the picture layout" "said 'PICTURE picture_box=True'"
judge "named for the lesson it came from" "said \"title='Picture from Picture lesson'\""
judge "nothing was fetched again to get it ($(fetched_since) requests)" '[[ $(fetched_since) -eq 0 ]]'
judge "the teacher's clipboard is theirs again" teacher_text_kept
if [[ "$color" == gray ]]; then
judge "the preview shows it in black and white" "said 'shown_gray=True'"
fi
judge "one job reached CUPS" '[[ $(count) -eq 1 ]]'
dims=$(grep -o 'image=[0-9]*x[0-9]*' <<< "$out" | head -1 | cut -d= -f2)
judge_layout "$dims" "$size" "$position" auto fit "$color" ;;
rclick-broken)
judge "a picture that never loaded is still offered as a picture" "said \"MENU .*'Print image…'\""
judge "and the teacher is told it could not be taken, with Print off" \
"said \"BROKEN status='This picture could not be taken from the page\" && said 'print_enabled=False pages=0 picture=False'"
judge "the whole page is not printed instead" '[[ $(count) -eq 0 ]]'
judge "the teacher's clipboard is untouched" teacher_text_kept ;;
rclick-cancel)
judge "the picture preview came up" "said 'PREVIEW picture=True'"
judge "Cancel sends nothing" '[[ $(count) -eq 0 ]]'
judge "the teacher's clipboard is theirs again" teacher_text_kept
judge "and nothing was left behind" "said 'scratch_left=\[\]'" ;;
esac esac
[[ "$failed" -eq 0 ]] || { echo "$out" | tail -15 | sed 's/^/ /'; } [[ "$failed" -eq 0 ]] || { echo "$out" | tail -15 | sed 's/^/ /'; }
+312 -7
View File
@@ -23,9 +23,8 @@ from PyQt6.QtWebEngineCore import QWebEngineProfile, QWebEngineSettings
notes = [] notes = []
wi.notify = lambda s, b: (notes.append(s), print("NOTIFY", s, "|", b, flush=True)) wi.notify = lambda s, b: (notes.append(s), print("NOTIFY", s, "|", b, flush=True))
app = QApplication(sys.argv) app = QApplication(sys.argv)
profile = QWebEngineProfile("rig", app) # Wonder Ink's own profile, settings and all — not one of the rig's.
for a in ("PdfViewerEnabled", "PluginsEnabled"): profile = wi.build_profile(app, "rig")
profile.settings().setAttribute(getattr(QWebEngineSettings.WebAttribute, a), True)
window = wi.WonderInk(profile) window = wi.WonderInk(profile)
profile.downloadRequested.connect(window.on_download) profile.downloadRequested.connect(window.on_download)
window.resize(1280, 800); window.show() window.resize(1280, 800); window.show()
@@ -60,7 +59,53 @@ def when(cond, then, label, limit=30):
def preview(): return window._preview def preview(): return window._preview
def ready(): return preview() is not None and preview()._pdf is not None def ready(): return preview() is not None and preview()._pdf is not None
def describe(d): def describe(d):
return f"shown={d._page in d._images} pages={d._pages} printers={[d._printer.itemText(i) for i in range(d._printer.count())]} paper={d.paper()} colour_offered={d._colour.isVisible()} status={d._status.text()!r}" return f"picture={d._picture_box.isVisible()} shown={(d._pdf, d._page) in d._images} pages={d._pages} printers={[d._printer.itemText(i) for i in range(d._printer.count())]} paper={d.paper()} colour_offered={d._colour.isVisible()} status={d._status.text()!r}"
def shown_gray():
"""Is the page the preview is drawing grey, pixel for pixel?"""
pixmap = preview()._view._pixmap
return None if pixmap is None else pixmap.toImage().allGray()
def keep_preview():
import shutil
out = Path("/tmp/rig-out"); out.mkdir(exist_ok=True)
shutil.copy(preview()._pdf, out / "preview.pdf")
def toggles(sequence, then):
"""Color (0) and Black and white (1) in turn, as a teacher would, each shown before the next."""
def step(i):
if i == len(sequence): return then()
start = time.monotonic(); preview()._colour.setCurrentIndex(sequence[i])
def shown():
d = preview()
stamp(f"TOGGLE {i} want={'gray' if sequence[i] else 'colour'} took={time.monotonic() - start:.2f} "
f"pdf={d._pdf.name} source={d._source.name} page={d._page}/{d._pages} shown_gray={shown_gray()}")
step(i + 1)
when(ready, shown, f"toggle {i}")
step(0)
def colour_lesson(view, title="Rainbow lesson"):
# Backgrounds do not print, so the colour is in the words and the drawings.
shapes = ("<svg width='600' height='120'><circle cx='60' cy='60' r='50' fill='#e00000'/>"
"<rect x='130' y='10' width='200' height='100' fill='#0050e0'/>"
"<circle cx='450' cy='60' r='50' fill='#00a000'/></svg>")
view.setHtml(f"<html><head><title>{title}</title></head><body>" + "".join(
f"<h1 style='color:#c00'>{title}, part {i}</h1>{shapes}"
f"<p style='color:#060'>{'God put a rainbow in the sky. ' * 60}</p>" for i in range(4)
) + "</body></html>", QUrl("https://app.wonderink.org/rainbow"))
def colour_worksheet(name="Colour the Rainbow"):
from PyQt6.QtGui import QColor
d = wi.DOWNLOADS; d.mkdir(parents=True, exist_ok=True)
p = d / f"{name}.pdf"
w = QPdfWriter(str(p)); w.setPageSize(QPageSize(QPageSize.PageSizeId.Letter)); w.setResolution(300)
pa = QPainter(w); pa.setFont(QFont("DejaVu Sans", 28))
for page, colours in enumerate((("#e00000", "#ff9900"), ("#00a000", "#0050e0"))):
if page: w.newPage()
pa.setPen(QColor(colours[0])); pa.drawText(200, 400, f"WORKSHEET {name} page {page + 1}")
pa.fillRect(200, 600, 1000, 800, QColor(colours[1]))
pa.end()
return p
def finish(code=0): def finish(code=0):
stamp(f"RESULT notes={notes} preview_open={preview() is not None} tabs={window._tabs.count()}") stamp(f"RESULT notes={notes} preview_open={preview() is not None} tabs={window._tabs.count()}")
@@ -77,7 +122,8 @@ actions = {
"cancel": lambda view: when(ready, lambda: (stamp("PREVIEW " + describe(preview())), preview()._cancel.click(), finish()), "preview"), "cancel": lambda view: when(ready, lambda: (stamp("PREVIEW " + describe(preview())), preview()._cancel.click(), finish()), "preview"),
"options": lambda view: when(ready, lambda: ( "options": lambda view: when(ready, lambda: (
preview()._which.setCurrentIndex(1), preview()._range.setText("2-3"), preview()._which.setCurrentIndex(1), preview()._range.setText("2-3"),
preview()._copies.setValue(2), preview()._colour.setCurrentIndex(1), press_print()), "preview"), preview()._copies.setValue(2), preview()._colour.setCurrentIndex(1),
when(ready, press_print, "black and white")), "preview"),
"bad-range": lambda view: when(ready, lambda: ( "bad-range": lambda view: when(ready, lambda: (
preview()._which.setCurrentIndex(1), preview()._range.setText("7"), preview()._print.click(), preview()._which.setCurrentIndex(1), preview()._range.setText("7"), preview()._print.click(),
stamp(f"bad range: open={preview() is not None} status={preview()._status.text()!r}"), stamp(f"bad range: open={preview() is not None} status={preview()._status.text()!r}"),
@@ -101,7 +147,265 @@ actions = {
QTimer.singleShot(800, lambda: (stamp(f"second request while open: still one preview={preview() is not None}"), press_print()))), "preview"), QTimer.singleShot(800, lambda: (stamp(f"second request while open: still one preview={preview() is not None}"), press_print()))), "preview"),
} }
if scenario == "repeat": SITE = "http://127.0.0.1:8094"
if scenario.startswith("bw-"):
# bw-web: a colourful lesson, turned to page 2, Color and Black and white
# back and forth, then page 2 twice in black and white.
# bw-pdf: a colourful worksheet, all of it, in black and white.
# bw-colour-again: black and white, then back to Color, then Print.
# bw-cancel: black and white, then Cancel.
if scenario == "bw-pdf":
window.open_pdf(colour_worksheet()); view = window.current()
else:
view = window.open_tab(); colour_lesson(view)
def colour_first():
stamp(f"COLOUR FIRST shown_gray={shown_gray()} pdf={preview()._pdf.name}")
if scenario == "bw-web":
preview()._turn(1)
toggles([1, 0, 1, 0, 1], lambda: (
preview()._which.setCurrentIndex(1), preview()._range.setText("2"),
preview()._copies.setValue(2), keep_preview(), press_print()))
elif scenario == "bw-pdf":
toggles([1], lambda: (keep_preview(), press_print()))
elif scenario == "bw-colour-again":
toggles([1, 0], lambda: (keep_preview(), press_print()))
else:
toggles([1], lambda: (stamp("PREVIEW " + describe(preview())), preview()._cancel.click(), finish()))
begin = lambda *_: (QTimer.singleShot(0, lambda: window.print_view(view)), when(ready, colour_first, "preview"))
if scenario == "bw-pdf":
QTimer.singleShot(2500, begin)
else:
view.loadFinished.connect(begin)
elif scenario.startswith("links:"):
# links:follow every kind of link on the lab's signed-in links page,
# clicked in turn: what opens where, and what does not
# links:pdf a worksheet reached by a link, printed
# links:image a picture reached by a link, printed as a picture
# links:image-rclick the same picture, right-click Print image
# links:tabs-print several links opened, some closed, then the page printed
from PyQt6.QtCore import QEvent, QPointF, Qt
from PyQt6.QtGui import QMouseEvent
from PyQt6.QtWidgets import QApplication as Application
variant = scenario.split(":", 1)[1]
links_view = window.open_tab(); links_view.load(QUrl(f"{SITE}/login"))
def on_links():
return links_view.url().path() == "/lesson" or links_view.url().path() == "/links"
def click_in(view, selector, then, button=Qt.MouseButton.LeftButton):
js = ("(function(){var e=document.querySelector(%r); if(!e) return null; e.scrollIntoView({block:'center'});"
"var r=e.getBoundingClientRect(); return [r.x+r.width/2, r.y+r.height/2]})()" % selector)
def at(xy):
if not xy:
stamp(f"NO {selector}"); return finish(6)
zoom = view.zoomFactor(); point = QPointF(xy[0] * zoom, xy[1] * zoom)
for kind, buttons in ((QEvent.Type.MouseButtonPress, button), (QEvent.Type.MouseButtonRelease, Qt.MouseButton.NoButton)):
Application.sendEvent(view.focusProxy(), QMouseEvent(kind, point, QPointF(view.mapToGlobal(point)),
button, buttons, Qt.KeyboardModifier.NoModifier))
QTimer.singleShot(1500, then)
view.page().runJavaScript(js, at)
def tabs():
return [window._tabs.widget(i).url().toString().replace(SITE, "") for i in range(window._tabs.count())]
def report(label):
current = window.current()
stamp(f"LINK {label} tabs={len(tabs())} current={current.url().toString().replace(SITE, '')!r} "
f"current_is_new={current is not links_view} worksheet={current in window._pdf_views}")
def back_to_links():
window._tabs.setCurrentWidget(links_view)
def go_links(then):
# From the lesson the site sent us to after signing in, to the links page, in this tab.
links_view.load(QUrl(f"{SITE}/links"))
when(lambda: links_view.url().path() == "/links", lambda: QTimer.singleShot(1200, then), "the links page")
steps = []
if variant == "follow":
order = [("#blank", "text link, target=_blank, to a picture"), ("#external", "another site"),
("#linked-picture", "a linked picture"), ("#pdf", "a worksheet PDF"),
("#popup", "a lesson button that opens a window"), ("#button", "a lesson button"),
("#anchor", "an in-page anchor"), ("#same", "a link with no target")]
def next_link(i=0):
if i == len(order):
# Back, as the right-click menu's Back does, then close every tab the links opened.
links_view.page().triggerAction(links_view.page().WebAction.Back)
def closed_all():
report("after Back")
for view in [window._tabs.widget(j) for j in range(window._tabs.count())]:
if view is not links_view:
window.close_tab(window._tabs.indexOf(view))
QTimer.singleShot(800, lambda: (report("after closing the opened tabs"),
stamp(f"TABS {tabs()}"), finish()))
return QTimer.singleShot(2000, closed_all)
selector, label = order[i]
back_to_links()
click_in(links_view, selector, lambda: (report(f"{selector} ({label})"),
stamp(f"BUTTON {links_view.page().title()!r}") if selector == "#button" else None,
QTimer.singleShot(1500 if selector == "#pdf" else 200, lambda: next_link(i + 1))))
when(on_links, lambda: go_links(lambda: (report("start"), next_link())), "signed in")
elif variant in ("pdf", "image", "image-rclick", "tabs-print"):
target = {"pdf": "#pdf", "image": "#blank", "image-rclick": "#blank", "tabs-print": "#blank"}[variant]
def opened():
report(f"{target} opened")
view = window.current()
if variant == "pdf":
QTimer.singleShot(2500, lambda: (QTimer.singleShot(0, lambda: window.print_view(window.current())),
when(ready, lambda: (keep_preview(), press_print()), "worksheet preview")))
elif variant == "image":
QTimer.singleShot(0, lambda: window.print_view(view))
when(lambda: ready() and preview()._picture is not None, lambda: (
stamp(f"PICTURE picture_box={preview()._picture_box.isVisible()} image={preview()._picture.width()}x{preview()._picture.height()}"),
keep_preview(), press_print()), "picture preview")
elif variant == "image-rclick":
def menu_up(menu):
items = [a.text() for a in menu.actions() if a.text()]
stamp(f"MENU {items}")
action = next(a for a in menu.actions() if a.text() == "Print image…")
menu.close()
when(lambda: ready() and preview()._picture is not None, lambda: (
stamp(f"PICTURE picture_box={preview()._picture_box.isVisible()} image={preview()._picture.width()}x{preview()._picture.height()}"),
keep_preview(), press_print()), "picture preview")
QTimer.singleShot(0, action.trigger)
click_in(view, "img", lambda: when(lambda: Application.activePopupWidget() is not None,
lambda: menu_up(Application.activePopupWidget()), "menu"),
button=Qt.MouseButton.RightButton)
else:
# Three links opened, two of them closed again, then the page they came from printed.
def more():
back_to_links()
click_in(links_view, "#external", lambda: (back_to_links(), click_in(links_view, "#linked-picture", close_two)))
def close_two():
for view in [window._tabs.widget(j) for j in range(window._tabs.count())][-2:]:
window.close_tab(window._tabs.indexOf(view))
back_to_links(); report("before printing")
QTimer.singleShot(0, lambda: window.print_view(links_view))
when(ready, lambda: (keep_preview(), press_print()), "page preview")
more()
when(on_links, lambda: go_links(lambda: (back_to_links(), click_in(links_view, target, lambda: QTimer.singleShot(1500, opened)))), "signed in")
elif scenario.startswith("rclick"):
# A lesson behind a sign-in, with pictures in it; a real right-click.
# rclick-page on the words: the page prints
# rclick-image:ID:SIZE:POSITION[:gray] on a picture: that picture prints
# rclick-broken a picture that never loaded
# rclick-cancel a picture, then Cancel
from PyQt6.QtCore import QEvent, QPointF, Qt
from PyQt6.QtGui import QMouseEvent
from PyQt6.QtWidgets import QApplication as Application
parts = scenario.split(":")
target = {"rclick-page": "words", "rclick-page-gray": "words", "rclick-broken": "broken",
"rclick-cancel": "private-jpg"}.get(parts[0], parts[1] if len(parts) > 1 else "")
clipboard = Application.clipboard()
view = window.open_tab(); view.load(QUrl(f"{SITE}/login"))
page_ready = []
def poll_ready():
if view.url().path() == "/lesson":
view.page().runJavaScript("window.__ready === true", lambda ok: page_ready.append(1) if ok else None)
if not page_ready: QTimer.singleShot(200, poll_ready)
QTimer.singleShot(500, poll_ready)
def right_click(then):
js = ("(function(){var e=document.getElementById(%r);e.scrollIntoView({block:'center'});"
"var r=e.getBoundingClientRect();return [r.x+r.width/2,r.y+r.height/2]})()" % target)
def at(xy):
zoom = view.zoomFactor(); x, y = xy[0] * zoom, xy[1] * zoom
clipboard.setText("TEACHER'S TEXT, John 3:16")
stamp(f"RCLICK {target} at {x:.0f},{y:.0f} since={time.time():.3f}")
for kind, buttons in ((QEvent.Type.MouseButtonPress, Qt.MouseButton.RightButton),
(QEvent.Type.MouseButtonRelease, Qt.MouseButton.NoButton)):
Application.sendEvent(view.focusProxy(), QMouseEvent(
kind, QPointF(x, y), QPointF(view.mapToGlobal(QPointF(x, y))),
Qt.MouseButton.RightButton, buttons, Qt.KeyboardModifier.NoModifier))
when(lambda: Application.activePopupWidget() is not None,
lambda: then(Application.activePopupWidget()), "menu")
view.page().runJavaScript(js, at)
def choose(menu, text, after):
items = [a.text() for a in menu.actions() if a.text()]
stamp(f"MENU {items}")
action = next((a for a in menu.actions() if a.text() == text), None)
menu.close()
if action is None:
stamp(f"NO {text!r} IN THE MENU"); return finish(5)
after()
QTimer.singleShot(0, action.trigger)
def picture_ready():
d = preview(); return d is not None and d._picture is not None and d._pdf is not None
def clipboard_line():
stamp(f"CLIPBOARD {clipboard.text()!r}")
if parts[0] == "rclick-page":
go = lambda: right_click(lambda m: choose(m, "Print page…", lambda: when(ready, lambda: (
clipboard_line(), keep_preview(), press_print()), "page preview")))
elif parts[0] == "rclick-page-gray":
# The page with all its pictures, in black and white: the slow case.
def gray_page():
start = time.monotonic(); preview()._colour.setCurrentIndex(1)
when(ready, lambda: (
stamp(f"GRAY PAGE took={time.monotonic() - start:.1f} shown_gray={shown_gray()} pages={preview()._pages}"),
keep_preview(), press_print()), "black and white", limit=70)
go = lambda: right_click(lambda m: choose(m, "Print page…", lambda: when(ready, gray_page, "page preview")))
elif parts[0] == "rclick-broken":
go = lambda: right_click(lambda m: choose(m, "Print image…", lambda: when(
lambda: preview() is not None and "could not be taken" in preview()._status.text(), lambda: (
stamp(f"BROKEN status={preview()._status.text()!r} print_enabled={preview()._print.isEnabled()} "
f"pages={preview()._pages} picture={preview()._picture is not None}"),
clipboard_line(), preview()._cancel.click(), finish()), "the picture failing", limit=15)))
elif parts[0] == "rclick-cancel":
go = lambda: right_click(lambda m: choose(m, "Print image…", lambda: when(picture_ready, lambda: (
stamp("PREVIEW " + describe(preview())), clipboard_line(), preview()._cancel.click(), finish()),
"picture preview")))
else:
_, _, size, position, *colour = parts
def lay_out():
d = preview()
d._size.setCurrentIndex(d._size.findData(int(size)))
d._place.choose(position.replace("-", " "))
if colour == ["gray"]:
d._colour.setCurrentIndex(1)
QTimer.singleShot(900, lambda: when(ready, lambda: (
stamp(f"PICTURE picture_box={d._picture_box.isVisible()} image={d._picture.width()}x{d._picture.height()} "
f"title={d._title!r} shown_gray={shown_gray()}"),
clipboard_line(), keep_preview(), press_print()), "layout"))
go = lambda: right_click(lambda m: choose(m, "Print image…", lambda: when(picture_ready, lay_out, "picture preview")))
when(lambda: bool(page_ready), go, "the lesson and its pictures", limit=30)
elif scenario.startswith("picture:"):
# picture:FILE:SIZE:POSITION:PAPER:FILL[:color]
# Opens a picture on its own, chooses the layout with the preview's own
# controls, keeps the exact PDF the preview showed, then prints it.
_, name, size, position, paper, fill, *rest = scenario.split(":")
view = window.open_tab(); view.load(QUrl(f"http://127.0.0.1:8089/{name}"))
def ready_picture():
d = preview()
return d is not None and d._picture is not None and d._pdf is not None
def choose():
d = preview()
d._size.setCurrentIndex(d._size.findData(int(size)))
d._place.choose(position.replace("-", " "))
d._turned.setCurrentIndex(d._turned.findData(paper))
d._fill.setChecked(fill == "fill")
if rest and rest[0] == "gray":
d._colour.setCurrentIndex(1)
# The layout settles (120 ms), then black and white if asked for.
QTimer.singleShot(900, lambda: when(ready, keep_and_print, "layout"))
def keep_and_print():
import shutil
d = preview()
out = Path("/tmp/rig-out"); out.mkdir(exist_ok=True)
shutil.copy(d._pdf, out / "preview.pdf")
stamp(f"PICTURE picture_box={d._picture_box.isVisible()} size_enabled={d._size.isEnabled()} "
f"image={d._picture.width()}x{d._picture.height()} labels={d._colour_label.text()!r},{d._colour.itemText(0)!r} "
f"shown_gray={shown_gray()}")
press_print()
QTimer.singleShot(2500, lambda: (QTimer.singleShot(0, lambda: window.print_view(view)),
when(ready_picture, choose, "picture preview")))
elif scenario == "dark-page":
# A lesson page with a black background. Chrome does not print page
# backgrounds unless asked, and neither may Wonder Ink.
view = window.open_tab()
view.setHtml("<html><head><title>Night sky</title></head><body style='background:#000;color:#fff'>"
+ "<h1>Stars</h1>" + "<p>God made the stars. </p>" * 40 + "</body></html>",
QUrl("https://app.wonderink.org/night"))
def keep_dark():
import shutil
out = Path("/tmp/rig-out"); out.mkdir(exist_ok=True)
shutil.copy(preview()._pdf, out / "preview.pdf")
press_print()
view.loadFinished.connect(lambda ok: (QTimer.singleShot(0, lambda: window.print_view(view)),
when(ready, keep_dark, "dark preview")))
elif scenario == "repeat":
view = window.open_tab(); lesson(view); n = [0] view = window.open_tab(); lesson(view); n = [0]
def once(): def once():
if n[0] == 5: return finish() if n[0] == 5: return finish()
@@ -113,7 +417,8 @@ elif scenario in ("pdf", "pdf-options"):
if scenario == "pdf-options": if scenario == "pdf-options":
actions["pdf-options"] = lambda view: when(ready, lambda: ( actions["pdf-options"] = lambda view: when(ready, lambda: (
preview()._which.setCurrentIndex(1), preview()._range.setText("2"), preview()._which.setCurrentIndex(1), preview()._range.setText("2"),
preview()._copies.setValue(3), preview()._colour.setCurrentIndex(1), press_print()), "preview") preview()._copies.setValue(3), preview()._colour.setCurrentIndex(1),
when(ready, press_print, "black and white")), "preview")
QTimer.singleShot(2500, lambda: (QTimer.singleShot(0, lambda: window.print_view(view)), QTimer.singleShot(2500, lambda: (QTimer.singleShot(0, lambda: window.print_view(view)),
actions[scenario if scenario == "pdf-options" else "web"](view))) actions[scenario if scenario == "pdf-options" else "web"](view)))
elif scenario == "pdf-viewer-button": elif scenario == "pdf-viewer-button":
+234
View File
@@ -0,0 +1,234 @@
#!/bin/bash
# Temporary workspace restore: do a teacher's recent tabs come back — and
# only for a while, and never with their sign-in?
#
# Each scenario launches the INSTALLED classroom-wonderink one or more times
# (tools/lab/wonderink-workspace-rig.py), against the lab's stand-in for
# WonderInk's start page and a second origin standing in for its public
# resource files, and is judged by the tabs each launch opens and by the
# kept file itself. Time between launches is made to pass by moving the
# time of last use in the file; time within a launch is real, with the
# limit shortened by the test-only override where a scenario needs it.
#
# tools/lab/wonderink-workspace-check [SCENARIO...]
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
NAME="${LAB_NAME:-classroom-v2-lab}"
COMMAND="${WONDERINK:-/usr/local/bin/classroom-wonderink}"
DIR=/opt/wonderink-check
SITE=http://127.0.0.1:8094
SLIDE=http://localhost:8095/img/ark-wide.png
OTHER=http://localhost:8095/img/ark-tall.png
KEPT=/home/teacher/.local/share/ekklesia-classroom/wonderink-workspace.json
SHEETS="/home/teacher/Downloads/Wonder Ink"
lab() { docker exec -i "$NAME" bash -c "$1"; }
lab "mkdir -p $DIR"
for file in wonderink-workspace-rig.py wonderink-lesson-site.py wonderink-picture-measure.py; do
docker cp "$ROOT/tools/lab/$file" "$NAME:$DIR/$file" >/dev/null
done
lab "chmod -R a+rX $DIR; id teacher >/dev/null 2>&1 || useradd -m teacher; install -d -o teacher -m 700 /tmp/teacher-rt"
lab "test -f $DIR/www/ark-wide.webp || { mkdir -p $DIR/www; QT_QPA_PLATFORM=offscreen \
QTWEBENGINE_CHROMIUM_FLAGS='--no-sandbox --disable-gpu' python3 $DIR/wonderink-picture-measure.py make $DIR/www; }
chmod -R a+r $DIR/www"
lab "pkill -f '[w]onderink-lesson-site.py'; : > $DIR/site.log; chmod a+rw $DIR/site.log"
docker exec -d "$NAME" python3 "$DIR/wonderink-lesson-site.py" "$DIR/www" 8094 8095 "$DIR/site.log"
lab 'for _ in $(seq 20); do curl -s -o /dev/null http://127.0.0.1:8094/app && exit 0; sleep 0.3; done; exit 1' || {
echo "the lesson site did not start" >&2
exit 1
}
passed=0
failed=0
out=""
judge() {
if eval "$2"; then
printf ' \033[32mPASS\033[0m %s\n' "$1"
passed=$((passed + 1))
else
printf ' \033[31mFAIL\033[0m %s\n' "$1"
failed=$((failed + 1))
fi
}
# launch [limit=SECONDS] -- STEP... one Wonder Ink; its output appended to $out
launch() {
local env=""
[[ "$1" == limit=* ]] && { env="CLASSROOM_WONDERINK_TEST_WORKSPACE_SECONDS=${1#limit=}"; shift; }
[[ "$1" == -- ]] && shift
out+=$'\n'"=== launch $*"$'\n'
out+=$(lab "su teacher -c 'cd /tmp; XDG_RUNTIME_DIR=/tmp/teacher-rt QT_QPA_PLATFORM=offscreen \
QTWEBENGINE_CHROMIUM_FLAGS=\"--no-sandbox --disable-gpu\" $env \
timeout 180 python3 $DIR/wonderink-workspace-rig.py $COMMAND $SITE $* 2>&1'; echo \"exit=\$?\"")
}
# The time of last use in the kept file, made SECONDS older (negative: the future).
age() {
lab "su teacher -c \"python3 -c 'import json,sys; p=sys.argv[1]; d=json.load(open(p)); d[\\\"last_activity\\\"]-=float(sys.argv[2]); json.dump(d, open(p, \\\"w\\\"))' $KEPT $1\""
}
said() { grep -q -- "$1" <<< "$out"; }
nth() { grep -o -- "$1.*" <<< "$out" | sed -n "${2}p"; }
kept_file() { lab "test -e $KEPT"; }
# The age a STATE line reports for the kept file.
age_of() { grep -o 'age=[0-9]*' <<< "$1" | cut -d= -f2; }
clean() { lab "rm -f $KEPT $KEPT.new; rm -rf \"$SHEETS\"; install -d -o teacher \"$SHEETS\""; out=""; }
# Every launch ended by itself with 0, except one the scenario killed on purpose.
no_crash() {
! said Traceback && ! said Segmentation && \
! awk '/\] CRASH/ {killed=1} /^exit=/ {if ($0 != "exit=0" && !(killed && $0 == "exit=137")) bad=1; killed=0} END {exit !bad}' <<< "$out"
}
THREE="touch open:$SLIDE open:$OTHER front:1 touch"
RESTORED="tabs=\\['/app', '$SLIDE', '$OTHER'\\] active=1"
FRESH="tabs=\\['/app'\\] active=0 kept\\[none\\]"
scenarios=("$@")
[[ ${#scenarios[@]} -gt 0 ]] || scenarios=(restore-5min restore-90min expire-2h1m expire-next-day expire-next-week
reopen-does-not-refresh activity-refreshes background-ignored preview-ignored printing-counts
closed-stays-closed reordered worksheet worksheet-gone crash-then-restore restore-crashes
corrupt future-format half-written privacy restored-tabs-print)
for scenario in "${scenarios[@]}"; do
echo "$scenario"
clean
before=$failed
case "$scenario" in
restore-5min|restore-90min)
minutes=${scenario#restore-}; minutes=${minutes%min}
launch -- $THREE state quit
age $((minutes * 60))
launch -- state front:0 state quit
judge "three tabs, the middle one in front, closed; $minutes minutes later: all three back" \
"nth STATE 2 | grep -q \"$RESTORED\""
judge "in the same order, the same one in front" "nth START 2 | grep -q 'tabs=3'"
judge "the tabs behind wait to load until they are shown (a PDF behind opened at 10%)" \
"nth STATE 2 | grep -q 'unshown=2'"
judge "and one clicked loads: the start page, asking to sign in" \
"nth STATE 3 | grep -q \"pages=\\['Signed out'\\] unshown=1\"" ;;
expire-2h1m|expire-next-day|expire-next-week)
case "$scenario" in
expire-2h1m) seconds=$((2 * 3600 + 60)); label="two hours and a minute" ;;
expire-next-day) seconds=$((24 * 3600)); label="the next day" ;;
*) seconds=$((7 * 24 * 3600)); label="the next week" ;;
esac
launch -- $THREE quit
age $seconds
launch -- state quit
judge "$label: a fresh Wonder Ink, one tab on the start page" "nth STATE 1 | grep -q \"$FRESH\""
judge "the old workspace is gone, and says why" "said 'starting fresh (expired)' && ! kept_file" ;;
reopen-does-not-refresh)
launch limit=20 -- $THREE quit
launch limit=20 -- wait:8 quit
launch limit=20 -- wait:8 quit
launch limit=20 -- state quit
judge "reopened twice, nobody touching it: restored both times" \
"nth START 2 | grep -q 'tabs=3' && nth START 3 | grep -q 'tabs=3'"
judge "but reopening never renewed it: fresh once the limit had passed" \
"nth STATE 1 | grep -q \"$FRESH\"" ;;
activity-refreshes)
launch -- $THREE quit
age 600
launch -- wait:4 state touch quit
# The time of last use is written at most every 30 s, and at quit.
restored=$(age_of "$(nth STATE 1)"); touched=$(age_of "$(nth QUIT 2)")
judge "restoring it did not count as using it (age $restored s, from ten minutes)" "(( restored >= 600 ))"
judge "a click on the restored tabs does (age $touched s at quit)" "(( touched <= 5 ))" ;;
background-ignored)
launch -- "touch open:$SITE/app?busy=1 touch" quit
age 600
launch -- wait:20 state quit
judge "a page busy by itself for 20 s (a request a second, an animation) is not activity (age $(age_of "$(nth STATE 1)") s)" \
"(( \$(age_of \"\$(nth STATE 1)\") >= 615 ))"
judge "and it really was busy" "[[ \$(lab 'grep -c beacon $DIR/site.log') -gt 10 ]]" ;;
preview-ignored)
launch -- $THREE quit
age 600
launch -- preview:15 state quit
judge "a print preview left open 15 s untouched is not activity (age $(age_of "$(nth STATE 1)") s)" \
"(( \$(age_of \"\$(nth STATE 1)\") >= 610 ))" ;;
printing-counts)
launch -- $THREE quit
age 600
before_jobs=$(lab 'lpstat -W all -o 2>/dev/null | wc -l')
launch -- front:2 print state quit
judge "pressing Print is using Wonder Ink (age $(age_of "$(nth STATE 1)") s)" "(( \$(age_of \"\$(nth STATE 1)\") <= 5 ))"
judge "and the restored picture tab printed" "(( \$(lab 'lpstat -W all -o | wc -l') > before_jobs ))" ;;
closed-stays-closed)
launch -- $THREE close:1 touch quit
launch -- state quit
judge "a tab closed before closing Wonder Ink stays closed" \
"nth STATE 1 | grep -q \"tabs=\\['/app', '$OTHER'\\]\"" ;;
reordered)
launch -- $THREE move:0:2 touch quit
launch -- state quit
judge "tabs dragged into another order come back in that order" \
"nth STATE 1 | grep -q \"tabs=\\['$SLIDE', '$OTHER', '/app'\\]\"" ;;
worksheet)
launch -- touch worksheet:Colour-the-Ark open:$SLIDE front:1 touch quit
launch -- state quit
judge "a worksheet comes back as a worksheet, where it was, in front" \
"nth STATE 1 | grep -q \"tabs=\\['/app', 'worksheet:Colour-the-Ark.pdf', '$SLIDE'\\] active=1\"" ;;
worksheet-gone)
launch -- touch worksheet:Colour-the-Ark open:$SLIDE touch quit
lab "rm -f \"$SHEETS/Colour-the-Ark.pdf\""
launch -- state quit
judge "a worksheet whose file has gone is left out, and nothing else is" \
"nth STATE 1 | grep -q \"tabs=\\['/app', '$SLIDE'\\]\"" ;;
crash-then-restore)
launch -- $THREE wait:2 crash
launch -- state quit
judge "killed outright: the tabs come back all the same" "nth STATE 1 | grep -q \"$RESTORED\"" ;;
restore-crashes)
launch -- $THREE quit
launch -- crash
launch -- crash
launch -- state quit
judge "a restore that crashes is tried once more" "nth START 2 | grep -q 'tabs=3' && nth START 3 | grep -q 'tabs=3 attempts=2'"
judge "and then abandoned: fresh, without asking anybody" \
"said 'starting fresh (it failed to restore 2 times)' && nth STATE 1 | grep -q \"tabs=\\['/app'\\]\"" ;;
corrupt)
lab "su teacher -c 'mkdir -p $(dirname $KEPT); echo \"{not json\" > $KEPT'"
launch -- state quit
judge "an unreadable file: fresh, and Wonder Ink opens" "said 'starting fresh (unreadable)' && nth STATE 1 | grep -q \"$FRESH\"" ;;
future-format)
launch -- $THREE quit
lab "su teacher -c \"python3 -c 'import json,sys; p=sys.argv[1]; d=json.load(open(p)); d[\\\"format\\\"]=99; json.dump(d, open(p, \\\"w\\\"))' $KEPT\""
launch -- state quit
judge "a file from a later version: fresh" "said 'starting fresh (from another version)' && nth STATE 1 | grep -q \"$FRESH\"" ;;
half-written)
launch -- $THREE quit
lab "su teacher -c 'echo \"{\\\"format\\\": 1, \\\"last_act\" > $KEPT.new'"
launch -- state quit
judge "a write cut off half way leaves the last whole one, which is restored" "nth STATE 1 | grep -q \"$RESTORED\"" ;;
privacy)
launch -- signin touch state open:$SLIDE open:$OTHER touch quit
judge "signed in on the stand-in, whose sign-in is Basic auth as WonderInk's is" \
"nth STATE 1 | grep -q \"pages=\\['Signed in as teacher'\\]\""
judge "the kept file has no password, Basic-auth credential, cookie or header in it" \
"kept_file && ! lab \"grep -qiE 'hunter2|Basic|dGVhY2hlc|teacher@|rrf|cookie|x-rrf|authoriz|token' $KEPT\""
judge "only addresses, titles and times: the fields it is meant to have" \
"lab \"python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if set(d)=={\\\"format\\\",\\\"last_activity\\\",\\\"active\\\",\\\"attempts\\\",\\\"tabs\\\"} and all(set(t)=={\\\"kind\\\",\\\"where\\\",\\\"title\\\"} for t in d[\\\"tabs\\\"]) else 1)' $KEPT\""
launch -- front:0 state quit
judge "and the restored start page is signed out: the site asks again" \
"said 'restored 3 recent tabs' && nth STATE 2 | grep -q \"pages=\\['Signed out'\\]\"" ;;
restored-tabs-print)
launch -- touch worksheet:Colour-the-Ark open:$SLIDE touch quit
before_jobs=$(lab 'lpstat -W all -o 2>/dev/null | wc -l')
launch -- front:1 print front:2 print state quit
judge "a restored worksheet tab and a restored picture tab both print" \
"(( \$(lab 'lpstat -W all -o | wc -l') >= before_jobs + 2 ))" ;;
esac
judge "Wonder Ink never crashed by itself" no_crash
[[ "$failed" -eq "$before" ]] || \
{ echo "$out" | grep -E 'STATE|START|QUIT|CRASH|PRINTED|exit=|Traceback|starting|restored|TEST' | tail -14 | sed 's/^/ /'; }
done
echo
echo "$passed passed, $failed failed"
[[ "$failed" -eq 0 ]]
+201
View File
@@ -0,0 +1,201 @@
"""One launch of Wonder Ink, its recent tabs restored or not, and a few steps.
usage: wonderink-workspace-rig.py WONDERINK_COMMAND SITE STEP...
Run by tools/lab/wonderink-workspace-check inside the lab, against the
command the package installed, started as main() starts it: the real
profile, downloads connected, then open_workspace() — which restores the
recent tabs or opens the start page. The start page is the lab's
stand-in for WonderInk's sign-in (tools/lab/wonderink-lesson-site.py,
/app) rather than the real site.
Steps, in order:
open:PATH open SITE+PATH (or a full http address) in a new tab, in front
worksheet:NAME a worksheet PDF downloaded and opened in a tab
signin click the stand-in's Sign In (a real click)
touch one real click on the page in front
front:N bring tab N to the front, as clicking it does (a real click)
close:N close tab N, as its × does
move:A:B drag tab A to where B is
preview:N the print preview open N seconds, nobody touching it
print Print, and the preview's Print button pressed by a real click
wait:N N seconds, nobody touching anything
state what the tabs are, and what the kept file says
quit close the window
crash SIGKILL, no chance to write anything
"""
import importlib.machinery
import importlib.util
import json
import os
import signal
import sys
import time
from pathlib import Path
loader = importlib.machinery.SourceFileLoader("wonderink", sys.argv[1])
spec = importlib.util.spec_from_loader("wonderink", loader)
wi = importlib.util.module_from_spec(spec)
loader.exec_module(wi)
from PyQt6.QtCore import QEvent, QEventLoop, QPointF, Qt, QTimer # noqa: E402
from PyQt6.QtGui import QMouseEvent, QPageSize, QPainter, QPdfWriter # noqa: E402
from PyQt6.QtWidgets import QApplication # noqa: E402
site, steps = sys.argv[2].rstrip("/"), sys.argv[3:]
wi.HOME_PAGE = f"{site}/app"
wi.notify = lambda summary, body: print("NOTIFY", summary, "|", body, flush=True)
app = QApplication(sys.argv[:1])
t0 = time.monotonic()
def stamp(message):
print(f"[{time.monotonic() - t0:6.2f}s] {message}", flush=True)
profile = wi.build_profile(app, "workspace-rig")
wi.DOWNLOADS.mkdir(parents=True, exist_ok=True)
window = wi.WonderInk(profile)
profile.downloadRequested.connect(window.on_download)
keeper = wi.open_workspace(window)
window.resize(1100, 700)
window.show()
stamp(f"START tabs={window._tabs.count()} attempts={keeper._attempts}")
def spin(seconds, until=None):
end = time.monotonic() + seconds
while time.monotonic() < end:
app.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 50)
if until is not None and until():
return True
time.sleep(0.01)
return False
def js(code, view=None):
box = []
(view or window.current()).page().runJavaScript(code, lambda result: box.append(result))
spin(5, lambda: bool(box))
return box[0] if box else None
def press(widget, point, button=Qt.MouseButton.LeftButton):
for kind, buttons in ((QEvent.Type.MouseButtonPress, button),
(QEvent.Type.MouseButtonRelease, Qt.MouseButton.NoButton)):
QApplication.sendEvent(widget, QMouseEvent(kind, point, QPointF(widget.mapToGlobal(point)),
button, buttons, Qt.KeyboardModifier.NoModifier))
def click_page(selector="body"):
view = window.current()
where = js("(function(){var e=document.querySelector(%s); if(!e) return [20, 20]; var r=e.getBoundingClientRect();"
"return [r.x+Math.min(r.width/2, 40), r.y+Math.min(r.height/2, 10)]})()" % json.dumps(selector)) or [20, 20]
zoom = view.zoomFactor()
press(view.focusProxy(), QPointF(where[0] * zoom, where[1] * zoom))
def where_of(view):
# A restored tab not yet shown is the tab it will load when it is.
if view in window._pending:
kept = keeper._pending_tabs[view]
if kept["kind"] == "worksheet":
return "worksheet:" + Path(kept["where"]).name
return kept["where"].replace(site, "")
if view in window._pdf_views:
return "worksheet:" + window._pdf_views[view].name
return view.url().toString().replace(site, "")
def state(label="STATE"):
tabs = [where_of(window._tabs.widget(i)) for i in range(window._tabs.count())]
# What the stand-in's start page says in each tab that shows it.
pages = [js("document.getElementById('state') ? document.getElementById('state').textContent : ''",
window._tabs.widget(i)) for i in range(window._tabs.count())]
kept = "none"
if wi.WORKSPACE_FILE.exists():
data = json.loads(wi.WORKSPACE_FILE.read_text())
kept = (f"tabs={len(data['tabs'])} active={data['active']} attempts={data['attempts']} "
f"age={time.time() - data['last_activity']:.0f}")
stamp(f"{label} tabs={tabs} active={window._tabs.currentIndex()} kept[{kept}] pages={[p for p in pages if p]} "
f"unshown={len(window._pending)}")
def make_worksheet(name):
path = wi.DOWNLOADS / f"{name}.pdf"
writer = QPdfWriter(str(path))
writer.setPageSize(QPageSize(QPageSize.PageSizeId.Letter))
painter = QPainter(writer)
painter.drawText(200, 400, f"WORKSHEET {name}")
painter.end()
return path
def run():
spin(3)
for step in steps:
name, _, rest = step.partition(":")
stamp(f"STEP {step}")
if name == "open":
window.open_tab(rest if rest.startswith("http") else site + rest)
spin(2.5)
elif name == "worksheet":
window.open_pdf(make_worksheet(rest))
spin(2.5)
elif name == "signin":
click_page("#signin")
spin(1.5)
elif name == "touch":
click_page()
spin(1.5)
elif name == "front":
bar = window._tabs.tabBar()
press(bar, QPointF(bar.tabRect(int(rest)).center()))
spin(1.5)
elif name == "close":
window.close_tab(int(rest))
spin(1.5)
elif name == "move":
a, b = (int(n) for n in rest.split(":"))
window._tabs.tabBar().moveTab(a, b)
spin(1.5)
elif name == "preview":
QTimer.singleShot(int(float(rest) * 1000), lambda: window._preview.reject() if window._preview else None)
window.print_view(window.current())
spin(1.5)
elif name == "print":
def pressed():
preview = window._preview
if preview is None or preview._pdf is None:
return QTimer.singleShot(200, pressed)
button = preview._print
press(button, QPointF(button.rect().center()))
QTimer.singleShot(500, pressed)
window.print_view(window.current())
spin(2)
stamp(f"PRINTED preview_open={window._preview is not None}")
elif name == "wait":
spin(float(rest))
elif name == "state":
state()
elif name == "quit":
window.close()
spin(1)
state("QUIT")
app.quit()
return
elif name == "crash":
stamp("CRASH")
os.kill(os.getpid(), signal.SIGKILL)
app.quit()
QTimer.singleShot(0, run)
QTimer.singleShot(240_000, lambda: (stamp("TIMEOUT"), app.exit(3)))
sys.exit(app.exec())
+401
View File
@@ -0,0 +1,401 @@
#!/bin/bash
# THE RELEASE STEPS, AS THE GITEA WORKFLOWS TAKE THEM — OR A PERSON DOES.
#
# tools/release-ci.sh build VERSION [FROM_VERSION...]
# tools/release-ci.sh gate VERSION SHA256
# tools/release-ci.sh preview VERSION SHA256 GATE_REPORT_NAME
# tools/release-ci.sh stable VERSION SHA256 APPROVAL
# tools/release-ci.sh import VERSION SHA256 COMMIT [GATE_REPORT_FILE]
#
# Glue, and only glue. Building is tools/build-release.sh, the gate is
# tools/release-gate.sh, publishing is update_canary.sh and promoting is
# update_stable.sh, exactly as when a person runs them; the record is
# tools/gitea-release. Nothing here decides what a good release is, so
# nothing here can come to disagree with them. See docs/releasing.md.
#
# build from the commit checked out, whose VERSION says VERSION: the
# package, its SHA256SUMS and build-info.json, the gate, and a
# Gitea pre-release vVERSION holding all of it. Refuses a
# version that has a release, a tag or a package already.
# gate the gate again, on the file that release holds, for Preview;
# the report is attached to the release under a new name.
# preview that file, published to canary (Preview) by update_canary.sh
# on that gate report, read back, and recorded.
# stable that file, promoted by update_stable.sh VERSION SHA256 once a
# person has approved it, read back, and recorded.
# import a release published before Gitea kept the record (0.2.6.6):
# the file as the update server serves it, recorded as-is.
#
# CREDENTIALS. build, gate and import need only GITEA_TOKEN, for the
# release. preview and stable also need the update server, through
# RELEASE_SSH_KEY (the private key's text), RELEASE_SSH_KNOWN_HOSTS,
# RELEASE_SSH_ADDRESS and RELEASE_SSH_USER, which only the release runner's
# jobs are given, and without which a job refuses to publish. Run by hand
# (no GITHUB_ACTIONS), ~/.ssh/config is used as it always was.
set -euo pipefail
shopt -s inherit_errexit
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
source "$ROOT/tools/release-lib.sh"
GITEA="$ROOT/tools/gitea-release"
WORK="${RELEASE_CI_WORK:-$(mktemp -d)}"
mkdir -p "$WORK"
run_url() {
if [[ -n "${GITHUB_RUN_NUMBER:-}" ]]; then
printf '%s/%s/actions/runs/%s\n' "$GITHUB_SERVER_URL" "$GITHUB_REPOSITORY" "$GITHUB_RUN_NUMBER"
else
printf 'by hand on %s by %s\n' "$(hostname)" "$(id -un)"
fi
}
actor() { printf '%s\n' "${GITHUB_ACTOR:-$(id -un)}"; }
fail() { echo "release-ci: $*" >&2; exit 1; }
# The version a channel serves, read the way a classroom reads it.
served() {
curl -fsS -H 'Cache-Control: no-cache' "$RELEASE_URL/$1/manifest.json" \
| python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])'
}
# Does Gitea have a release vVERSION? Yes, no, or stop: a question Gitea
# could not answer is never taken as "no".
has_release() {
local status=0
"$GITEA" show "v$1" >/dev/null 2>&1 || status=$?
case "$status" in
0) return 0 ;;
3) return 1 ;;
*) fail "could not ask Gitea whether v$1 exists" ;;
esac
}
# Is a package of this name on the update server? Only a 404 is a no.
on_server() {
local code
code="$(curl -sS -o /dev/null -w '%{http_code}' -I "$RELEASE_URL/packages/$1" || true)"
case "$code" in
200) return 0 ;;
404) return 1 ;;
*) fail "could not ask the update server about $1 (HTTP ${code:-none})" ;;
esac
}
require_sha256() {
[[ "$1" =~ ^[0-9a-f]{64}$ ]] || fail "not a SHA-256 checksum: $1"
}
# The release's package, downloaded and proven to be the file named: the
# checksum given, the checksum the release's own SHA256SUMS records, and
# the version dpkg reads out of it all have to agree.
fetch() {
local version="$1" hash="$2" name
name="$(release_package_name "$version")"
"$GITEA" download "v$version" "$name" "$WORK/$name" >&2
"$GITEA" download "v$version" SHA256SUMS "$WORK/SHA256SUMS" >&2
[[ "$(sha256sum "$WORK/$name" | awk '{print $1}')" == "$hash" ]] \
|| fail "v$version's $name is not sha256 $hash"
grep -qx "$hash $name" "$WORK/SHA256SUMS" \
|| fail "v$version's SHA256SUMS does not record $name as $hash"
[[ "$(release_package_version "$WORK/$name")" == "$version" ]] \
|| fail "$name does not say it is $version"
printf '%s\n' "$WORK/$name"
}
# The update server, for the release runner: the key it was given, in a
# configuration of its own (RELEASE_SSH_CONFIG, used with ssh -F), so
# nothing on the runner's own disk is read or overwritten, and the key is
# deleted when this ends. Under Actions there is no other way: a job
# without the key stops, rather than reach for whatever credentials the
# machine it runs on happens to hold. Measured: on the development VM,
# which holds the production publish key, the fallback would have
# published a throwaway build to the real server.
ssh_setup() {
local dir="$WORK/ssh"
if [[ -z "${RELEASE_SSH_KEY:-}" ]]; then
if [[ -n "${GITHUB_ACTIONS:-}${GITEA_ACTIONS:-}" ]]; then
fail "no RELEASE_SSH_KEY: set the CLASSROOM_UPDATES_SSH_KEY secret. A job never uses the runner's own ssh keys"
fi
echo "release-ci: no RELEASE_SSH_KEY; by hand, so this machine's own ssh configuration" >&2
return
fi
[[ -n "${RELEASE_SSH_KNOWN_HOSTS:-}" ]] || fail "RELEASE_SSH_KNOWN_HOSTS is required with RELEASE_SSH_KEY"
mkdir -p "$dir"
chmod 700 "$dir"
(umask 077; printf '%s\n' "$RELEASE_SSH_KEY" > "$dir/key")
trap 'rm -f "$WORK/ssh/key"' EXIT
printf '%s\n' "$RELEASE_SSH_KNOWN_HOSTS" > "$dir/known_hosts"
cat > "$dir/config" <<EOF
Host $RELEASE_HOST
HostName ${RELEASE_SSH_ADDRESS:?RELEASE_SSH_ADDRESS is required}
User ${RELEASE_SSH_USER:?RELEASE_SSH_USER is required}
IdentityFile $dir/key
IdentitiesOnly yes
UserKnownHostsFile $dir/known_hosts
GlobalKnownHostsFile /dev/null
StrictHostKeyChecking yes
BatchMode yes
EOF
chmod 600 "$dir/config"
export RELEASE_SSH_CONFIG="$dir/config"
}
# Every package on the server and its checksum, to prove afterwards that
# publishing added one file and changed none.
server_packages() {
release_remote "cd '$RELEASE_ROOT/packages' && sha256sum -- *.deb" | sort
}
# ---------------------------------------------------------------------------
cmd_build() {
local version="$1" commit name out package hash previous
shift
release_require_version "$version"
# Releases are cut from the integration branch or a maintenance branch,
# never from a feature branch that nobody has reviewed.
local ref="${GITHUB_REF:-refs/heads/$(git rev-parse --abbrev-ref HEAD)}"
case "$ref" in
refs/heads/master|refs/heads/release/*) ;;
*) fail "releases are built from master or release/*, not $ref" ;;
esac
[[ -z "$(git status --porcelain --untracked-files=no)" ]] || fail "the checkout has uncommitted changes"
[[ "$(cat VERSION)" == "$version" ]] \
|| fail "VERSION says $(cat VERSION). Commit the release first: VERSION=$version, the manual rebuilt"
commit="$(git rev-parse HEAD)"
name="$(release_package_name "$version")"
# NEVER A VERSION TWICE. Not one Gitea has, not one the update server
# has, and not one that is not newer than what either channel serves.
# update_canary.sh asks the server the authoritative question again,
# over ssh, before anything is published.
if has_release "$version"; then
fail "v$version already has a Gitea release; a version is built once"
fi
if on_server "$name"; then
fail "$name is already on the update server"
fi
for channel in canary stable; do
release_version_gt "$version" "$(served "$channel")" \
|| fail "$channel serves $(served "$channel"); $version is not newer"
done
# The manual's revision date is the commit's, so the build reproduces
# the tree it was built from rather than stamping today on it.
SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)" CHECK_LOG_DIR="$WORK/checks" \
"$ROOT/tools/build-release.sh" "$version"
[[ -z "$(git status --porcelain --untracked-files=no)" ]] \
|| { git status --short >&2; fail "the build changed the tree; the package is not the commit's"; }
out="$ROOT/dist/release-$version"
rm -rf "$out"
mkdir -p "$out"
package="$out/$name"
cp "$ROOT/dist/$name" "$package"
hash="$(sha256sum "$package" | awk '{print $1}')"
(cd "$out" && sha256sum "$name" > SHA256SUMS)
python3 - "$out/build-info.json" <<PY
import json, sys
json.dump({
"version": "$version",
"package": "$name",
"sha256": "$hash",
"commit": "$commit",
"ref": "$ref",
"built": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"built_by": "$(run_url)",
"dpkg_version": "$(release_package_version "$package")",
}, open(sys.argv[1], "w"), indent=2)
PY
cp "$WORK/checks/summary.md" "$out/checks.md"
RELEASE_GATE_REPORT="$out/gate-report.txt" "$ROOT/tools/release-gate.sh" "$package" "$@"
previous="$(git describe --tags --abbrev=0 --match 'v*' HEAD^ 2>/dev/null || true)"
{
echo "Ekklesia Classroom $version, built from \`$commit\` on \`${ref#refs/heads/}\`."
echo
echo "| | |"
echo "| --- | --- |"
echo "| Package | \`$name\` |"
echo "| SHA-256 | \`$hash\` |"
echo "| Source | \`$commit\` |"
echo "| Built | $(run_url) |"
echo
echo "Status: **built and gated. Not published.** Preview and Stable are"
echo "separate, deliberate steps; see docs/releasing.md."
echo
echo "## Changes${previous:+ since $previous}"
echo
git log --no-merges --format='- %s (%h)' "${previous:+$previous..}HEAD" | head -n 60
echo
echo "## Release gate"
echo
echo '```'
cat "$out/gate-report.txt"
echo '```'
} > "$out/notes.md"
"$GITEA" create "v$version" "$commit" "Ekklesia Classroom $version" "$out/notes.md" --prerelease
"$GITEA" upload "v$version" "$package" "$out/SHA256SUMS" "$out/build-info.json" \
"$out/gate-report.txt" "$out/checks.md"
"$GITEA" record "v$version" "built and gated, sha256 \`$hash\` ($(run_url))"
echo
echo "Built $name"
echo " sha256 $hash"
echo " commit $commit"
echo "Next, deliberately: the Preview workflow, with version $version and this sha256."
[[ -z "${GITHUB_OUTPUT:-}" ]] || printf 'sha256=%s\n' "$hash" >> "$GITHUB_OUTPUT"
}
cmd_gate() {
local version="$1" hash="$2" package report
require_sha256 "$hash"
package="$(fetch "$version" "$hash")"
# A new name every run: a report attached to a release is never replaced.
report="$WORK/gate-report-preview-$(date -u +%Y%m%dT%H%M%SZ).txt"
RELEASE_GATE_REPORT="$report" "$ROOT/tools/release-gate.sh" "$package" ${RELEASE_GATE_FROM:-}
"$GITEA" upload "v$version" "$report"
"$GITEA" record "v$version" "gated for Preview: $(basename "$report") ($(run_url))"
[[ -z "${GITHUB_OUTPUT:-}" ]] || printf 'report=%s\n' "$(basename "$report")" >> "$GITHUB_OUTPUT"
}
cmd_preview() {
local version="$1" hash="$2" report_name="$3" package before after added
require_sha256 "$hash"
package="$(fetch "$version" "$hash")"
"$GITEA" download "v$version" "$report_name" "$WORK/$report_name"
ssh_setup
export RELEASE_LOCAL="$WORK/published"
before="$(server_packages)"
RELEASE_GATE_REPORT_PASSED="$WORK/$report_name" "$ROOT/update_canary.sh" "$version" "$package"
# One file added, and not a byte of any other changed.
after="$(server_packages)"
added="$(comm -13 <(printf '%s\n' "$before") <(printf '%s\n' "$after"))"
[[ -z "$(comm -23 <(printf '%s\n' "$before") <(printf '%s\n' "$after"))" ]] \
|| fail "a package already on the server changed while publishing"
[[ "$added" == "$hash $(basename "$package")" ]] \
|| fail "publishing added [$added], not exactly $(basename "$package")"
release_verify_channel canary "$version" | tee "$WORK/preview.txt"
echo "stable still serves $(served stable)" | tee -a "$WORK/preview.txt"
"$GITEA" record "v$version" "published to Preview (canary), sha256 \`$hash\`, on gate report $report_name; manifest, package and latest.deb read back over HTTPS; no other package changed; stable still serves $(served stable) ($(run_url), $(actor))"
}
cmd_stable() {
local version="$1" hash="$2" approval="$3" package before after body
require_sha256 "$hash"
[[ -n "$approval" ]] || fail "a promotion names who approved it and on what checks"
# Only a file that went to Preview is promoted, and only that file.
body="$("$GITEA" notes "v$version")"
grep -qF "published to Preview (canary), sha256 \`$hash\`" <<< "$body" \
|| fail "v$version has no record of $hash going to Preview; promote only what Preview ran"
package="$(fetch "$version" "$hash")"
ssh_setup
export RELEASE_LOCAL="$WORK/published"
mkdir -p "$RELEASE_LOCAL/packages"
cp "$package" "$RELEASE_LOCAL/packages/"
before="$(server_packages)"
"$ROOT/update_stable.sh" "$version" "$hash"
after="$(server_packages)"
[[ "$before" == "$after" ]] || fail "a package on the server changed while promoting"
"$GITEA" record "v$version" "promoted to Stable, sha256 \`$hash\`, the file Preview ran; read back over HTTPS. Approval: $approval ($(run_url), $(actor))"
"$GITEA" stable "v$version"
}
cmd_import() {
local version="$1" hash="$2" commit="$3" report="${4:-}" name out
require_sha256 "$hash"
name="$(release_package_name "$version")"
out="$WORK/import-$version"
mkdir -p "$out"
git cat-file -e "$commit^{commit}" 2>/dev/null || fail "$commit is not a commit here"
# Only a release with no record yet. One that has a record keeps it.
if has_release "$version"; then
fail "v$version already has a Gitea release; its record is kept as it is"
fi
curl -fsS -o "$out/$name" "$RELEASE_URL/packages/$name"
[[ "$(sha256sum "$out/$name" | awk '{print $1}')" == "$hash" ]] \
|| fail "the update server's $name is not sha256 $hash"
[[ "$(release_package_version "$out/$name")" == "$version" ]] || fail "$name does not say it is $version"
(cd "$out" && sha256sum "$name" > SHA256SUMS)
{
echo "Ekklesia Classroom $version, from \`$commit\`."
echo
echo "| | |"
echo "| --- | --- |"
echo "| Package | \`$name\` |"
echo "| SHA-256 | \`$hash\` |"
echo "| Source | \`$commit\` |"
echo
echo "Built and published before Gitea kept the release record. The"
echo "package here is the file the update server serves, byte for byte."
echo
echo "## Changes"
echo
git log -1 --format=%B "$commit"
} > "$out/notes.md"
"$GITEA" create "v$version" "$commit" "Ekklesia Classroom $version" "$out/notes.md" --prerelease
"$GITEA" upload "v$version" "$out/$name" "$out/SHA256SUMS" ${report:+"$report"}
"$GITEA" record "v$version" "imported from the update server, sha256 \`$hash\` ($(run_url))"
# Where it stands is read off the server, never assumed: the lines the
# stable step looks for are only written if the channel really serves
# this file now.
for channel in canary stable; do
if release_verify_channel "$channel" "$version" >/dev/null 2>&1 \
&& [[ "$(curl -fsS "$RELEASE_URL/$channel/manifest.json" \
| python3 -c 'import json, sys; print(json.load(sys.stdin)["sha256"])')" == "$hash" ]]; then
case "$channel" in
canary) "$GITEA" record "v$version" "published to Preview (canary), sha256 \`$hash\`, by update_canary.sh before Gitea kept the record; canary serves it, read back over HTTPS at import" ;;
stable) "$GITEA" record "v$version" "promoted to Stable, sha256 \`$hash\`, by update_stable.sh before Gitea kept the record; stable serves it, read back over HTTPS at import"
"$GITEA" stable "v$version" ;;
esac
fi
done
}
case "${1:-}" in
build) shift; [[ $# -ge 1 ]] || fail "build VERSION [FROM_VERSION...]"; cmd_build "$@" ;;
gate) [[ $# -eq 3 ]] || fail "gate VERSION SHA256"; cmd_gate "$2" "$3" ;;
preview) [[ $# -eq 4 ]] || fail "preview VERSION SHA256 GATE_REPORT_NAME"; cmd_preview "$2" "$3" "$4" ;;
stable) [[ $# -eq 4 ]] || fail "stable VERSION SHA256 APPROVAL"; cmd_stable "$2" "$3" "$4" ;;
import) [[ $# -ge 4 && $# -le 5 ]] || fail "import VERSION SHA256 COMMIT [GATE_REPORT_FILE]"; shift; cmd_import "$@" ;;
*) sed -n '2,8p' "$0" >&2; exit 2 ;;
esac
+219
View File
@@ -0,0 +1,219 @@
#!/bin/bash
# THE RELEASE GATE: may this exact file go to a classroom?
#
# tools/release-gate.sh PACKAGE.deb [FROM_VERSION...]
# tools/release-gate.sh --ci PACKAGE.deb for CI; see below
# tools/release-gate.sh --still-current REPORT PACKAGE.deb
#
# Runs tools/validate-trixie-release.sh against PACKAGE as
#
# * a fresh install;
# * an upgrade from what canary serves today, on a canary room;
# * an upgrade from what stable serves today, on a stable room;
# * an upgrade from each FROM_VERSION, on a stable room — the release a
# room is really still on, which can be several behind stable.
#
# The releases it upgrades from are downloaded over HTTPS the way a
# classroom downloads them, and each is checked against the SHA-256 its
# manifest publishes, or, for a FROM_VERSION, against the version dpkg
# reads out of it. Nothing here needs a credential or writes to the
# update server, so a CI runner can run it on code nobody has reviewed.
#
# Every run is one line in RELEASE_GATE_REPORT (default: printed only),
# so the result can be attached to the release it describes.
#
# It stops at the first failure: a gate that carries on reports a pass
# for a machine it never finished building.
#
# --ci is for a commit, not a release. Between releases master's VERSION
# is what canary already serves, and no room can upgrade to a version it
# has; such an upgrade is recorded as SKIP, with why, instead of failing
# every commit. Its report says it is a CI report and can never stand in
# for a release's.
#
# --still-current REPORT PACKAGE asks whether a gate report may still be
# relied on to publish PACKAGE: it has to be a release gate's report, for
# exactly these bytes, that passed, and whose upgrades were from exactly
# what canary and stable serve now. A report made before either channel
# moved is not current, and the gate has to be run again. Exit 0 or 1.
set -euo pipefail
# The fetches below run in $(...), which otherwise ignores set -e: a
# manifest that failed to download would go on to compare an empty hash
# with an empty hash, and match.
shopt -s inherit_errexit
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$ROOT/tools/release-lib.sh"
usage() {
echo "Usage: $0 [--ci] PACKAGE.deb [FROM_VERSION...]" >&2
echo " $0 --still-current REPORT PACKAGE.deb" >&2
exit 2
}
mode=release
case "${1:-}" in
--ci) mode=ci; shift ;;
--still-current) mode=check; shift ;;
esac
if [[ "$mode" == check ]]; then
[[ $# -eq 2 ]] || usage
gate_report="$1"
shift
fi
[[ $# -ge 1 ]] || usage
candidate="$(realpath "$1")"
shift
extra_from=("$@")
[[ -f "$candidate" ]] || { echo "Package not found: $candidate" >&2; exit 1; }
version="$(release_package_version "$candidate")"
release_require_version "$version"
# The name a release is published under is the version dpkg reads out of
# it. A file called 0.2.6.6 that says it is 0.2.6.5 is the wrong file.
if [[ "$(basename "$candidate")" != "$(release_package_name "$version")" ]]; then
echo "$(basename "$candidate") says it is $version." >&2
exit 1
fi
hash="$(sha256sum "$candidate" | awk '{print $1}')"
# The version a channel serves, read the way a classroom reads it.
served() {
curl -fsS -H 'Cache-Control: no-cache' "$RELEASE_URL/$1/manifest.json" \
| python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])'
}
if [[ "$mode" == check ]]; then
stale() { echo "The gate report $gate_report cannot be relied on: $1" >&2; exit 1; }
name="$(basename "$candidate")"
[[ -f "$gate_report" ]] || stale "there is no such file"
grep -qx "release gate for $name" "$gate_report" || stale "it is not about $name"
grep -qx "mode release" "$gate_report" || stale "it is not a release gate's report"
grep -qx "sha256 $hash" "$gate_report" || stale "it is about other bytes than $hash"
[[ "$(tail -n 1 "$gate_report")" == *": $name passed the release gate" ]] \
|| stale "it does not end in a pass"
! grep -q '^SKIP' "$gate_report" || stale "it skipped something"
grep -q "^PASS fresh install of $version " "$gate_report" || stale "it has no fresh install"
for channel in canary stable; do
now="$(served "$channel")"
grep -qF "PASS upgrade $now -> $version on a $channel room ($channel serves $now)" "$gate_report" \
|| stale "$channel serves $now now, and the report did not upgrade from that"
done
echo "The gate report for $name, sha256 $hash, is current: canary and stable are where it left them."
exit 0
fi
report="${RELEASE_GATE_REPORT:-}"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
record() {
printf '%s\n' "$1"
[[ -z "$report" ]] || printf '%s\n' "$1" >> "$report"
}
[[ -z "$report" ]] || : > "$report"
record "release gate for $(basename "$candidate")"
record "sha256 $hash"
record "mode $mode"
record "started $(date -u +%Y-%m-%dT%H:%M:%SZ)"
# A channel's current package, fetched as a classroom fetches it and
# proven to be what the manifest promises.
fetch_channel() {
local channel="$1" manifest from from_hash name got
manifest="$(curl -fsS -H 'Cache-Control: no-cache' "$RELEASE_URL/$channel/manifest.json")"
read -r from from_hash < <(python3 -c 'import json, sys
m = json.loads(sys.argv[1])
print(m["version"], m["sha256"])' "$manifest")
release_require_version "$from"
[[ "$from_hash" =~ ^[0-9a-f]{64}$ ]]
name="$(release_package_name "$from")"
curl -fsS -o "$work/$name" "$RELEASE_URL/packages/$name"
got="$(sha256sum "$work/$name" | awk '{print $1}')"
if [[ "$got" != "$from_hash" ]]; then
echo "$channel serves $name with sha256 $got; its manifest says $from_hash" >&2
return 1
fi
printf '%s\n' "$work/$name"
}
# A published release by version, for the rooms still on an older one.
fetch_version() {
local from="$1" name
release_require_version "$from"
name="$(release_package_name "$from")"
curl -fsS -o "$work/$name" "$RELEASE_URL/packages/$name"
if [[ "$(release_package_version "$work/$name")" != "$from" ]]; then
echo "$name does not say it is $from" >&2
return 1
fi
printf '%s\n' "$work/$name"
}
run() {
local label="$1" started
shift
started=$SECONDS
echo
echo "== $label"
"$@"
record "PASS $label ($(( (SECONDS - started) / 60 )) min)"
}
# A new machine is a stable room: that is what a fresh device.conf says,
# so that is the manifest its updater is asked about.
run "fresh install of $version (a new machine, on stable)" \
env VALIDATION_CHANNEL=stable \
"$ROOT/tools/validate-trixie-release.sh" "$candidate"
for channel in canary stable; do
previous="$(fetch_channel "$channel")"
from="$(release_package_version "$previous")"
if ! release_version_gt "$version" "$from"; then
if [[ "$mode" == ci ]]; then
record "SKIP upgrade $from -> $version on a $channel room: $channel serves $from, not older than this commit's VERSION (a CI build, not a release)"
continue
fi
echo "$channel is on $from; $version is not newer, so no room could take it." >&2
exit 1
fi
run "upgrade $from -> $version on a $channel room ($channel serves $from)" \
env VALIDATION_CHANNEL="$channel" \
"$ROOT/tools/validate-trixie-release.sh" "$candidate" "$previous"
done
for from in "${extra_from[@]}"; do
previous="$(fetch_version "$from")"
run "upgrade $from -> $version on a stable room" \
env VALIDATION_CHANNEL=stable \
"$ROOT/tools/validate-trixie-release.sh" "$candidate" "$previous"
done
if [[ "$mode" == ci ]]; then
record "finished $(date -u +%Y-%m-%dT%H:%M:%SZ): $(basename "$candidate") passed the CI gate, which is not a release gate"
else
record "finished $(date -u +%Y-%m-%dT%H:%M:%SZ): $(basename "$candidate") passed the release gate"
fi
+21 -3
View File
@@ -66,8 +66,26 @@ release_package_version() {
dpkg-deb --field "$1" Version dpkg-deb --field "$1" Version
} }
# ssh to the update server. With RELEASE_SSH_CONFIG, that file and nothing
# else configures it — `ssh -F` ignores ~/.ssh/config and the system's — so
# a release job uses only the key it was given, and never the credentials
# of whatever machine it happens to run on. Without it, this machine's own
# configuration, as a person publishing by hand always has.
release_ssh() {
if [[ -n "${RELEASE_SSH_CONFIG:-}" ]]; then
ssh -F "$RELEASE_SSH_CONFIG" "$@"
else
ssh "$@"
fi
}
# The same, as rsync's -e.
release_rsync_ssh() {
printf 'ssh%s\n' "${RELEASE_SSH_CONFIG:+ -F $RELEASE_SSH_CONFIG}"
}
release_remote() { release_remote() {
ssh "$RELEASE_HOST" "$@" release_ssh "$RELEASE_HOST" "$@"
} }
# Every version that has ever been published, one per line, read from the # Every version that has ever been published, one per line, read from the
@@ -151,7 +169,7 @@ release_upload_package() {
name="$(basename "$package")" name="$(basename "$package")"
local_hash="$(sha256sum "$package" | awk '{print $1}')" local_hash="$(sha256sum "$package" | awk '{print $1}')"
rsync -av --ignore-existing -e ssh \ rsync -av --ignore-existing -e "$(release_rsync_ssh)" \
"$package" "$RELEASE_HOST:$RELEASE_ROOT/packages/" "$package" "$RELEASE_HOST:$RELEASE_ROOT/packages/"
remote_hash="$(release_remote "sha256sum '$RELEASE_ROOT/packages/$name'" | awk '{print $1}')" remote_hash="$(release_remote "sha256sum '$RELEASE_ROOT/packages/$name'" | awk '{print $1}')"
@@ -192,7 +210,7 @@ release_publish_manifest() {
EOF EOF
# Manifest deliberately goes LAST, after the package it names. # Manifest deliberately goes LAST, after the package it names.
rsync -av -e ssh \ rsync -av -e "$(release_rsync_ssh)" \
"$RELEASE_LOCAL/$channel/manifest.json" \ "$RELEASE_LOCAL/$channel/manifest.json" \
"$RELEASE_HOST:$RELEASE_ROOT/$channel/manifest.json" "$RELEASE_HOST:$RELEASE_ROOT/$channel/manifest.json"
+89
View File
@@ -117,6 +117,13 @@ CUSTOM_SETTING=preserve-me-too
CONFIG CONFIG
sed -i "s/@CHANNEL@/${channel}/" /etc/ekklesia-classroom/device.conf sed -i "s/@CHANNEL@/${channel}/" /etc/ekklesia-classroom/device.conf
previous_version="$(dpkg-query -W -f='${Version}' ekklesia-classroom)" previous_version="$(dpkg-query -W -f='${Version}' ekklesia-classroom)"
# The release this is an upgrade FROM, proven rather than assumed: an
# install that silently did not happen makes every check below a
# check of a fresh install instead.
[[ "$previous_version" == "$(dpkg-deb --field "$previous" Version)" ]]
[[ "$(dpkg-query -W -f='${db:Status-Abbrev}' ekklesia-classroom)" == "ii " ]]
echo "installed the previous release, ekklesia-classroom $previous_version"
fi fi
run_logged candidate-install 1200 apt-get install -y -qq /packages/candidate.deb run_logged candidate-install 1200 apt-get install -y -qq /packages/candidate.deb
@@ -130,6 +137,7 @@ run_logged candidate-install 1200 apt-get install -y -qq /packages/candidate.deb
# for ever. # for ever.
candidate_version="$(dpkg-deb --field /packages/candidate.deb Version)" candidate_version="$(dpkg-deb --field /packages/candidate.deb Version)"
[[ "$(dpkg-query -W -f='${Version}' ekklesia-classroom)" == "$candidate_version" ]] [[ "$(dpkg-query -W -f='${Version}' ekklesia-classroom)" == "$candidate_version" ]]
[[ "$(dpkg-query -W -f='${db:Status-Abbrev}' ekklesia-classroom)" == "ii " ]]
[[ "$(cat /usr/local/lib/ekklesia-classroom/VERSION)" == "$candidate_version" ]] [[ "$(cat /usr/local/lib/ekklesia-classroom/VERSION)" == "$candidate_version" ]]
echo "installed ekklesia-classroom $candidate_version${previous_version:+ over $previous_version}" echo "installed ekklesia-classroom $candidate_version${previous_version:+ over $previous_version}"
@@ -145,6 +153,20 @@ systemctl is-enabled NetworkManager.service >/dev/null
systemctl is-enabled cups.service >/dev/null systemctl is-enabled cups.service >/dev/null
! systemctl is-enabled NetworkManager-wait-online.service >/dev/null 2>&1 ! systemctl is-enabled NetworkManager-wait-online.service >/dev/null 2>&1
# The update check: daily at 03:00, caught up after a night switched off,
# and still so after this install or upgrade. The reboot after an update
# waits until nobody is using the computer, in a unit nothing enables.
systemctl is-enabled ekklesia-classroom-update.timer >/dev/null
timer=/etc/systemd/system/ekklesia-classroom-update.timer
grep -qx 'OnCalendar=\*-\*-\* 03:00:00' "$timer"
grep -qx 'Persistent=true' "$timer"
! grep -qE '^(OnBootSec|OnStartupSec|OnUnitActiveSec|OnUnitInactiveSec|OnActiveSec)=' "$timer"
! ls /etc/systemd/system/ekklesia-classroom-update.timer.d/*.conf >/dev/null 2>&1
test -x /usr/local/bin/classroom-reboot-when-idle
[[ "$(systemctl is-enabled ekklesia-classroom-reboot-when-idle.service 2>/dev/null || true)" == "static" ]]
! grep -q '"systemctl", "reboot"' /usr/local/bin/ekklesia-classroom-update
grep -q 'ekklesia-classroom-reboot-when-idle.service' /usr/local/bin/ekklesia-classroom-update
# The audio policy reaches a session, and is ordered after the server it # The audio policy reaches a session, and is ordered after the server it
# actually talks to. Ordering it only after pipewire and wireplumber is # actually talks to. Ordering it only after pipewire and wireplumber is
# what let it race PipeWire's PulseAudio server at boot, fail its one # what let it race PipeWire's PulseAudio server at boot, fail its one
@@ -485,6 +507,71 @@ if [[ -n "$previous" ]]; then
grep -q 'var PRESENTATIONS = false;' \ grep -q 'var PRESENTATIONS = false;' \
/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js /usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js
# TINY PEBBLES, 0.2.2 -> 0.2.6.4: THE DESKTOP WITH NO WALLPAPER.
#
# The room's desktop was containment 10, made by Plasma after the
# layout had run, with nothing configured on it; it showed Plasma's
# own wallpaper. What a desktop gets when nothing wrote it is the look
# and feel's default, and that has to be a package Plasma can find.
lnf=/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/defaults
[[ "$(sed -n '/^\[Wallpaper\]/,/^\[/s/^Image=//p' "$lnf")" == EkklesiaClassroom ]]
grep -q '"Id": "EkklesiaClassroom"' /usr/share/wallpapers/EkklesiaClassroom/metadata.json
[[ "$(readlink -f /usr/share/wallpapers/EkklesiaClassroom/contents/images/*.png)" \
== /usr/share/backgrounds/ekklesia/classroom-calm.png ]]
grep -qx 'ExecStartPre=-/usr/local/bin/classroom-desktop-wallpaper' \
/etc/systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf
# And the repair the package installed, on that room's own file: the
# panel built with no screen took 1 and its applets 2 to 9.
wallpaper_home=$(mktemp -d)
cat > "$wallpaper_home/plasma-org.kde.plasma.desktop-appletsrc" <<'APPLETS'
[Containments][1]
formfactor=2
location=4
plugin=org.kde.panel
wallpaperplugin=org.kde.image
[Containments][1][Applets][2]
plugin=org.kde.plasma.quicklaunch
[Containments][1][Applets][9]
plugin=org.kde.plasma.digitalclock
[Containments][1][General]
AppletOrder=2;3;4;5;6;7;8;9
[Containments][10]
activityId=62d3d55d-bd31-4fc8-9a7b-a5b9b2b99b60
formfactor=0
immutability=1
lastScreen=0
location=0
plugin=org.kde.plasma.folder
wallpaperplugin=org.kde.image
[Containments][10][General]
positions={"1920x1080":[]}
APPLETS
applets="$wallpaper_home/plasma-org.kde.plasma.desktop-appletsrc"
panel_before=$(sed -n '/^\[Containments\]\[1\]/,/^$/p' "$applets")
! XDG_CONFIG_HOME="$wallpaper_home" classroom-desktop-wallpaper --check >/dev/null
XDG_CONFIG_HOME="$wallpaper_home" classroom-desktop-wallpaper \
| grep -q 'configured on desktop 10'
sed -n '/^\[Containments\]\[10\]\[Wallpaper\]\[org.kde.image\]\[General\]$/,/^$/p' "$applets" \
| grep -qx 'Image=file:///usr/share/backgrounds/ekklesia/classroom-calm.png'
[[ "$(sed -n '/^\[Containments\]\[1\]/,/^$/p' "$applets")" == "$panel_before" ]]
[[ "$(grep -c '^\[Containments\]\[[0-9]*\]$' "$applets")" -eq 2 ]]
# Twice changes nothing, and a room that was already right is not
# written at all.
repaired=$(sha256sum < "$applets")
XDG_CONFIG_HOME="$wallpaper_home" classroom-desktop-wallpaper \
| grep -q 'the classroom wallpaper already'
[[ "$(sha256sum < "$applets")" == "$repaired" ]]
XDG_CONFIG_HOME="$wallpaper_home" classroom-desktop-wallpaper --check >/dev/null
rm -rf "$wallpaper_home"
# And a room that was carried through the experiment on its own # And a room that was carried through the experiment on its own
# channel comes home, because that channel is the obsolete one now. # channel comes home, because that channel is the obsolete one now.
sed -i 's/^CHANNEL=.*/CHANNEL=classroom-v2/' /etc/ekklesia-classroom/device.conf sed -i 's/^CHANNEL=.*/CHANNEL=classroom-v2/' /etc/ekklesia-classroom/device.conf
@@ -505,6 +592,8 @@ if [[ -n "$previous" ]]; then
run_logged candidate-reinstall 900 \ run_logged candidate-reinstall 900 \
apt-get install --reinstall -y -qq /packages/candidate.deb apt-get install --reinstall -y -qq /packages/candidate.deb
[[ "$(dpkg-query -W -f='${Version} ${db:Status-Abbrev}' ekklesia-classroom)" \
== "$candidate_version ii " ]]
# An install that changes nothing must change nothing. # An install that changes nothing must change nothing.
for setting in LIVE_ENABLED AUDIO_OUTPUT UI_SCALE; do for setting in LIVE_ENABLED AUDIO_OUTPUT UI_SCALE; do
+54 -35
View File
@@ -12,9 +12,12 @@ source "$ROOT/tools/release-lib.sh"
# there are two — 0.2.6 is followed by 0.2.6.1 if it is a fix and by # there are two — 0.2.6 is followed by 0.2.6.1 if it is a fix and by
# 0.2.7 if it is not — and which one a release is, is a decision about # 0.2.7 if it is not — and which one a release is, is a decision about
# what is in it, not arithmetic. # what is in it, not arithmetic.
if [[ $# -ne 1 ]]; then if [[ $# -lt 1 || $# -gt 2 ]]; then
current="$(release_channel_version canary || true)" current="$(release_channel_version canary || true)"
echo "Usage: $0 VERSION" >&2 echo "Usage: $0 VERSION [PACKAGE.deb]" >&2
echo >&2
echo "With PACKAGE.deb, that file is gated and published and nothing is" >&2
echo "built, so the file that was tested is the file that is published." >&2
echo >&2 echo >&2
echo "Canary is on ${current:-an unknown version}. Name the release, for example:" >&2 echo "Canary is on ${current:-an unknown version}. Name the release, for example:" >&2
if [[ -n "$current" ]] && release_version_valid "$current"; then if [[ -n "$current" ]] && release_version_valid "$current"; then
@@ -28,62 +31,78 @@ fi
version="$1" version="$1"
package_name="$(release_package_name "$version")" package_name="$(release_package_name "$version")"
package_path="$ROOT/dist/$package_name" package_path="$ROOT/dist/$package_name"
prebuilt="${2:-}"
echo "Current canary: $(release_channel_version canary || echo unknown)" echo "Current canary: $(release_channel_version canary || echo unknown)"
echo "New canary: $version" echo "New canary: $version"
echo echo
# RELEASE_GATE_FROM="0.2.2" adds the upgrade from an older release that
# rooms are still on to the gate, beside canary's and stable's own.
#
# Before anything is built: a version that has been published, or that # Before anything is built: a version that has been published, or that
# would not be newer than everything that has been, stops here. # would not be newer than everything that has been, stops here.
release_check_new_version "$version" release_check_new_version "$version"
"$ROOT/tools/build-release.sh" "$version" # ONE FILE, FROM THE GATE TO THE ROOM.
#
# Given a package, that package is what is gated and published, and
# nothing is rebuilt: a build is not byte-for-byte repeatable, so a second
# build is a second, untested file. Without one, it is built here.
if [[ -n "$prebuilt" ]]; then
package_path="$(realpath "$prebuilt")"
# Validate the exact artifact that will be published. If a previous canary is if [[ "$(basename "$package_path")" != "$package_name" ]]; then
# available locally, exercise the real upgrade path as well as a fresh install. echo "$prebuilt is not named $package_name." >&2
validation_args=("$package_path") exit 1
if [[ -f "$RELEASE_LOCAL/canary/manifest.json" ]]; then
previous_package_name="$(python3 -c \
'import json, sys; print(json.load(open(sys.argv[1]))["package"].rsplit("/", 1)[-1])' \
"$RELEASE_LOCAL/canary/manifest.json")"
previous_package="$RELEASE_LOCAL/packages/$previous_package_name"
if [[ -f "$previous_package" && "$previous_package" != "$package_path" ]]; then
validation_args+=("$previous_package")
fi fi
if [[ "$(release_package_version "$package_path")" != "$version" ]]; then
echo "$prebuilt says it is $(release_package_version "$package_path"), not $version." >&2
exit 1
fi
echo "Publishing the package already built: $package_path"
else
"$ROOT/tools/build-release.sh" "$version"
fi fi
"$ROOT/tools/validate-trixie-release.sh" "${validation_args[@]}" # A fresh install, and the upgrade from whatever canary and stable serve
# today, each on a room of that channel. Every canary is a stable release
# in waiting, and the desktops are not on the previous canary: they are on
# whatever stable names, which can be several releases back (0.2.2 under
# 0.2.6.1). A package that upgrades the laptop and breaks the desktops has
# to fail here, not on a Sunday. See tools/release-gate.sh.
hash="$(sha256sum "$package_path" | awk '{print $1}')"
# And the stable rooms' own path, on the same file. Every canary is a # RELEASE_GATE_REPORT_PASSED=REPORT relies on a gate already run on this
# stable release in waiting, and the desktops are not on the previous # file instead — Preview's gate job, which has Docker, where the publish
# canary: they are on whatever stable names, which can be several # job has the key and no Docker. The report is only accepted if it is a
# releases back (0.2.2 under 0.2.6.1). A package that upgrades the # pass for exactly these bytes, from exactly what canary and stable serve
# laptop and breaks the desktops has to fail here, not on a Sunday. # now; anything else and nothing is published.
if [[ -f "$RELEASE_LOCAL/stable/manifest.json" ]]; then if [[ -n "${RELEASE_GATE_REPORT_PASSED:-}" ]]; then
stable_package_name="$(python3 -c \ "$ROOT/tools/release-gate.sh" --still-current "$RELEASE_GATE_REPORT_PASSED" "$package_path"
'import json, sys; print(json.load(open(sys.argv[1]))["package"].rsplit("/", 1)[-1])' \ else
"$RELEASE_LOCAL/stable/manifest.json")" "$ROOT/tools/release-gate.sh" "$package_path" ${RELEASE_GATE_FROM:-}
stable_package="$RELEASE_LOCAL/packages/$stable_package_name"
if [[ -f "$stable_package" && "$stable_package" != "${validation_args[1]:-}" ]]; then
echo
echo "Rehearsing the stable rooms' upgrade from $stable_package_name"
VALIDATION_CHANNEL=stable \
"$ROOT/tools/validate-trixie-release.sh" "$package_path" "$stable_package"
fi
fi fi
# Checked again after the gate, which takes long enough for somebody else # Checked again after the gate, which takes long enough for somebody else
# to have published in the meantime. # to have published in the meantime — or for the file to have changed.
release_check_new_version "$version" release_check_new_version "$version"
hash="$(sha256sum "$package_path" | awk '{print $1}')" if [[ "$(sha256sum "$package_path" | awk '{print $1}')" != "$hash" ]]; then
echo "$package_path changed while it was being gated. Nothing was published." >&2
exit 1
fi
mkdir -p "$RELEASE_LOCAL/packages" mkdir -p "$RELEASE_LOCAL/packages"
cp -n "$package_path" "$RELEASE_LOCAL/packages/$package_name" cp -n "$package_path" "$RELEASE_LOCAL/packages/$package_name"
if [[ "$(sha256sum "$RELEASE_LOCAL/packages/$package_name" | awk '{print $1}')" != "$hash" ]]; then
echo "$RELEASE_LOCAL/packages/$package_name is not the file that passed the gate." >&2
exit 1
fi
echo echo
echo "Publishing package..." echo "Publishing package..."
release_upload_package "$RELEASE_LOCAL/packages/$package_name" release_upload_package "$RELEASE_LOCAL/packages/$package_name"
+24 -1
View File
@@ -9,7 +9,23 @@ source "$ROOT/tools/release-lib.sh"
# Stable never gets a package of its own. It is pointed at one canary # Stable never gets a package of its own. It is pointed at one canary
# already published, so the file every classroom downloads is byte for # already published, so the file every classroom downloads is byte for
# byte the one the canary machine ran. # byte the one the canary machine ran.
if [[ $# -eq 1 ]]; then #
# ./update_stable.sh VERSION SHA256 the release that was approved, by
# the checksum it was approved as
# ./update_stable.sh VERSION the release, whatever file it is
# ./update_stable.sh whatever canary is on
#
# With SHA256, anything but that exact file is refused. The checksum is
# the one written down when the release went to canary and passed its
# physical checks, so the approval and the promotion name the same bytes.
expected_hash="${2:-}"
if [[ -n "$expected_hash" && ! "$expected_hash" =~ ^[0-9a-f]{64}$ ]]; then
echo "Not a SHA-256 checksum: $expected_hash" >&2
exit 1
fi
if [[ $# -ge 1 ]]; then
version="$1" version="$1"
else else
if [[ ! -f "$RELEASE_LOCAL/canary/manifest.json" ]]; then if [[ ! -f "$RELEASE_LOCAL/canary/manifest.json" ]]; then
@@ -50,6 +66,13 @@ fi
hash="$(sha256sum "$package_path" | awk '{print $1}')" hash="$(sha256sum "$package_path" | awk '{print $1}')"
if [[ -n "$expected_hash" && "$hash" != "$expected_hash" ]]; then
echo "$package_name is not the file that was approved:" >&2
echo " approved: $expected_hash" >&2
echo " here: $hash" >&2
exit 1
fi
echo "Promoting $version to STABLE (from ${current:-nothing})" echo "Promoting $version to STABLE (from ${current:-nothing})"
# Refuses unless the server already holds exactly this package. # Refuses unless the server already holds exactly this package.