Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6946bae455 | ||
|
|
259c43bee8 | ||
|
|
1d9da3bca0 | ||
|
|
88ea3e86fd | ||
|
|
1cb9fd35d2 | ||
|
|
48b3097c43 | ||
|
|
10433b850d | ||
|
|
690ef95aff |
@@ -20,10 +20,32 @@ and the **×** on a tab to close it.
|
|||||||
There is no address bar, on purpose. Wonder Ink can take you anywhere
|
There is no address bar, on purpose. Wonder Ink can take you anywhere
|
||||||
the lesson needs to go.
|
the lesson needs to go.
|
||||||
|
|
||||||
|
## Making the lesson bigger or smaller
|
||||||
|
|
||||||
|
Press **Ctrl** and **+** to make the lesson bigger, **Ctrl** and **−** to
|
||||||
|
make it smaller, and **Ctrl** and **0** to go back to normal. You can also
|
||||||
|
right-click the lesson and choose **Make the lesson bigger**. When several
|
||||||
|
tabs are open, the **− 100% +** buttons at the top right do the same.
|
||||||
|
|
||||||
|
The size applies to every tab and is remembered next time.
|
||||||
|
|
||||||
|
## Printing
|
||||||
|
|
||||||
|
Press **Ctrl** and **P**, click **Print** at the top right, or right-click
|
||||||
|
and choose **Print…**. You will see the pages exactly as they will print.
|
||||||
|
|
||||||
|
* Choose **Only some pages** to print, for example, `2` or `1-3`.
|
||||||
|
* Set **Copies** for the number of children.
|
||||||
|
* Choose **Black and white** to save colour ink.
|
||||||
|
|
||||||
|
Click **Print**. Wonder Ink says when the printer has it and goes back to
|
||||||
|
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.
|
read or print. 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.
|
||||||
|
|||||||
@@ -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 · Revision 22 September 2026</p>
|
<p class="revision">River of Glory · Revision 23 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>
|
||||||
@@ -131,8 +131,15 @@ figcaption { color: #93a5aa; font-size: .9rem; margin-top: .4rem; }
|
|||||||
<ol><li>Click <strong>Wonder Ink</strong> on the bar.</li><li>Sign in with your Wonder Ink account.</li></ol>
|
<ol><li>Click <strong>Wonder Ink</strong> on the bar.</li><li>Sign in with your Wonder Ink account.</li></ol>
|
||||||
<p>A lesson often opens several tabs — the lesson, its video, an activity. They appear in a row across the top. Click a tab to move between them, and the <strong>×</strong> on a tab to close it.</p>
|
<p>A lesson often opens several tabs — the lesson, its video, an activity. They appear in a row across the top. Click a tab to move between them, and the <strong>×</strong> on a tab to close it.</p>
|
||||||
<p>There is no address bar, on purpose. Wonder Ink can take you anywhere the lesson needs to go.</p>
|
<p>There is no address bar, on purpose. Wonder Ink can take you anywhere the lesson needs to go.</p>
|
||||||
|
<h3>Making the lesson bigger or smaller</h3>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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.</p>
|
<p>Click the worksheet link in the lesson. It opens in a new tab, ready to read or print. A worksheet'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>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.</p>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ endobj
|
|||||||
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 22 September 2026) 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 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
|
||||||
@@ -206,7 +206,7 @@ BT /F1 10.5 Tf 1 0 0 1 62.0 203.4 Tm (Download it again. Your original download
|
|||||||
endstream
|
endstream
|
||||||
endobj
|
endobj
|
||||||
20 0 obj
|
20 0 obj
|
||||||
<< /Length 1838 >>
|
<< /Length 3431 >>
|
||||||
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
|
||||||
@@ -221,13 +221,30 @@ BT /F1 10.5 Tf 1 0 0 1 94.0 634.5 Tm (Sign in with your Wonder Ink account.) Tj
|
|||||||
BT /F1 10.5 Tf 1 0 0 1 62.0 614.6 Tm (A lesson often opens several tabs - the lesson, its video, an activity. They appear in a row) Tj ET
|
BT /F1 10.5 Tf 1 0 0 1 62.0 614.6 Tm (A lesson often opens several tabs - the lesson, its video, an activity. They appear in a row) Tj ET
|
||||||
BT /F1 10.5 Tf 1 0 0 1 62.0 599.7 Tm (across the top. Click a tab to move between them, and the x on a tab to close it.) Tj ET
|
BT /F1 10.5 Tf 1 0 0 1 62.0 599.7 Tm (across the top. Click a tab to move between them, and the x on a tab to close it.) Tj ET
|
||||||
BT /F1 10.5 Tf 1 0 0 1 62.0 579.7 Tm (There is no address bar, on purpose. Wonder Ink can take you anywhere the lesson needs to go.) Tj ET
|
BT /F1 10.5 Tf 1 0 0 1 62.0 579.7 Tm (There is no address bar, on purpose. Wonder Ink can take you anywhere the lesson needs to go.) Tj ET
|
||||||
BT /F2 12.5 Tf 1 0 0 1 62.0 551.8 Tm (Worksheets and colouring pages) Tj ET
|
BT /F2 12.5 Tf 1 0 0 1 62.0 551.8 Tm (Making the lesson bigger or smaller) Tj ET
|
||||||
BT /F1 10.5 Tf 1 0 0 1 62.0 531.1 Tm (Click the worksheet link in the lesson. It opens in a new tab, ready to read or print.) Tj ET
|
BT /F1 10.5 Tf 1 0 0 1 62.0 531.1 Tm (Press Ctrl and + to make the lesson bigger, Ctrl and ? to make it smaller, and Ctrl and 0 to go) Tj ET
|
||||||
BT /F1 10.5 Tf 1 0 0 1 62.0 511.2 Tm (Worksheets you download are kept separately from everything else on the computer, so they never) Tj ET
|
BT /F1 10.5 Tf 1 0 0 1 62.0 516.2 Tm (back to normal. You can also right-click the lesson and choose Make the lesson bigger. When) Tj ET
|
||||||
BT /F1 10.5 Tf 1 0 0 1 62.0 496.3 Tm (get mixed up with anything.) 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 /F2 12.5 Tf 1 0 0 1 62.0 468.4 Tm (When you have finished) Tj ET
|
BT /F1 10.5 Tf 1 0 0 1 62.0 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 447.6 Tm (Close Wonder Ink at the end of your session. Closing it signs you out, so the next teacher in) Tj ET
|
BT /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 (this room starts with their own account and not yours.) 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 417.8 Tm (the pages exactly as they will print.) Tj ET
|
||||||
|
BT /F1 10.5 Tf 1 0 0 1 78.0 397.9 Tm (-) Tj ET
|
||||||
|
BT /F1 10.5 Tf 1 0 0 1 94.0 397.9 Tm (Choose Only some pages to print, for example, `2` or `1-3`.) Tj ET
|
||||||
|
BT /F1 10.5 Tf 1 0 0 1 78.0 383.0 Tm (-) Tj ET
|
||||||
|
BT /F1 10.5 Tf 1 0 0 1 94.0 383.0 Tm (Set Copies for the number of children.) Tj ET
|
||||||
|
BT /F1 10.5 Tf 1 0 0 1 78.0 368.1 Tm (-) Tj ET
|
||||||
|
BT /F1 10.5 Tf 1 0 0 1 94.0 368.1 Tm (Choose Black and white to save colour ink.) 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 62.0 333.2 Tm (nothing.) 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 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 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 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 234.8 Tm (get mixed up with anything.) 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 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 171.3 Tm (this room starts with their own account and not yours.) Tj ET
|
||||||
endstream
|
endstream
|
||||||
endobj
|
endobj
|
||||||
21 0 obj
|
21 0 obj
|
||||||
@@ -349,14 +366,14 @@ xref
|
|||||||
0000007041 00000 n
|
0000007041 00000 n
|
||||||
0000011178 00000 n
|
0000011178 00000 n
|
||||||
0000014320 00000 n
|
0000014320 00000 n
|
||||||
0000016211 00000 n
|
0000017804 00000 n
|
||||||
0000017739 00000 n
|
0000019332 00000 n
|
||||||
0000019292 00000 n
|
0000020885 00000 n
|
||||||
0000020511 00000 n
|
0000022104 00000 n
|
||||||
0000022330 00000 n
|
0000023923 00000 n
|
||||||
0000022428 00000 n
|
0000024021 00000 n
|
||||||
trailer
|
trailer
|
||||||
<< /Size 27 /Root 1 0 R >>
|
<< /Size 27 /Root 1 0 R >>
|
||||||
startxref
|
startxref
|
||||||
22531
|
24124
|
||||||
%%EOF
|
%%EOF
|
||||||
@@ -110,3 +110,89 @@ curl -fsSL -o c.deb https://updates.riverofglorymn.org/canary/latest.deb
|
|||||||
sha256sum c.deb
|
sha256sum c.deb
|
||||||
curl -fsS https://updates.riverofglorymn.org/canary/manifest.json
|
curl -fsS https://updates.riverofglorymn.org/canary/manifest.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Version numbers and releasing
|
||||||
|
|
||||||
|
```
|
||||||
|
0.2.6 a feature release
|
||||||
|
0.2.6.1 the first maintenance release of 0.2.6
|
||||||
|
0.2.6.2 the second
|
||||||
|
0.2.7 the next feature release
|
||||||
|
0.3.0 a larger milestone
|
||||||
|
```
|
||||||
|
|
||||||
|
Three parts for a feature release, four for a maintenance release of one.
|
||||||
|
The leading zero stays until Classroom is feature-complete; 1.0.0 belongs
|
||||||
|
to the wider Ekklesia platform's milestone. Nothing else is a version: no
|
||||||
|
leading zeros (dpkg reads `0.2.06` as `0.2.6`), no `0.2.6.0`, no suffixes.
|
||||||
|
The build, both publish scripts and every classroom's updater refuse
|
||||||
|
anything else, from one pattern held to one list of cases by
|
||||||
|
`tests/test-release-versions.sh`.
|
||||||
|
|
||||||
|
Which of two versions is newer is always `dpkg --compare-versions`, never
|
||||||
|
our own arithmetic, because dpkg is what installs them:
|
||||||
|
`0.2.2 < 0.2.6 < 0.2.6.1 < 0.2.6.2 < 0.2.6.10 < 0.2.7`.
|
||||||
|
|
||||||
|
### A published version is never used again
|
||||||
|
|
||||||
|
0.2.6 was rebuilt with later fixes and uploaded a second time under the
|
||||||
|
same number, replacing the first upload. A room that had installed the
|
||||||
|
first one could never be offered the second: the updater only installs a
|
||||||
|
version newer than its own. So now:
|
||||||
|
|
||||||
|
* `update_canary.sh VERSION` refuses a version that has ever been
|
||||||
|
published — on the server or in `repo/packages` — and one that is not
|
||||||
|
newer than every package ever published. It checks before building and
|
||||||
|
again after the Trixie gate.
|
||||||
|
* Packages are uploaded with `rsync --ignore-existing`, and the checksum on
|
||||||
|
the server is compared with the one built before anything points at it.
|
||||||
|
* A manifest is only written once the server holds exactly the package
|
||||||
|
and checksum it names.
|
||||||
|
* After publishing, the channel is read back over HTTPS the way a classroom
|
||||||
|
reads it: manifest, versioned package, `latest.deb` and the version dpkg
|
||||||
|
reads out of the file must all agree.
|
||||||
|
|
||||||
|
A publish that fails after the local copy into `repo/packages` has spent
|
||||||
|
that number. Use the next one.
|
||||||
|
|
||||||
|
### Canary
|
||||||
|
|
||||||
|
```
|
||||||
|
./update_canary.sh 0.2.6.1 # a maintenance release
|
||||||
|
./update_canary.sh 0.2.7 # the next feature release
|
||||||
|
```
|
||||||
|
|
||||||
|
The version is always given. With four parts there are two possible next
|
||||||
|
numbers, and which one a release is depends on what is in it. Run with no
|
||||||
|
argument it prints the current canary and both candidates, and builds
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
### Stable
|
||||||
|
|
||||||
|
```
|
||||||
|
./update_stable.sh 0.2.6.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Only with the owner's written approval for that release. Stable is never
|
||||||
|
given a package of its own: it is pointed at one canary already published,
|
||||||
|
byte for byte, and refuses a version that is not newer than stable's
|
||||||
|
current one or whose package the server does not hold with the same
|
||||||
|
checksum.
|
||||||
|
|
||||||
|
### Checking a channel without changing it
|
||||||
|
|
||||||
|
```
|
||||||
|
ROOT=$PWD bash -c 'source tools/release-lib.sh; release_verify_channel stable; release_verify_channel canary'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rehearsing an upgrade
|
||||||
|
|
||||||
|
`tools/validate-trixie-release.sh NEW.deb [PREVIOUS.deb]` installs into a
|
||||||
|
clean Debian 13 container. `VALIDATION_CHANNEL=stable` rehearses a stable
|
||||||
|
room, for example the desktops' real path:
|
||||||
|
|
||||||
|
```
|
||||||
|
VALIDATION_CHANNEL=stable tools/validate-trixie-release.sh \
|
||||||
|
dist/ekklesia-classroom_0.2.6.1_amd64.deb \
|
||||||
|
repo/packages/ekklesia-classroom_0.2.2_amd64.deb
|
||||||
|
```
|
||||||
|
|||||||
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 719 KiB |
|
After Width: | Height: | Size: 726 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 168 KiB |
@@ -574,3 +574,90 @@ ifupdown stanza is already gone. **Do not run `--adopt` on it.**
|
|||||||
- [ ] `nmcli -g 802-11-wireless.hidden connection show "ROGC Classroom"`
|
- [ ] `nmcli -g 802-11-wireless.hidden connection show "ROGC Classroom"`
|
||||||
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
|
||||||
|
|
||||||
|
Everything below was done in the lab except what a lab cannot do: paper,
|
||||||
|
a classroom's own graphics, a real keyboard, its network and its screen.
|
||||||
|
Those are these checks. Stable stays on 0.2.2 until they pass.
|
||||||
|
|
||||||
|
Before starting, on each machine: `dpkg-query -W ekklesia-classroom`
|
||||||
|
must say `0.2.6.1`. The desktops only get it once stable is moved, so on
|
||||||
|
a desktop install it by hand for the test:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -fsSL -o c.deb https://updates.riverofglorymn.org/canary/latest.deb
|
||||||
|
sha256sum c.deb # must match .../canary/manifest.json
|
||||||
|
sudo apt-get install -y ./c.deb && sudo reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
This does **not** move the machine to canary (see provisioning.md): its
|
||||||
|
next automatic update follows stable again, which is still 0.2.2 — so
|
||||||
|
after testing, either promote or reinstall 0.2.2 the same way.
|
||||||
|
|
||||||
|
### Little Waders — printing through Elmer
|
||||||
|
|
||||||
|
- [ ] Open Wonder Ink, sign in, open a lesson with a worksheet
|
||||||
|
- [ ] Click the worksheet; it opens as a tab named after the file, with a
|
||||||
|
document icon
|
||||||
|
- [ ] Ctrl+P (or right-click → Print…): the preview shows the worksheet's
|
||||||
|
own pages, page count right, ‹ › turn them
|
||||||
|
- [ ] Printer shows **Elmer**, preselected
|
||||||
|
- [ ] Print → a notification "Sent to the printer — … — Elmer", Wonder Ink
|
||||||
|
stays open on the lesson
|
||||||
|
- [ ] **The worksheet comes out of Elmer**, every page, right size, in colour
|
||||||
|
- [ ] Pages `2`, Copies `2`, Black and white → exactly two grey copies of
|
||||||
|
page 2 come out
|
||||||
|
- [ ] Print a normal lesson page the same way; it comes out, Letter
|
||||||
|
- [ ] Open the preview and press Cancel → nothing prints
|
||||||
|
- [ ] Print three times in a row → three printouts, no crash
|
||||||
|
- [ ] `lpstat -W completed -o` shows each job; `journalctl --user -b | grep
|
||||||
|
"printed '"` shows Wonder Ink's line for each
|
||||||
|
|
||||||
|
### Size and zoom
|
||||||
|
|
||||||
|
- [ ] A lesson in Wonder Ink is the same size as the same page in the
|
||||||
|
browser on that machine (it was smaller in 0.2.2)
|
||||||
|
- [ ] **Ctrl and +** (the real keyboard), **Ctrl and −**, **Ctrl and 0**
|
||||||
|
change it; a "Lesson size" badge appears
|
||||||
|
- [ ] Open a second tab: it is the same size; the − 100% + and Print
|
||||||
|
buttons are at the top right
|
||||||
|
- [ ] Close Wonder Ink and open it again: the size is remembered
|
||||||
|
- [ ] A worksheet tab's own − / + (in the PDF toolbar) zoom it
|
||||||
|
- [ ] A lesson video still goes fullscreen and comes back with the tabs
|
||||||
|
|
||||||
|
### Loading, and the machine's graphics
|
||||||
|
|
||||||
|
From the maintenance terminal (Meta+Ctrl+Alt+Shift+K), in the session:
|
||||||
|
|
||||||
|
```
|
||||||
|
classroom-wonderink --diagnose
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Note the **GPU line**. A driver's name is right. "none … drawing in
|
||||||
|
software" means QtWebEngine has no GPU on this machine — compare
|
||||||
|
`chrome://gpu` in the browser; if Chrome has one, that is the cause of
|
||||||
|
slow lessons and is the next thing to fix
|
||||||
|
- [ ] Note the sign-in time (the lab: about 2.9 s wired). Much slower here
|
||||||
|
with a driver named above points at the network
|
||||||
|
- [ ] `free -m`: Wonder Ink needs about 610 MB plus 115 MB per extra tab
|
||||||
|
|
||||||
|
### Signing out
|
||||||
|
|
||||||
|
- [ ] Sign in, close Wonder Ink, open it again → **signed out**. (Cookies
|
||||||
|
are session-only and measured so; the site's Local Storage is not
|
||||||
|
cleared, which is the one way this could fail and has never been
|
||||||
|
tested with a real account)
|
||||||
|
|
||||||
|
### After a reboot, and the other rooms
|
||||||
|
|
||||||
|
- [ ] Reboot Little Waders: it comes up as the classroom, Wonder Ink opens
|
||||||
|
- [ ] On one other desktop, 0.2.6.1 installed: the browser, the DVD player
|
||||||
|
where there is a drive, Parent Call where enabled, sound through the
|
||||||
|
television, the panel order — all as on 0.2.2
|
||||||
|
- [ ] **Presentations is not on the panel** of any desktop
|
||||||
|
(`grep PRESENTATIONS_ENABLED /etc/ekklesia-classroom/device.conf`
|
||||||
|
says `false`)
|
||||||
|
|
||||||
|
If anything here fails, leave the desktops on stable 0.2.2 and fix it in
|
||||||
|
0.2.6.2 on canary.
|
||||||
|
|||||||
@@ -55,10 +55,15 @@ the display scale for itself asks that command rather than the file:
|
|||||||
* **Plasma** — the panel, the clock, notifications, the volume display and
|
* **Plasma** — the panel, the clock, notifications, the volume display and
|
||||||
every dialog, because `classroom-scale` sets it as the display's scale
|
every dialog, because `classroom-scale` sets it as the display's scale
|
||||||
through `kscreen-doctor` at every login.
|
through `kscreen-doctor` at every login.
|
||||||
* **Chrome and Wonder Ink** — `--force-device-scale-factor`, because
|
* **Chrome** — `--force-device-scale-factor`, because Chrome on the sway
|
||||||
Chrome on Wayland was measured not to follow the compositor's output
|
session was measured not to follow the compositor's output scale on its
|
||||||
scale on its own. It is the same number, not a second scaling: Chrome
|
own. **Under Plasma it does**, so this is now a second scaling: measured
|
||||||
replaces its detected scale with it.
|
at 150%, Chrome's pages report `devicePixelRatio` 2.25, not 1.5. Left as
|
||||||
|
it is in 0.2.6.1 because it is the size teachers know; see
|
||||||
|
[wonder-ink.md](wonder-ink.md#how-large-a-lesson-is).
|
||||||
|
* **Wonder Ink** — its window follows the compositor as every Qt
|
||||||
|
application does, and its *pages* start at a zoom of `UI_SCALE`, so a
|
||||||
|
lesson is the size the browser shows it.
|
||||||
* **The disc player's own surfaces** — they are measured in the logical
|
* **The disc player's own surfaces** — they are measured in the logical
|
||||||
pixels the compositor is already scaling, so they follow without being
|
pixels the compositor is already scaling, so they follow without being
|
||||||
told.
|
told.
|
||||||
|
|||||||
@@ -140,9 +140,8 @@ Alongside it, two Qt defaults are turned off rather than left implicit:
|
|||||||
`LocalContentCanAccessFileUrls` (on by default, and would let a local
|
`LocalContentCanAccessFileUrls` (on by default, and would let a local
|
||||||
document read other local documents) and `LocalContentCanAccessRemoteUrls`.
|
document read other local documents) and `LocalContentCanAccessRemoteUrls`.
|
||||||
|
|
||||||
The print dialog's **Print to File** option is also removed. Choosing it
|
Printing has no **Print to File** and no file chooser of any kind: the
|
||||||
enables an output path with a "..." button, which is a file chooser, in a
|
preview (below) offers the room's CUPS printers and nothing else.
|
||||||
room whose whole design is that there is no file chooser.
|
|
||||||
|
|
||||||
### What a teacher still cannot reach
|
### What a teacher still cannot reach
|
||||||
|
|
||||||
@@ -182,6 +181,84 @@ is Chrome's business and stays that way.
|
|||||||
Downloads folder. That is partly tidiness and mostly the security
|
Downloads folder. That is partly tidiness and mostly the security
|
||||||
argument: it is the only place a worksheet can come from.
|
argument: it is the only place a worksheet can come from.
|
||||||
|
|
||||||
|
## Printing
|
||||||
|
|
||||||
|
### The crash (0.2.2, Little Waders)
|
||||||
|
|
||||||
|
Wonder Ink closed completely every time a teacher confirmed a print job:
|
||||||
|
`SIGSEGV in QPrinter::resolution()`, twice, both through QtWebEngine's
|
||||||
|
print-completion path.
|
||||||
|
|
||||||
|
`QWebEngineView.print(printer)` is asynchronous. In Qt 6.8.2
|
||||||
|
(`qwebengineview.cpp`) it stores the raw `QPrinter*` and returns; when
|
||||||
|
Chromium has rendered the page, `didPrintPage()` calls
|
||||||
|
`currentPrinter->resolution()` and hands the printer to a worker thread.
|
||||||
|
0.2.2 made the printer a local variable in `print_view()`, so PyQt deleted
|
||||||
|
it when the function returned and Qt read freed memory. Reproduced in the
|
||||||
|
lab with the 0.2.2 code: `QPrinter::resolution()` ←
|
||||||
|
`QWebEnginePagePrivate::didPrintPage` ← `WebContentsAdapter::didPrintPage`,
|
||||||
|
the Little Waders backtrace, and no job in CUPS.
|
||||||
|
|
||||||
|
Keeping the printer alive until `printFinished` stops the crash — that was
|
||||||
|
built and measured first — and it is still not a working print path:
|
||||||
|
|
||||||
|
| Measured, QtWebEngine 6.8.2, lab queue built from Elmer's PPD | Qt `view.print()` | `printToPdf()` + `lp` |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A three-page lesson | 24.6 MB of 300 dpi pictures, no text, A4 on a test queue | 18 KB vector PDF with its text, on Letter, 0.43 s through Elmer's filters |
|
||||||
|
| A worksheet PDF tab | **one blank page** | the worksheet file itself |
|
||||||
|
| A PDF a site shows inline | — | `printToPdf` never answers (`CreatePreviewDocument got 0 page count`) — so it is downloaded and printed as a file |
|
||||||
|
| A queue refusing jobs | reported as **printed** | "Refusing did not take the job: Destination … is not accepting jobs." |
|
||||||
|
| Black and white on Elmer | — | IPP `print-color-mode` is **ignored** by the generic PCL 6 driver ("Unknown option", prints colour); `ColorModel=Grayscale` gives `pxlmono` |
|
||||||
|
|
||||||
|
Qt's path also mishandles three things its own source makes plain: a
|
||||||
|
second `print()` on a page that is still printing is refused with no
|
||||||
|
`printFinished`, the worker keeps the printer after the tab has gone, and
|
||||||
|
quitting destroys the printer under that worker.
|
||||||
|
|
||||||
|
So 0.2.6.1 does not use Qt printing at all. **Print** makes a PDF — a
|
||||||
|
worksheet already is one; a lesson page is rendered by the engine onto the
|
||||||
|
printer's own paper with Chrome-like 10 mm margins — shows it, and hands it
|
||||||
|
to CUPS with `lp`, which is how Chrome prints in the same rooms. Nothing
|
||||||
|
asynchronous ever holds a printer, so closing a tab or Wonder Ink cannot
|
||||||
|
break a job: once `lp` returns, the job is CUPS's.
|
||||||
|
|
||||||
|
### The preview
|
||||||
|
|
||||||
|
One dialog, for lessons and worksheets alike:
|
||||||
|
|
||||||
|
* the document as it will print, a whole page at a time, ‹ › to turn;
|
||||||
|
* **Printer** — the CUPS destinations, the system default preselected
|
||||||
|
(Elmer, in every room);
|
||||||
|
* **Pages** — all, or some: `2`, `1-3`, `1, 3, 5-6`, checked against the
|
||||||
|
document with the reason given if wrong;
|
||||||
|
* **Copies** — 1 to 50;
|
||||||
|
* **Colour** — colour or black and white, only where the printer's driver
|
||||||
|
has a grey mode, and asked for in that driver's own terms;
|
||||||
|
* **Cancel** sends nothing; **Print** returns to the lesson once CUPS has
|
||||||
|
the job, and a notification says where it went. A refused job keeps the
|
||||||
|
preview open with CUPS's reason.
|
||||||
|
|
||||||
|
Choosing a printer with different paper lays a lesson page out again. A
|
||||||
|
page that closes its own tab while the preview is open is reported, not
|
||||||
|
printed. Print pressed again while a preview is open does nothing, and so
|
||||||
|
does a page calling `window.print()` meanwhile. The PDF viewer's own print
|
||||||
|
button opens the same preview.
|
||||||
|
|
||||||
|
Temporary PDFs live in the teacher's `$XDG_RUNTIME_DIR` and are removed
|
||||||
|
when the preview closes.
|
||||||
|
|
||||||
|
`tools/lab/wonderink-print-check` runs all of this against the installed
|
||||||
|
command in the lab, and judges every scenario by what CUPS actually
|
||||||
|
received. `tests/test-classroom-printing.sh` covers the CUPS side at every
|
||||||
|
build. **Neither is paper.** The first print through the real Elmer is a
|
||||||
|
job for somebody standing next to it.
|
||||||
|
|
||||||
|
### Any other error
|
||||||
|
|
||||||
|
An exception escaping any Qt handler makes PyQt6 abort the whole process —
|
||||||
|
measured: a mistake in one slot and the window vanishes. Wonder Ink now
|
||||||
|
logs it to the journal and carries on.
|
||||||
|
|
||||||
## What stayed the same
|
## What stayed the same
|
||||||
|
|
||||||
* **A separate profile from the ordinary browser.** A teacher's Wonder Ink
|
* **A separate profile from the ordinary browser.** A teacher's Wonder Ink
|
||||||
@@ -199,13 +276,112 @@ argument: it is the only place a worksheet can come from.
|
|||||||
`~/.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.** Chrome has to be handed `UI_SCALE` because
|
* **One scale for the room** — see *How large a lesson is*, below.
|
||||||
it was measured not to follow the compositor on Wayland. Qt does follow
|
|
||||||
it — measured at `UI_SCALE=2.0`, where a Qt client on that session
|
## How large a lesson is
|
||||||
reports `devicePixelRatio 2.0` without being told anything — so Wonder
|
|
||||||
Ink is deliberately *not* told, or the scale would be applied twice.
|
Teachers found lessons in Wonder Ink too small next to the same site in
|
||||||
|
the ordinary browser. Measured in the lab (Plasma 6 on KWin, Debian 13) by
|
||||||
|
asking each page for `window.devicePixelRatio` and its width in CSS pixels,
|
||||||
|
with the display scale set the way a room sets it (`kscreen-doctor` and
|
||||||
|
`UI_SCALE`):
|
||||||
|
|
||||||
|
| Display scale | Wonder Ink 0.2.6 | Chrome, as `classroom-browser` starts it |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 100% | dpr 1, 1920 px wide | dpr 1, 1920 px wide |
|
||||||
|
| 150% | dpr 1.5, 1280 px wide | **dpr 2.25**, 854 px wide |
|
||||||
|
| 200% | dpr 2, 960 px wide | **dpr 4**, 500 px wide |
|
||||||
|
|
||||||
|
Wonder Ink was right by the desktop's measure: it follows the display
|
||||||
|
scale exactly, like the panel and every dialog. **Chrome is scaled twice.**
|
||||||
|
Under Plasma it follows the compositor's scale itself, and
|
||||||
|
`classroom-browser` also forces `--force-device-scale-factor=UI_SCALE` — a
|
||||||
|
flag from the sway session, where Chrome was measured *not* following the
|
||||||
|
compositor. So in any room above 100%, every page in the browser is
|
||||||
|
`UI_SCALE` times larger than the desktop around it, and that is the size
|
||||||
|
teachers read lessons at.
|
||||||
|
|
||||||
|
0.2.6.1 does not change the browser — it is the size the rooms know, and
|
||||||
|
changing it everywhere is its own decision. Wonder Ink's pages start at
|
||||||
|
the browser's size instead: **page zoom = `UI_SCALE`**, the room's own
|
||||||
|
clamped number, which is exactly 1.0 in a room at 100%. Wonder Ink's
|
||||||
|
window, tabs and dialogs are not zoomed; Qt already scales them once,
|
||||||
|
correctly. Measured after the change: at 150% the lesson reports dpr 2.25
|
||||||
|
and 853 px, Chrome dpr 2.25 and 854 px.
|
||||||
|
|
||||||
|
**The teacher's own size** goes on top: Ctrl+Plus (and Ctrl+=, and the
|
||||||
|
keypad's), Ctrl+Minus, Ctrl+0 for normal, Ctrl+wheel, or the right-click
|
||||||
|
menu. Chrome's steps, 50% to 300% of the room's normal size, one level for
|
||||||
|
every tab including new ones. It is remembered in
|
||||||
|
`~/.config/ekklesia-classroom/wonderink.conf` as a single `LEVEL=` line —
|
||||||
|
its own file, nowhere near the engine profile, so remembering a size can
|
||||||
|
never mean remembering a sign-in — and relative to the room, so an
|
||||||
|
administrator changing `UI_SCALE` later still gets the right result.
|
||||||
|
|
||||||
|
Measured along the way:
|
||||||
|
|
||||||
|
* the engine keeps a tab's zoom across every navigation, same site or not,
|
||||||
|
so it is set once when the tab is made and never re-applied;
|
||||||
|
* zoom does not change what prints: the same lesson is 3 pages at 100%,
|
||||||
|
200% and 400%;
|
||||||
|
* **page zoom does not reach a worksheet tab.** Chromium's PDF viewer
|
||||||
|
follows the display scale like the window, fits the page to its width,
|
||||||
|
and has its own − / + zoom in its toolbar, which is what a teacher uses
|
||||||
|
there;
|
||||||
|
* two Qt actions on one key are ambiguous and Qt does *neither* — Ctrl+Minus
|
||||||
|
did nothing while it was bound once by name and again as KDE's standard
|
||||||
|
zoom-out key. One action per command now carries all its keys, which
|
||||||
|
also fixed Ctrl+Shift+Tab (bound twice the same way since 0.2.1).
|
||||||
|
|
||||||
|
`tools/lab/wonderink-zoom-check` asks the installed command all of this.
|
||||||
|
The lab's injected keyboard does not reach its nested session, so the
|
||||||
|
check delivers keys through Qt's own key path to the focused page; a
|
||||||
|
physical Ctrl+Plus on a classroom keyboard is a hardware check.
|
||||||
|
|
||||||
|
## Why lessons load slowly
|
||||||
|
|
||||||
|
Measured before anything was changed, and nothing about loading was
|
||||||
|
changed, because the evidence does not point at Wonder Ink.
|
||||||
|
|
||||||
|
In the lab, on the same connection, the same page, timed by each
|
||||||
|
engine's own Navigation Timing until the sign-in form exists (the site
|
||||||
|
is a 3.7 KB shell that builds itself in JavaScript, so its load event is
|
||||||
|
not the moment a teacher can use it):
|
||||||
|
|
||||||
|
| Until the sign-in page is usable | Wonder Ink | Chrome |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| cold cache, 3 runs | 2.86 – 3.00 s | 2.79 – 3.48 s |
|
||||||
|
| warm cache, 3 runs | 2.20 – 2.49 s | 1.96 – 2.15 s |
|
||||||
|
| window on screen after launch | 0.33 s | 0.42 s |
|
||||||
|
|
||||||
|
* **The site is the weight:** 119 – 160 requests and 3.2 MB for a sign-in
|
||||||
|
form, about 430 KB warm. Its console says it loads the Google Maps
|
||||||
|
JavaScript API *twice*, synchronously. That is Wonder Ink's to fix.
|
||||||
|
* Wonder Ink is about a quarter of a second slower warm: it revalidates
|
||||||
|
the page shell (62 – 87 ms to first byte) where Chrome serves it from
|
||||||
|
its cache. Not the "very long time" teachers describe.
|
||||||
|
* Memory: about 610 MB for one tab and 115 MB more for each further tab —
|
||||||
|
eight tabs, 1.4 GB — one renderer each. Idle CPU about 1%: nothing
|
||||||
|
spins in the background.
|
||||||
|
* The network: from the lab, 0.14 – 0.22 s to first byte; from the
|
||||||
|
development host, 0.42 – 0.44 s. Cloudflare in front, uncached.
|
||||||
|
|
||||||
|
What the lab **cannot** show is the machines: it draws in software for
|
||||||
|
both engines. A classroom whose graphics fail in QtWebEngine but work in
|
||||||
|
Chrome would look exactly like the complaint. So there is a command for a
|
||||||
|
technician, run from the maintenance terminal inside the teacher's
|
||||||
|
session (Meta+Ctrl+Alt+Shift+K):
|
||||||
|
|
||||||
|
classroom-wonderink --diagnose
|
||||||
|
|
||||||
|
It prints the engine's versions, the scale, **what WebGL reports as the
|
||||||
|
renderer** — a real driver's name, or "none … drawing in software" — and
|
||||||
|
times the sign-in page from a cold cache in a throwaway profile, beside a
|
||||||
|
running Wonder Ink without touching it. Compare its time with the lab's
|
||||||
|
2.9 s, and its renderer with Chrome's on the same machine (open
|
||||||
|
`chrome://gpu` there).
|
||||||
|
|
||||||
|
|
||||||
## Turning it off
|
|
||||||
|
|
||||||
One future device is a laptop that will not have Wonder Ink at all.
|
One future device is a laptop that will not have Wonder Ink at all.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"""Printing a PDF to the room's printer, and showing what will be printed.
|
||||||
|
|
||||||
|
Wonder Ink's printing, without Qt's printing in it. Every document Wonder
|
||||||
|
Ink prints is a PDF by the time it gets here — a worksheet already is
|
||||||
|
one, and a lesson page is turned into one by the engine — so what is left
|
||||||
|
is the part the rest of the classroom already trusts: CUPS.
|
||||||
|
|
||||||
|
WHY NOT QPRINTER.
|
||||||
|
|
||||||
|
Measured in the lab on Debian 13 (QtWebEngine 6.8.2), against a queue
|
||||||
|
built from Elmer's own PPD with a file in place of the printer:
|
||||||
|
|
||||||
|
QWebEngineView.print() a lesson page arrived as 24.6 MB of
|
||||||
|
300 dpi pictures, no text left in it;
|
||||||
|
a worksheet PDF tab arrived as ONE BLANK
|
||||||
|
PAGE; a queue rejecting jobs was reported
|
||||||
|
as printed successfully; and on 0.2.2 it
|
||||||
|
crashed, because the printer was deleted
|
||||||
|
before Qt had finished with it.
|
||||||
|
|
||||||
|
printToPdf() + lp the same lesson was an 18 KB PDF with its
|
||||||
|
text, on Letter, through Elmer's filters
|
||||||
|
in 0.43 s; a rejected job is an error
|
||||||
|
with CUPS's own words in it.
|
||||||
|
|
||||||
|
The second is what Chrome does, and Chrome is what the rooms already
|
||||||
|
print from without trouble.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
# lp hands the file to the scheduler and returns; it does not wait for
|
||||||
|
# the printer. A scheduler that has not answered in this long is not
|
||||||
|
# going to, and a teacher is standing at the dialog.
|
||||||
|
SUBMIT_TIMEOUT = 30
|
||||||
|
QUERY_TIMEOUT = 10
|
||||||
|
|
||||||
|
# What a worksheet can reasonably be asked for. A spin box that goes to
|
||||||
|
# 999 is one slip of a finger away from a ream of paper.
|
||||||
|
MAX_COPIES = 50
|
||||||
|
|
||||||
|
# Paper names a driver uses, in the terms Qt's page sizes are named by.
|
||||||
|
# Anything else is Letter, which is what the church's printer is.
|
||||||
|
PAPER = {"Letter": "Letter", "A4": "A4", "Legal": "Legal"}
|
||||||
|
|
||||||
|
# The driver's own words for "no colour", in the order they are tried.
|
||||||
|
# Elmer's generic PCL 6 driver ignores the IPP print-color-mode attribute
|
||||||
|
# altogether — measured, it logs "Unknown option" and prints in colour —
|
||||||
|
# so black and white has to be asked for in the PPD's terms.
|
||||||
|
GRAY_CHOICES = ("Grayscale", "Gray", "Mono", "Monochrome", "BlackWhite")
|
||||||
|
|
||||||
|
|
||||||
|
def _run(command, timeout=QUERY_TIMEOUT):
|
||||||
|
try:
|
||||||
|
return subprocess.run(
|
||||||
|
command, capture_output=True, text=True, timeout=timeout
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as error:
|
||||||
|
return subprocess.CompletedProcess(command, 1, "", str(error))
|
||||||
|
|
||||||
|
|
||||||
|
def printers():
|
||||||
|
"""(names, default) for every printer CUPS will accept a job for.
|
||||||
|
|
||||||
|
lpstat -e is the list a teacher can print to: in a classroom that is
|
||||||
|
Elmer and nothing else, because discovery is switched off.
|
||||||
|
"""
|
||||||
|
names = [
|
||||||
|
line.strip()
|
||||||
|
for line in _run(["lpstat", "-e"]).stdout.splitlines()
|
||||||
|
if line.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
default = None
|
||||||
|
match = re.search(
|
||||||
|
r"system default destination:\s*(\S+)", _run(["lpstat", "-d"]).stdout
|
||||||
|
)
|
||||||
|
|
||||||
|
if match and match.group(1) in names:
|
||||||
|
default = match.group(1)
|
||||||
|
elif names:
|
||||||
|
default = names[0]
|
||||||
|
|
||||||
|
return names, default
|
||||||
|
|
||||||
|
|
||||||
|
def options(printer):
|
||||||
|
"""What this printer's driver offers, parsed from lpoptions -l.
|
||||||
|
|
||||||
|
{"paper": "Letter", "gray": ("ColorModel", "Grayscale") or None}
|
||||||
|
"""
|
||||||
|
found = {"paper": "Letter", "gray": None}
|
||||||
|
|
||||||
|
for line in _run(["lpoptions", "-p", printer, "-l"]).stdout.splitlines():
|
||||||
|
if ":" not in line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key, choices = line.split(":", 1)
|
||||||
|
key = key.split("/", 1)[0].strip()
|
||||||
|
choices = choices.split()
|
||||||
|
default = next((c[1:] for c in choices if c.startswith("*")), None)
|
||||||
|
plain = [c.lstrip("*") for c in choices]
|
||||||
|
|
||||||
|
if key == "PageSize" and default in PAPER:
|
||||||
|
found["paper"] = PAPER[default]
|
||||||
|
|
||||||
|
if key in ("ColorModel", "ColorMode", "OutputMode"):
|
||||||
|
gray = next((c for c in GRAY_CHOICES if c in plain), None)
|
||||||
|
|
||||||
|
if gray is not None and found["gray"] is None:
|
||||||
|
found["gray"] = (key, gray)
|
||||||
|
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def page_count(pdf):
|
||||||
|
"""How many pages the PDF has, or 0 if it cannot be read."""
|
||||||
|
result = _run(["pdfinfo", str(pdf)])
|
||||||
|
match = re.search(r"^Pages:\s+(\d+)", result.stdout, re.MULTILINE)
|
||||||
|
return int(match.group(1)) if match else 0
|
||||||
|
|
||||||
|
|
||||||
|
def render_page(pdf, number, width):
|
||||||
|
"""PNG bytes of one page, WIDTH pixels wide, or None.
|
||||||
|
|
||||||
|
Rendered into a directory of its own and read back. Measured on
|
||||||
|
poppler 25.03: asked for "-" as its output, pdftoppm does NOT write to
|
||||||
|
stdout — it writes a file called "-.png" (or "--1.png") into whatever
|
||||||
|
directory it was started in, which for Wonder Ink is the teacher's
|
||||||
|
home, and the preview showed nothing at all.
|
||||||
|
"""
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="wonderink-page-") as directory:
|
||||||
|
root = Path(directory) / "page"
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"pdftoppm", "-f", str(number), "-l", str(number),
|
||||||
|
"-scale-to-x", str(int(width)), "-scale-to-y", "-1",
|
||||||
|
"-png", "-singlefile", "--", str(pdf), str(root),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=QUERY_TIMEOUT,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return None
|
||||||
|
|
||||||
|
image = root.with_suffix(".png")
|
||||||
|
|
||||||
|
if result.returncode != 0 or not image.is_file():
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = image.read_bytes()
|
||||||
|
return data or None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_pages(text, count):
|
||||||
|
"""A teacher's page list, as CUPS wants it, or ValueError saying why.
|
||||||
|
|
||||||
|
"" or "all" is every page (None). Otherwise numbers and ranges
|
||||||
|
separated by commas — "2", "1-3", "1, 3, 5-6" — each inside the
|
||||||
|
document. Returned normalised: "1-3,5".
|
||||||
|
"""
|
||||||
|
text = (text or "").strip().lower()
|
||||||
|
|
||||||
|
if text in ("", "all"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
for piece in re.split(r"\s*,\s*", text):
|
||||||
|
match = re.fullmatch(r"(\d+)(?:\s*-\s*(\d+))?", piece)
|
||||||
|
|
||||||
|
if not match:
|
||||||
|
raise ValueError(
|
||||||
|
f"“{piece}” is not a page number. "
|
||||||
|
"Use numbers like 2 or 1-3, separated by commas."
|
||||||
|
)
|
||||||
|
|
||||||
|
first = int(match.group(1))
|
||||||
|
last = int(match.group(2) or first)
|
||||||
|
|
||||||
|
if first < 1 or last < first:
|
||||||
|
raise ValueError(f"“{piece}” is not a range of pages.")
|
||||||
|
|
||||||
|
if last > count:
|
||||||
|
raise ValueError(
|
||||||
|
f"This document has {count} page{'s' if count != 1 else ''}."
|
||||||
|
)
|
||||||
|
|
||||||
|
parts.append(str(first) if first == last else f"{first}-{last}")
|
||||||
|
|
||||||
|
return ",".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def submit(pdf, printer, copies=1, pages=None, gray=None, title="Wonder Ink"):
|
||||||
|
"""Hand a PDF to CUPS. (True, job id) or (False, what went wrong).
|
||||||
|
|
||||||
|
Success means the scheduler has the job and its id — which is the
|
||||||
|
thing Qt's own print path could not say: it reported a rejected job
|
||||||
|
as printed.
|
||||||
|
"""
|
||||||
|
copies = max(1, min(int(copies), MAX_COPIES))
|
||||||
|
command = ["lp", "-d", printer, "-n", str(copies), "-t", title]
|
||||||
|
|
||||||
|
if pages:
|
||||||
|
command += ["-P", pages]
|
||||||
|
|
||||||
|
if gray:
|
||||||
|
command += ["-o", f"{gray[0]}={gray[1]}"]
|
||||||
|
|
||||||
|
command += ["--", str(pdf)]
|
||||||
|
result = _run(command, timeout=SUBMIT_TIMEOUT)
|
||||||
|
match = re.search(r"request id is (\S+)", result.stdout)
|
||||||
|
|
||||||
|
if result.returncode == 0 and match:
|
||||||
|
return True, match.group(1)
|
||||||
|
|
||||||
|
message = (result.stderr or result.stdout).strip()
|
||||||
|
# "lp: The printer or class is not accepting jobs." reads better
|
||||||
|
# without the program's name in front of it.
|
||||||
|
message = re.sub(r"^lp:\s*", "", message) or "The printer did not accept the job."
|
||||||
|
return False, message
|
||||||
@@ -5,6 +5,7 @@ import fcntl
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -13,6 +14,21 @@ 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"
|
||||||
|
CACHE_DIR = "/var/cache/ekklesia-classroom"
|
||||||
|
PACKAGE = "ekklesia-classroom"
|
||||||
|
|
||||||
|
# What a published version looks like: 0.2.6 for a feature release,
|
||||||
|
# 0.2.6.1 for a maintenance release of it. Anything else in a manifest is
|
||||||
|
# a mistake on the server, and a classroom does nothing with it. The same
|
||||||
|
# pattern is in tools/release-lib.sh; tests/test-release-versions.sh holds
|
||||||
|
# the two to one list of good and bad versions.
|
||||||
|
#
|
||||||
|
# Only the SHAPE is decided here. Which of two versions is newer is always
|
||||||
|
# dpkg's answer, because dpkg is what installs them.
|
||||||
|
VERSION_PATTERN = re.compile(
|
||||||
|
r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\.[1-9][0-9]*)?$"
|
||||||
|
)
|
||||||
|
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
||||||
|
|
||||||
|
|
||||||
def log(message):
|
def log(message):
|
||||||
@@ -50,13 +66,70 @@ def installed_version():
|
|||||||
return result.stdout.strip()
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def valid_version(version):
|
||||||
|
return isinstance(version, str) and bool(VERSION_PATTERN.match(version))
|
||||||
|
|
||||||
|
|
||||||
def version_is_newer(candidate, current):
|
def version_is_newer(candidate, current):
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["dpkg", "--compare-versions", candidate, "gt", current]
|
["dpkg", "--compare-versions", candidate, "gt", current],
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
return result.returncode == 0
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_problem(manifest):
|
||||||
|
"""Why this manifest must not be acted on, or None if it may be.
|
||||||
|
|
||||||
|
A manifest names a version, the package file for exactly that
|
||||||
|
version, and that file's checksum. The package name is checked
|
||||||
|
against the version, not merely read, so a manifest that says 0.2.6.1
|
||||||
|
and points at the 0.2.6 file is refused rather than installed and
|
||||||
|
then offered again at every check.
|
||||||
|
"""
|
||||||
|
if not isinstance(manifest, dict):
|
||||||
|
return "the manifest is not an object"
|
||||||
|
|
||||||
|
version = manifest.get("version")
|
||||||
|
|
||||||
|
if not valid_version(version):
|
||||||
|
return f"the manifest names an invalid version: {version!r}"
|
||||||
|
|
||||||
|
package = manifest.get("package")
|
||||||
|
expected = f"/packages/{PACKAGE}_{version}_amd64.deb"
|
||||||
|
|
||||||
|
if package != expected:
|
||||||
|
return f"the manifest names {package!r} for version {version}"
|
||||||
|
|
||||||
|
checksum = manifest.get("sha256")
|
||||||
|
|
||||||
|
if not isinstance(checksum, str) or not SHA256_PATTERN.match(checksum.lower()):
|
||||||
|
return f"the manifest's sha256 is not a checksum: {checksum!r}"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def package_identity(path):
|
||||||
|
"""(Package, Version) as dpkg reads them out of a downloaded file."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["dpkg-deb", "--field", path, "Package", "Version"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
fields = {}
|
||||||
|
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
if ":" in line:
|
||||||
|
key, value = line.split(":", 1)
|
||||||
|
fields[key.strip()] = value.strip()
|
||||||
|
|
||||||
|
return fields.get("Package"), fields.get("Version")
|
||||||
|
|
||||||
|
|
||||||
def fetch_json(url):
|
def fetch_json(url):
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
url,
|
url,
|
||||||
@@ -134,6 +207,12 @@ def main():
|
|||||||
log(f"Unable to fetch update manifest: {exc}")
|
log(f"Unable to fetch update manifest: {exc}")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
problem = manifest_problem(manifest)
|
||||||
|
|
||||||
|
if problem:
|
||||||
|
log(f"Ignoring the {channel} manifest: {problem}.")
|
||||||
|
return 1
|
||||||
|
|
||||||
candidate = manifest["version"]
|
candidate = manifest["version"]
|
||||||
|
|
||||||
log(f"Available version: {candidate}")
|
log(f"Available version: {candidate}")
|
||||||
@@ -149,10 +228,10 @@ def main():
|
|||||||
package_url = urljoin(base_url, manifest["package"].lstrip("/"))
|
package_url = urljoin(base_url, manifest["package"].lstrip("/"))
|
||||||
expected_hash = manifest["sha256"].lower()
|
expected_hash = manifest["sha256"].lower()
|
||||||
|
|
||||||
os.makedirs("/var/cache/ekklesia-classroom", exist_ok=True)
|
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||||
|
|
||||||
package_path = os.path.join(
|
package_path = os.path.join(
|
||||||
"/var/cache/ekklesia-classroom",
|
CACHE_DIR,
|
||||||
os.path.basename(manifest["package"]),
|
os.path.basename(manifest["package"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -177,6 +256,18 @@ def main():
|
|||||||
|
|
||||||
log("SHA-256 verification passed.")
|
log("SHA-256 verification passed.")
|
||||||
|
|
||||||
|
# The checksum proves the file is the one the manifest describes;
|
||||||
|
# this proves the manifest described the right file. A package
|
||||||
|
# that installs as some other version would be offered again at
|
||||||
|
# every check, and installed again, and the room rebooted, for
|
||||||
|
# ever.
|
||||||
|
name, version = package_identity(temporary_path)
|
||||||
|
|
||||||
|
if name != PACKAGE or version != candidate:
|
||||||
|
log(f"The package is {name} {version}, not {PACKAGE} {candidate}.")
|
||||||
|
os.unlink(temporary_path)
|
||||||
|
return 1
|
||||||
|
|
||||||
os.replace(temporary_path, package_path)
|
os.replace(temporary_path, package_path)
|
||||||
|
|
||||||
log(f"Installing {candidate}")
|
log(f"Installing {candidate}")
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Wonder Ink's printing: which printers, which options, what is sent.
|
||||||
|
#
|
||||||
|
# CUPS is not on the development host, so lpstat, lpoptions, lp and
|
||||||
|
# pdfinfo are stand-ins that answer with the output the real ones gave in
|
||||||
|
# the lab — Elmer's own PPD options included. Whether a job really goes
|
||||||
|
# through Elmer's filters is tools/lab/wonderink-print-check's question.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$WORK"' EXIT
|
||||||
|
mkdir -p "$WORK/bin"
|
||||||
|
|
||||||
|
cat > "$WORK/bin/lpstat" <<EOF
|
||||||
|
#!/bin/bash
|
||||||
|
[[ -f "$WORK/cups-down" ]] && { echo "lpstat: Scheduler is not running." >&2; exit 1; }
|
||||||
|
case "\$1" in
|
||||||
|
-e) cat "$WORK/printers" 2>/dev/null ;;
|
||||||
|
-d) cat "$WORK/default" 2>/dev/null || echo "no system default destination" ;;
|
||||||
|
esac
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# lpoptions -p NAME -l, as the real one printed it for Elmer's PPD.
|
||||||
|
cat > "$WORK/bin/lpoptions" <<EOF
|
||||||
|
#!/bin/bash
|
||||||
|
cat "$WORK/options-\$2" 2>/dev/null
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$WORK/bin/lp" <<EOF
|
||||||
|
#!/bin/bash
|
||||||
|
printf '%s\n' "\$@" > "$WORK/lp-args"
|
||||||
|
if [[ -f "$WORK/lp-refuses" ]]; then
|
||||||
|
echo 'lp: Destination "Elmer" is not accepting jobs.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "request id is Elmer-42 (1 file(s))"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$WORK/bin/pdfinfo" <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
[[ "$(head -c 5 "$1" 2>/dev/null)" == "%PDF-" ]] || { echo "Syntax Error" >&2; exit 1; }
|
||||||
|
echo "Producer: test"
|
||||||
|
echo "Pages: $(grep -c '^page' "$1")"
|
||||||
|
EOF
|
||||||
|
chmod +x "$WORK"/bin/*
|
||||||
|
|
||||||
|
printf 'Elmer\n' > "$WORK/printers"
|
||||||
|
echo "system default destination: Elmer" > "$WORK/default"
|
||||||
|
cat > "$WORK/options-Elmer" <<'EOF'
|
||||||
|
PrintoutMode/Print Quality: Draft Draft.Gray *Normal Normal.Gray High High.Gray
|
||||||
|
PageSize/Page Size: Custom.WIDTHxHEIGHT *Letter A4 11x17 A3 A5 B5 Env10 EnvC5 EnvDL EnvISOB5 EnvMonarch Executive Legal
|
||||||
|
Duplex/Double-Sided Printing: DuplexNoTumble DuplexTumble *None
|
||||||
|
ColorModel/Color Mode: FromPrintoutMode *Color Grayscale
|
||||||
|
PrinterResolution/Resolution: *FromPrintoutMode 300x300dpi 600x600dpi 1200x600dpi 1200x1200dpi
|
||||||
|
EOF
|
||||||
|
cat > "$WORK/options-Office" <<'EOF'
|
||||||
|
PageSize/Media Size: Letter *A4 Legal
|
||||||
|
EOF
|
||||||
|
|
||||||
|
PATH="$WORK/bin:$PATH" WORK="$WORK" PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY'
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import classroom_printing as printing
|
||||||
|
|
||||||
|
root = Path(sys.argv[1])
|
||||||
|
work = Path(os.environ["WORK"])
|
||||||
|
passed = failed = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(label, condition, detail=""):
|
||||||
|
global passed, failed
|
||||||
|
if condition:
|
||||||
|
print(f" ok {label}")
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
print(f" FAIL {label}")
|
||||||
|
if detail:
|
||||||
|
print(f" {detail}")
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
|
||||||
|
def lp_args():
|
||||||
|
return (work / "lp-args").read_text().splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
print("1. which printers a teacher is offered")
|
||||||
|
check("Elmer, and Elmer is the default", printing.printers() == (["Elmer"], "Elmer"))
|
||||||
|
(work / "printers").write_text("Elmer\nOffice\n")
|
||||||
|
(work / "default").write_text("system default destination: Office\n")
|
||||||
|
check("the system default is preselected, whichever it is",
|
||||||
|
printing.printers() == (["Elmer", "Office"], "Office"))
|
||||||
|
(work / "default").write_text("no system default destination\n")
|
||||||
|
check("with no default, the first printer",
|
||||||
|
printing.printers() == (["Elmer", "Office"], "Elmer"))
|
||||||
|
(work / "default").write_text("system default destination: Gone\n")
|
||||||
|
check("a default that is not in the list is not offered",
|
||||||
|
printing.printers() == (["Elmer", "Office"], "Elmer"))
|
||||||
|
(work / "printers").write_text("")
|
||||||
|
check("no printers at all is an empty list, not an error",
|
||||||
|
printing.printers() == ([], None))
|
||||||
|
(work / "cups-down").touch()
|
||||||
|
check("and so is CUPS not running", printing.printers() == ([], None))
|
||||||
|
(work / "cups-down").unlink()
|
||||||
|
(work / "printers").write_text("Elmer\n")
|
||||||
|
(work / "default").write_text("system default destination: Elmer\n")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("2. what each printer's driver offers")
|
||||||
|
elmer = printing.options("Elmer")
|
||||||
|
check("Elmer's paper is Letter", elmer["paper"] == "Letter", elmer)
|
||||||
|
# Measured: this driver ignores print-color-mode and prints in colour.
|
||||||
|
check("black and white is asked for in the driver's own terms",
|
||||||
|
elmer["gray"] == ("ColorModel", "Grayscale"), elmer)
|
||||||
|
office = printing.options("Office")
|
||||||
|
check("an A4 printer is A4", office["paper"] == "A4", office)
|
||||||
|
check("a printer with no grey choice offers none", office["gray"] is None, office)
|
||||||
|
unknown = printing.options("Nobody")
|
||||||
|
check("a printer that says nothing is Letter, colour only",
|
||||||
|
unknown == {"paper": "Letter", "gray": None}, unknown)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("3. a teacher's page list")
|
||||||
|
for text, expected in (("", None), ("all", None), (" ", None), ("2", "2"),
|
||||||
|
("1-3", "1-3"), ("1, 3, 5-6", "1,3,5-6"), ("3-3", "3"),
|
||||||
|
("1 - 2", "1-2"), ("6", "6")):
|
||||||
|
try:
|
||||||
|
got = printing.parse_pages(text, 6)
|
||||||
|
except ValueError as error:
|
||||||
|
got = f"refused: {error}"
|
||||||
|
check(f"{text!r} is {expected!r}", got == expected, got)
|
||||||
|
|
||||||
|
for text in ("7", "0", "3-1", "1-9", "one", "1;lp -d x", "-2", "1--2", "2,", "1.5"):
|
||||||
|
try:
|
||||||
|
printing.parse_pages(text, 6)
|
||||||
|
check(f"{text!r} is refused", False)
|
||||||
|
except ValueError as error:
|
||||||
|
check(f"{text!r} is refused, and says why: {error}", bool(str(error)))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("4. what is handed to CUPS")
|
||||||
|
pdf = work / "Colour the Ark.pdf"
|
||||||
|
pdf.write_text("%PDF-1.4\npage\npage\n")
|
||||||
|
check("a PDF with two pages has two", printing.page_count(pdf) == 2)
|
||||||
|
check("something that is not a PDF has none",
|
||||||
|
printing.page_count(work / "printers") == 0)
|
||||||
|
|
||||||
|
ok, answer = printing.submit(pdf, "Elmer")
|
||||||
|
args = lp_args()
|
||||||
|
check("a job is accepted, with its id", (ok, answer) == (True, "Elmer-42"), answer)
|
||||||
|
check("to Elmer, one copy, named for the lesson",
|
||||||
|
args[:6] == ["-d", "Elmer", "-n", "1", "-t", "Wonder Ink"], args)
|
||||||
|
check("the file comes after --, so no name can be read as an option",
|
||||||
|
args[-2:] == ["--", str(pdf)], args)
|
||||||
|
check("and nothing else is asked for", "-P" not in args and "-o" not in args, args)
|
||||||
|
|
||||||
|
printing.submit(pdf, "Elmer", copies=3, pages="2-3", gray=("ColorModel", "Grayscale"),
|
||||||
|
title="Noah")
|
||||||
|
args = lp_args()
|
||||||
|
check("copies, pages and black and white arrive as lp options",
|
||||||
|
args[args.index("-n") + 1] == "3"
|
||||||
|
and args[args.index("-P") + 1] == "2-3"
|
||||||
|
and args[args.index("-o") + 1] == "ColorModel=Grayscale"
|
||||||
|
and args[args.index("-t") + 1] == "Noah", args)
|
||||||
|
|
||||||
|
printing.submit(pdf, "Elmer", copies=500)
|
||||||
|
check(f"copies are capped at {printing.MAX_COPIES}",
|
||||||
|
lp_args()[lp_args().index("-n") + 1] == str(printing.MAX_COPIES))
|
||||||
|
printing.submit(pdf, "Elmer", copies=0)
|
||||||
|
check("and never fewer than one", lp_args()[lp_args().index("-n") + 1] == "1")
|
||||||
|
|
||||||
|
(work / "lp-refuses").touch()
|
||||||
|
ok, answer = printing.submit(pdf, "Elmer")
|
||||||
|
check("a refused job is a failure, in CUPS's own words",
|
||||||
|
not ok and answer == 'Destination "Elmer" is not accepting jobs.', answer)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("5. Wonder Ink prints through this, and only this")
|
||||||
|
ink = (root / "scripts/classroom-wonderink").read_text()
|
||||||
|
code = "\n".join(line for line in ink.splitlines() if not line.lstrip().startswith("#"))
|
||||||
|
# The 0.2.2 crash, and the blank worksheet, both came from Qt's print path.
|
||||||
|
check("no QPrinter", "QPrinter(" not in code and "QPrintDialog" not in code)
|
||||||
|
check("no QWebEngineView.print()", not re.search(r"\bview\.print\(", code))
|
||||||
|
check("a lesson page becomes a PDF by printToPdf", "printToPdf(" in code)
|
||||||
|
check("which is given up on if the engine never answers",
|
||||||
|
"PREPARE_TIMEOUT_MS" in code and "self._timer.start(PREPARE_TIMEOUT_MS)" in code)
|
||||||
|
check("the preview submits with classroom_printing.submit",
|
||||||
|
"classroom_printing.submit(" in code)
|
||||||
|
check("there is no file chooser in it", "QFileDialog" not in code)
|
||||||
|
check("a worksheet tab is printed from its file",
|
||||||
|
"self._pdf_views[view] = resolved" in code)
|
||||||
|
check("an inline PDF is fetched as a file first", "view.page().download(view.url())" in code)
|
||||||
|
check("an exception in a handler is logged, not fatal",
|
||||||
|
"sys.excepthook = keep_going" in code)
|
||||||
|
check("the module is packaged",
|
||||||
|
"lib/classroom_printing.py" in (root / "tools/build-release.sh").read_text())
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f"{passed} passed, {failed} failed")
|
||||||
|
sys.exit(1 if failed else 0)
|
||||||
|
PY
|
||||||
@@ -235,16 +235,36 @@ 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/validate-trixie-release.sh"')
|
||||||
package_copy = canary.index('cp "$package_path"')
|
package_copy = canary.index('cp -n "$package_path"')
|
||||||
first_rsync = canary.index("rsync -av")
|
first_upload = canary.index("release_upload_package")
|
||||||
check("Trixie validation runs before the repository copy", validation < package_copy)
|
check("Trixie validation runs before the repository copy", validation < package_copy)
|
||||||
check("Trixie validation runs before any upload", validation < first_rsync)
|
check("Trixie validation runs before any upload", validation < first_upload)
|
||||||
|
# A version is checked before anything is built for it, and again after
|
||||||
|
# the long gate, immediately before the first upload.
|
||||||
|
check("a spent version is refused before the build",
|
||||||
|
canary.index("release_check_new_version") < canary.index("build-release.sh"))
|
||||||
|
check("and again after the gate",
|
||||||
|
validation < canary.rindex("release_check_new_version") < first_upload)
|
||||||
|
check("the stable rooms' upgrade is rehearsed on the same file before publishing",
|
||||||
|
"VALIDATION_CHANNEL=stable" in canary
|
||||||
|
and canary.index("VALIDATION_CHANNEL=stable") < first_upload)
|
||||||
|
check("the published channel is read back afterwards",
|
||||||
|
canary.index("release_verify_channel canary") > canary.index("release_publish_manifest canary"))
|
||||||
# The gate checks convergence, not just that the package installs: an
|
# The gate checks convergence, not just that the package installs: an
|
||||||
# upgraded room ends on the v2 channel with every setting this release
|
# upgraded room ends on the v2 channel with every setting this release
|
||||||
# has, exactly once, and the command a technician runs is safe to run on
|
# has, exactly once, and the command a technician runs is safe to run on
|
||||||
# a machine that is already converged.
|
# a machine that is already converged.
|
||||||
check("the gate checks a deliberate channel survives",
|
check("the gate checks a deliberate channel survives",
|
||||||
"'^CHANNEL=canary$'" in validation_script)
|
'"^CHANNEL=${channel}\\$"' in validation_script)
|
||||||
|
check("and that channel can be stable as well as canary",
|
||||||
|
"VALIDATION_CHANNEL" in validation_script
|
||||||
|
and "stable|canary)" in validation_script)
|
||||||
|
check("the gate checks the installed version is the package's own",
|
||||||
|
"/usr/local/lib/ekklesia-classroom/VERSION" in validation_script)
|
||||||
|
check("and asks the installed updater about it",
|
||||||
|
"ekklesia-classroom-update --check" in validation_script)
|
||||||
|
check("and that an upgrade leaves Presentations off",
|
||||||
|
"grep -q '^PRESENTATIONS_ENABLED=false$'" in validation_script)
|
||||||
check("and that the experiment's channel comes home",
|
check("and that the experiment's channel comes home",
|
||||||
"'^CHANNEL=stable$'" in validation_script)
|
"'^CHANNEL=stable$'" in validation_script)
|
||||||
check("and that every new setting arrived exactly once",
|
check("and that every new setting arrived exactly once",
|
||||||
@@ -288,9 +308,24 @@ check("and asks the one command for it",
|
|||||||
# UI_SCALE=2.0: kscreen-doctor reports the output at scale 2, and a Qt
|
# UI_SCALE=2.0: kscreen-doctor reports the output at scale 2, and a Qt
|
||||||
# client on that session reports devicePixelRatio 2.0 without being told
|
# client on that session reports devicePixelRatio 2.0 without being told
|
||||||
# anything. Forcing it as well would apply the scale twice.
|
# anything. Forcing it as well would apply the scale twice.
|
||||||
|
wonderink_code = "\n".join(line for line in wonderink.splitlines()
|
||||||
|
if not line.lstrip().startswith("#"))
|
||||||
check("Wonder Ink is not told twice",
|
check("Wonder Ink is not told twice",
|
||||||
"force-device-scale-factor" not in wonderink
|
"force-device-scale-factor" not in wonderink_code
|
||||||
and "QT_SCALE_FACTOR" not in wonderink)
|
and "QT_SCALE_FACTOR" not in wonderink_code)
|
||||||
|
# Its PAGES are, because Chrome under Plasma turned out to be scaled twice
|
||||||
|
# (dpr 2.25 at 150%, measured) and a lesson has to be the size teachers
|
||||||
|
# read it at in the browser. The window itself is left to Qt.
|
||||||
|
# A technician's command, never a teacher's: it runs in a throwaway
|
||||||
|
# profile that something owns (an unowned one crashed it), and it never
|
||||||
|
# touches the signed-in profile a teacher may have open beside it.
|
||||||
|
diag = wonderink_code[wonderink_code.index("def diagnose("):wonderink_code.index("def main(")]
|
||||||
|
check("--diagnose uses an owned, throwaway profile",
|
||||||
|
"QWebEngineProfile(app)" in diag and "build_profile" not in diag
|
||||||
|
and '"--diagnose" in sys.argv' in wonderink_code)
|
||||||
|
check("its pages start at the room's scale, which is the browser's size",
|
||||||
|
"room_zoom=ui_scale(config)" in wonderink_code
|
||||||
|
and "self._room_zoom * self._level" in wonderink_code)
|
||||||
check("the clock is sized by the bar, not by a number",
|
check("the clock is sized by the bar, not by a number",
|
||||||
'autoFontAndSize", true' in layout and "fontSize" not in layout)
|
'autoFontAndSize", true' in layout and "fontSize" not in layout)
|
||||||
|
|
||||||
@@ -325,8 +360,11 @@ check("local documents may not read other local documents",
|
|||||||
"LocalContentCanAccessFileUrls, False" in wonderink)
|
"LocalContentCanAccessFileUrls, False" in wonderink)
|
||||||
check("nor reach the network",
|
check("nor reach the network",
|
||||||
"LocalContentCanAccessRemoteUrls, False" in wonderink)
|
"LocalContentCanAccessRemoteUrls, False" in wonderink)
|
||||||
check("there is no file chooser behind the print dialog",
|
# Qt's print dialog has a Print to File path with a "..." chooser behind
|
||||||
"PrintDialogOption.PrintToFile, False" in wonderink)
|
# it. Wonder Ink no longer uses that dialog: its preview offers CUPS
|
||||||
|
# printers and nothing else, and no file dialog exists anywhere in it.
|
||||||
|
check("there is no file chooser behind printing",
|
||||||
|
"QFileDialog" not in wonderink and "QPrintDialog" not in wonderink)
|
||||||
check("nothing downloaded is executed",
|
check("nothing downloaded is executed",
|
||||||
"subprocess" not in wonderink.split("def notify")[0]
|
"subprocess" not in wonderink.split("def notify")[0]
|
||||||
and "QDesktopServices" not in wonderink
|
and "QDesktopServices" not in wonderink
|
||||||
@@ -357,8 +395,17 @@ check("the Present-view autoplay fix is still there",
|
|||||||
"PlaybackRequiresUserGesture, False" in wonderink)
|
"PlaybackRequiresUserGesture, False" in wonderink)
|
||||||
check("fullscreen still returns to a maximised window",
|
check("fullscreen still returns to a maximised window",
|
||||||
"WindowFullScreen" in wonderink)
|
"WindowFullScreen" in wonderink)
|
||||||
|
# One text box exists, and it is the print preview's page range. Anywhere
|
||||||
|
# else a text box in this window is an address bar.
|
||||||
|
preview = wonderink[wonderink.index("class PrintPreview"):wonderink.index("\ndef notify")]
|
||||||
|
# The tab strip has one corner widget, and it is the lesson's size and
|
||||||
|
# Print — not the new-tab button a browser puts there.
|
||||||
|
controls = wonderink[wonderink.index("class LessonControls"):wonderink.index("class ZoomBadge")]
|
||||||
check("still no new-tab button or address bar",
|
check("still no new-tab button or address bar",
|
||||||
"setCornerWidget" not in wonderink and "QLineEdit" not in wonderink)
|
wonderink.count("setCornerWidget(") == 1
|
||||||
|
and "setCornerWidget(self._controls" in wonderink
|
||||||
|
and "open_tab" not in controls and "QLineEdit" not in controls
|
||||||
|
and wonderink.count("QLineEdit(") == 1 and "QLineEdit(" in preview)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("7b. the disc player is decided before a session starts")
|
print("7b. the disc player is decided before a session starts")
|
||||||
@@ -477,27 +524,33 @@ print("7c-iii. the permanent download addresses")
|
|||||||
# .../stable/latest.deb and .../canary/latest.deb. One address per channel
|
# .../stable/latest.deb and .../canary/latest.deb. One address per channel
|
||||||
# that never changes, and still no second record of which version is
|
# that never changes, and still no second record of which version is
|
||||||
# current: the pointer is a symlink to the package the manifest names.
|
# current: the pointer is a symlink to the package the manifest names.
|
||||||
|
# Both channels publish through one function, so the order below is the
|
||||||
|
# order for both.
|
||||||
|
release_lib = (root / "tools/release-lib.sh").read_text()
|
||||||
|
publish = release_lib[release_lib.index("release_publish_manifest() {"):]
|
||||||
|
publish = publish[:publish.index("\n}\n\n")]
|
||||||
|
publish_code = "\n".join(line for line in publish.splitlines()
|
||||||
|
if not line.lstrip().startswith("#"))
|
||||||
for channel, script in (("canary", "update_canary.sh"),
|
for channel, script in (("canary", "update_canary.sh"),
|
||||||
("stable", "update_stable.sh")):
|
("stable", "update_stable.sh")):
|
||||||
text = (root / script).read_text()
|
text = (root / script).read_text()
|
||||||
code = "\n".join(line for line in text.splitlines()
|
check(f"{script} publishes {channel} through the shared function",
|
||||||
if not line.lstrip().startswith("#"))
|
f"release_publish_manifest {channel} " in text)
|
||||||
check(f"{script} moves the {channel} pointer",
|
check("the pointer is moved to the versioned package",
|
||||||
f"{channel}/latest.deb" in text or "latest.deb" in code)
|
"ln -sfn ../packages/$name latest.deb.new" in publish_code)
|
||||||
check(f"{script} points it at the versioned package",
|
|
||||||
"ln -sfn ../packages/$package_name" in code)
|
|
||||||
# Atomic, so nothing downloads a half-written pointer, and after the
|
# Atomic, so nothing downloads a half-written pointer, and after the
|
||||||
# manifest, so the pointer can lag it but never lead it — it can never
|
# manifest, so the pointer can lag it but never lead it — it can never
|
||||||
# offer a package the channel has not published.
|
# offer a package the channel has not published.
|
||||||
check(f"{script} swaps it by rename rather than in place",
|
check("it is swapped by rename rather than in place",
|
||||||
"mv -Tf latest.deb.new latest.deb" in code)
|
"mv -Tf latest.deb.new latest.deb" in publish_code)
|
||||||
# Against the rsync that actually uploads the manifest, not the echo
|
check("after the manifest goes up",
|
||||||
# at the end that prints its URL.
|
publish_code.index("latest.deb") > publish_code.index("$channel/manifest.json\""))
|
||||||
upload = f"classroom-updates/{channel}/manifest.json"
|
check("and refuses to point at a package that is not there",
|
||||||
check(f"{script} moves it after the manifest goes up",
|
"test -f ../packages/$name" in publish_code)
|
||||||
code.index("latest.deb") > code.index(upload))
|
check("the manifest is refused unless the server holds exactly that package",
|
||||||
check(f"{script} refuses to point at a package that is not there",
|
publish_code.index("sha256sum") < publish_code.index("cat >"))
|
||||||
"test -f ../packages/$package_name" in code)
|
check("a package upload never replaces a published file",
|
||||||
|
"--ignore-existing" in release_lib)
|
||||||
|
|
||||||
check("and neither script writes a version anywhere but the manifest",
|
check("and neither script writes a version anywhere but the manifest",
|
||||||
"latest.json" not in (root / "update_canary.sh").read_text()
|
"latest.json" not in (root / "update_canary.sh").read_text()
|
||||||
|
|||||||
@@ -0,0 +1,549 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Three- and four-part versions, through everything that builds, publishes
|
||||||
|
# and installs a classroom package.
|
||||||
|
#
|
||||||
|
# Nothing here reaches the real update server. The server is a directory,
|
||||||
|
# ssh and rsync are stand-ins that act on it, and it is served over HTTP
|
||||||
|
# on the loopback interface so that the real updater, the real channel
|
||||||
|
# check and the real dpkg are the ones being asked.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
SERVER_PID=""
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
[[ -z "$SERVER_PID" ]] || kill "$SERVER_PID" 2>/dev/null
|
||||||
|
rm -rf "$WORK"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
passed=0
|
||||||
|
failed=0
|
||||||
|
|
||||||
|
check() {
|
||||||
|
local label="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
if "$@" >/dev/null 2>&1; then
|
||||||
|
echo " ok $label"
|
||||||
|
passed=$((passed + 1))
|
||||||
|
else
|
||||||
|
echo " FAIL $label"
|
||||||
|
failed=$((failed + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
refuses() {
|
||||||
|
! "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# A package that is nothing but its version, the way dpkg will read it.
|
||||||
|
fake_deb() {
|
||||||
|
local version="$1" out="$2" stage="$WORK/stage-$version"
|
||||||
|
|
||||||
|
mkdir -p "$stage/DEBIAN"
|
||||||
|
cat > "$stage/DEBIAN/control" <<EOF
|
||||||
|
Package: ${3:-ekklesia-classroom}
|
||||||
|
Version: $version
|
||||||
|
Architecture: amd64
|
||||||
|
Maintainer: test
|
||||||
|
Description: test package
|
||||||
|
EOF
|
||||||
|
dpkg-deb --root-owner-group --build "$stage" "$out" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The update server, as a directory.
|
||||||
|
SERVER="$WORK/server"
|
||||||
|
LOCAL="$WORK/local"
|
||||||
|
mkdir -p "$SERVER/packages" "$SERVER/stable" "$SERVER/canary" \
|
||||||
|
"$LOCAL/packages" "$LOCAL/canary" "$LOCAL/stable" "$WORK/bin"
|
||||||
|
|
||||||
|
for version in 0.1.45~hypr1 0.1.45 0.2.2 0.2.5 0.2.6; do
|
||||||
|
fake_deb "$version" "$SERVER/packages/ekklesia-classroom_${version}_amd64.deb"
|
||||||
|
cp "$SERVER/packages/ekklesia-classroom_${version}_amd64.deb" "$LOCAL/packages/"
|
||||||
|
done
|
||||||
|
# Not a classroom package, and on the real server all the same.
|
||||||
|
echo v2 > "$SERVER/packages/ekklesia-classroom-v2.deb"
|
||||||
|
|
||||||
|
manifest() {
|
||||||
|
local version="$1" file="${2:-ekklesia-classroom_$1_amd64.deb}" hash
|
||||||
|
hash="$(sha256sum "$SERVER/packages/$file" | awk '{print $1}')"
|
||||||
|
printf '{\n "version": "%s",\n "package": "/packages/%s",\n "sha256": "%s",\n "requires_reboot": true\n}\n' \
|
||||||
|
"$version" "$file" "$hash"
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest 0.2.2 > "$SERVER/stable/manifest.json"
|
||||||
|
manifest 0.2.6 > "$SERVER/canary/manifest.json"
|
||||||
|
ln -s ../packages/ekklesia-classroom_0.2.2_amd64.deb "$SERVER/stable/latest.deb"
|
||||||
|
ln -s ../packages/ekklesia-classroom_0.2.6_amd64.deb "$SERVER/canary/latest.deb"
|
||||||
|
cp "$SERVER/stable/manifest.json" "$LOCAL/stable/"
|
||||||
|
cp "$SERVER/canary/manifest.json" "$LOCAL/canary/"
|
||||||
|
|
||||||
|
# ssh HOST COMMAND: run COMMAND here. The release scripts only ever talk
|
||||||
|
# to one host, and it is this directory.
|
||||||
|
cat > "$WORK/bin/ssh" <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
shift
|
||||||
|
exec bash -c "$*"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# rsync [-av] [--ignore-existing] -e ssh SOURCE HOST:DEST
|
||||||
|
cat > "$WORK/bin/rsync" <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
keep=0
|
||||||
|
args=()
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--ignore-existing) keep=1 ;;
|
||||||
|
-e) shift ;;
|
||||||
|
-*) ;;
|
||||||
|
*) args+=("$1") ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
source="${args[0]}"
|
||||||
|
dest="${args[1]#*:}"
|
||||||
|
[[ -d "$dest" ]] && dest="$dest/$(basename "$source")"
|
||||||
|
if [[ "$keep" == 1 && -e "$dest" ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
cp "$source" "$dest"
|
||||||
|
EOF
|
||||||
|
chmod +x "$WORK/bin/ssh" "$WORK/bin/rsync"
|
||||||
|
|
||||||
|
PORT="$(python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])')"
|
||||||
|
python3 -m http.server --bind 127.0.0.1 --directory "$SERVER" "$PORT" \
|
||||||
|
>/dev/null 2>&1 &
|
||||||
|
SERVER_PID=$!
|
||||||
|
|
||||||
|
for _ in $(seq 1 50); do
|
||||||
|
curl -fsS "http://127.0.0.1:$PORT/stable/manifest.json" >/dev/null 2>&1 && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "0. the library, sourced the way the release scripts source it"
|
||||||
|
# With nothing in the environment and set -u in force. Every test below
|
||||||
|
# exports its own RELEASE_* values, and a default that only worked when
|
||||||
|
# somebody had already set it once stopped the real build dead.
|
||||||
|
check "it loads under set -u with nothing set" \
|
||||||
|
env -i PATH="$PATH" bash -c "set -euo pipefail; ROOT=/checkout; source '$ROOT/tools/release-lib.sh'
|
||||||
|
[[ \$RELEASE_HOST == classroom-control ]]
|
||||||
|
[[ \$RELEASE_ROOT == /srv/ekklesia/classroom-updates ]]
|
||||||
|
[[ \$RELEASE_URL == https://updates.riverofglorymn.org ]]
|
||||||
|
[[ \$RELEASE_LOCAL == /checkout/repo ]]"
|
||||||
|
check "and build-release.sh gets as far as checking the version" \
|
||||||
|
bash -c "'$ROOT/tools/build-release.sh' 0.2.06 2>&1 | grep -q 'Invalid version: 0.2.06'"
|
||||||
|
echo
|
||||||
|
|
||||||
|
export PATH="$WORK/bin:$PATH"
|
||||||
|
export RELEASE_HOST=test-server RELEASE_ROOT="$SERVER" \
|
||||||
|
RELEASE_URL="http://127.0.0.1:$PORT" RELEASE_LOCAL="$LOCAL"
|
||||||
|
|
||||||
|
# shellcheck source=../tools/release-lib.sh
|
||||||
|
source "$ROOT/tools/release-lib.sh"
|
||||||
|
|
||||||
|
GOOD=(0.2.2 0.2.6 0.2.6.1 0.2.6.2 0.2.7 0.3.0 0.2.10 0.2.6.10 1.0.0 10.20.30.40)
|
||||||
|
BAD=("" 0.2 0.2.6.1.1 0.2.06 00.2.6 0.2.6.0 0.2.6.01 v0.2.6 0.2.6-1 0.2.6~rc1
|
||||||
|
"0.2.6 " " 0.2.6" 0.2.6. .0.2.6 0..2 0.2.a "0.2.6;reboot" "0.2.6
|
||||||
|
0.2.7" 0.2.6+1 -0.2.6)
|
||||||
|
|
||||||
|
echo "1. what a version may look like"
|
||||||
|
for version in "${GOOD[@]}"; do
|
||||||
|
check "the release scripts accept $version" release_version_valid "$version"
|
||||||
|
done
|
||||||
|
for version in "${BAD[@]}"; do
|
||||||
|
check "the release scripts refuse '${version//$'\n'/\\n}'" refuses release_version_valid "$version"
|
||||||
|
done
|
||||||
|
|
||||||
|
# The updater decides with its own copy of the pattern, because it runs
|
||||||
|
# on a classroom that has no checkout. The two copies are held to the
|
||||||
|
# same list.
|
||||||
|
python3 - "$ROOT/scripts/ekklesia-classroom-update" "${GOOD[@]}" -- "${BAD[@]}" <<'PY'
|
||||||
|
import importlib.machinery
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
|
||||||
|
loader = importlib.machinery.SourceFileLoader("updater", sys.argv[1])
|
||||||
|
spec = importlib.util.spec_from_loader("updater", loader)
|
||||||
|
updater = importlib.util.module_from_spec(spec)
|
||||||
|
loader.exec_module(updater)
|
||||||
|
|
||||||
|
rest = sys.argv[2:]
|
||||||
|
split = rest.index("--")
|
||||||
|
good, bad = rest[:split], rest[split + 1:]
|
||||||
|
wrong = [v for v in good if not updater.valid_version(v)]
|
||||||
|
wrong += [v for v in bad if updater.valid_version(v)]
|
||||||
|
wrong += [v for v in (None, 26, 0.26, ["0.2.6"]) if updater.valid_version(v)]
|
||||||
|
|
||||||
|
for version in wrong:
|
||||||
|
print(f" FAIL the updater disagrees about {version!r}")
|
||||||
|
|
||||||
|
sys.exit(1 if wrong else 0)
|
||||||
|
PY
|
||||||
|
check "the updater agrees about every one of them, and refuses non-strings" test $? -eq 0
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "2. which version is newer, as dpkg decides it"
|
||||||
|
ordered=(0.1.45~hypr1 0.1.45 0.2.2 0.2.5 0.2.6 0.2.6.1 0.2.6.2 0.2.6.10 0.2.7 0.2.10 0.3.0)
|
||||||
|
for ((i = 1; i < ${#ordered[@]}; i++)); do
|
||||||
|
check "${ordered[i-1]} < ${ordered[i]}" \
|
||||||
|
release_version_gt "${ordered[i]}" "${ordered[i-1]}"
|
||||||
|
check "and not the other way round" \
|
||||||
|
refuses release_version_gt "${ordered[i-1]}" "${ordered[i]}"
|
||||||
|
done
|
||||||
|
check "0.2.6.1 is not newer than itself" refuses release_version_gt 0.2.6.1 0.2.6.1
|
||||||
|
check "the highest of everything published is 0.2.6" \
|
||||||
|
test "$(release_published_versions | release_highest)" = 0.2.6
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "3. a version may be published once, and only if it is newer"
|
||||||
|
check "0.2.6.1 may be published" release_check_new_version 0.2.6.1
|
||||||
|
check "0.2.7 may be published" release_check_new_version 0.2.7
|
||||||
|
check "0.2.6 may not: it is already on the server" refuses release_check_new_version 0.2.6
|
||||||
|
check "0.2.5.1 may not: it is older than 0.2.6" refuses release_check_new_version 0.2.5.1
|
||||||
|
check "0.2.2 may not" refuses release_check_new_version 0.2.2
|
||||||
|
check "0.2.06 may not: dpkg would call it 0.2.6" refuses release_check_new_version 0.2.06
|
||||||
|
check "0.2.6.0 may not" refuses release_check_new_version 0.2.6.0
|
||||||
|
|
||||||
|
# Published only in the local copy — an upload that failed after the copy
|
||||||
|
# was made. The number is spent all the same.
|
||||||
|
fake_deb 0.2.8 "$LOCAL/packages/ekklesia-classroom_0.2.8_amd64.deb"
|
||||||
|
check "a version already copied into repo/ is spent too" refuses release_check_new_version 0.2.8
|
||||||
|
rm "$LOCAL/packages/ekklesia-classroom_0.2.8_amd64.deb"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "4. uploading never replaces a published package"
|
||||||
|
fake_deb 0.2.6.1 "$WORK/ekklesia-classroom_0.2.6.1_amd64.deb"
|
||||||
|
check "0.2.6.1 uploads and arrives intact" \
|
||||||
|
release_upload_package "$WORK/ekklesia-classroom_0.2.6.1_amd64.deb"
|
||||||
|
check "and the server copy is the built one" \
|
||||||
|
cmp "$WORK/ekklesia-classroom_0.2.6.1_amd64.deb" \
|
||||||
|
"$SERVER/packages/ekklesia-classroom_0.2.6.1_amd64.deb"
|
||||||
|
|
||||||
|
before="$(sha256sum "$SERVER/packages/ekklesia-classroom_0.2.6_amd64.deb")"
|
||||||
|
mkdir -p "$WORK/rebuilt"
|
||||||
|
fake_deb 0.2.6 "$WORK/rebuilt/ekklesia-classroom_0.2.6_amd64.deb" ekklesia-classroom-rebuilt
|
||||||
|
check "a different 0.2.6 is refused" \
|
||||||
|
refuses release_upload_package "$WORK/rebuilt/ekklesia-classroom_0.2.6_amd64.deb"
|
||||||
|
check "and the published 0.2.6 is untouched" \
|
||||||
|
test "$(sha256sum "$SERVER/packages/ekklesia-classroom_0.2.6_amd64.deb")" = "$before"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "5. a channel points at exactly what was uploaded"
|
||||||
|
hash_0261="$(sha256sum "$WORK/ekklesia-classroom_0.2.6.1_amd64.deb" | awk '{print $1}')"
|
||||||
|
stable_before="$(cat "$SERVER/stable/manifest.json")"
|
||||||
|
|
||||||
|
check "a manifest promising a different checksum is refused" \
|
||||||
|
refuses release_publish_manifest canary 0.2.6.1 "$(printf '0%.0s' {1..64})"
|
||||||
|
check "and canary still says 0.2.6" test "$(release_channel_version canary)" = 0.2.6
|
||||||
|
|
||||||
|
check "canary is moved to 0.2.6.1" release_publish_manifest canary 0.2.6.1 "$hash_0261"
|
||||||
|
check "its manifest says so" test "$(release_channel_version canary)" = 0.2.6.1
|
||||||
|
check "latest.deb follows it" \
|
||||||
|
test "$(readlink "$SERVER/canary/latest.deb")" = ../packages/ekklesia-classroom_0.2.6.1_amd64.deb
|
||||||
|
check "what a classroom downloads agrees, over HTTP" release_verify_channel canary 0.2.6.1
|
||||||
|
check "stable's manifest was not touched" \
|
||||||
|
test "$(cat "$SERVER/stable/manifest.json")" = "$stable_before"
|
||||||
|
check "and stable still verifies as 0.2.2" release_verify_channel stable 0.2.2
|
||||||
|
check "the check notices a channel saying the wrong version" \
|
||||||
|
refuses release_verify_channel stable 0.2.6.1
|
||||||
|
check "0.2.6.1 is now spent" refuses release_check_new_version 0.2.6.1
|
||||||
|
check "0.2.6.2 is next in line" release_check_new_version 0.2.6.2
|
||||||
|
|
||||||
|
# A package on the server that says it is a different version from its
|
||||||
|
# name. The channel check has to read the package, not just its name.
|
||||||
|
fake_deb 0.2.6 "$SERVER/packages/ekklesia-classroom_0.2.9_amd64.deb"
|
||||||
|
manifest 0.2.9 > "$SERVER/canary/manifest.json"
|
||||||
|
ln -sfn ../packages/ekklesia-classroom_0.2.9_amd64.deb "$SERVER/canary/latest.deb"
|
||||||
|
check "a package that says it is another version fails the channel check" \
|
||||||
|
refuses release_verify_channel canary 0.2.9
|
||||||
|
rm "$SERVER/packages/ekklesia-classroom_0.2.9_amd64.deb"
|
||||||
|
manifest 0.2.6.1 > "$SERVER/canary/manifest.json"
|
||||||
|
ln -sfn ../packages/ekklesia-classroom_0.2.6.1_amd64.deb "$SERVER/canary/latest.deb"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "6. update_stable.sh"
|
||||||
|
cp "$WORK/ekklesia-classroom_0.2.6.1_amd64.deb" "$LOCAL/packages/"
|
||||||
|
check "refuses a version that is not newer than stable" \
|
||||||
|
refuses "$ROOT/update_stable.sh" 0.2.2
|
||||||
|
check "refuses a malformed version" refuses "$ROOT/update_stable.sh" 0.2.06
|
||||||
|
fake_deb 0.2.9 "$LOCAL/packages/ekklesia-classroom_0.2.9_amd64.deb"
|
||||||
|
check "refuses a package that was built but never uploaded" \
|
||||||
|
refuses "$ROOT/update_stable.sh" 0.2.9
|
||||||
|
rm "$LOCAL/packages/ekklesia-classroom_0.2.9_amd64.deb"
|
||||||
|
check "stable is still 0.2.2 after all of that" \
|
||||||
|
test "$(cat "$SERVER/stable/manifest.json")" = "$stable_before"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "7. update_canary.sh"
|
||||||
|
check "without a version it builds nothing and names both choices" \
|
||||||
|
bash -c "'$ROOT/update_canary.sh' 2>&1 | grep -q '0.2.6.2 .*maintenance' \
|
||||||
|
&& '$ROOT/update_canary.sh' 2>&1 | grep -q '0.2.7 .*feature' \
|
||||||
|
&& ! '$ROOT/update_canary.sh'"
|
||||||
|
check "a published version is refused before anything is built" \
|
||||||
|
bash -c "! '$ROOT/update_canary.sh' 0.2.6 2>&1 | grep -q 'Validating sources'"
|
||||||
|
check "a malformed version is refused" refuses "$ROOT/update_canary.sh" 0.2.6.1.1
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo
|
||||||
|
echo "8. a classroom's updater, against that server"
|
||||||
|
|
||||||
|
# The package database and apt, as far as the updater can tell. The
|
||||||
|
# stand-in apt installs by reading the version out of the file it is
|
||||||
|
# given, which is what the real one does.
|
||||||
|
cat > "$WORK/bin/dpkg-query" <<EOF
|
||||||
|
#!/bin/bash
|
||||||
|
[[ -s "$WORK/installed" ]] || exit 1
|
||||||
|
cat "$WORK/installed"
|
||||||
|
EOF
|
||||||
|
cat > "$WORK/bin/apt-get" <<EOF
|
||||||
|
#!/bin/bash
|
||||||
|
echo "\$*" >> "$WORK/apt.log"
|
||||||
|
dpkg-deb --field "\${@: -1}" Version > "$WORK/installed"
|
||||||
|
EOF
|
||||||
|
cat > "$WORK/bin/systemctl" <<EOF
|
||||||
|
#!/bin/bash
|
||||||
|
echo "\$*" >> "$WORK/systemctl.log"
|
||||||
|
EOF
|
||||||
|
chmod +x "$WORK/bin/dpkg-query" "$WORK/bin/apt-get" "$WORK/bin/systemctl"
|
||||||
|
|
||||||
|
UPDATE_URL="http://127.0.0.1:$PORT" WORK="$WORK" SERVER="$SERVER" \
|
||||||
|
python3 - "$ROOT/scripts/ekklesia-classroom-update" <<'PY'
|
||||||
|
import contextlib
|
||||||
|
import hashlib
|
||||||
|
import importlib.machinery
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
work = Path(os.environ["WORK"])
|
||||||
|
server = Path(os.environ["SERVER"])
|
||||||
|
|
||||||
|
loader = importlib.machinery.SourceFileLoader("updater", sys.argv[1])
|
||||||
|
spec = importlib.util.spec_from_loader("updater", loader)
|
||||||
|
updater = importlib.util.module_from_spec(spec)
|
||||||
|
loader.exec_module(updater)
|
||||||
|
|
||||||
|
updater.LOCKFILE = str(work / "update.lock")
|
||||||
|
updater.CACHE_DIR = str(work / "cache")
|
||||||
|
|
||||||
|
passed = failed = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(label, condition, detail=""):
|
||||||
|
global passed, failed
|
||||||
|
if condition:
|
||||||
|
print(f" ok {label}")
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
print(f" FAIL {label}")
|
||||||
|
if detail:
|
||||||
|
print(f" {detail}")
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
|
||||||
|
def device(channel, installed):
|
||||||
|
config = work / f"device-{channel}.conf"
|
||||||
|
config.write_text(
|
||||||
|
"# a classroom\n"
|
||||||
|
f"UPDATE_URL={os.environ['UPDATE_URL']}\n"
|
||||||
|
f"CHANNEL={channel}\n"
|
||||||
|
"PRESENTATIONS_ENABLED=false\n"
|
||||||
|
"UI_SCALE=1.25\n"
|
||||||
|
)
|
||||||
|
updater.CONFIG = str(config)
|
||||||
|
(work / "installed").write_text(installed)
|
||||||
|
for log in ("apt.log", "systemctl.log"):
|
||||||
|
(work / log).unlink(missing_ok=True)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def run(*args):
|
||||||
|
sys.argv = ["ekklesia-classroom-update", *args]
|
||||||
|
out = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(out):
|
||||||
|
status = updater.main()
|
||||||
|
return status, out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def installs():
|
||||||
|
log = work / "apt.log"
|
||||||
|
return log.read_text().splitlines() if log.exists() else []
|
||||||
|
|
||||||
|
|
||||||
|
def installed():
|
||||||
|
return (work / "installed").read_text().strip()
|
||||||
|
|
||||||
|
|
||||||
|
def deb(version, name=None, package="ekklesia-classroom"):
|
||||||
|
stage = work / f"stage-{version}-{package}"
|
||||||
|
(stage / "DEBIAN").mkdir(parents=True, exist_ok=True)
|
||||||
|
(stage / "DEBIAN" / "control").write_text(
|
||||||
|
f"Package: {package}\nVersion: {version}\nArchitecture: amd64\n"
|
||||||
|
"Maintainer: test\nDescription: test\n"
|
||||||
|
)
|
||||||
|
out = server / "packages" / (name or f"ekklesia-classroom_{version}_amd64.deb")
|
||||||
|
out.unlink(missing_ok=True)
|
||||||
|
subprocess.run(["dpkg-deb", "--root-owner-group", "--build", stage, out],
|
||||||
|
check=True, capture_output=True)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def publish(channel, version, file=None, sha=None, raw=None):
|
||||||
|
path = server / channel / "manifest.json"
|
||||||
|
if raw is not None:
|
||||||
|
path.write_text(raw)
|
||||||
|
return
|
||||||
|
file = file or f"ekklesia-classroom_{version}_amd64.deb"
|
||||||
|
digest = sha or hashlib.sha256((server / "packages" / file).read_bytes()).hexdigest()
|
||||||
|
path.write_text(json.dumps({
|
||||||
|
"version": version, "package": f"/packages/{file}",
|
||||||
|
"sha256": digest, "requires_reboot": True,
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
publish("stable", "0.2.2")
|
||||||
|
publish("canary", "0.2.6.1")
|
||||||
|
|
||||||
|
config = device("canary", "0.2.6")
|
||||||
|
before = config.read_text()
|
||||||
|
status, out = run("--check")
|
||||||
|
check("a canary device on 0.2.6 sees 0.2.6.1 as an update",
|
||||||
|
status == 0 and "Update available: 0.2.6 -> 0.2.6.1" in out, out)
|
||||||
|
check("and --check installs nothing", installs() == [])
|
||||||
|
|
||||||
|
status, out = run("--no-reboot")
|
||||||
|
check("it installs 0.2.6.1", status == 0 and installed() == "0.2.6.1", out)
|
||||||
|
check("once, from the versioned file",
|
||||||
|
len(installs()) == 1 and installs()[0].endswith("ekklesia-classroom_0.2.6.1_amd64.deb"),
|
||||||
|
installs())
|
||||||
|
check("--no-reboot is respected", not (work / "systemctl.log").exists())
|
||||||
|
check("its device.conf is exactly as it was", config.read_text() == before)
|
||||||
|
|
||||||
|
for attempt in range(3):
|
||||||
|
run("--no-reboot")
|
||||||
|
check("a device on 0.2.6.1 does not install 0.2.6.1 again",
|
||||||
|
len(installs()) == 1, installs())
|
||||||
|
status, out = run()
|
||||||
|
check("nor reboot for it", "Already up to date" in out
|
||||||
|
and not (work / "systemctl.log").exists(), out)
|
||||||
|
|
||||||
|
deb("0.2.7")
|
||||||
|
publish("canary", "0.2.7")
|
||||||
|
status, out = run()
|
||||||
|
check("a device on 0.2.6.1 recognises 0.2.7 as newer and installs it",
|
||||||
|
status == 0 and installed() == "0.2.7", out)
|
||||||
|
check("and reboots, because the manifest asks it to",
|
||||||
|
(work / "systemctl.log").read_text().strip() == "reboot")
|
||||||
|
publish("canary", "0.2.6.1")
|
||||||
|
|
||||||
|
device("canary", "0.2.7")
|
||||||
|
status, out = run()
|
||||||
|
check("a device on 0.2.7 is not moved back to 0.2.6.1",
|
||||||
|
"Already up to date" in out and installs() == [], out)
|
||||||
|
|
||||||
|
# Three parts, exactly as before.
|
||||||
|
publish("canary", "0.2.6")
|
||||||
|
device("canary", "0.2.5")
|
||||||
|
status, out = run("--no-reboot")
|
||||||
|
check("a device on 0.2.5 still updates to 0.2.6", installed() == "0.2.6", out)
|
||||||
|
publish("canary", "0.2.6.1")
|
||||||
|
|
||||||
|
# The channels.
|
||||||
|
config = device("stable", "0.2.2")
|
||||||
|
status, out = run("--no-reboot")
|
||||||
|
check("a stable device on 0.2.2 reads the stable manifest",
|
||||||
|
f"{os.environ['UPDATE_URL']}/stable/manifest.json" in out, out)
|
||||||
|
check("and does not install the canary release",
|
||||||
|
installs() == [] and installed() == "0.2.2" and "Already up to date" in out, out)
|
||||||
|
|
||||||
|
deb("0.2.6.2")
|
||||||
|
publish("stable", "0.2.6.2")
|
||||||
|
status, out = run("--no-reboot")
|
||||||
|
check("a stable device goes from 0.2.2 straight to a four-part release when stable has one",
|
||||||
|
installed() == "0.2.6.2", out)
|
||||||
|
publish("stable", "0.2.2")
|
||||||
|
(server / "packages" / "ekklesia-classroom_0.2.6.2_amd64.deb").unlink()
|
||||||
|
|
||||||
|
# A room with no package installed at all reports 0.0.0 and takes anything.
|
||||||
|
device("canary", "")
|
||||||
|
status, out = run("--check")
|
||||||
|
check("a machine with nothing installed is offered 0.2.6.1",
|
||||||
|
"0.0.0 -> 0.2.6.1" in out, out)
|
||||||
|
|
||||||
|
# Malformed manifests: nothing downloaded, nothing installed, and a
|
||||||
|
# failure the timer's journal will show.
|
||||||
|
sha = hashlib.sha256((server / "packages" / "ekklesia-classroom_0.2.6.1_amd64.deb").read_bytes()).hexdigest()
|
||||||
|
bad = {
|
||||||
|
"a version with a suffix": {"version": "0.2.6.1-1"},
|
||||||
|
"a version with five parts": {"version": "0.2.6.1.1"},
|
||||||
|
"a version with a leading zero": {"version": "0.2.06"},
|
||||||
|
"a version that is a number": {"version": 0.3},
|
||||||
|
"an empty version": {"version": ""},
|
||||||
|
"a version with a shell in it": {"version": "0.2.7;reboot"},
|
||||||
|
"no version at all": {"version": None},
|
||||||
|
"a package for a different version": {"version": "0.2.7",
|
||||||
|
"package": "/packages/ekklesia-classroom_0.2.6.1_amd64.deb"},
|
||||||
|
"a package outside /packages": {"version": "0.2.7",
|
||||||
|
"package": "/../ekklesia-classroom_0.2.7_amd64.deb"},
|
||||||
|
"a checksum that is not one": {"version": "0.2.7", "sha256": "abc"},
|
||||||
|
}
|
||||||
|
for label, fields in bad.items():
|
||||||
|
manifest = {"version": "0.2.7",
|
||||||
|
"package": "/packages/ekklesia-classroom_0.2.7_amd64.deb",
|
||||||
|
"sha256": sha, "requires_reboot": True}
|
||||||
|
manifest.update(fields)
|
||||||
|
if manifest["version"] is None:
|
||||||
|
del manifest["version"]
|
||||||
|
publish("canary", None, raw=json.dumps(manifest))
|
||||||
|
device("canary", "0.2.6")
|
||||||
|
status, out = run("--no-reboot")
|
||||||
|
check(f"refused: {label}",
|
||||||
|
status == 1 and installs() == [] and installed() == "0.2.6", out)
|
||||||
|
|
||||||
|
for label, raw in (("a manifest that is not JSON", "<html>502</html>"),
|
||||||
|
("a manifest that is a list", "[]")):
|
||||||
|
publish("canary", None, raw=raw)
|
||||||
|
device("canary", "0.2.6")
|
||||||
|
status, out = run("--no-reboot")
|
||||||
|
check(f"refused: {label}", status == 1 and installs() == [], out)
|
||||||
|
|
||||||
|
# The file arrives, matches its checksum, and is the wrong package.
|
||||||
|
shutil.rmtree(updater.CACHE_DIR, ignore_errors=True)
|
||||||
|
deb("0.2.6", name="ekklesia-classroom_0.2.7_amd64.deb")
|
||||||
|
publish("canary", "0.2.7")
|
||||||
|
device("canary", "0.2.6")
|
||||||
|
status, out = run("--no-reboot")
|
||||||
|
check("a 0.2.7 file that installs as 0.2.6 is refused",
|
||||||
|
status == 1 and installs() == [] and "not ekklesia-classroom 0.2.7" in out, out)
|
||||||
|
check("and not left in the cache",
|
||||||
|
not list(Path(updater.CACHE_DIR).glob("*0.2.7*")))
|
||||||
|
|
||||||
|
deb("0.2.7", package="something-else", name="ekklesia-classroom_0.2.7_amd64.deb")
|
||||||
|
publish("canary", "0.2.7")
|
||||||
|
status, out = run("--no-reboot")
|
||||||
|
check("so is a file that is some other package", status == 1 and installs() == [], out)
|
||||||
|
|
||||||
|
deb("0.2.7")
|
||||||
|
publish("canary", "0.2.7", sha="0" * 64)
|
||||||
|
status, out = run("--no-reboot")
|
||||||
|
check("and a file whose checksum is wrong",
|
||||||
|
status == 1 and installs() == [] and "verification FAILED" in out, out)
|
||||||
|
|
||||||
|
publish("canary", "0.2.6.1")
|
||||||
|
print(f"\n updater: {passed} passed, {failed} failed")
|
||||||
|
sys.exit(1 if failed else 0)
|
||||||
|
PY
|
||||||
|
check "every updater case above passed" test $? -eq 0
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "$passed passed, $failed failed"
|
||||||
|
[[ "$failed" -eq 0 ]]
|
||||||
@@ -11,11 +11,10 @@ fi
|
|||||||
|
|
||||||
VERSION="$1"
|
VERSION="$1"
|
||||||
|
|
||||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
# Three parts for a feature release, four for a maintenance release of
|
||||||
echo "Invalid version: $VERSION"
|
# one: 0.2.6, then 0.2.6.1. See tools/release-lib.sh.
|
||||||
echo "Expected MAJOR.MINOR.PATCH, for example 0.1.6"
|
source "$ROOT/tools/release-lib.sh"
|
||||||
exit 1
|
release_require_version "$VERSION" || exit 1
|
||||||
fi
|
|
||||||
|
|
||||||
STAGE="$ROOT/packaging/root"
|
STAGE="$ROOT/packaging/root"
|
||||||
OUTPUT="$ROOT/dist/ekklesia-classroom_${VERSION}_amd64.deb"
|
OUTPUT="$ROOT/dist/ekklesia-classroom_${VERSION}_amd64.deb"
|
||||||
@@ -384,6 +383,11 @@ install -Dm755 \
|
|||||||
"$ROOT/scripts/classroom-wonderink" \
|
"$ROOT/scripts/classroom-wonderink" \
|
||||||
"$STAGE/usr/local/bin/classroom-wonderink"
|
"$STAGE/usr/local/bin/classroom-wonderink"
|
||||||
|
|
||||||
|
# Wonder Ink's printing: a PDF, shown, then handed to CUPS with lp.
|
||||||
|
install -Dm644 \
|
||||||
|
"$ROOT/lib/classroom_printing.py" \
|
||||||
|
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_printing.py"
|
||||||
|
|
||||||
# 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
|
||||||
@@ -568,6 +572,16 @@ dpkg-deb \
|
|||||||
--root-owner-group \
|
--root-owner-group \
|
||||||
--build "$STAGE" "$OUTPUT"
|
--build "$STAGE" "$OUTPUT"
|
||||||
|
|
||||||
|
# The version dpkg reads out of the file is the one a classroom will
|
||||||
|
# report, so it is checked rather than assumed to have survived the sed.
|
||||||
|
built_version="$(release_package_version "$OUTPUT")"
|
||||||
|
|
||||||
|
if [[ "$built_version" != "$VERSION" ]]; then
|
||||||
|
echo "The package says it is $built_version, not $VERSION" >&2
|
||||||
|
rm -f "$OUTPUT"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Built:"
|
echo "Built:"
|
||||||
echo "$OUTPUT"
|
echo "$OUTPUT"
|
||||||
|
|||||||
@@ -302,8 +302,11 @@ check "the launcher names the shell's app id" ekklesia-wonderink \
|
|||||||
# Comments stripped first. The shell has a comment saying Ctrl+T is
|
# Comments stripped first. The shell has a comment saying Ctrl+T is
|
||||||
# deliberately absent, and a check that cannot tell that from a binding
|
# deliberately absent, and a check that cannot tell that from a binding
|
||||||
# is a check that fails for being right.
|
# is a check that fails for being right.
|
||||||
|
# One text box exists, in the print preview, for the page range. The
|
||||||
|
# check reads the code outside that class; anywhere else a text box in
|
||||||
|
# this window would be an address bar.
|
||||||
check "no code offers a new tab button or an address bar" 0 \
|
check "no code offers a new tab button or an address bar" 0 \
|
||||||
"$(in_lab "grep -vE '^[[:space:]]*#' /usr/local/bin/classroom-wonderink | grep -cE 'QLineEdit|setCornerWidget|addressBar|bind\\(\"Ctrl\\+T' || true")"
|
"$(in_lab "sed '/^class PrintPreview/,/^def notify/d; /setCornerWidget(self._controls/d' /usr/local/bin/classroom-wonderink | grep -vE '^[[:space:]]*#' | grep -cE 'QLineEdit\\(|setCornerWidget|addressBar|bind\\(\"Ctrl\\+T' || true")"
|
||||||
# Separate from the ordinary browser, which is the whole reason the split
|
# Separate from the ordinary browser, which is the whole reason the split
|
||||||
# exists: a teacher's Wonder Ink sign-in and the room's shared YouTube
|
# exists: a teacher's Wonder Ink sign-in and the room's shared YouTube
|
||||||
# account must never be in one profile.
|
# account must never be in one profile.
|
||||||
@@ -329,7 +332,7 @@ check "and the browser is untouched by any of this" 0 \
|
|||||||
|
|
||||||
section "a lesson's worksheets come back as tabs"
|
section "a lesson's worksheets come back as tabs"
|
||||||
check "worksheets have a directory of Wonder Ink's own" 1 \
|
check "worksheets have a directory of Wonder Ink's own" 1 \
|
||||||
"$(in_lab 'grep -c "\"Wonder Ink\"$" /usr/local/bin/classroom-wonderink || true')"
|
"$(in_lab 'grep -c ") / \"Wonder Ink\"$" /usr/local/bin/classroom-wonderink || true')"
|
||||||
check "a finished PDF becomes a tab" 1 \
|
check "a finished PDF becomes a tab" 1 \
|
||||||
"$(in_lab 'grep -c "def open_worksheet" /usr/local/bin/classroom-wonderink || true')"
|
"$(in_lab 'grep -c "def open_worksheet" /usr/local/bin/classroom-wonderink || true')"
|
||||||
check "checked on its contents, not its name" 1 \
|
check "checked on its contents, not its name" 1 \
|
||||||
@@ -344,8 +347,10 @@ check "and a guard consults it on every navigation" 1 \
|
|||||||
"$(in_lab 'grep -c "def acceptNavigationRequest" /usr/local/bin/classroom-wonderink || true')"
|
"$(in_lab 'grep -c "def acceptNavigationRequest" /usr/local/bin/classroom-wonderink || true')"
|
||||||
check "a local document cannot read other local documents" 1 \
|
check "a local document cannot read other local documents" 1 \
|
||||||
"$(in_lab 'grep -c "LocalContentCanAccessFileUrls, False" /usr/local/bin/classroom-wonderink || true')"
|
"$(in_lab 'grep -c "LocalContentCanAccessFileUrls, False" /usr/local/bin/classroom-wonderink || true')"
|
||||||
check "no file chooser behind the print dialog" 1 \
|
# Qt's print dialog, and its Print to File chooser, are gone altogether:
|
||||||
"$(in_lab 'grep -c "PrintDialogOption.PrintToFile, False" /usr/local/bin/classroom-wonderink || true')"
|
# the preview offers CUPS printers and nothing else.
|
||||||
|
check "no file chooser behind printing" 0 \
|
||||||
|
"$(in_lab 'grep -cE "QFileDialog|QPrintDialog" /usr/local/bin/classroom-wonderink || true')"
|
||||||
check "nothing downloaded is handed to the desktop to run" 0 \
|
check "nothing downloaded is handed to the desktop to run" 0 \
|
||||||
"$(in_lab 'grep -cE "QDesktopServices|xdg-open" /usr/local/bin/classroom-wonderink || true')"
|
"$(in_lab 'grep -cE "QDesktopServices|xdg-open" /usr/local/bin/classroom-wonderink || true')"
|
||||||
check "and no file browser was added" 0 \
|
check "and no file browser was added" 0 \
|
||||||
@@ -365,10 +370,13 @@ 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')"
|
||||||
|
# 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
|
||||||
|
# until 0.2.6.1 and so failed on every lab run in between.
|
||||||
check "the layout carries a marker the setting can switch" 1 \
|
check "the layout carries a marker the setting can switch" 1 \
|
||||||
"$(in_lab 'grep -c "CLASSROOM-WONDERINK-LAUNCHER \*/ \"applications:ekklesia-wonderink.desktop\"" /usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js || true')"
|
"$(in_lab 'grep -cE "^var WONDERINK = (true|false);" /usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js || true')"
|
||||||
check "and it is switched on for this machine" 0 \
|
check "and it is switched on for this machine" 1 \
|
||||||
"$(in_lab 'grep -c "// /\* CLASSROOM-WONDERINK-LAUNCHER" /usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js || true')"
|
"$(in_lab 'grep -c "^var WONDERINK = true;" /usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js || true')"
|
||||||
|
|
||||||
section "the room's own parts survived"
|
section "the room's own parts survived"
|
||||||
check "parent call installed" 1 "$(in_lab 'ls /usr/local/bin/classroom-parent-call 2>/dev/null | wc -l')"
|
check "parent call installed" 1 "$(in_lab 'ls /usr/local/bin/classroom-parent-call 2>/dev/null | wc -l')"
|
||||||
|
|||||||
@@ -90,11 +90,16 @@ up)
|
|||||||
# Container-local: this netns belongs to the container alone and
|
# Container-local: this netns belongs to the container alone and
|
||||||
# nothing on the host or the church network is changed by it. Debian's
|
# nothing on the host or the church network is changed by it. Debian's
|
||||||
# own mirrors are untouched, so installs still work.
|
# own mirrors are untouched, so installs still work.
|
||||||
|
# 10.10 is the printers. The package provisions Elmer at
|
||||||
|
# socket://10.10.0.155:9100, and the development host reaches that
|
||||||
|
# address over Tailscale, so an unfenced lab that printed would have
|
||||||
|
# put paper in the church's real printer.
|
||||||
|
#
|
||||||
# A blackhole route rather than a firewall rule: the lab image has no
|
# A blackhole route rather than a firewall rule: the lab image has no
|
||||||
# iptables, and the rig's guard had been quietly doing nothing because
|
# iptables, and the rig's guard had been quietly doing nothing because
|
||||||
# of it. Routes need no netfilter modules and cannot silently fail.
|
# of it. Routes need no netfilter modules and cannot silently fail.
|
||||||
docker exec "$NAME" sh -c '
|
docker exec "$NAME" sh -c '
|
||||||
for net in 10.30.0.0/16 10.40.0.0/16; do
|
for net in 10.10.0.0/16 10.30.0.0/16 10.40.0.0/16; do
|
||||||
ip route replace blackhole "$net" || exit 1
|
ip route replace blackhole "$net" || exit 1
|
||||||
done' || echo " WARNING: could not fence the church networks"
|
done' || echo " WARNING: could not fence the church networks"
|
||||||
|
|
||||||
@@ -102,7 +107,7 @@ up)
|
|||||||
# bash feature: under dash it fails whatever the network is doing, so
|
# bash feature: under dash it fails whatever the network is doing, so
|
||||||
# the check passed on a lab that could reach the broker perfectly
|
# the check passed on a lab that could reach the broker perfectly
|
||||||
# well. Measured.
|
# well. Measured.
|
||||||
for target in 10.40.0.2/1883 10.30.0.2/1025; do
|
for target in 10.40.0.2/1883 10.30.0.2/1025 10.10.0.155/9100; do
|
||||||
if docker exec "$NAME" timeout 4 bash -c "exec 3<>/dev/tcp/${target}" 2>/dev/null; then
|
if docker exec "$NAME" timeout 4 bash -c "exec 3<>/dev/tcp/${target}" 2>/dev/null; then
|
||||||
echo "REFUSING TO CONTINUE: ${target} is reachable from the lab" >&2
|
echo "REFUSING TO CONTINUE: ${target} is reachable from the lab" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -140,11 +145,11 @@ reboot)
|
|||||||
# named after container ids on the real broker, from sessions that
|
# named after container ids on the real broker, from sessions that
|
||||||
# had been fenced when they started and were not after a reboot.
|
# had been fenced when they started and were not after a reboot.
|
||||||
docker exec "$NAME" sh -c '
|
docker exec "$NAME" sh -c '
|
||||||
for net in 10.30.0.0/16 10.40.0.0/16; do
|
for net in 10.10.0.0/16 10.30.0.0/16 10.40.0.0/16; do
|
||||||
ip route replace blackhole "$net" || exit 1
|
ip route replace blackhole "$net" || exit 1
|
||||||
done' || echo " WARNING: could not fence the church networks"
|
done' || echo " WARNING: could not fence the church networks"
|
||||||
|
|
||||||
for target in 10.40.0.2/1883 10.30.0.2/1025; do
|
for target in 10.40.0.2/1883 10.30.0.2/1025 10.10.0.155/9100; do
|
||||||
if docker exec "$NAME" timeout 4 bash -c "exec 3<>/dev/tcp/${target}" 2>/dev/null; then
|
if docker exec "$NAME" timeout 4 bash -c "exec 3<>/dev/tcp/${target}" 2>/dev/null; then
|
||||||
echo "REFUSING TO CONTINUE: ${target} is reachable from the lab" >&2
|
echo "REFUSING TO CONTINUE: ${target} is reachable from the lab" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Does Wonder Ink print — and does a job really reach CUPS?
|
||||||
|
#
|
||||||
|
# The lab's CUPS gets an "Elmer" built from Elmer's own PPD and options,
|
||||||
|
# writing to a file instead of socket://10.10.0.155:9100, so every job
|
||||||
|
# goes through the same filters the real printer's jobs do and none can
|
||||||
|
# reach a printer. A second queue refuses jobs, to show a teacher is told.
|
||||||
|
#
|
||||||
|
# Each scenario runs the INSTALLED classroom-wonderink under a real
|
||||||
|
# QtWebEngine (offscreen), presses the preview's buttons, and is then
|
||||||
|
# judged by CUPS's own record of what it was given.
|
||||||
|
#
|
||||||
|
# tools/lab/wonderink-print-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}"
|
||||||
|
|
||||||
|
lab() { docker exec -i "$NAME" bash -c "$1"; }
|
||||||
|
|
||||||
|
# Not /tmp: in the Plasma lab /tmp is a tmpfs, and docker cp writes into
|
||||||
|
# the layer underneath it, where nothing can see the file. Measured — the
|
||||||
|
# first run there "failed" every scenario for exactly that reason.
|
||||||
|
RIG=/opt/wonderink-check/wonderink-print-rig.py
|
||||||
|
lab 'mkdir -p /opt/wonderink-check'
|
||||||
|
docker cp "$ROOT/tools/lab/wonderink-print-rig.py" "$NAME:$RIG" >/dev/null
|
||||||
|
lab "chmod -R a+rX /opt/wonderink-check && test -r $RIG" || {
|
||||||
|
echo "could not put the rig in the lab" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# The church's printer network is fenced off inside the lab. Checked, not
|
||||||
|
# assumed: a lab job must never be able to reach the real Elmer.
|
||||||
|
lab 'ip route replace blackhole 10.10.0.0/16 2>/dev/null
|
||||||
|
! timeout 3 bash -c "exec 3<>/dev/tcp/10.10.0.155/9100" 2>/dev/null' || {
|
||||||
|
echo "REFUSING TO CONTINUE: the real Elmer is reachable from the lab" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
lab '
|
||||||
|
set -e
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
command -v lpadmin >/dev/null && command -v pdftoppm >/dev/null || \
|
||||||
|
apt-get install -y -qq cups cups-client poppler-utils >/dev/null 2>&1
|
||||||
|
dpkg -s foomatic-db-compressed-ppds >/dev/null 2>&1 || \
|
||||||
|
apt-get install -y -qq foomatic-db-compressed-ppds cups-filters ghostscript >/dev/null 2>&1
|
||||||
|
grep -q "^FileDevice Yes" /etc/cups/cups-files.conf || echo "FileDevice Yes" >> /etc/cups/cups-files.conf
|
||||||
|
pkill -x cupsd || true; sleep 1; /usr/sbin/cupsd; sleep 1
|
||||||
|
cupsctl --debug-logging
|
||||||
|
: > /var/log/cups/error_log
|
||||||
|
mkdir -p /var/lab-print && chown lp /var/lab-print
|
||||||
|
driver="foomatic-db-compressed-ppds:0/ppd/foomatic-ppd/Generic-PCL_6_PCL_XL_Printer-pxlcolor.ppd"
|
||||||
|
for q in $(lpstat -e 2>/dev/null); do lpadmin -x "$q"; done
|
||||||
|
lpadmin -p Elmer -E -v file:///var/lab-print/elmer.pxl -m "$driver" \
|
||||||
|
-D "LAB STAND-IN for Elmer: its PPD, a file for a printer" \
|
||||||
|
-o PrintoutMode=Normal -o ColorModel=Color 2>/dev/null
|
||||||
|
lpadmin -d Elmer
|
||||||
|
lpadmin -p Refusing -E -v file:///dev/null -m "$driver" -D "LAB: refuses jobs" 2>/dev/null
|
||||||
|
cupsreject Refusing
|
||||||
|
id teacher >/dev/null 2>&1 || useradd -m teacher
|
||||||
|
install -d -o teacher -m 700 /tmp/teacher-rt
|
||||||
|
# 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.
|
||||||
|
mkdir -p /opt/wonderink-check/www
|
||||||
|
' || { echo "could not prepare the lab's CUPS" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Started detached. A background process started inside a docker exec
|
||||||
|
# dies with it in the Plasma lab, whose PID 1 is systemd.
|
||||||
|
lab 'pgrep -f "[h]ttp.server 8089" >/dev/null' || \
|
||||||
|
docker exec -d -w /opt/wonderink-check/www "$NAME" python3 -m http.server 8089
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
# Runs one scenario; leaves its output in $out and the jobs it caused in $jobs.
|
||||||
|
run() {
|
||||||
|
local before
|
||||||
|
before=$(lab 'lpstat -W all -o 2>/dev/null | wc -l')
|
||||||
|
out=$(lab "su teacher -c 'cd /tmp; XDG_RUNTIME_DIR=/tmp/teacher-rt QT_QPA_PLATFORM=offscreen \
|
||||||
|
QTWEBENGINE_CHROMIUM_FLAGS=\"--no-sandbox --disable-gpu\" \
|
||||||
|
timeout 120 python3 $RIG $COMMAND $1 2>&1'; echo \"exit=\$?\"")
|
||||||
|
sleep 3
|
||||||
|
jobs=$(lab "lpstat -W all -o | tail -n +$((before + 1)) | awk '{print \$1}' | tr '\n' ' '")
|
||||||
|
job_options() { lab "grep -E '\\[Job ${1#*-}\\] argv\\[(4|5)\\]' /var/log/cups/error_log"; }
|
||||||
|
job_device() { lab "grep -E '\\[Job ${1#*-}\\]' /var/log/cups/error_log | grep -oE 'sDEVICE=[a-z]+' | sort -u"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
count() { wc -w <<< "$jobs"; }
|
||||||
|
said() { grep -q -- "$1" <<< "$out"; }
|
||||||
|
exited_cleanly() { said 'exit=0' && ! said 'Traceback' && ! said 'Segmentation' && ! said 'Aborted'; }
|
||||||
|
|
||||||
|
scenarios=("$@")
|
||||||
|
[[ ${#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)
|
||||||
|
|
||||||
|
# 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'; }
|
||||||
|
|
||||||
|
for scenario in "${scenarios[@]}"; do
|
||||||
|
echo "$scenario"
|
||||||
|
run "$scenario"
|
||||||
|
|
||||||
|
judge "Wonder Ink did not crash, and exited by itself" exited_cleanly
|
||||||
|
|
||||||
|
# The preview has to SHOW the page, not merely count it. A preview
|
||||||
|
# that printed the right thing while showing a blank was what the
|
||||||
|
# first version of this check let through.
|
||||||
|
if said 'PREVIEW '; then
|
||||||
|
judge "the preview drew the page it will print" "! grep 'PREVIEW ' <<< \"\$out\" | grep -q 'shown=False'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$scenario" in
|
||||||
|
web)
|
||||||
|
judge "the preview showed the lesson, 3 Letter pages, Elmer chosen" \
|
||||||
|
"said \"pages=3 printers=\\['Elmer', 'Refusing'\\] paper=Letter\""
|
||||||
|
judge "one job reached CUPS" '[[ $(count) -eq 1 ]]'
|
||||||
|
judge "Elmer's filters printed it in colour" '[[ "$(job_device $jobs)" == *pxlcolor* ]]'
|
||||||
|
judge "and nothing was left behind" "said 'scratch_left=\\[\\]'" ;;
|
||||||
|
pdf|pdf-viewer-button)
|
||||||
|
judge "the preview showed the worksheet's own 2 pages" "said 'pages=2'"
|
||||||
|
judge "one job reached CUPS" '[[ $(count) -eq 1 ]]' ;;
|
||||||
|
pdf-options)
|
||||||
|
judge "one job" '[[ $(count) -eq 1 ]]'
|
||||||
|
judge "page 2 only, three copies" \
|
||||||
|
'[[ "$(job_options $jobs)" == *"argv[4]=\"3\""* && "$(job_options $jobs)" == *page-ranges=2* ]]'
|
||||||
|
judge "in black and white, by the driver's own option" \
|
||||||
|
'[[ "$(job_options $jobs)" == *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlmono* ]]' ;;
|
||||||
|
inline)
|
||||||
|
judge "the inline PDF was recognised" "said \"mime='application/pdf'\""
|
||||||
|
judge "and opened as a worksheet tab" "said 'current_is_pdf=True'"
|
||||||
|
judge "one job reached CUPS" '[[ $(count) -eq 1 ]]' ;;
|
||||||
|
options)
|
||||||
|
judge "pages 2-3, two copies, black and white" \
|
||||||
|
'[[ "$(job_options $jobs)" == *page-ranges=2-3* && "$(job_options $jobs)" == *"argv[4]=\"2\""* && "$(job_device $jobs)" == *pxlmono* ]]' ;;
|
||||||
|
bad-range)
|
||||||
|
judge "a page that does not exist is explained, not printed" "said 'This document has 3 pages'"
|
||||||
|
judge "and once corrected, one job" '[[ $(count) -eq 1 ]]' ;;
|
||||||
|
broken)
|
||||||
|
judge "a refusing printer is reported in CUPS's words" "said 'Refusing did not take the job'"
|
||||||
|
judge "and no job is claimed" '[[ $(count) -eq 0 ]] && ! said "NOTIFY Sent"' ;;
|
||||||
|
cancel)
|
||||||
|
judge "Cancel sends nothing" '[[ $(count) -eq 0 ]]' ;;
|
||||||
|
page-closes-itself)
|
||||||
|
judge "the preview says the page closed, and cannot print" \
|
||||||
|
"said \"status='The page was closed.' print_enabled=False\""
|
||||||
|
judge "and sends nothing" '[[ $(count) -eq 0 ]]' ;;
|
||||||
|
close-window)
|
||||||
|
judge "closing Wonder Ink with the preview open sends nothing" '[[ $(count) -eq 0 ]]' ;;
|
||||||
|
page-prints-again)
|
||||||
|
judge "a second print request does not open a second preview" "said 'still one preview=True'"
|
||||||
|
judge "one job" '[[ $(count) -eq 1 ]]' ;;
|
||||||
|
repeat)
|
||||||
|
judge "five prints in a row, five jobs" '[[ $(count) -eq 5 ]]' ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
[[ "$failed" -eq 0 ]] || { echo "$out" | tail -15 | sed 's/^/ /'; }
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "$passed passed, $failed failed"
|
||||||
|
[[ "$failed" -eq 0 ]]
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""Drive Wonder Ink's print preview through one scenario, headless.
|
||||||
|
|
||||||
|
usage: wonderink-print-rig.py WONDERINK_COMMAND SCENARIO
|
||||||
|
|
||||||
|
Run by tools/lab/wonderink-print-check inside the lab, against the
|
||||||
|
command the package installed. It presses the preview's own buttons the
|
||||||
|
way a teacher would, and prints what happened; the check around it asks
|
||||||
|
CUPS what actually arrived.
|
||||||
|
"""
|
||||||
|
import importlib.machinery, importlib.util, sys, time, faulthandler, gc, os
|
||||||
|
faulthandler.enable()
|
||||||
|
path, scenario = sys.argv[1], sys.argv[2]
|
||||||
|
loader = importlib.machinery.SourceFileLoader("wonderink", path)
|
||||||
|
spec = importlib.util.spec_from_loader("wonderink", loader)
|
||||||
|
wi = importlib.util.module_from_spec(spec); loader.exec_module(wi)
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from PyQt6.QtCore import QTimer, QUrl
|
||||||
|
from PyQt6.QtGui import QPdfWriter, QPainter, QFont, QPageSize
|
||||||
|
from PyQt6.QtWidgets import QApplication
|
||||||
|
from PyQt6.QtWebEngineCore import QWebEngineProfile, QWebEngineSettings
|
||||||
|
|
||||||
|
notes = []
|
||||||
|
wi.notify = lambda s, b: (notes.append(s), print("NOTIFY", s, "|", b, flush=True))
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
profile = QWebEngineProfile("rig", app)
|
||||||
|
for a in ("PdfViewerEnabled", "PluginsEnabled"):
|
||||||
|
profile.settings().setAttribute(getattr(QWebEngineSettings.WebAttribute, a), True)
|
||||||
|
window = wi.WonderInk(profile)
|
||||||
|
profile.downloadRequested.connect(window.on_download)
|
||||||
|
window.resize(1280, 800); window.show()
|
||||||
|
t0 = time.monotonic()
|
||||||
|
def stamp(m): print(f"[{time.monotonic()-t0:6.2f}s] {m}", flush=True)
|
||||||
|
|
||||||
|
def lesson(view, title="Noahs Ark lesson"):
|
||||||
|
view.setHtml(f"<html><head><title>{title}</title></head><body>" + "".join(
|
||||||
|
f"<h1>{title}, part {i}</h1><p>{'Noah built an ark. ' * 80}</p>" for i in range(5)
|
||||||
|
) + "</body></html>", QUrl("https://app.wonderink.org/lesson"))
|
||||||
|
|
||||||
|
def worksheet(name="Colour the Ark"):
|
||||||
|
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))
|
||||||
|
pa.drawText(200, 400, f"WORKSHEET {name} page one"); pa.drawRect(200, 600, 2000, 1500)
|
||||||
|
w.newPage(); pa.drawText(200, 400, f"WORKSHEET {name} page two"); pa.end()
|
||||||
|
return p
|
||||||
|
|
||||||
|
def when(cond, then, label, limit=30):
|
||||||
|
start = time.monotonic()
|
||||||
|
def poll():
|
||||||
|
try: ok = cond()
|
||||||
|
except Exception as e: ok = False
|
||||||
|
if ok: return then()
|
||||||
|
if time.monotonic() - start > limit:
|
||||||
|
stamp(f"GAVE UP waiting for {label}"); app.exit(4); return
|
||||||
|
QTimer.singleShot(100, poll)
|
||||||
|
QTimer.singleShot(100, poll)
|
||||||
|
|
||||||
|
def preview(): return window._preview
|
||||||
|
def ready(): return preview() is not None and preview()._pdf is not None
|
||||||
|
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}"
|
||||||
|
|
||||||
|
def finish(code=0):
|
||||||
|
stamp(f"RESULT notes={notes} preview_open={preview() is not None} tabs={window._tabs.count()}")
|
||||||
|
QTimer.singleShot(1500, lambda: app.exit(code))
|
||||||
|
|
||||||
|
def press_print(then=None):
|
||||||
|
d = preview(); stamp("PREVIEW " + describe(d))
|
||||||
|
d._print.click()
|
||||||
|
stamp(f"after Print: open={preview() is not None} status={d._status.text() if preview() else ''!r}")
|
||||||
|
(then or finish)()
|
||||||
|
|
||||||
|
actions = {
|
||||||
|
"web": lambda view: when(ready, press_print, "preview"),
|
||||||
|
"cancel": lambda view: when(ready, lambda: (stamp("PREVIEW " + describe(preview())), preview()._cancel.click(), finish()), "preview"),
|
||||||
|
"options": lambda view: when(ready, lambda: (
|
||||||
|
preview()._which.setCurrentIndex(1), preview()._range.setText("2-3"),
|
||||||
|
preview()._copies.setValue(2), preview()._colour.setCurrentIndex(1), press_print()), "preview"),
|
||||||
|
"bad-range": lambda view: when(ready, lambda: (
|
||||||
|
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}"),
|
||||||
|
preview()._range.setText("1"), press_print()), "preview"),
|
||||||
|
"broken": lambda view: when(ready, lambda: (
|
||||||
|
preview()._printer.setCurrentText("Refusing"),
|
||||||
|
stamp(f"switched to Refusing: paper={preview().paper()} status={preview()._status.text()!r}"),
|
||||||
|
when(ready, lambda: (
|
||||||
|
stamp(f"re-rendered on {preview().paper()}: pages={preview()._pages}"),
|
||||||
|
preview()._print.click(),
|
||||||
|
stamp(f"broken: open={preview() is not None} status={preview()._status.text()!r}"),
|
||||||
|
preview()._cancel.click(), finish()), "re-render")), "preview"),
|
||||||
|
"page-closes-itself": lambda view: when(ready, lambda: (
|
||||||
|
view.page().runJavaScript("window.close()"),
|
||||||
|
when(lambda: preview() and "closed" in preview()._status.text(), lambda: (
|
||||||
|
stamp(f"after page closed: status={preview()._status.text()!r} print_enabled={preview()._print.isEnabled()}"),
|
||||||
|
preview()._cancel.click(), finish()), "closed message")), "preview"),
|
||||||
|
"close-window": lambda view: when(ready, lambda: (stamp("closing window with preview open"), window.close()), "preview"),
|
||||||
|
"page-prints-again": lambda view: when(ready, lambda: (
|
||||||
|
view.page().runJavaScript("window.print()"),
|
||||||
|
QTimer.singleShot(800, lambda: (stamp(f"second request while open: still one preview={preview() is not None}"), press_print()))), "preview"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if scenario == "repeat":
|
||||||
|
view = window.open_tab(); lesson(view); n = [0]
|
||||||
|
def once():
|
||||||
|
if n[0] == 5: return finish()
|
||||||
|
n[0] += 1; QTimer.singleShot(0, lambda: window.print_view(view))
|
||||||
|
when(ready, lambda: press_print(then=lambda: QTimer.singleShot(200, once)), "preview")
|
||||||
|
view.loadFinished.connect(lambda ok: once() if n[0] == 0 else None)
|
||||||
|
elif scenario in ("pdf", "pdf-options"):
|
||||||
|
window.open_pdf(worksheet()); view = window.current()
|
||||||
|
if scenario == "pdf-options":
|
||||||
|
actions["pdf-options"] = lambda view: when(ready, lambda: (
|
||||||
|
preview()._which.setCurrentIndex(1), preview()._range.setText("2"),
|
||||||
|
preview()._copies.setValue(3), preview()._colour.setCurrentIndex(1), press_print()), "preview")
|
||||||
|
QTimer.singleShot(2500, lambda: (QTimer.singleShot(0, lambda: window.print_view(view)),
|
||||||
|
actions[scenario if scenario == "pdf-options" else "web"](view)))
|
||||||
|
elif scenario == "pdf-viewer-button":
|
||||||
|
# The viewer's own print button reaches the page as a print request.
|
||||||
|
window.open_pdf(worksheet()); view = window.current()
|
||||||
|
QTimer.singleShot(2500, lambda: (actions["web"](view), view.page().printRequested.emit()))
|
||||||
|
elif scenario == "inline":
|
||||||
|
view = window.open_tab(); view.load(QUrl("http://127.0.0.1:8089/ark.pdf"))
|
||||||
|
QTimer.singleShot(4000, lambda: (stamp(f"inline mime={window._mime.get(view)!r} shows_pdf={window._shows_pdf(view)}"),
|
||||||
|
QTimer.singleShot(0, lambda: window.print_view(view)),
|
||||||
|
when(ready, lambda: (stamp(f"worksheet tab opened: tabs={window._tabs.count()} current_is_pdf={window.current() in window._pdf_views}"), press_print()), "inline preview")))
|
||||||
|
else:
|
||||||
|
if scenario == "page-closes-itself":
|
||||||
|
lesson(window.open_tab(), "The other lesson")
|
||||||
|
view = window.open_tab(); lesson(view)
|
||||||
|
fired = []
|
||||||
|
def go(ok):
|
||||||
|
if fired: return
|
||||||
|
fired.append(1); QTimer.singleShot(0, lambda: window.print_view(view)); actions[scenario](view)
|
||||||
|
view.loadFinished.connect(go)
|
||||||
|
|
||||||
|
QTimer.singleShot(90000, lambda: (stamp("TIMEOUT"), app.exit(3)))
|
||||||
|
rc = app.exec()
|
||||||
|
stamp(f"EXIT {rc} scratch_left={sorted(p.name for p in Path(os.environ.get('XDG_RUNTIME_DIR','/tmp')).glob('wonderink-print-*'))}")
|
||||||
|
sys.exit(rc)
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# How large is a lesson in Wonder Ink, and do the keys change it?
|
||||||
|
#
|
||||||
|
# Runs the INSTALLED classroom-wonderink under a real QtWebEngine
|
||||||
|
# (offscreen), builds its window the way main() does, and asks the page
|
||||||
|
# itself what it is rendering at. Keys are delivered through Qt's own key
|
||||||
|
# path to the focused page — the same shortcut map a keyboard reaches —
|
||||||
|
# because the lab's injected keyboard does not reach the nested session.
|
||||||
|
# A physical Ctrl+Plus is a hardware check.
|
||||||
|
#
|
||||||
|
# tools/lab/wonderink-zoom-check
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
NAME="${LAB_NAME:-classroom-v2-lab}"
|
||||||
|
COMMAND="${WONDERINK:-/usr/local/bin/classroom-wonderink}"
|
||||||
|
|
||||||
|
docker exec -i \
|
||||||
|
-e QT_QPA_PLATFORM=offscreen \
|
||||||
|
-e QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox --disable-gpu" \
|
||||||
|
-e COMMAND="$COMMAND" \
|
||||||
|
"$NAME" bash -c 'export XDG_RUNTIME_DIR=$(mktemp -d) HOME=$(mktemp -d); python3 -' <<'PY'
|
||||||
|
import importlib.util, os, sys
|
||||||
|
from importlib.machinery import SourceFileLoader
|
||||||
|
|
||||||
|
loader = SourceFileLoader("wonderink", os.environ["COMMAND"])
|
||||||
|
spec = importlib.util.spec_from_loader("wonderink", loader)
|
||||||
|
ink = importlib.util.module_from_spec(spec)
|
||||||
|
loader.exec_module(ink)
|
||||||
|
|
||||||
|
from PyQt6.QtCore import QEventLoop, Qt, QTimer
|
||||||
|
from PyQt6.QtTest import QTest
|
||||||
|
from PyQt6.QtWebEngineCore import QWebEngineProfile
|
||||||
|
from PyQt6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
app = QApplication(sys.argv[:1])
|
||||||
|
passed = failed = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(label, condition, detail=""):
|
||||||
|
global passed, failed
|
||||||
|
if condition:
|
||||||
|
print(f" \033[32mPASS\033[0m {label}")
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
print(f" \033[31mFAIL\033[0m {label}" + (f" [{detail}]" if detail else ""))
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
|
||||||
|
def wait(ms):
|
||||||
|
loop = QEventLoop()
|
||||||
|
QTimer.singleShot(ms, loop.quit)
|
||||||
|
loop.exec()
|
||||||
|
|
||||||
|
|
||||||
|
def page_dpr(view):
|
||||||
|
answer = []
|
||||||
|
view.page().runJavaScript("window.devicePixelRatio", answer.append)
|
||||||
|
for _ in range(50):
|
||||||
|
if answer:
|
||||||
|
return answer[0]
|
||||||
|
wait(100)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
PAGE = "data:text/html,<title>lesson</title><p>Noah built an ark.</p>"
|
||||||
|
KEY = {
|
||||||
|
"Ctrl+=": (Qt.Key.Key_Equal, Qt.KeyboardModifier.ControlModifier),
|
||||||
|
"Ctrl+Plus": (Qt.Key.Key_Plus, Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier),
|
||||||
|
"keypad Ctrl+Plus": (Qt.Key.Key_Plus, Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.KeypadModifier),
|
||||||
|
"Ctrl+Minus": (Qt.Key.Key_Minus, Qt.KeyboardModifier.ControlModifier),
|
||||||
|
"keypad Ctrl+Minus": (Qt.Key.Key_Minus, Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.KeypadModifier),
|
||||||
|
"Ctrl+0": (Qt.Key.Key_0, Qt.KeyboardModifier.ControlModifier),
|
||||||
|
"Ctrl+Shift+Tab": (Qt.Key.Key_Backtab, Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def press(window, name):
|
||||||
|
target = QApplication.focusWidget() or window.current()
|
||||||
|
QTest.keyClick(target, *KEY[name])
|
||||||
|
wait(400)
|
||||||
|
|
||||||
|
|
||||||
|
def open_window(room_zoom):
|
||||||
|
profile = QWebEngineProfile(f"zoomcheck{room_zoom}", app)
|
||||||
|
window = ink.WonderInk(profile, room_zoom=room_zoom)
|
||||||
|
window.resize(1280, 800)
|
||||||
|
window.show()
|
||||||
|
for _ in range(3):
|
||||||
|
window.open_tab(PAGE)
|
||||||
|
wait(2500)
|
||||||
|
window.current().setFocus()
|
||||||
|
wait(300)
|
||||||
|
return window
|
||||||
|
|
||||||
|
|
||||||
|
def factors(window):
|
||||||
|
return [round(window._tabs.widget(i).zoomFactor(), 3) for i in range(window._tabs.count())]
|
||||||
|
|
||||||
|
|
||||||
|
print("1. a room's normal size is the browser's")
|
||||||
|
for room in (1.0, 1.5, 2.0):
|
||||||
|
ink.SETTINGS.unlink(missing_ok=True)
|
||||||
|
window = open_window(room)
|
||||||
|
check(f"UI_SCALE {room}: every tab starts at {room}", factors(window) == [room] * 3, factors(window))
|
||||||
|
window.close()
|
||||||
|
wait(300)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("2. the keys a teacher knows, at UI_SCALE 1.5")
|
||||||
|
ink.SETTINGS.unlink(missing_ok=True)
|
||||||
|
window = open_window(1.5)
|
||||||
|
for name, level in (("Ctrl+=", 1.1), ("Ctrl+Plus", 1.25), ("keypad Ctrl+Plus", 1.5),
|
||||||
|
("Ctrl+Minus", 1.25), ("keypad Ctrl+Minus", 1.1), ("Ctrl+0", 1.0)):
|
||||||
|
press(window, name)
|
||||||
|
check(f"{name} makes it {round(level * 100)}% in every tab",
|
||||||
|
window.level() == level and factors(window) == [round(1.5 * level, 3)] * 3,
|
||||||
|
f"level={window.level()} factors={factors(window)}")
|
||||||
|
|
||||||
|
press(window, "Ctrl+Plus")
|
||||||
|
dpr = page_dpr(window.current())
|
||||||
|
check("and the page itself renders at it", dpr is not None and abs(dpr - 1.65) < 0.01, dpr)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("3. new tabs, and the next time Wonder Ink opens")
|
||||||
|
view = window.open_tab(PAGE)
|
||||||
|
wait(1500)
|
||||||
|
check("a new tab starts at the teacher's size", round(view.zoomFactor(), 3) == 1.65, view.zoomFactor())
|
||||||
|
check("the level is saved on its own", ink.SETTINGS.read_text().strip() == "LEVEL=1.1",
|
||||||
|
ink.SETTINGS.read_text())
|
||||||
|
check("in its own file, not the web profile",
|
||||||
|
"wonderink-shell" not in str(ink.SETTINGS) and ink.SETTINGS.name == "wonderink.conf")
|
||||||
|
index = window._tabs.currentIndex()
|
||||||
|
press(window, "Ctrl+Shift+Tab")
|
||||||
|
check("Ctrl+Shift+Tab still goes back a tab", window._tabs.currentIndex() == index - 1,
|
||||||
|
f"{index} -> {window._tabs.currentIndex()}")
|
||||||
|
window.close()
|
||||||
|
wait(300)
|
||||||
|
|
||||||
|
window = open_window(2.0)
|
||||||
|
check("reopened in a room at 2.0, the teacher's 110% is kept",
|
||||||
|
window.level() == 1.1 and factors(window) == [2.2] * 3, factors(window))
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
ink.SETTINGS.write_text("LEVEL=banana\n")
|
||||||
|
check("a damaged setting is the normal size", ink.load_level() == 1.0)
|
||||||
|
ink.SETTINGS.write_text("LEVEL=40\n")
|
||||||
|
check("an absurd one too", ink.load_level() == 1.0)
|
||||||
|
|
||||||
|
window = open_window(3.0)
|
||||||
|
press(window, "keypad Ctrl+Plus"); press(window, "keypad Ctrl+Plus"); press(window, "keypad Ctrl+Plus")
|
||||||
|
check("a large room zoomed further never passes the engine's limit",
|
||||||
|
max(factors(window)) <= 5.0, factors(window))
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f"{passed} passed, {failed} failed")
|
||||||
|
sys.exit(1 if failed else 0)
|
||||||
|
PY
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
# Release helpers shared by tools/build-release.sh, update_canary.sh and
|
||||||
|
# update_stable.sh. Sourced, not run.
|
||||||
|
#
|
||||||
|
# VERSIONS.
|
||||||
|
#
|
||||||
|
# 0.2.6 a feature release
|
||||||
|
# 0.2.6.1 the first maintenance release of 0.2.6
|
||||||
|
# 0.2.6.2 the second
|
||||||
|
# 0.2.7 the next feature release
|
||||||
|
#
|
||||||
|
# Three or four dot-separated numbers, and nothing else. No leading zeros,
|
||||||
|
# because dpkg compares 0.2.06 and 0.2.6 as the SAME version, and two
|
||||||
|
# packages that dpkg thinks are equal are one package too many. No fourth
|
||||||
|
# part of 0: maintenance releases count from 1, and 0.2.6.0 would sort
|
||||||
|
# after 0.2.6 while claiming to be it.
|
||||||
|
#
|
||||||
|
# Ordering is dpkg's, always, through `dpkg --compare-versions`. That is
|
||||||
|
# what a classroom's updater and apt use to decide what is newer, so
|
||||||
|
# anything else here could only ever disagree with them.
|
||||||
|
#
|
||||||
|
# PUBLISHED IS PERMANENT.
|
||||||
|
#
|
||||||
|
# 0.2.6 was rebuilt and uploaded a second time under the same version, and
|
||||||
|
# the second upload replaced the first on the server. A room that had
|
||||||
|
# installed the first one could never be offered the second: the updater
|
||||||
|
# only installs a version newer than its own, and they were equal. So a
|
||||||
|
# version number, once it has been on the server, is never used again,
|
||||||
|
# and a package file on the server is never written twice.
|
||||||
|
|
||||||
|
RELEASE_HOST="${RELEASE_HOST:-classroom-control}"
|
||||||
|
RELEASE_ROOT="${RELEASE_ROOT:-/srv/ekklesia/classroom-updates}"
|
||||||
|
RELEASE_URL="${RELEASE_URL:-https://updates.riverofglorymn.org}"
|
||||||
|
# The local copy of what was published. ROOT is the checkout, set by
|
||||||
|
# whichever script sourced this.
|
||||||
|
RELEASE_LOCAL="${RELEASE_LOCAL:-$ROOT/repo}"
|
||||||
|
|
||||||
|
# The same pattern is in scripts/ekklesia-classroom-update, which is what
|
||||||
|
# a classroom checks a manifest against. tests/test-release-versions.sh
|
||||||
|
# holds both to the same list of good and bad versions.
|
||||||
|
RELEASE_VERSION_PATTERN='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\.[1-9][0-9]*)?$'
|
||||||
|
|
||||||
|
release_version_valid() {
|
||||||
|
[[ "$1" =~ $RELEASE_VERSION_PATTERN ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
release_require_version() {
|
||||||
|
if ! release_version_valid "$1"; then
|
||||||
|
echo "Invalid version: $1" >&2
|
||||||
|
echo "Expected MAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH.MAINTENANCE," >&2
|
||||||
|
echo "for example 0.2.7 or 0.2.6.1" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
release_version_gt() {
|
||||||
|
dpkg --compare-versions "$1" gt "$2"
|
||||||
|
}
|
||||||
|
|
||||||
|
release_package_name() {
|
||||||
|
printf 'ekklesia-classroom_%s_amd64.deb\n' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The version dpkg itself reads out of a built package, which is the one a
|
||||||
|
# classroom will report after installing it.
|
||||||
|
release_package_version() {
|
||||||
|
dpkg-deb --field "$1" Version
|
||||||
|
}
|
||||||
|
|
||||||
|
release_remote() {
|
||||||
|
ssh "$RELEASE_HOST" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Every version that has ever been published, one per line, read from the
|
||||||
|
# package file names on the server and in the local copy of it. Names that
|
||||||
|
# are not a classroom package (ekklesia-classroom-v2.deb) are skipped;
|
||||||
|
# experiment builds such as 0.1.45~hypr1 are kept, because dpkg orders
|
||||||
|
# them and they were published.
|
||||||
|
release_published_versions() {
|
||||||
|
{
|
||||||
|
release_remote "ls -1 '$RELEASE_ROOT/packages'" || return 1
|
||||||
|
ls -1 "$RELEASE_LOCAL/packages" 2>/dev/null || true
|
||||||
|
} | sed -n 's/^ekklesia-classroom_\(.*\)_amd64\.deb$/\1/p' | sort -u
|
||||||
|
}
|
||||||
|
|
||||||
|
release_highest() {
|
||||||
|
local highest="" version
|
||||||
|
|
||||||
|
while read -r version; do
|
||||||
|
[[ -n "$version" ]] || continue
|
||||||
|
|
||||||
|
if [[ -z "$highest" ]] || release_version_gt "$version" "$highest"; then
|
||||||
|
highest="$version"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '%s\n' "$highest"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The version a channel's manifest on the server names, or nothing.
|
||||||
|
release_channel_version() {
|
||||||
|
release_remote "cat '$RELEASE_ROOT/$1/manifest.json' 2>/dev/null" \
|
||||||
|
| python3 -c 'import json, sys
|
||||||
|
try:
|
||||||
|
print(json.load(sys.stdin)["version"])
|
||||||
|
except Exception:
|
||||||
|
pass'
|
||||||
|
}
|
||||||
|
|
||||||
|
# May VERSION be published as a new package at all? It has to be valid,
|
||||||
|
# its file name must never have been used, and it must be newer than every
|
||||||
|
# package ever published, so that it is newer than whatever any room is
|
||||||
|
# running whichever channel that room is on.
|
||||||
|
release_check_new_version() {
|
||||||
|
local version="$1" name published highest
|
||||||
|
|
||||||
|
release_require_version "$version" || return 1
|
||||||
|
name="$(release_package_name "$version")"
|
||||||
|
|
||||||
|
published="$(release_published_versions)" || {
|
||||||
|
echo "Could not list the published packages on $RELEASE_HOST." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if grep -qxF -- "$version" <<< "$published"; then
|
||||||
|
echo "$version has already been published." >&2
|
||||||
|
echo "A published version is never rebuilt or replaced. Use the next one." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if release_remote "test -e '$RELEASE_ROOT/packages/$name'"; then
|
||||||
|
echo "$name already exists on $RELEASE_HOST." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
highest="$(release_highest <<< "$published")"
|
||||||
|
|
||||||
|
if [[ -n "$highest" ]] && ! release_version_gt "$version" "$highest"; then
|
||||||
|
echo "$version is not newer than $highest, the highest version already published." >&2
|
||||||
|
echo "Every release must sort after every one before it." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Upload a package that has never been on the server, and prove that what
|
||||||
|
# arrived is what was built. --ignore-existing is the second lock on the
|
||||||
|
# rule above: even if the check were wrong, rsync would not replace a
|
||||||
|
# published file.
|
||||||
|
release_upload_package() {
|
||||||
|
local package="$1" name local_hash remote_hash
|
||||||
|
|
||||||
|
name="$(basename "$package")"
|
||||||
|
local_hash="$(sha256sum "$package" | awk '{print $1}')"
|
||||||
|
|
||||||
|
rsync -av --ignore-existing -e ssh \
|
||||||
|
"$package" "$RELEASE_HOST:$RELEASE_ROOT/packages/"
|
||||||
|
|
||||||
|
remote_hash="$(release_remote "sha256sum '$RELEASE_ROOT/packages/$name'" | awk '{print $1}')"
|
||||||
|
|
||||||
|
if [[ "$remote_hash" != "$local_hash" ]]; then
|
||||||
|
echo "The package on $RELEASE_HOST does not match the one built here." >&2
|
||||||
|
echo " built: $local_hash" >&2
|
||||||
|
echo " on server: ${remote_hash:-missing}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Write CHANNEL's manifest for VERSION locally, then upload it, then move
|
||||||
|
# the channel's latest.deb. The package must already be on the server with
|
||||||
|
# exactly the checksum the manifest is about to promise.
|
||||||
|
release_publish_manifest() {
|
||||||
|
local channel="$1" version="$2" hash="$3" name remote_hash
|
||||||
|
|
||||||
|
name="$(release_package_name "$version")"
|
||||||
|
remote_hash="$(release_remote "sha256sum '$RELEASE_ROOT/packages/$name'" | awk '{print $1}')"
|
||||||
|
|
||||||
|
if [[ "$remote_hash" != "$hash" ]]; then
|
||||||
|
echo "Refusing to point $channel at $name:" >&2
|
||||||
|
echo " the manifest would say $hash" >&2
|
||||||
|
echo " the server has ${remote_hash:-no such file}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$RELEASE_LOCAL/$channel"
|
||||||
|
|
||||||
|
cat > "$RELEASE_LOCAL/$channel/manifest.json" <<EOF
|
||||||
|
{
|
||||||
|
"version": "$version",
|
||||||
|
"package": "/packages/$name",
|
||||||
|
"sha256": "$hash",
|
||||||
|
"requires_reboot": true
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Manifest deliberately goes LAST, after the package it names.
|
||||||
|
rsync -av -e ssh \
|
||||||
|
"$RELEASE_LOCAL/$channel/manifest.json" \
|
||||||
|
"$RELEASE_HOST:$RELEASE_ROOT/$channel/manifest.json"
|
||||||
|
|
||||||
|
# THE PERMANENT URL.
|
||||||
|
#
|
||||||
|
# .../CHANNEL/latest.deb is a symlink beside the manifest pointing at
|
||||||
|
# the versioned package the manifest names, so there is one download
|
||||||
|
# address that never changes and still no second place recording which
|
||||||
|
# version is current. The manifest remains the only answer to that
|
||||||
|
# question.
|
||||||
|
#
|
||||||
|
# It is swapped AFTER the manifest, and by rename, for two reasons. A
|
||||||
|
# rename is atomic, so nothing ever downloads a half-written pointer;
|
||||||
|
# and going last means latest.deb can only ever lag the manifest by a
|
||||||
|
# moment, never lead it — it can never offer a package the channel has
|
||||||
|
# not published. The versioned file itself is never touched again once
|
||||||
|
# uploaded.
|
||||||
|
release_remote "
|
||||||
|
set -e
|
||||||
|
cd '$RELEASE_ROOT/$channel'
|
||||||
|
test -f ../packages/$name
|
||||||
|
ln -sfn ../packages/$name latest.deb.new
|
||||||
|
mv -Tf latest.deb.new latest.deb
|
||||||
|
"
|
||||||
|
}
|
||||||
|
|
||||||
|
# What a classroom on CHANNEL actually sees, read over HTTPS the way the
|
||||||
|
# updater reads it: the manifest, the versioned package it names, and the
|
||||||
|
# permanent latest.deb, all agreeing with each other and with EXPECTED if
|
||||||
|
# one is given. Read-only; safe to run against stable at any time.
|
||||||
|
#
|
||||||
|
# A subshell, so the temporary directory is removed however it returns.
|
||||||
|
release_verify_channel() (
|
||||||
|
channel="$1"
|
||||||
|
expected="${2:-}"
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
|
||||||
|
manifest="$(curl -fsS -H 'Cache-Control: no-cache' \
|
||||||
|
"$RELEASE_URL/$channel/manifest.json")" || {
|
||||||
|
echo " FAIL $channel: manifest could not be fetched" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
read -r version package hash < <(python3 -c 'import json, sys
|
||||||
|
m = json.loads(sys.argv[1])
|
||||||
|
print(m["version"], m["package"], m["sha256"])' "$manifest")
|
||||||
|
|
||||||
|
name="$(release_package_name "$version")"
|
||||||
|
|
||||||
|
if [[ -n "$expected" && "$version" != "$expected" ]]; then
|
||||||
|
echo " FAIL $channel: manifest says $version, expected $expected" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$package" != "/packages/$name" ]]; then
|
||||||
|
echo " FAIL $channel: manifest names $package for version $version" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -fsS -o "$work/versioned.deb" "$RELEASE_URL$package" || {
|
||||||
|
echo " FAIL $channel: $package could not be downloaded" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
curl -fsSL -H 'Cache-Control: no-cache' -o "$work/latest.deb" \
|
||||||
|
"$RELEASE_URL/$channel/latest.deb" || {
|
||||||
|
echo " FAIL $channel: latest.deb could not be downloaded" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
got_hash="$(sha256sum "$work/versioned.deb" | awk '{print $1}')"
|
||||||
|
latest_hash="$(sha256sum "$work/latest.deb" | awk '{print $1}')"
|
||||||
|
deb_version="$(release_package_version "$work/versioned.deb")"
|
||||||
|
|
||||||
|
if [[ "$got_hash" != "$hash" ]]; then
|
||||||
|
echo " FAIL $channel: $name has sha256 $got_hash, manifest says $hash" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$latest_hash" != "$hash" ]]; then
|
||||||
|
echo " FAIL $channel: latest.deb has sha256 $latest_hash, manifest says $hash" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$deb_version" != "$version" ]]; then
|
||||||
|
echo " FAIL $channel: the package itself says $deb_version, manifest says $version" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " ok $channel: $version, $name, sha256 $hash, latest.deb agrees"
|
||||||
|
)
|
||||||
@@ -18,6 +18,16 @@ if [[ $# -eq 2 ]]; then
|
|||||||
[[ -f "$previous" ]] || { echo "Package not found: $previous" >&2; exit 1; }
|
[[ -f "$previous" ]] || { echo "Package not found: $previous" >&2; exit 1; }
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# The channel the upgraded room is on. canary by default, which is the
|
||||||
|
# machine a new release goes to first; stable to rehearse the day the
|
||||||
|
# desktops are moved, from the release they are actually on.
|
||||||
|
channel="${VALIDATION_CHANNEL:-canary}"
|
||||||
|
|
||||||
|
case "$channel" in
|
||||||
|
stable|canary) ;;
|
||||||
|
*) echo "VALIDATION_CHANNEL must be stable or canary" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
echo "Validating $(basename "$candidate") on Debian Trixie"
|
echo "Validating $(basename "$candidate") on Debian Trixie"
|
||||||
|
|
||||||
docker_args=(
|
docker_args=(
|
||||||
@@ -41,7 +51,7 @@ fi
|
|||||||
# instead teaches everybody to re-run it until it passes.
|
# instead teaches everybody to re-run it until it passes.
|
||||||
timeout --signal=TERM --kill-after=15s 2700s \
|
timeout --signal=TERM --kill-after=15s 2700s \
|
||||||
docker "${docker_args[@]}" debian:trixie-slim \
|
docker "${docker_args[@]}" debian:trixie-slim \
|
||||||
bash -s -- "${previous:+/packages/previous.deb}" <<'CONTAINER'
|
bash -s -- "${previous:+/packages/previous.deb}" "$channel" <<'CONTAINER'
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
|
|
||||||
validation_failure() {
|
validation_failure() {
|
||||||
@@ -87,6 +97,7 @@ run_logged() {
|
|||||||
run_logged apt-update 300 apt-get update -qq
|
run_logged apt-update 300 apt-get update -qq
|
||||||
|
|
||||||
previous="${1:-}"
|
previous="${1:-}"
|
||||||
|
channel="${2:-canary}"
|
||||||
|
|
||||||
if [[ -n "$previous" ]]; then
|
if [[ -n "$previous" ]]; then
|
||||||
run_logged previous-install 1200 apt-get install -y -qq "$previous"
|
run_logged previous-install 1200 apt-get install -y -qq "$previous"
|
||||||
@@ -94,7 +105,7 @@ if [[ -n "$previous" ]]; then
|
|||||||
cat > /etc/ekklesia-classroom/device.conf <<'CONFIG'
|
cat > /etc/ekklesia-classroom/device.conf <<'CONFIG'
|
||||||
# Configuration carried forward from 0.1.36
|
# Configuration carried forward from 0.1.36
|
||||||
UPDATE_URL=https://updates.riverofglorymn.org
|
UPDATE_URL=https://updates.riverofglorymn.org
|
||||||
CHANNEL=canary
|
CHANNEL=@CHANNEL@
|
||||||
DVD_ENABLED=false
|
DVD_ENABLED=false
|
||||||
UI_SCALE=1.25
|
UI_SCALE=1.25
|
||||||
MQTT_ENABLED=true
|
MQTT_ENABLED=true
|
||||||
@@ -104,10 +115,28 @@ MQTT_USERNAME=classroom
|
|||||||
MQTT_PASSWORD=preserve-me
|
MQTT_PASSWORD=preserve-me
|
||||||
CUSTOM_SETTING=preserve-me-too
|
CUSTOM_SETTING=preserve-me-too
|
||||||
CONFIG
|
CONFIG
|
||||||
|
sed -i "s/@CHANNEL@/${channel}/" /etc/ekklesia-classroom/device.conf
|
||||||
|
previous_version="$(dpkg-query -W -f='${Version}' ekklesia-classroom)"
|
||||||
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
|
||||||
|
|
||||||
|
# THE VERSION, AS THE MACHINE REPORTS IT.
|
||||||
|
#
|
||||||
|
# Three places say which release a classroom is running: dpkg's database,
|
||||||
|
# which the updater compares against the manifest; the VERSION file the
|
||||||
|
# package carries; and the file name the release was published under.
|
||||||
|
# They have to be one number, or a room either never updates or updates
|
||||||
|
# for ever.
|
||||||
|
candidate_version="$(dpkg-deb --field /packages/candidate.deb Version)"
|
||||||
|
[[ "$(dpkg-query -W -f='${Version}' ekklesia-classroom)" == "$candidate_version" ]]
|
||||||
|
[[ "$(cat /usr/local/lib/ekklesia-classroom/VERSION)" == "$candidate_version" ]]
|
||||||
|
echo "installed ekklesia-classroom $candidate_version${previous_version:+ over $previous_version}"
|
||||||
|
|
||||||
|
if [[ -n "${previous_version:-}" ]]; then
|
||||||
|
dpkg --compare-versions "$candidate_version" gt "$previous_version"
|
||||||
|
fi
|
||||||
|
|
||||||
command -v nmtui >/dev/null
|
command -v nmtui >/dev/null
|
||||||
command -v lpadmin >/dev/null
|
command -v lpadmin >/dev/null
|
||||||
command -v lpinfo >/dev/null
|
command -v lpinfo >/dev/null
|
||||||
@@ -376,6 +405,64 @@ timeout --signal=TERM --kill-after=5s 45s classroom-provision \
|
|||||||
>/tmp/provision-second.log
|
>/tmp/provision-second.log
|
||||||
grep -q 'Elmer is already correctly configured' /tmp/provision-second.log
|
grep -q 'Elmer is already correctly configured' /tmp/provision-second.log
|
||||||
|
|
||||||
|
# THE UPDATER THIS PACKAGE INSTALLED, AGAINST A MANIFEST.
|
||||||
|
#
|
||||||
|
# Served from this container, so nothing reaches the real update server.
|
||||||
|
# The installed version has to be "up to date" against a manifest naming
|
||||||
|
# itself — or the room reinstalls and reboots at every check — and has to
|
||||||
|
# see the next feature release as newer.
|
||||||
|
mkdir -p /tmp/updates/packages "/tmp/updates/$channel"
|
||||||
|
cp /packages/candidate.deb "/tmp/updates/packages/ekklesia-classroom_${candidate_version}_amd64.deb"
|
||||||
|
|
||||||
|
serve_manifest() {
|
||||||
|
python3 - "$1" "$channel" <<'MANIFEST'
|
||||||
|
import hashlib, json, sys
|
||||||
|
version, channel = sys.argv[1], sys.argv[2]
|
||||||
|
name = f"ekklesia-classroom_{version}_amd64.deb"
|
||||||
|
try:
|
||||||
|
digest = hashlib.sha256(open(f"/tmp/updates/packages/{name}", "rb").read()).hexdigest()
|
||||||
|
except FileNotFoundError:
|
||||||
|
digest = "0" * 64
|
||||||
|
json.dump({"version": version, "package": f"/packages/{name}",
|
||||||
|
"sha256": digest, "requires_reboot": True},
|
||||||
|
open(f"/tmp/updates/{channel}/manifest.json", "w"))
|
||||||
|
MANIFEST
|
||||||
|
}
|
||||||
|
|
||||||
|
python3 -m http.server --bind 127.0.0.1 --directory /tmp/updates 8765 \
|
||||||
|
>/tmp/updates.log 2>&1 &
|
||||||
|
updates_server=$!
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
cp /etc/ekklesia-classroom/device.conf /tmp/device.conf.before-updater
|
||||||
|
sed -i 's|^UPDATE_URL=.*|UPDATE_URL=http://127.0.0.1:8765|' \
|
||||||
|
/etc/ekklesia-classroom/device.conf
|
||||||
|
|
||||||
|
serve_manifest "$candidate_version"
|
||||||
|
ekklesia-classroom-update --check >/tmp/updater-same.log 2>&1
|
||||||
|
grep -q "Already up to date" /tmp/updater-same.log
|
||||||
|
|
||||||
|
# The next milestone after whatever this is, and a release every
|
||||||
|
# classroom has long since passed.
|
||||||
|
IFS=. read -r major minor _ <<< "$candidate_version"
|
||||||
|
next_version="${major}.$((minor + 1)).0"
|
||||||
|
|
||||||
|
serve_manifest "$next_version"
|
||||||
|
ekklesia-classroom-update --check >/tmp/updater-next.log 2>&1
|
||||||
|
grep -q "Update available: $candidate_version -> $next_version" /tmp/updater-next.log
|
||||||
|
|
||||||
|
serve_manifest 0.2.2
|
||||||
|
ekklesia-classroom-update --check >/tmp/updater-older.log 2>&1
|
||||||
|
grep -q "Already up to date" /tmp/updater-older.log
|
||||||
|
|
||||||
|
echo '{"version": "0.2.06", "package": "/packages/x.deb", "sha256": "x"}' \
|
||||||
|
> "/tmp/updates/$channel/manifest.json"
|
||||||
|
! ekklesia-classroom-update --check >/tmp/updater-bad.log 2>&1
|
||||||
|
grep -q "invalid version" /tmp/updater-bad.log
|
||||||
|
|
||||||
|
kill "$updates_server"
|
||||||
|
cp /tmp/device.conf.before-updater /etc/ekklesia-classroom/device.conf
|
||||||
|
|
||||||
if [[ -n "$previous" ]]; then
|
if [[ -n "$previous" ]]; then
|
||||||
# The room's own values, untouched by the upgrade.
|
# The room's own values, untouched by the upgrade.
|
||||||
grep -q '^DVD_ENABLED=false$' /etc/ekklesia-classroom/device.conf
|
grep -q '^DVD_ENABLED=false$' /etc/ekklesia-classroom/device.conf
|
||||||
@@ -386,7 +473,17 @@ if [[ -n "$previous" ]]; then
|
|||||||
# The channel a room was put on deliberately survives the upgrade.
|
# The channel a room was put on deliberately survives the upgrade.
|
||||||
# canary is where the next development release goes, and the Plasma
|
# canary is where the next development release goes, and the Plasma
|
||||||
# classroom is the stable line now, so nothing here has to move.
|
# classroom is the stable line now, so nothing here has to move.
|
||||||
grep -q '^CHANNEL=canary$' /etc/ekklesia-classroom/device.conf
|
grep -q "^CHANNEL=${channel}\$" /etc/ekklesia-classroom/device.conf
|
||||||
|
|
||||||
|
# An optional feature an existing room never had stays off. A
|
||||||
|
# Presentations launcher appearing on the desktops' panels by itself
|
||||||
|
# would be an upgrade changing a classroom, which upgrades do not do.
|
||||||
|
[[ "$(grep -c '^PRESENTATIONS_ENABLED=' /etc/ekklesia-classroom/device.conf)" -eq 1 ]]
|
||||||
|
grep -q '^PRESENTATIONS_ENABLED=false$' /etc/ekklesia-classroom/device.conf
|
||||||
|
classroom-presentations 2>&1 | grep -q 'PRESENTATIONS_ENABLED is false'
|
||||||
|
classroom-desktop-layout >/dev/null 2>&1 || true
|
||||||
|
grep -q 'var PRESENTATIONS = false;' \
|
||||||
|
/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js
|
||||||
|
|
||||||
# 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.
|
||||||
@@ -395,7 +492,7 @@ if [[ -n "$previous" ]]; then
|
|||||||
grep -q '^CHANNEL=stable$' /etc/ekklesia-classroom/device.conf
|
grep -q '^CHANNEL=stable$' /etc/ekklesia-classroom/device.conf
|
||||||
grep -q '^MQTT_PASSWORD=preserve-me$' /etc/ekklesia-classroom/device.conf
|
grep -q '^MQTT_PASSWORD=preserve-me$' /etc/ekklesia-classroom/device.conf
|
||||||
grep -q '^CUSTOM_SETTING=preserve-me-too$' /etc/ekklesia-classroom/device.conf
|
grep -q '^CUSTOM_SETTING=preserve-me-too$' /etc/ekklesia-classroom/device.conf
|
||||||
sed -i 's/^CHANNEL=.*/CHANNEL=canary/' /etc/ekklesia-classroom/device.conf
|
sed -i "s/^CHANNEL=.*/CHANNEL=${channel}/" /etc/ekklesia-classroom/device.conf
|
||||||
|
|
||||||
# Every setting this release has that the room predates, appended
|
# Every setting this release has that the room predates, appended
|
||||||
# exactly once. classroom-migrate hands device.conf to the shared
|
# exactly once. classroom-migrate hands device.conf to the shared
|
||||||
@@ -415,7 +512,7 @@ if [[ -n "$previous" ]]; then
|
|||||||
done
|
done
|
||||||
grep -q '^MQTT_PASSWORD=preserve-me$' /etc/ekklesia-classroom/device.conf
|
grep -q '^MQTT_PASSWORD=preserve-me$' /etc/ekklesia-classroom/device.conf
|
||||||
grep -q '^CUSTOM_SETTING=preserve-me-too$' /etc/ekklesia-classroom/device.conf
|
grep -q '^CUSTOM_SETTING=preserve-me-too$' /etc/ekklesia-classroom/device.conf
|
||||||
grep -q '^CHANNEL=canary$' /etc/ekklesia-classroom/device.conf
|
grep -q "^CHANNEL=${channel}\$" /etc/ekklesia-classroom/device.conf
|
||||||
|
|
||||||
# And by hand, on a machine that is already converged: the command a
|
# And by hand, on a machine that is already converged: the command a
|
||||||
# technician runs must be safe to run twice.
|
# technician runs must be safe to run twice.
|
||||||
|
|||||||
@@ -4,32 +4,50 @@ set -euo pipefail
|
|||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
current_version="$(cat VERSION)"
|
source "$ROOT/tools/release-lib.sh"
|
||||||
|
|
||||||
if [[ $# -eq 1 ]]; then
|
# The version is always said out loud.
|
||||||
version="$1"
|
#
|
||||||
else
|
# With only three parts there was one obvious next number. With four
|
||||||
IFS='.' read -r major minor patch <<< "$current_version"
|
# there are two — 0.2.6 is followed by 0.2.6.1 if it is a fix and by
|
||||||
version="${major}.${minor}.$((patch + 1))"
|
# 0.2.7 if it is not — and which one a release is, is a decision about
|
||||||
|
# what is in it, not arithmetic.
|
||||||
|
if [[ $# -ne 1 ]]; then
|
||||||
|
current="$(release_channel_version canary || true)"
|
||||||
|
echo "Usage: $0 VERSION" >&2
|
||||||
|
echo >&2
|
||||||
|
echo "Canary is on ${current:-an unknown version}. Name the release, for example:" >&2
|
||||||
|
if [[ -n "$current" ]] && release_version_valid "$current"; then
|
||||||
|
IFS='.' read -r major minor patch maintenance <<< "$current"
|
||||||
|
echo " $0 ${major}.${minor}.${patch}.$(( ${maintenance:-0} + 1 )) a maintenance release" >&2
|
||||||
|
echo " $0 ${major}.${minor}.$(( patch + 1 )) the next feature release" >&2
|
||||||
|
fi
|
||||||
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
package_name="ekklesia-classroom_${version}_amd64.deb"
|
version="$1"
|
||||||
|
package_name="$(release_package_name "$version")"
|
||||||
package_path="$ROOT/dist/$package_name"
|
package_path="$ROOT/dist/$package_name"
|
||||||
|
|
||||||
echo "Current version: $current_version"
|
echo "Current canary: $(release_channel_version canary || echo unknown)"
|
||||||
echo "New canary: $version"
|
echo "New canary: $version"
|
||||||
echo
|
echo
|
||||||
|
|
||||||
|
# Before anything is built: a version that has been published, or that
|
||||||
|
# would not be newer than everything that has been, stops here.
|
||||||
|
release_check_new_version "$version"
|
||||||
|
|
||||||
"$ROOT/tools/build-release.sh" "$version"
|
"$ROOT/tools/build-release.sh" "$version"
|
||||||
|
|
||||||
# Validate the exact artifact that will be published. If a previous canary is
|
# Validate the exact artifact that will be published. If a previous canary is
|
||||||
# available locally, exercise the real upgrade path as well as a fresh install.
|
# available locally, exercise the real upgrade path as well as a fresh install.
|
||||||
validation_args=("$package_path")
|
validation_args=("$package_path")
|
||||||
|
|
||||||
if [[ -f "$ROOT/repo/canary/manifest.json" ]]; then
|
if [[ -f "$RELEASE_LOCAL/canary/manifest.json" ]]; then
|
||||||
previous_package_name="$(python3 -c \
|
previous_package_name="$(python3 -c \
|
||||||
'import json; print(json.load(open("repo/canary/manifest.json"))["package"].rsplit("/", 1)[-1])')"
|
'import json, sys; print(json.load(open(sys.argv[1]))["package"].rsplit("/", 1)[-1])' \
|
||||||
previous_package="$ROOT/repo/packages/$previous_package_name"
|
"$RELEASE_LOCAL/canary/manifest.json")"
|
||||||
|
previous_package="$RELEASE_LOCAL/packages/$previous_package_name"
|
||||||
|
|
||||||
if [[ -f "$previous_package" && "$previous_package" != "$package_path" ]]; then
|
if [[ -f "$previous_package" && "$previous_package" != "$package_path" ]]; then
|
||||||
validation_args+=("$previous_package")
|
validation_args+=("$previous_package")
|
||||||
@@ -38,62 +56,49 @@ fi
|
|||||||
|
|
||||||
"$ROOT/tools/validate-trixie-release.sh" "${validation_args[@]}"
|
"$ROOT/tools/validate-trixie-release.sh" "${validation_args[@]}"
|
||||||
|
|
||||||
|
# And the stable rooms' own path, on the same file. 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.
|
||||||
|
if [[ -f "$RELEASE_LOCAL/stable/manifest.json" ]]; then
|
||||||
|
stable_package_name="$(python3 -c \
|
||||||
|
'import json, sys; print(json.load(open(sys.argv[1]))["package"].rsplit("/", 1)[-1])' \
|
||||||
|
"$RELEASE_LOCAL/stable/manifest.json")"
|
||||||
|
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
|
||||||
|
|
||||||
|
# Checked again after the gate, which takes long enough for somebody else
|
||||||
|
# to have published in the meantime.
|
||||||
|
release_check_new_version "$version"
|
||||||
|
|
||||||
hash="$(sha256sum "$package_path" | awk '{print $1}')"
|
hash="$(sha256sum "$package_path" | awk '{print $1}')"
|
||||||
|
|
||||||
mkdir -p "$ROOT/repo/packages" "$ROOT/repo/canary"
|
mkdir -p "$RELEASE_LOCAL/packages"
|
||||||
|
cp -n "$package_path" "$RELEASE_LOCAL/packages/$package_name"
|
||||||
cp "$package_path" "$ROOT/repo/packages/$package_name"
|
|
||||||
|
|
||||||
cat > "$ROOT/repo/canary/manifest.json" <<EOF
|
|
||||||
{
|
|
||||||
"version": "$version",
|
|
||||||
"package": "/packages/$package_name",
|
|
||||||
"sha256": "$hash",
|
|
||||||
"requires_reboot": true
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Publishing package..."
|
echo "Publishing package..."
|
||||||
|
release_upload_package "$RELEASE_LOCAL/packages/$package_name"
|
||||||
rsync -av \
|
|
||||||
-e ssh \
|
|
||||||
"$ROOT/repo/packages/$package_name" \
|
|
||||||
classroom-control:/srv/ekklesia/classroom-updates/packages/
|
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Publishing canary manifest..."
|
echo "Publishing canary manifest..."
|
||||||
|
release_publish_manifest canary "$version" "$hash"
|
||||||
|
|
||||||
# Manifest deliberately goes LAST.
|
echo "$RELEASE_URL/canary/latest.deb -> $package_name"
|
||||||
rsync -av \
|
|
||||||
-e ssh \
|
|
||||||
"$ROOT/repo/canary/manifest.json" \
|
|
||||||
classroom-control:/srv/ekklesia/classroom-updates/canary/manifest.json
|
|
||||||
|
|
||||||
|
echo
|
||||||
# THE PERMANENT URL.
|
echo "Checking what a classroom will see..."
|
||||||
#
|
release_verify_channel canary "$version"
|
||||||
# .../canary/latest.deb is a symlink beside the manifest pointing at the
|
echo " stable is still on $(release_channel_version stable || echo unknown)"
|
||||||
# versioned package the manifest names, so there is one download address
|
|
||||||
# that never changes and still no second place recording which version is
|
|
||||||
# current. The manifest remains the only answer to that question.
|
|
||||||
#
|
|
||||||
# It is swapped AFTER the manifest, and by rename, for two reasons. A
|
|
||||||
# rename is atomic, so nothing ever downloads a half-written pointer; and
|
|
||||||
# going last means latest.deb can only ever lag the manifest by a moment,
|
|
||||||
# never lead it — it can never offer a package the channel has not
|
|
||||||
# published. The versioned file itself is never touched again once
|
|
||||||
# uploaded.
|
|
||||||
ssh classroom-control "
|
|
||||||
set -e
|
|
||||||
cd /srv/ekklesia/classroom-updates/canary
|
|
||||||
test -f ../packages/$package_name
|
|
||||||
ln -sfn ../packages/$package_name latest.deb.new
|
|
||||||
mv -Tf latest.deb.new latest.deb
|
|
||||||
"
|
|
||||||
|
|
||||||
echo "https://updates.riverofglorymn.org/canary/latest.deb -> $package_name"
|
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Published Ekklesia Classroom $version to CANARY"
|
echo "Published Ekklesia Classroom $version to CANARY"
|
||||||
echo "https://updates.riverofglorymn.org/canary/manifest.json"
|
echo "$RELEASE_URL/canary/manifest.json"
|
||||||
|
|||||||
@@ -4,11 +4,28 @@ set -euo pipefail
|
|||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
|
source "$ROOT/tools/release-lib.sh"
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# byte the one the canary machine ran.
|
||||||
if [[ $# -eq 1 ]]; then
|
if [[ $# -eq 1 ]]; then
|
||||||
version="$1"
|
version="$1"
|
||||||
|
else
|
||||||
|
if [[ ! -f "$RELEASE_LOCAL/canary/manifest.json" ]]; then
|
||||||
|
echo "No local canary manifest exists."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
package_name="ekklesia-classroom_${version}_amd64.deb"
|
version="$(python3 -c \
|
||||||
package_path="$ROOT/repo/packages/$package_name"
|
'import json, sys; print(json.load(open(sys.argv[1]))["version"])' \
|
||||||
|
"$RELEASE_LOCAL/canary/manifest.json")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
release_require_version "$version"
|
||||||
|
|
||||||
|
package_name="$(release_package_name "$version")"
|
||||||
|
package_path="$RELEASE_LOCAL/packages/$package_name"
|
||||||
|
|
||||||
if [[ ! -f "$package_path" ]]; then
|
if [[ ! -f "$package_path" ]]; then
|
||||||
echo "Package does not exist locally:"
|
echo "Package does not exist locally:"
|
||||||
@@ -16,64 +33,33 @@ if [[ $# -eq 1 ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
hash="$(sha256sum "$package_path" | awk '{print $1}')"
|
if [[ "$(release_package_version "$package_path")" != "$version" ]]; then
|
||||||
|
echo "$package_name says it is $(release_package_version "$package_path")." >&2
|
||||||
mkdir -p "$ROOT/repo/stable"
|
|
||||||
|
|
||||||
cat > "$ROOT/repo/stable/manifest.json" <<EOF
|
|
||||||
{
|
|
||||||
"version": "$version",
|
|
||||||
"package": "/packages/$package_name",
|
|
||||||
"sha256": "$hash",
|
|
||||||
"requires_reboot": true
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
else
|
|
||||||
if [[ ! -f "$ROOT/repo/canary/manifest.json" ]]; then
|
|
||||||
echo "No local canary manifest exists."
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p "$ROOT/repo/stable"
|
# A classroom only ever installs a version newer than its own, so a stable
|
||||||
cp "$ROOT/repo/canary/manifest.json" "$ROOT/repo/stable/manifest.json"
|
# manifest that went backwards would reach nobody and would only make the
|
||||||
|
# channel say something untrue.
|
||||||
|
current="$(release_channel_version stable || true)"
|
||||||
|
|
||||||
version="$(python3 -c \
|
if [[ -n "$current" ]] && ! release_version_gt "$version" "$current"; then
|
||||||
'import json; print(json.load(open("repo/stable/manifest.json"))["version"])')"
|
echo "Stable is on $current; $version is not newer." >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Promoting $version to STABLE"
|
hash="$(sha256sum "$package_path" | awk '{print $1}')"
|
||||||
|
|
||||||
rsync -av \
|
echo "Promoting $version to STABLE (from ${current:-nothing})"
|
||||||
-e ssh \
|
|
||||||
"$ROOT/repo/stable/manifest.json" \
|
|
||||||
classroom-control:/srv/ekklesia/classroom-updates/stable/manifest.json
|
|
||||||
|
|
||||||
|
# Refuses unless the server already holds exactly this package.
|
||||||
|
release_publish_manifest stable "$version" "$hash"
|
||||||
|
|
||||||
package_name="ekklesia-classroom_${version}_amd64.deb"
|
echo "$RELEASE_URL/stable/latest.deb -> $package_name"
|
||||||
|
|
||||||
# THE PERMANENT URL.
|
echo
|
||||||
#
|
release_verify_channel stable "$version"
|
||||||
# .../stable/latest.deb is a symlink beside the manifest pointing at the
|
|
||||||
# versioned package the manifest names, so there is one download address
|
|
||||||
# that never changes and still no second place recording which version is
|
|
||||||
# current. The manifest remains the only answer to that question.
|
|
||||||
#
|
|
||||||
# It is swapped AFTER the manifest, and by rename, for two reasons. A
|
|
||||||
# rename is atomic, so nothing ever downloads a half-written pointer; and
|
|
||||||
# going last means latest.deb can only ever lag the manifest by a moment,
|
|
||||||
# never lead it — it can never offer a package the channel has not
|
|
||||||
# published. The versioned file itself is never touched again once
|
|
||||||
# uploaded.
|
|
||||||
ssh classroom-control "
|
|
||||||
set -e
|
|
||||||
cd /srv/ekklesia/classroom-updates/stable
|
|
||||||
test -f ../packages/$package_name
|
|
||||||
ln -sfn ../packages/$package_name latest.deb.new
|
|
||||||
mv -Tf latest.deb.new latest.deb
|
|
||||||
"
|
|
||||||
|
|
||||||
echo "https://updates.riverofglorymn.org/stable/latest.deb -> $package_name"
|
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Ekklesia Classroom $version is now STABLE"
|
echo "Ekklesia Classroom $version is now STABLE"
|
||||||
echo "https://updates.riverofglorymn.org/stable/manifest.json"
|
echo "$RELEASE_URL/stable/manifest.json"
|
||||||
|
|||||||