Compare commits
@@ -0,0 +1,87 @@
|
||||
# EVERY PUSH AND EVERY PULL REQUEST: THE REPOSITORY'S OWN CHECKS.
|
||||
#
|
||||
# This runs code nobody has reviewed yet. It is given no secret, it only
|
||||
# ever runs on the ekklesia-ci runner, which holds no credential that
|
||||
# reaches the update server or a classroom, and nothing it builds is kept
|
||||
# as something that could be installed. See docs/releasing.md.
|
||||
#
|
||||
# The steps are the repository's own scripts. A test added to tests/ is
|
||||
# run here with nothing in this file to change.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
name: checks
|
||||
runs-on: ekklesia-ci
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Sources, the manual, every test, and the change's hygiene
|
||||
env:
|
||||
PR_BASE: ${{ github.event.pull_request.base.sha }}
|
||||
PUSH_BEFORE: ${{ github.event.before }}
|
||||
run: |
|
||||
# What the change is compared with: the pull request's base, or
|
||||
# what the branch was before this push, or where it left master.
|
||||
base="${PR_BASE:-$PUSH_BEFORE}"
|
||||
if [ -z "$base" ] || ! git cat-file -e "$base^{commit}" 2>/dev/null; then
|
||||
base="$(git merge-base HEAD origin/master 2>/dev/null || git rev-list --max-parents=0 HEAD | tail -n 1)"
|
||||
fi
|
||||
CHECK_LOG_DIR="$RUNNER_TEMP/checks" tools/check.sh --base "$base"
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: cat "$RUNNER_TEMP/checks/summary.md" 2>/dev/null || echo "no summary was written"
|
||||
|
||||
- name: Keep every check's full output
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: check-logs
|
||||
path: ${{ runner.temp }}/checks
|
||||
|
||||
package:
|
||||
name: package and container gate
|
||||
needs: checks
|
||||
runs-on: ekklesia-ci
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build the package this commit describes
|
||||
run: |
|
||||
version="$(cat VERSION)"
|
||||
SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)" tools/build-release.sh "$version"
|
||||
deb="dist/ekklesia-classroom_${version}_amd64.deb"
|
||||
# It is the package it says it is, and building it changed nothing
|
||||
# in the tree but, perhaps, the manual's revision date.
|
||||
test "$(dpkg-deb --field "$deb" Package)" = ekklesia-classroom
|
||||
test "$(dpkg-deb --field "$deb" Version)" = "$version"
|
||||
test -z "$(git status --porcelain --untracked-files=no -- . ':(exclude)docs/manual/build')"
|
||||
mkdir -p "$RUNNER_TEMP/package"
|
||||
{ dpkg-deb --info "$deb"; echo; dpkg-deb --contents "$deb"; } > "$RUNNER_TEMP/package/contents.txt"
|
||||
sha256sum "$deb" > "$RUNNER_TEMP/package/sha256.txt"
|
||||
|
||||
- name: Install it on Debian Trixie, fresh and over what the rooms run
|
||||
run: |
|
||||
version="$(cat VERSION)"
|
||||
# --ci: an upgrade from a release that is not older than this
|
||||
# commit's VERSION (master between releases) is recorded as
|
||||
# skipped, not failed. A release build is never --ci.
|
||||
RELEASE_GATE_REPORT="$RUNNER_TEMP/package/gate-report.txt" \
|
||||
tools/release-gate.sh --ci "dist/ekklesia-classroom_${version}_amd64.deb"
|
||||
|
||||
- name: Keep the package's contents and the gate report (not the package)
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: package-report
|
||||
path: ${{ runner.temp }}/package
|
||||
@@ -0,0 +1,54 @@
|
||||
# BUILD A RELEASE: ONE COMMIT, ONE PACKAGE, KEPT FOR GOOD.
|
||||
#
|
||||
# Run by hand from master or a release/* branch, on the release commit:
|
||||
# the one whose VERSION says the version being released. It builds the
|
||||
# package from exactly that commit, runs every check and the full
|
||||
# container gate on that file, and creates the Gitea pre-release
|
||||
# vVERSION holding the package, SHA256SUMS, build-info.json and the
|
||||
# reports. Nothing is published: Preview and Stable are their own
|
||||
# workflows, and each is a person's decision. See docs/releasing.md.
|
||||
#
|
||||
# A version that already has a Gitea release, a tag, or a package on the
|
||||
# update server is refused. A version is built once.
|
||||
name: Release 1 - build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'The version to build; VERSION at this commit must say the same (e.g. 0.2.6.7)'
|
||||
required: true
|
||||
from_versions:
|
||||
description: 'Older releases rooms are still on, rehearsed as upgrades too (e.g. 0.2.2)'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build, check, gate and record
|
||||
runs-on: ekklesia-ci
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build, check, gate, and create the Gitea pre-release
|
||||
id: build
|
||||
env:
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
FROM_VERSIONS: ${{ github.event.inputs.from_versions }}
|
||||
GITEA_TOKEN: ${{ secrets.RELEASE_API_TOKEN || github.token }}
|
||||
run: |
|
||||
# shellcheck disable=SC2086 # FROM_VERSIONS is a list of versions
|
||||
RELEASE_CI_WORK="$RUNNER_TEMP/release" tools/release-ci.sh build "$VERSION" $FROM_VERSIONS
|
||||
|
||||
- name: Keep the reports with this run as well
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: release-build-reports
|
||||
path: |
|
||||
dist/release-*/gate-report.txt
|
||||
dist/release-*/checks.md
|
||||
dist/release-*/build-info.json
|
||||
${{ runner.temp }}/release/checks
|
||||
@@ -0,0 +1,38 @@
|
||||
# RECORD A RELEASE PUBLISHED BEFORE GITEA KEPT THE RECORD.
|
||||
#
|
||||
# For 0.2.6.6 and anything earlier worth recording: the package is taken
|
||||
# from the update server as it is served, checked against the SHA-256
|
||||
# given, and kept in a Gitea release tagged at the commit given. Whether
|
||||
# it is on Preview or Stable is read off the server, never assumed.
|
||||
# Nothing is built or published.
|
||||
name: Release - import an existing release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'A version already on the update server'
|
||||
required: true
|
||||
sha256:
|
||||
description: 'Its SHA-256'
|
||||
required: true
|
||||
commit:
|
||||
description: 'The full commit it was built from'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
import:
|
||||
name: record it
|
||||
runs-on: ekklesia-ci
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Import
|
||||
env:
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
SHA256: ${{ github.event.inputs.sha256 }}
|
||||
COMMIT: ${{ github.event.inputs.commit }}
|
||||
GITEA_TOKEN: ${{ secrets.RELEASE_API_TOKEN || github.token }}
|
||||
run: RELEASE_CI_WORK="$RUNNER_TEMP/release" tools/release-ci.sh import "$VERSION" "$SHA256" "$COMMIT"
|
||||
@@ -0,0 +1,74 @@
|
||||
# PUBLISH TO PREVIEW: THE BUILT FILE, TO THE CANARY CHANNEL.
|
||||
#
|
||||
# "Preview" is the canary channel on updates.riverofglorymn.org under a
|
||||
# friendlier name; the URL and the laptop's device.conf are unchanged.
|
||||
#
|
||||
# Two jobs, so that no job has both the update server's key and Docker:
|
||||
#
|
||||
# gate on ekklesia-ci: the full container gate, again, on the file
|
||||
# the Gitea release holds, against what canary and stable
|
||||
# serve right now. No secret.
|
||||
# publish on ekklesia-release: update_canary.sh publishes that same
|
||||
# file on that gate's report — it refuses a report for other
|
||||
# bytes, or one made before canary or stable moved — then reads
|
||||
# everything back over HTTPS and records it on the release.
|
||||
#
|
||||
# Never automatic. Nothing publishes because a branch was pushed.
|
||||
name: Release 2 - publish to Preview (canary)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'The version, as built by "Release 1 - build"'
|
||||
required: true
|
||||
sha256:
|
||||
description: 'Its SHA-256, from that release: nothing else is published'
|
||||
required: true
|
||||
from_versions:
|
||||
description: 'Older releases rooms are still on, rehearsed as upgrades too (e.g. 0.2.2)'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
name: gate the release's file
|
||||
runs-on: ekklesia-ci
|
||||
outputs:
|
||||
report: ${{ steps.gate.outputs.report }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: The container gate, on exactly that file
|
||||
id: gate
|
||||
env:
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
SHA256: ${{ github.event.inputs.sha256 }}
|
||||
RELEASE_GATE_FROM: ${{ github.event.inputs.from_versions }}
|
||||
GITEA_TOKEN: ${{ secrets.RELEASE_API_TOKEN || github.token }}
|
||||
run: RELEASE_CI_WORK="$RUNNER_TEMP/release" tools/release-ci.sh gate "$VERSION" "$SHA256"
|
||||
|
||||
publish:
|
||||
name: publish to canary and read it back
|
||||
needs: gate
|
||||
runs-on: ekklesia-release
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: What publishing needs
|
||||
run: |
|
||||
command -v ssh rsync curl python3 dpkg-deb git >/dev/null \
|
||||
|| { apt-get update -qq && apt-get install -y -qq --no-install-recommends \
|
||||
openssh-client rsync curl python3 dpkg git ca-certificates >/dev/null; }
|
||||
|
||||
- name: Publish, on the gate's report, and record it
|
||||
env:
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
SHA256: ${{ github.event.inputs.sha256 }}
|
||||
REPORT: ${{ needs.gate.outputs.report }}
|
||||
GITEA_TOKEN: ${{ secrets.RELEASE_API_TOKEN || github.token }}
|
||||
RELEASE_SSH_KEY: ${{ secrets.CLASSROOM_UPDATES_SSH_KEY }}
|
||||
RELEASE_SSH_KNOWN_HOSTS: ${{ vars.CLASSROOM_UPDATES_KNOWN_HOSTS }}
|
||||
RELEASE_SSH_ADDRESS: ${{ vars.CLASSROOM_UPDATES_ADDRESS }}
|
||||
RELEASE_SSH_USER: ${{ vars.CLASSROOM_UPDATES_USER }}
|
||||
run: RELEASE_CI_WORK="$RUNNER_TEMP/release" tools/release-ci.sh preview "$VERSION" "$SHA256" "$REPORT"
|
||||
@@ -0,0 +1,57 @@
|
||||
# PROMOTE TO STABLE: THE FILE PREVIEW RAN, AND NOTHING ELSE.
|
||||
#
|
||||
# Only after a person has done the physical checks in docs/testing.md on
|
||||
# real hardware and says so here. No build, no gate: stable is pointed at
|
||||
# the package already on the server, and update_stable.sh refuses any
|
||||
# file but the SHA-256 given. The Gitea release must already record that
|
||||
# exact file going to Preview.
|
||||
#
|
||||
# Merging code never runs this. Nothing runs it but a person.
|
||||
name: Release 3 - promote to Stable
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'The version on Preview to promote'
|
||||
required: true
|
||||
sha256:
|
||||
description: 'Its SHA-256, exactly as approved'
|
||||
required: true
|
||||
approval:
|
||||
description: 'Who approved it, when, and which physical checks passed on which machine'
|
||||
required: true
|
||||
confirm:
|
||||
description: 'Type: PROMOTE <version> TO STABLE'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
name: promote the approved file and read it back
|
||||
runs-on: ekklesia-release
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: What promoting needs
|
||||
run: |
|
||||
command -v ssh rsync curl python3 dpkg-deb git >/dev/null \
|
||||
|| { apt-get update -qq && apt-get install -y -qq --no-install-recommends \
|
||||
openssh-client rsync curl python3 dpkg git ca-certificates >/dev/null; }
|
||||
|
||||
- name: Promote, and record it
|
||||
env:
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
SHA256: ${{ github.event.inputs.sha256 }}
|
||||
APPROVAL: ${{ github.event.inputs.approval }}
|
||||
CONFIRM: ${{ github.event.inputs.confirm }}
|
||||
GITEA_TOKEN: ${{ secrets.RELEASE_API_TOKEN || github.token }}
|
||||
RELEASE_SSH_KEY: ${{ secrets.CLASSROOM_UPDATES_SSH_KEY }}
|
||||
RELEASE_SSH_KNOWN_HOSTS: ${{ vars.CLASSROOM_UPDATES_KNOWN_HOSTS }}
|
||||
RELEASE_SSH_ADDRESS: ${{ vars.CLASSROOM_UPDATES_ADDRESS }}
|
||||
RELEASE_SSH_USER: ${{ vars.CLASSROOM_UPDATES_USER }}
|
||||
run: |
|
||||
if [ "$CONFIRM" != "PROMOTE $VERSION TO STABLE" ]; then
|
||||
echo "Confirmation must read exactly: PROMOTE $VERSION TO STABLE" >&2
|
||||
exit 1
|
||||
fi
|
||||
RELEASE_CI_WORK="$RUNNER_TEMP/release" tools/release-ci.sh stable "$VERSION" "$SHA256" "$APPROVAL"
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22" width="22" height="22">
|
||||
<!-- The comment lives inside <svg>, not above it. gdk-pixbuf
|
||||
recognises an SVG by sniffing the first bytes of the file, and a
|
||||
comment before the opening tag pushes it out of that window:
|
||||
every GTK program then reports "couldn't recognize the image file
|
||||
format" while Qt renders it happily. Measured on both of the
|
||||
classroom's other icons, and not learned again here. -->
|
||||
<!-- Presentations.
|
||||
|
||||
Breeze's own view-presentation is a bar chart on a board, which in
|
||||
a church reads as a business meeting rather than as tonight's
|
||||
Bible study. x-office-presentation is a projector screen and comes
|
||||
closest, but it is a filetype mark: it says "a file of this kind"
|
||||
where the panel needs "the place your slides are".
|
||||
|
||||
So: a screen on a stand with a slide showing on it — a title line
|
||||
and two lines of text, which is the shape of every one of these
|
||||
sessions. Same 22-unit grid, same single-path style and the same
|
||||
ColorScheme-Text class as Parent Call, so it recolours with the
|
||||
session and needs no second file for the light theme. -->
|
||||
<defs>
|
||||
<style type="text/css">.ColorScheme-Text { color: #fcfcfc; }</style>
|
||||
</defs>
|
||||
<g class="ColorScheme-Text" style="fill:currentColor;fill-opacity:1;stroke:none">
|
||||
<!-- The screen: an outline, so the slide inside it reads as content
|
||||
rather than the whole mark being one solid block at 22px. -->
|
||||
<path d="M2 3h18a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm1 2v8h16V5z"/>
|
||||
<!-- The slide on it: a title, and two lines under it. -->
|
||||
<path d="M5 7h7v1.5H5zm0 3h12v1H5zm0 2.2h9v1H5z" style="fill-opacity:0.85"/>
|
||||
<!-- The stand. A presentation is a thing set up in front of a room,
|
||||
and the two feet are what say so at this size. -->
|
||||
<path d="M10.25 16h1.5v3.2l2.9 1.45-.67 1.34-2.98-1.49-2.98 1.49-.67-1.34 2.9-1.45z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,19 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Help
|
||||
GenericName=Classroom manual
|
||||
Comment=How to use this classroom computer
|
||||
# Breeze's own. A question mark in a circle is the one mark that needs no
|
||||
# explaining, and this is the one place in the classroom where borrowing
|
||||
# from Breeze is better than drawing something of our own.
|
||||
Icon=help-contents
|
||||
Exec=/usr/local/bin/classroom-manual
|
||||
Terminal=false
|
||||
Categories=Documentation;
|
||||
StartupNotify=true
|
||||
# Chrome in application mode reports the app id Chrome derives from the
|
||||
# --app URL, not this entry, so the panel cannot match the manual window
|
||||
# to this launcher. That is deliberate and harmless: Help is a Quick
|
||||
# Launch button, which is a launcher and never a task, so there is no
|
||||
# button here waiting to be matched.
|
||||
NoDisplay=false
|
||||
@@ -0,0 +1,28 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Presentations
|
||||
GenericName=Slides
|
||||
Comment=Show a presentation that was downloaded in the browser
|
||||
# Drawn for this: a screen with a slide on it and a corner turned, which
|
||||
# is a presentation rather than a document or a photograph. Breeze's
|
||||
# view-presentation is a chart on a board, which in a church reads as a
|
||||
# business meeting. Uses ColorScheme-Text like the classroom's other two
|
||||
# marks, so it recolours with the session and needs no light-theme twin.
|
||||
Icon=ekklesia-presentations
|
||||
Exec=/usr/local/bin/classroom-presentations
|
||||
Terminal=false
|
||||
Categories=Office;Presentation;
|
||||
StartupNotify=true
|
||||
# How the panel matches the window on screen to this launcher. GTK sets
|
||||
# the Wayland app id from GLib.set_prgname, which classroom-presentations
|
||||
# sets to exactly this. A mismatch here is not an error anywhere — it is
|
||||
# silently two buttons on the panel instead of one.
|
||||
StartupWMClass=ekklesia-presentations
|
||||
# One window. Pressing the panel button again brings the library forward
|
||||
# rather than opening a second copy of it.
|
||||
X-GNOME-SingleWindow=true
|
||||
# Deliberately visible to the applications index, like the disc player and
|
||||
# Parent Call: the panel resolves its launcher through that index, and an
|
||||
# entry hidden from it gives the bar a blank page instead of the mark.
|
||||
# There is no application menu in this classroom for it to clutter.
|
||||
NoDisplay=false
|
||||
@@ -24,8 +24,18 @@ cursorTheme=breeze_cursors
|
||||
library=org.kde.breeze
|
||||
theme=Breeze
|
||||
|
||||
# The picture a desktop gets when nothing has said which. That is every
|
||||
# desktop Plasma makes on its own after the layout has run: a television
|
||||
# switched on after the computer, a second screen, a new activity.
|
||||
#
|
||||
# A wallpaper PACKAGE NAME, not a file. Plasma's DefaultWallpaper looks it
|
||||
# up as wallpapers/<name> and nothing else. This line was a path until
|
||||
# 0.2.6.6, which Plasma cannot load as a package, so every such desktop
|
||||
# fell back to Plasma's own purple one — measured, and it is what Tiny
|
||||
# Pebbles showed after the 0.2.2 upgrade. The package is a directory whose
|
||||
# one image is a link to the classroom's file; see build-release.sh.
|
||||
[Wallpaper]
|
||||
Image=/usr/share/backgrounds/ekklesia/classroom-calm.png
|
||||
Image=EkklesiaClassroom
|
||||
|
||||
[KSplash]
|
||||
Theme=None
|
||||
|
||||
@@ -12,19 +12,22 @@
|
||||
|
||||
// WHICH APPLICATIONS THIS DEVICE OFFERS.
|
||||
//
|
||||
// classroom-desktop-layout rewrites the three lines between the markers
|
||||
// to match device.conf and this machine's hardware, and nothing else in
|
||||
// classroom-desktop-layout rewrites the lines between the markers to
|
||||
// match device.conf and this machine's hardware, and nothing else in
|
||||
// this file. Each application is its own line because each is its own
|
||||
// setting: a laptop taking children's church needs Wonder Ink and a tower
|
||||
// used for Bible study does not, so the shape of the machine is never
|
||||
// allowed to decide what it is for.
|
||||
//
|
||||
// All true here is the shipped default, which is what an existing
|
||||
// classroom must come up as.
|
||||
// The first three are true here, which is what an existing classroom must
|
||||
// come up as. PRESENTATIONS is false, which is what an existing classroom
|
||||
// must come up as too: no room has ever had it, and an upgrade may not
|
||||
// put a button on a bar nobody asked for.
|
||||
/* CLASSROOM-FEATURES-BEGIN */
|
||||
var WONDERINK = true;
|
||||
var DVD = true;
|
||||
var PARENT_CALL = true;
|
||||
var PRESENTATIONS = false;
|
||||
/* CLASSROOM-FEATURES-END */
|
||||
|
||||
var WALLPAPER = "/usr/share/backgrounds/ekklesia/classroom-calm.png";
|
||||
@@ -34,6 +37,13 @@ var WALLPAPER = "/usr/share/backgrounds/ekklesia/classroom-calm.png";
|
||||
var PANEL_HEIGHT = 76;
|
||||
|
||||
// The idle desktop: the wallpaper, and nothing on it.
|
||||
//
|
||||
// Only the desktops that exist right now. A screen that appears later —
|
||||
// the television switched on after a reboot at three in the morning, which
|
||||
// is what Tiny Pebbles had — gets a desktop Plasma makes without this
|
||||
// file. That one takes the look and feel's default wallpaper, which is the
|
||||
// classroom's package, and classroom-desktop-wallpaper writes it in by
|
||||
// name before the shell next starts.
|
||||
var screens = desktops();
|
||||
for (var i = 0; i < screens.length; i++) {
|
||||
var desktop = screens[i];
|
||||
@@ -84,6 +94,10 @@ if (PARENT_CALL) {
|
||||
parentCall.writeConfig("maxSectionCount", 1);
|
||||
}
|
||||
|
||||
// Help is intentionally absent from this release's teacher-facing panel.
|
||||
// Its launcher, source pages and build pipeline remain installed for the
|
||||
// documentation correction pass; only the unfinished entry point is hidden.
|
||||
|
||||
var leftGap = panel.addWidget("org.kde.plasma.panelspacer");
|
||||
leftGap.currentConfigGroup = ["General"];
|
||||
leftGap.writeConfig("expanding", true);
|
||||
@@ -114,6 +128,17 @@ if (DVD) {
|
||||
launchers.push("applications:ekklesia-dvd.desktop");
|
||||
}
|
||||
|
||||
// LAST, AND THAT IS THE WHOLE REASON IT IS HERE.
|
||||
//
|
||||
// Every existing room has Wonder Ink, the browser and the disc player in
|
||||
// that order, and teachers have been pressing them in that order for
|
||||
// months. Appending keeps all three exactly where they are on every
|
||||
// machine that upgrades; putting Presentations anywhere else in the row
|
||||
// would move a button somebody reaches for without looking.
|
||||
if (PRESENTATIONS) {
|
||||
launchers.push("applications:ekklesia-presentations.desktop");
|
||||
}
|
||||
|
||||
tasks.writeConfig("launchers", launchers.join(","));
|
||||
|
||||
// THE ORDER STAYS PUT WHEN AN APPLICATION OPENS.
|
||||
|
||||
@@ -26,6 +26,12 @@ editable_desktop_icons=false
|
||||
editable_bookmarks=false
|
||||
movable_toolbars=false
|
||||
plasma/plasmashell/unlockedDesktop=false
|
||||
# A locked panel still offered "Configure Digital Clock…" on the clock's
|
||||
# right-click menu — measured on Plasma 6.3. This is Plasma's own key for
|
||||
# "no widget settings while the desktop is locked", and with it that entry
|
||||
# is gone. (The clock's "Adjust Date and Time…" stays, and opens a System
|
||||
# Settings with nothing in it: every module is restricted below.)
|
||||
plasma/allow_configure_when_locked=false
|
||||
plasma-desktop/scripting_console=false
|
||||
# Nothing in the classroom should offer to file a bug or open a manual.
|
||||
action/help_report_bug=false
|
||||
@@ -47,6 +53,115 @@ action/help_contents=false
|
||||
# is not shown doors that will not open.
|
||||
[KDE Control Module Restrictions][$i]
|
||||
kcm_networkmanagement=false
|
||||
#
|
||||
# AND EVERY OTHER SETTINGS MODULE. A notification's Configure button —
|
||||
# measured on Plasma 6.3, from the "Memory Shortage Avoided" notice the
|
||||
# kernel's memory killer raised — runs `kcmshell6 notifications`, which
|
||||
# opened KDE's whole notification settings under the teacher's account:
|
||||
# every application and system service, with Apply. The notification
|
||||
# applet draws that button without asking this group; kcmshell6 and
|
||||
# System Settings do ask it, and with the module listed here the same
|
||||
# click opens only "The module kcm_notifications is disabled ... by the
|
||||
# system administrator". Measured.
|
||||
#
|
||||
# KDE has no wildcard for this, so the list is every module installed by
|
||||
# Debian 13's Plasma 6.3, as found on the lab machine. A teacher has a use
|
||||
# for none of them. tools/lab/converged-check fails if a module is ever
|
||||
# installed that is not listed here, so a Plasma update cannot quietly
|
||||
# reopen a door.
|
||||
#
|
||||
# The maintenance terminal is untouched: it runs with
|
||||
# XDG_CONFIG_DIRS=/etc/xdg, outside this file, so System Settings started
|
||||
# from it is the whole of System Settings.
|
||||
kcm_about-distro=false
|
||||
kcm_access=false
|
||||
kcm_activities=false
|
||||
kcm_audio_information=false
|
||||
kcm_autostart=false
|
||||
kcm_baloofile=false
|
||||
kcm_block_devices=false
|
||||
kcm_bluetooth=false
|
||||
kcm_bolt=false
|
||||
kcm_clock=false
|
||||
kcm_colors=false
|
||||
kcm_componentchooser=false
|
||||
kcm_cpu=false
|
||||
kcm_cursortheme=false
|
||||
kcm_desktoppaths=false
|
||||
kcm_desktoptheme=false
|
||||
kcm_device_automounter=false
|
||||
kcm_disks=false
|
||||
kcm_edid=false
|
||||
kcm_egl=false
|
||||
kcm_energyinfo=false
|
||||
kcm_feedback=false
|
||||
kcm_filetypes=false
|
||||
kcm_firewall=false
|
||||
kcm_firmware_security=false
|
||||
kcm_fontinst=false
|
||||
kcm_fonts=false
|
||||
kcm_gamecontroller=false
|
||||
kcm_glx=false
|
||||
kcm_icons=false
|
||||
kcm_interrupts=false
|
||||
kcm_kded=false
|
||||
kcm_keyboard=false
|
||||
kcm_keys=false
|
||||
kcm_kgamma=false
|
||||
kcm_krunner_kill=false
|
||||
kcm_krunnersettings=false
|
||||
kcm_kscreen=false
|
||||
kcm_kwallet5=false
|
||||
kcm_kwin_effects=false
|
||||
kcm_kwin_scripts=false
|
||||
kcm_kwin_virtualdesktops=false
|
||||
kcm_kwindecoration=false
|
||||
kcm_kwinoptions=false
|
||||
kcm_kwinrules=false
|
||||
kcm_kwinscreenedges=false
|
||||
kcm_kwinsupportinfo=false
|
||||
kcm_kwintabbox=false
|
||||
kcm_kwintouchscreen=false
|
||||
kcm_kwinxwayland=false
|
||||
kcm_landingpage=false
|
||||
kcm_lookandfeel=false
|
||||
kcm_memory=false
|
||||
kcm_mouse=false
|
||||
kcm_netpref=false
|
||||
kcm_network=false
|
||||
kcm_nightlight=false
|
||||
kcm_notifications=false
|
||||
kcm_opencl=false
|
||||
kcm_pci=false
|
||||
kcm_plasmasearch=false
|
||||
kcm_powerdevilprofilesconfig=false
|
||||
kcm_proxy=false
|
||||
kcm_pulseaudio=false
|
||||
kcm_qtquicksettings=false
|
||||
kcm_recentFiles=false
|
||||
kcm_regionandlang=false
|
||||
kcm_samba=false
|
||||
kcm_screenlocker=false
|
||||
kcm_sddm=false
|
||||
kcm_smserver=false
|
||||
kcm_solid_actions=false
|
||||
kcm_soundtheme=false
|
||||
kcm_splashscreen=false
|
||||
kcm_style=false
|
||||
kcm_tablet=false
|
||||
kcm_touchpad=false
|
||||
kcm_touchscreen=false
|
||||
kcm_usb=false
|
||||
kcm_users=false
|
||||
kcm_virtualkeyboard=false
|
||||
kcm_vulkan=false
|
||||
kcm_wallpaper=false
|
||||
kcm_wayland=false
|
||||
kcm_webshortcuts=false
|
||||
kcm_workspace=false
|
||||
kcm_xserver=false
|
||||
kcmspellchecking=false
|
||||
kwincompositing=false
|
||||
|
||||
# Not immutable. Marking this group [$i] froze the colour scheme so hard
|
||||
# that `classroom-theme light` changed the running session and then lost
|
||||
|
||||
@@ -82,15 +82,14 @@ NoiseStrength=2
|
||||
[org.kde.kdecoration2][$i]
|
||||
library=org.kde.breeze
|
||||
theme=Breeze
|
||||
# A pin and a close button, and nothing else.
|
||||
#
|
||||
# The pin is the Live View story: keeping a window above the others is a
|
||||
# window-manager state, not a feature the classroom has to invent. There is
|
||||
# deliberately no minimise button, because a minimised window is the one
|
||||
# thing a teacher cannot see the state of — measured, not assumed. Double
|
||||
# clicking the title bar still maximises, so nothing is unreachable.
|
||||
# A close button, and nothing else. `F` was the keep-above button, rendered
|
||||
# by Breeze as an unexplained double chevron beside Close. Classroom windows
|
||||
# that must stay above others request that state themselves; teachers do not
|
||||
# need a title-bar toggle for it. There is deliberately no minimise button,
|
||||
# because a minimised window is the one thing a teacher cannot see the state
|
||||
# of. Double clicking the title bar still maximises ordinary windows.
|
||||
ButtonsOnLeft=
|
||||
ButtonsOnRight=FX
|
||||
ButtonsOnRight=X
|
||||
ShowToolTips=false
|
||||
BorderSize=None
|
||||
BorderSizeAuto=false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Window rules.
|
||||
#
|
||||
# There are two: one about video, one about the Parent Call card.
|
||||
# There are three: video, the Parent Call card, and the presentation surface.
|
||||
#
|
||||
# Everything else the classroom used to arrange by hand is ordinary window
|
||||
# manager behaviour and needs no rule: a dialog stays in front of the window
|
||||
@@ -17,8 +17,8 @@
|
||||
# 314-line window arbiter was doing.
|
||||
|
||||
[General]
|
||||
count=2
|
||||
rules=classroom-video,classroom-parent-call
|
||||
count=3
|
||||
rules=classroom-video,classroom-parent-call,classroom-presentation
|
||||
|
||||
[classroom-video]
|
||||
Description=Video is an appliance screen, not a window
|
||||
@@ -48,3 +48,20 @@ skippagerrule=2
|
||||
# button, rather than behind it.
|
||||
above=true
|
||||
aboverule=2
|
||||
|
||||
[classroom-presentation]
|
||||
Description=A presentation is the whole display, not a large window
|
||||
# The library and Quick Look have the same Wayland app id, so the stable
|
||||
# presenter title is part of this match. Matching the app alone would make
|
||||
# every Presentations window fullscreen.
|
||||
wmclass=ekklesia-presentations
|
||||
wmclassmatch=1
|
||||
wmclasscomplete=false
|
||||
title=Presenting
|
||||
titlematch=1
|
||||
fullscreen=true
|
||||
fullscreenrule=2
|
||||
noborder=true
|
||||
noborderrule=2
|
||||
skiptaskbar=true
|
||||
skiptaskbarrule=2
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"KPlugin": {
|
||||
"Authors": [
|
||||
{
|
||||
"Name": "Ekklesia"
|
||||
}
|
||||
],
|
||||
"Id": "EkklesiaClassroom",
|
||||
"License": "GPLv2+",
|
||||
"Name": "Ekklesia Classroom"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
# Backlog — 0.3.0 and later
|
||||
|
||||
Recorded after hands-on testing of 0.2.6.1 in September 2026. **None of
|
||||
this is in 0.2.6.2 to 0.2.6.4**: 0.2.6.2 is limited to picture printing,
|
||||
the Presentations memory failure and the settings lockdown, 0.2.6.3 to
|
||||
the black-and-white preview and Print image, and 0.2.6.4 to temporary
|
||||
workspace restore (the two items that were here about links and sign-in
|
||||
retention are answered in wonder-ink.md). Each item says what was
|
||||
observed, so whoever picks it up starts from the evidence.
|
||||
|
||||
## Wonder Ink
|
||||
|
||||
- **An interface like Chrome's, visibly the classroom's.** Familiar tabs
|
||||
and back/forward/reload, without an address bar, new-tab button or
|
||||
anything that takes a teacher somewhere the lesson did not. The 0.2.6.1
|
||||
tab strip is a step towards it, not the design.
|
||||
- **The PDF viewer's own Download button.** It saves a copy, which the
|
||||
download handler then opens as another worksheet tab. Remove it, or
|
||||
intercept the download from a worksheet tab so it does nothing visible.
|
||||
(0.2.2 already skips re-opening a save from a worksheet tab — see
|
||||
`from_worksheet` in `on_download` — so check first which route it now
|
||||
takes.)
|
||||
- **One Print, not two.** A worksheet tab has the viewer's printer icon
|
||||
and Wonder Ink's Print; both now open the same preview, but a teacher
|
||||
sees two buttons. One teacher-facing workflow.
|
||||
- **Readable worksheet names.** Tabs show the downloaded file's name,
|
||||
which for many worksheets is a long generated prefix. Strip it, or take
|
||||
the name from the PDF's own title.
|
||||
- **Print image and CSS background pictures** (from 0.2.6.3). The engine's
|
||||
hit test does not call a CSS background a picture, so right-click there
|
||||
offers only Print page. Only worth doing if Sunday's testing finds
|
||||
Wonder Ink's site draws artwork that way; the lab has no account to
|
||||
tell. See wonder-ink.md, *Print image*.
|
||||
- **The clipboard after Print image** (from 0.2.6.3). It is put back, but
|
||||
owned by Wonder Ink afterwards, so text copied elsewhere before is gone
|
||||
once Wonder Ink closes — there is no clipboard manager in the session.
|
||||
Measured, and judged smaller than leaving the picture there.
|
||||
- **A speaker on the tab that is playing sound** (`QWebEnginePage.recentlyAudible`).
|
||||
- **A picture in its own tab, in a room above 100%** (seen testing
|
||||
0.2.6.4). Page zoom is the room's `UI_SCALE`, and the engine's picture
|
||||
viewer takes it too, so at 200% a slide opened from a lesson is shown
|
||||
twice its size with scrollbars rather than fitted to the window, as
|
||||
Chrome shows it. Printing is unaffected.
|
||||
- **Staying signed in, if WonderInk ever issues a real session token.**
|
||||
Today its sign-in is the teacher's email and password in a Basic-auth
|
||||
cookie (measured, 0.2.6.4), so keeping it would mean keeping a password;
|
||||
Wonder Ink does not. Worth asking WonderInk for a server-issued token
|
||||
that can be revoked; until then, closing Wonder Ink signs the teacher
|
||||
out, and only the recent tabs come back.
|
||||
- **Workspace time as a room setting**, if two hours turns out wrong for
|
||||
some room: `IDLE_LIMIT_SECONDS` in `classroom_wonderink_workspace.py` is
|
||||
the one place, and a device.conf key would be a small change.
|
||||
- **The site's sluggishness, where it is ours.** Measured in 0.2.6.1:
|
||||
Wonder Ink and Chrome take the same time (about 2.9 s cold) and the
|
||||
site is the weight — 119 – 160 requests, 3.2 MB, the Google Maps API
|
||||
loaded twice. Ours to look at: whether a classroom's QtWebEngine has its
|
||||
GPU (`classroom-wonderink --diagnose` on the machines), and the page
|
||||
shell being revalidated on every warm load (62 – 87 ms).
|
||||
- **The browser is scaled twice** under Plasma (dpr 2.25 at 150%):
|
||||
`classroom-browser` forces `UI_SCALE` on a Chrome that already follows
|
||||
the compositor. Deliberately left in 0.2.6.x because teachers know that
|
||||
size, and Wonder Ink was matched to it. Decide once, for both.
|
||||
|
||||
## Presentations
|
||||
|
||||
- **A normal, maximisable application** instead of the small floating
|
||||
library window.
|
||||
- **Consistently sized tiles in a responsive grid.**
|
||||
- **No unnecessary internal scrollbars or stretched layouts.** 0.2.6.2
|
||||
had to give a row extra height by hand when a card carries the
|
||||
memory-failure message; the layout should size to its cards.
|
||||
- **Keep the window's size and position** when importing a presentation
|
||||
or changing a cover.
|
||||
- **Click the slide to advance.**
|
||||
- **Hide the pointer after a moment of stillness while presenting**, and
|
||||
bring it back on movement.
|
||||
- **Keep the keyboard controls**, and never advance when a playback
|
||||
control itself is clicked.
|
||||
- **Remote control of a presentation** — later than 0.3.0.
|
||||
|
||||
## The launcher and Parent Call
|
||||
|
||||
- **Remove the subtitles under DVD and Presentations** on the launcher.
|
||||
- **Parent Call appears among the ordinary applications for a moment and
|
||||
then disappears**, which looks like a crash. Keep it reachable through
|
||||
its background or notification route without it flashing up as a
|
||||
window.
|
||||
|
||||
## The settings lockdown
|
||||
|
||||
- A notification's **Configure** button still shows, and opens a small
|
||||
"module disabled" window (0.2.6.2). Hiding it needs a change to Plasma's
|
||||
notification applet, not configuration — revisit if Plasma gains a
|
||||
Kiosk key for it.
|
||||
- The settings-module list is per Plasma release. `tools/lab/converged-check`
|
||||
fails when a new one appears; the list in `config/plasma/xdg/kdeglobals`
|
||||
then needs the new name.
|
||||
@@ -116,3 +116,45 @@ and nothing needs reinstalling.
|
||||
To put the session screen back, remove the D-Bus shadow and the
|
||||
`kglobalshortcutsrc` entries; both are package files, so an update will
|
||||
restore them, which is the intended direction.
|
||||
|
||||
## Settings the teacher must not reach (0.2.6.2)
|
||||
|
||||
During testing the kernel's memory killer raised Plasma's *Memory Shortage
|
||||
Avoided* notice, and its **Configure** button opened KDE's Notifications
|
||||
settings under the teacher's account — every application and system
|
||||
service, with Apply. Reproduced in the lab: the button runs
|
||||
`kcmshell6 notifications`.
|
||||
|
||||
Plasma draws that button on any notification whose application has KDE
|
||||
notification settings, without asking the Kiosk policy. What does ask is
|
||||
the thing it launches: `kcmshell6` and System Settings refuse a module
|
||||
listed under `[KDE Control Module Restrictions]`. KDE has no wildcard for
|
||||
that group, so **every settings module Debian 13's Plasma 6.3 installs —
|
||||
90, found on the lab machine — is listed**, immutable, in the classroom's
|
||||
`kdeglobals`. Measured afterwards:
|
||||
|
||||
| Entry point | Before | After |
|
||||
| --- | --- | --- |
|
||||
| A notification's Configure button | Notifications settings, editable | *The module kcm_notifications is disabled … by the system administrator* |
|
||||
| That window's **Help** button | KDE Help Center: menus, man pages, file dialogs | not installed (`Conflicts: khelpcenter`; plasma-desktop only recommends it) |
|
||||
| Volume: right-click | *Configure Audio Devices…* | entry gone — the applet asks the policy |
|
||||
| Clock: right-click | *Adjust Date and Time…*, *Configure Digital Clock…* | *Configure* gone (`plasma/allow_configure_when_locked=false`); *Adjust* opens a System Settings with nothing in it |
|
||||
| Bell, empty panel: right-click | no menu | no menu |
|
||||
| Network | already refused (0.2.4) | unchanged |
|
||||
|
||||
The button itself still shows on such a notification, and still opens the
|
||||
small "disabled" window: hiding it would mean replacing Plasma's
|
||||
notification applet, which is exactly what this appliance does not do.
|
||||
Notifications themselves are untouched — Parent Call, the room's
|
||||
messages, Wonder Ink's "Sent to the printer" all still appear.
|
||||
|
||||
**The maintenance route is unaffected.** `classroom-admin-terminal` starts
|
||||
Konsole with `XDG_CONFIG_DIRS=/etc/xdg`, outside this file, so anything a
|
||||
technician starts from it — `systemsettings`, `kcmshell6 kcm_notifications`
|
||||
— is the whole of it. Verified in the lab: the same module refused to the
|
||||
teacher opened fully, editable, from the maintenance terminal.
|
||||
|
||||
`tools/lab/converged-check` fails if any settings module on the machine is
|
||||
not refused in the classroom's cascade, so a Plasma update that adds one
|
||||
cannot quietly reopen this; and it checks the maintenance cascade still
|
||||
refuses nothing.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
title: Your classroom computer
|
||||
order: 0
|
||||
---
|
||||
|
||||
This computer is set up for one thing: running your room. It switches
|
||||
itself on into the classroom, and there is nothing on it to break.
|
||||
|
||||
You do not need a password. You do not need to sign in. You do not need
|
||||
to shut it down at the end — leave it on.
|
||||
|
||||
## The bar along the bottom
|
||||
|
||||
Everything you need is on the bar at the bottom of the screen.
|
||||
|
||||
The buttons in the middle are the things you can open. Which ones are
|
||||
there depends on what your room is set up for, so your bar may have
|
||||
fewer buttons than another room's.
|
||||
|
||||
The right-hand end shows the network, the volume, any messages and the
|
||||
clock.
|
||||
|
||||
To open something, click its button once. To come back to it later,
|
||||
click the same button again.
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
title: The Browser
|
||||
order: 10
|
||||
---
|
||||
|
||||
The **Browser** button opens the web: YouTube, a song, a video clip, a
|
||||
website a lesson sends you to.
|
||||
|
||||
## Using it
|
||||
|
||||
1. Click **Browser** on the bar.
|
||||
2. Type where you want to go in the bar at the top, or click **Home** to
|
||||
go back to YouTube.
|
||||
|
||||
The room stays signed in to YouTube, so you do not have to.
|
||||
|
||||
## Playing a video on the television
|
||||
|
||||
Click the full-screen button in the corner of the video, the same as you
|
||||
would at home. Press **Esc** to come back out of full screen.
|
||||
|
||||
## When you have finished
|
||||
|
||||
Leave it open, or close the window. The next person can open it again
|
||||
from the bar.
|
||||
|
||||
Anything you sign in to yourself — a personal account, an email — sign
|
||||
out of before you leave. The computer is shared.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
title: Sound and volume
|
||||
order: 20
|
||||
---
|
||||
|
||||
Sound goes to the television when one is connected, and to the
|
||||
computer's own speakers when one is not. You do not have to choose.
|
||||
|
||||
## Changing the volume
|
||||
|
||||
Use the volume keys on the keyboard, or click the speaker at the
|
||||
right-hand end of the bar and drag the slider.
|
||||
|
||||
A large volume bar appears on the screen while you are changing it, so
|
||||
you can see it from across the room.
|
||||
|
||||
## If there is no sound
|
||||
|
||||
1. Check the volume on the **television** as well as on the computer.
|
||||
Both have to be up.
|
||||
2. Check the television is showing this computer, and not something else
|
||||
plugged into it.
|
||||
3. If the television was switched on after the computer, the sound may
|
||||
still be going to the computer's own speakers. Turn the volume down
|
||||
and back up once; that is enough to move it across.
|
||||
|
||||
If none of that works, tell the office. Do not unplug anything.
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
title: Presentations
|
||||
order: 40
|
||||
feature: PRESENTATIONS_ENABLED
|
||||
printed-if: If your computer has a Presentations button on the bar
|
||||
---
|
||||
|
||||
**Presentations** holds the slides for your sessions. You download a
|
||||
presentation in the browser once, and from then on it is on this screen,
|
||||
ready to show on the television.
|
||||
|
||||
## Getting a presentation onto the computer
|
||||
|
||||
1. Click **Browser** and go to wherever the presentation is — your
|
||||
email, a shared folder, wherever it was sent to you.
|
||||
2. Download it the way you normally would.
|
||||
3. Wait until the download has finished.
|
||||
|
||||
That is all. It appears in **Presentations** by itself a few seconds
|
||||
later. You do not have to save it anywhere special, move it, or open it.
|
||||
|
||||
PowerPoint files and PDF files both work.
|
||||
|
||||
Your download stays in the browser exactly as it was. Nothing here
|
||||
changes it, moves it or deletes it.
|
||||
|
||||
## Opening the library
|
||||
|
||||
Click **Presentations** on the bar.
|
||||
|
||||

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

|
||||
|
||||
* **Next** and **Previous** move through the slides.
|
||||
* **Back to Presentations** returns to the library.
|
||||
* **Present** starts the slideshow straight away.
|
||||
|
||||
Looking through a session in Quick Look does not count as having
|
||||
presented it.
|
||||
|
||||
## Changing the picture on the card
|
||||
|
||||
The first slide of a session is often the same opening slide as every
|
||||
other session, which makes the cards hard to tell apart. Use the slide
|
||||
that names the session instead — usually the second one.
|
||||
|
||||
1. Click **Quick Look**.
|
||||
2. Press **Next** until you reach the slide you want.
|
||||
3. Click **Use this slide as the picture**.
|
||||
|
||||
The card changes straight away, and stays that way — after you close the
|
||||
library, and after the computer is switched off and on again.
|
||||
|
||||
Choosing the picture does not change where the slideshow starts. It
|
||||
always starts at the first slide.
|
||||
|
||||
## Showing a presentation
|
||||
|
||||
Click **Present**, on the card or inside Quick Look.
|
||||
|
||||
The slides fill the whole screen. Nothing else is on it.
|
||||
|
||||
| To do this | Press |
|
||||
| --- | --- |
|
||||
| Next slide | **Space**, the **right arrow**, the **down arrow** or **Page Down** |
|
||||
| Previous slide | The **left arrow**, the **up arrow** or **Page Up** |
|
||||
| First slide | **Home** |
|
||||
| Last slide | **End** |
|
||||
| Finish | **Esc** |
|
||||
|
||||
A presentation remote works too — the forward and back buttons on the
|
||||
clicker are the same as Page Down and Page Up.
|
||||
|
||||
## Finishing
|
||||
|
||||
Press **Esc**.
|
||||
|
||||
You come straight back to the library. Going past the last slide does
|
||||
the same thing.
|
||||
|
||||
## Removing a presentation
|
||||
|
||||
Click **Remove** on its card, and confirm.
|
||||
|
||||
That takes it off this screen. Your download in the browser is not
|
||||
touched, so you can always get it back by downloading it again.
|
||||
|
||||
## Presentations you no longer need
|
||||
|
||||
The computer tidies up after itself, so the library does not slowly fill
|
||||
up with old sessions.
|
||||
|
||||
A session you have shown is kept for a month after the last time you
|
||||
showed it. Showing it again starts that month over.
|
||||
|
||||
A session you downloaded but never showed is kept for six months.
|
||||
|
||||
You do not have to do anything about this, and nothing disappears while
|
||||
you are using it.
|
||||
|
||||
## If a presentation will not open
|
||||
|
||||
Its card says so, and offers only **Remove**. Usually the file did not
|
||||
download properly.
|
||||
|
||||
Download it again. Your original download is still there either way.
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
title: Wonder Ink
|
||||
order: 50
|
||||
feature: WONDERINK_ENABLED
|
||||
printed-if: If Wonder Ink is available in your classroom
|
||||
---
|
||||
|
||||
**Wonder Ink** opens the lesson. It is its own application, not the
|
||||
browser, and it opens straight into the lesson site.
|
||||
|
||||
## Using it
|
||||
|
||||
1. Click **Wonder Ink** on the bar.
|
||||
2. Sign in with your Wonder Ink account.
|
||||
|
||||
A lesson often opens several tabs — the lesson, its video, an activity.
|
||||
They appear in a row across the top. Click a tab to move between them,
|
||||
and the **×** on a tab to close it.
|
||||
|
||||
There is no address bar, on purpose. Wonder Ink can take you anywhere
|
||||
the lesson needs to go.
|
||||
|
||||
## Making the lesson bigger or smaller
|
||||
|
||||
Press **Ctrl** and **+** to make the lesson bigger, **Ctrl** and **−** to
|
||||
make it smaller, and **Ctrl** and **0** to go back to normal. You can also
|
||||
right-click the lesson and choose **Make the lesson bigger**. When several
|
||||
tabs are open, the **− 100% +** buttons at the top right do the same.
|
||||
|
||||
The size applies to every tab and is remembered next time.
|
||||
|
||||
## Printing
|
||||
|
||||
Press **Ctrl** and **P**, click **Print** at the top right, or right-click
|
||||
and choose **Print page…**. You will see the pages exactly as they will
|
||||
print.
|
||||
|
||||
* Choose **Only some pages** to print, for example, `2` or `1-3`.
|
||||
* Set **Copies** for the number of children.
|
||||
* Choose **Black and white** to save color ink. The preview turns grey,
|
||||
so you see what will come out; choose **Color** to go back.
|
||||
|
||||
**Printing a picture from the lesson.** Right-click the picture and
|
||||
choose **Print image…**. Only that picture prints, not the page around
|
||||
it. This works for pictures you cannot open by themselves.
|
||||
|
||||
In the preview for a picture, also under **Picture**: choose its
|
||||
**Size** (for example 50%), click one of the nine **Position** squares to
|
||||
put it in a corner or the middle, and choose **Paper** upright or
|
||||
sideways. The preview changes as you choose, and shows exactly what will
|
||||
print. **Fill the whole page** makes it cover the paper but cuts off the
|
||||
edges. A picture that is open in a tab by itself prints the same way.
|
||||
|
||||
If Wonder Ink says a picture *could not be taken from the page*, it had
|
||||
not finished loading: close the preview and try again, or choose **Print
|
||||
page…** to print the whole page instead.
|
||||
|
||||
Click **Print**. Wonder Ink says when the printer has it and goes back to
|
||||
the lesson. **Cancel** prints nothing.
|
||||
|
||||
## Worksheets and colouring pages
|
||||
|
||||
Click the worksheet link in the lesson. It opens in a new tab, ready to
|
||||
read or print. Any other link in a lesson — a slide, a picture, a video —
|
||||
also opens in a new tab with one click, and Wonder Ink shows it straight
|
||||
away; the lesson stays open in its own tab. A worksheet's own **−** and
|
||||
**+** at the top of the page make it bigger or smaller.
|
||||
|
||||
Worksheets you download are kept separately from everything else on the
|
||||
computer, so they never get mixed up with anything.
|
||||
|
||||
## If Wonder Ink closes by accident
|
||||
|
||||
Open it again. If you used it in the last two hours, **your recent tabs
|
||||
come back** — the slides, pictures and worksheets you had open, in the
|
||||
same order. Wonder Ink's own lesson tab comes back at the sign-in page:
|
||||
sign in again and open your lesson.
|
||||
|
||||
Your password is never kept. After two hours that nobody has used Wonder
|
||||
Ink, it forgets the tabs and the next person starts with a fresh Wonder
|
||||
Ink.
|
||||
|
||||
## When you have finished
|
||||
|
||||
**Close Wonder Ink at the end of your session.** Closing it signs you
|
||||
out, so the next teacher in this room starts with their own account and
|
||||
not yours. Your recent tabs are kept for two hours in case you need them
|
||||
again, but never your sign-in.
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
title: Parent Call
|
||||
order: 60
|
||||
feature: PARENT_CALL_ENABLED
|
||||
printed-if: If your computer has a Parent Call button in the bottom-left corner
|
||||
---
|
||||
|
||||
**Parent Call** asks the sanctuary to fetch a child's grown-up, without
|
||||
you leaving the room.
|
||||
|
||||
## Using it
|
||||
|
||||
1. Click **Parent Call**, in the bottom-left corner of the screen.
|
||||
2. Type the child's name.
|
||||
3. Click **Send**.
|
||||
|
||||
The card tells you what is happening. **Sent to sanctuary** means
|
||||
somebody there has it.
|
||||
|
||||
## What it does and does not promise
|
||||
|
||||
**Sent to sanctuary** means the message arrived. It does not mean it has
|
||||
been read out yet — somebody there decides when.
|
||||
|
||||
If it cannot reach the sanctuary it says so plainly. It will never tell
|
||||
you a call went through when it did not.
|
||||
|
||||
If that happens, send somebody.
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
title: Playing a DVD
|
||||
order: 70
|
||||
feature: DVD_ENABLED
|
||||
printed-if: If your computer has a DVD button and a disc drive
|
||||
---
|
||||
|
||||
## Playing a disc
|
||||
|
||||
1. Put the disc in the drive.
|
||||
2. Wait for **DVD Ready** to appear.
|
||||
3. Click **DVD** on the bar.
|
||||
|
||||
The disc fills the screen and plays on the television. Discs never start
|
||||
on their own — it always takes a button press.
|
||||
|
||||
## While it is playing
|
||||
|
||||
Move the mouse to the very bottom of the screen and a row of controls
|
||||
slides up: previous chapter, play and pause, next chapter, the disc
|
||||
menu, eject, and close.
|
||||
|
||||
**Space** also plays and pauses, and the arrow keys move around the disc
|
||||
menu.
|
||||
|
||||
## Finishing
|
||||
|
||||
Click **✕** on that row to stop, or **⏏** to stop and eject the disc.
|
||||
|
||||
Take the disc with you.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
title: Printing
|
||||
order: 80
|
||||
---
|
||||
|
||||
Printing goes to the church printer. There is nothing to choose and
|
||||
nothing to set up.
|
||||
|
||||
## Printing something
|
||||
|
||||
1. Press **Ctrl+P**, or use the **Print** button on the page you are
|
||||
looking at.
|
||||
2. Check the number of copies.
|
||||
3. Click **Print**.
|
||||
|
||||
## If nothing comes out
|
||||
|
||||
The printer may be off, out of paper, or somebody else's job may be
|
||||
ahead of yours. Your job waits in the queue and prints when the printer
|
||||
is ready again — you do not need to send it twice.
|
||||
|
||||
Sending it again two or three times is how ten copies of the same
|
||||
worksheet appear later.
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
title: If something goes wrong
|
||||
order: 90
|
||||
---
|
||||
|
||||
Almost nothing here needs a technical person, and nothing you can do
|
||||
from the keyboard will break the computer.
|
||||
|
||||
## The screen is black
|
||||
|
||||
Move the mouse or press a key. If it stays black, check the television
|
||||
is on and showing this computer.
|
||||
|
||||
## A window will not close
|
||||
|
||||
Click its button on the bar to bring it back, and close it normally.
|
||||
|
||||
## The bar has gone
|
||||
|
||||
Wait a few seconds; it puts itself back.
|
||||
|
||||
If it does not, switch the computer off at the power button, wait ten
|
||||
seconds, and switch it on again. It comes back to the same screen it
|
||||
always does.
|
||||
|
||||
## Something asks for a password
|
||||
|
||||
Nothing a teacher needs to do asks for a password. If a password box
|
||||
appears, you have reached something you did not mean to — press **Esc**
|
||||
or click **Cancel**, and carry on.
|
||||
|
||||
## It is still wrong
|
||||
|
||||
Tell the office, and say **which room** and **what you were doing when
|
||||
it happened**. That second part is the one that saves everybody time.
|
||||
@@ -0,0 +1,85 @@
|
||||
# The teacher's manual
|
||||
|
||||
**This directory is the only place teacher instructions are written.**
|
||||
|
||||
Everything a teacher reads comes out of the `.md` files beside this one:
|
||||
|
||||
* the **digital manual** on each classroom computer, which shows only the
|
||||
features that computer actually has; and
|
||||
* the **printable PDF**, which covers every feature with wording that
|
||||
stays true in a room that does not have all of them.
|
||||
|
||||
Both are built by `tools/build-manual` from these files. There is no
|
||||
second copy to keep in step, and there is no per-room manual: a room
|
||||
differs from another room by its `device.conf`, and the manual reads the
|
||||
same setting the panel does.
|
||||
|
||||
## Writing a page
|
||||
|
||||
Each page is Markdown with a small header:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Presentations
|
||||
order: 40
|
||||
feature: PRESENTATIONS_ENABLED
|
||||
printed-if: If your computer has a Presentations button
|
||||
---
|
||||
|
||||
## Downloading a presentation
|
||||
...
|
||||
```
|
||||
|
||||
| Field | Means |
|
||||
| --- | --- |
|
||||
| `title` | The heading, and the entry in the contents |
|
||||
| `order` | Where it comes. Common pages are 00–39, optional ones 40+ |
|
||||
| `feature` | The `device.conf` setting that decides whether a computer shows this page. Leave it out for a page every room gets |
|
||||
| `printed-if` | The conditional sentence the printed manual puts above the page. Required whenever `feature` is set |
|
||||
|
||||
Only `##` and `###` headings, ordinary paragraphs, `*` bullets, `1.`
|
||||
numbered steps, simple `|` tables, `` pictures
|
||||
and **bold** are supported. That is deliberate: the same text has to come
|
||||
out well as a web page and as print, and a manual for teachers does not
|
||||
need anything else.
|
||||
|
||||
## Writing style
|
||||
|
||||
The existing pages set it, and it matters more than the format:
|
||||
|
||||
* Say what the teacher does, not what the computer does.
|
||||
* One instruction per step, in the order they happen.
|
||||
* Name things the way the screen names them — **Present**, **Quick
|
||||
Look**, the **bar** along the bottom.
|
||||
* No paths, no package names, no settings files, no version numbers.
|
||||
* If something can go wrong, say what to do about it in the same breath.
|
||||
|
||||
## Building
|
||||
|
||||
```
|
||||
tools/build-manual # both, into docs/manual/build/
|
||||
tools/build-manual --check # verify only; what the release runs
|
||||
```
|
||||
|
||||
`tools/build-release.sh` builds and checks the manual on every release, so
|
||||
the digital and printed versions cannot drift apart. The check fails the
|
||||
build if a page declares a `feature` with no `printed-if`, if it names a
|
||||
feature `device.conf` does not have, or if the built files are missing.
|
||||
|
||||
## Screenshots
|
||||
|
||||
Pictures live in `docs/manual/images/`. They are captured from the real
|
||||
interface with `tools/lab/lab shot`, never drawn or mocked up. A manual
|
||||
showing a screen that does not exist is worse than one with no pictures.
|
||||
|
||||
They are **embedded** into the digital manual as data URIs, so it stays a
|
||||
single file that works wherever it is copied and with the network
|
||||
unplugged. A picture named by a page that is not in `images/` fails the
|
||||
build, as does one with no caption.
|
||||
|
||||
**The printed manual prints the caption, not the picture** — as
|
||||
`[ Picture: ... — see the computer's own Help ]`. Embedding a PNG in a
|
||||
PDF means decoding it and writing an `/XObject`, and this builder exists
|
||||
so that a release needs nothing but Python. If the printed manual ever
|
||||
needs real pictures, that is the thing to add, and it is a contained
|
||||
change: `render_pdf` is the only function that would move.
|
||||
@@ -0,0 +1,408 @@
|
||||
%PDF-1.4
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] /Count 12 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 15 0 R >>
|
||||
endobj
|
||||
4 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 16 0 R >>
|
||||
endobj
|
||||
5 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 17 0 R >>
|
||||
endobj
|
||||
6 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 18 0 R >>
|
||||
endobj
|
||||
7 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 19 0 R >>
|
||||
endobj
|
||||
8 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 20 0 R >>
|
||||
endobj
|
||||
9 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 21 0 R >>
|
||||
endobj
|
||||
10 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 22 0 R >>
|
||||
endobj
|
||||
11 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 23 0 R >>
|
||||
endobj
|
||||
12 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 24 0 R >>
|
||||
endobj
|
||||
13 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 25 0 R >>
|
||||
endobj
|
||||
14 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 27 0 R /F2 28 0 R >> >> /Contents 26 0 R >>
|
||||
endobj
|
||||
15 0 obj
|
||||
<< /Length 765 >>
|
||||
stream
|
||||
BT /F2 26 Tf 1 0 0 1 62.0 720.0 Tm (Classroom manual) Tj ET
|
||||
BT /F1 13 Tf 1 0 0 1 62.0 677.1 Tm (River of Glory) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 656.6 Tm (Revision 29 September 2026) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 623.7 Tm (This manual covers every classroom computer in the church. Not every room has every button, so) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 608.8 Tm (some pages begin by saying when they apply. If your computer does not have what a page describes,) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 593.9 Tm (that page is not for your room.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 569.0 Tm (Keep this copy in the room. If the date above is older than the one on the computer's own Help, ask) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 554.1 Tm (the office for a new one.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
16 0 obj
|
||||
<< /Length 1250 >>
|
||||
stream
|
||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Your classroom computer) Tj ET
|
||||
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 733.9 Tm (This computer is set up for one thing: running your room. It switches itself on into the) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 719.0 Tm (classroom, and there is nothing on it to break.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 699.0 Tm (You do not need a password. You do not need to sign in. You do not need to shut it down at the end -) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 684.1 Tm (leave it on.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 656.2 Tm (The bar along the bottom) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 635.5 Tm (Everything you need is on the bar at the bottom of the screen.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 615.6 Tm (The buttons in the middle are the things you can open. Which ones are there depends on what your) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 600.7 Tm (room is set up for, so your bar may have fewer buttons than another room's.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 580.7 Tm (The right-hand end shows the network, the volume, any messages and the clock.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 560.8 Tm (To open something, click its button once. To come back to it later, click the same button again.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
17 0 obj
|
||||
<< /Length 1408 >>
|
||||
stream
|
||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (The Browser) Tj ET
|
||||
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 733.9 Tm (The Browser button opens the web: YouTube, a song, a video clip, a website a lesson sends you to.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 706.0 Tm (Using it) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 685.2 Tm (1.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 685.2 Tm (Click Browser on the bar.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 670.3 Tm (2.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 670.3 Tm (Type where you want to go in the bar at the top, or click Home to go back to YouTube.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 650.4 Tm (The room stays signed in to YouTube, so you do not have to.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 622.5 Tm (Playing a video on the television) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 601.7 Tm (Click the full-screen button in the corner of the video, the same as you would at home. Press Esc) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 586.8 Tm (to come back out of full screen.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 558.9 Tm (When you have finished) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 538.2 Tm (Leave it open, or close the window. The next person can open it again from the bar.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 518.2 Tm (Anything you sign in to yourself - a personal account, an email - sign out of before you leave.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 503.3 Tm (The computer is shared.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
18 0 obj
|
||||
<< /Length 1694 >>
|
||||
stream
|
||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Sound and volume) Tj ET
|
||||
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 733.9 Tm (Sound goes to the television when one is connected, and to the computer's own speakers when one) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 719.0 Tm (is not. You do not have to choose.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 691.0 Tm (Changing the volume) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 670.3 Tm (Use the volume keys on the keyboard, or click the speaker at the right-hand end of the bar and) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 655.4 Tm (drag the slider.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 635.5 Tm (A large volume bar appears on the screen while you are changing it, so you can see it from across) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 620.6 Tm (the room.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 592.7 Tm (If there is no sound) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 571.9 Tm (1.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 571.9 Tm (Check the volume on the television as well as on the computer. Both have to be up.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 557.0 Tm (2.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 557.0 Tm (Check the television is showing this computer, and not something else plugged into it.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 542.1 Tm (3.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 542.1 Tm (If the television was switched on after the computer, the sound may still be going to the) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 527.2 Tm (computer's own speakers. Turn the volume down and back up once; that is enough to move it) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 512.3 Tm (across.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 492.4 Tm (If none of that works, tell the office. Do not unplug anything.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
19 0 obj
|
||||
<< /Length 4084 >>
|
||||
stream
|
||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Presentations) Tj ET
|
||||
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
|
||||
BT /F2 10.5 Tf 1 0 0 1 62.0 733.9 Tm (If your computer has a Presentations button on the bar.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 713.0 Tm (Presentations holds the slides for your sessions. You download a presentation in the browser) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 698.0 Tm (once, and from then on it is on this screen, ready to show on the television.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 670.1 Tm (Getting a presentation onto the computer) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 649.4 Tm (1.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 649.4 Tm (Click Browser and go to wherever the presentation is - your email, a shared folder,) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 634.5 Tm (wherever it was sent to you.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 619.6 Tm (2.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 619.6 Tm (Download it the way you normally would.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 604.7 Tm (3.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 604.7 Tm (Wait until the download has finished.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 584.7 Tm (That is all. It appears in Presentations by itself a few seconds later. You do not have to save) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 569.8 Tm (it anywhere special, move it, or open it.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 549.9 Tm (PowerPoint files and PDF files both work.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 530.0 Tm (Your download stays in the browser exactly as it was. Nothing here changes it, moves it or) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 515.1 Tm (deletes it.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 487.2 Tm (Opening the library) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 466.4 Tm (Click Presentations on the bar.) Tj ET
|
||||
BT /F1 9.5 Tf 1 0 0 1 62.0 446.5 Tm ([ Picture: The Presentations library, with a card for each session - see the computer's own Help ]) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 428.0 Tm (Each session is a card with its name and a picture of one of its slides. Find tonight's by looking) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 413.1 Tm (at the picture.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 393.2 Tm (If a session has only just been downloaded, its card says it is being got ready. It will be ready) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 378.3 Tm (in a moment.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 350.4 Tm (Quick Look - checking the slides first) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 329.7 Tm (Quick Look lets you read through a session before you show it to anybody.) Tj ET
|
||||
BT /F1 9.5 Tf 1 0 0 1 62.0 309.7 Tm ([ Picture: Quick Look, showing one slide with Next and Previous underneath - see the computer's own Help ]) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 291.3 Tm (-) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 291.3 Tm (Next and Previous move through the slides.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 276.3 Tm (-) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 276.3 Tm (Back to Presentations returns to the library.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 261.4 Tm (-) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 261.4 Tm (Present starts the slideshow straight away.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 241.5 Tm (Looking through a session in Quick Look does not count as having presented it.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 213.6 Tm (Changing the picture on the card) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 192.9 Tm (The first slide of a session is often the same opening slide as every other session, which makes) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 178.0 Tm (the cards hard to tell apart. Use the slide that names the session instead - usually the second) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 163.0 Tm (one.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 143.1 Tm (1.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 143.1 Tm (Click Quick Look.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 128.2 Tm (2.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 128.2 Tm (Press Next until you reach the slide you want.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 113.3 Tm (3.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 113.3 Tm (Click Use this slide as the picture.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 93.4 Tm (The card changes straight away, and stays that way - after you close the library, and after the) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
20 0 obj
|
||||
<< /Length 3089 >>
|
||||
stream
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 780.0 Tm (computer is switched off and on again.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 760.1 Tm (Choosing the picture does not change where the slideshow starts. It always starts at the first) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 745.2 Tm (slide.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 717.3 Tm (Showing a presentation) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 696.5 Tm (Click Present, on the card or inside Quick Look.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 676.6 Tm (The slides fill the whole screen. Nothing else is on it.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 656.7 Tm ( ) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 656.7 Tm (TO DO THIS PRESS) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 641.8 Tm ( ) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 641.8 Tm (Next slide Space, the right arrow, the down arrow or Page Down) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 626.9 Tm ( ) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 626.9 Tm (Previous slide The left arrow, the up arrow or Page Up) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 612.0 Tm ( ) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 612.0 Tm (First slide Home) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 597.1 Tm ( ) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 597.1 Tm (Last slide End) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 582.2 Tm ( ) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 582.2 Tm (Finish Esc) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 562.2 Tm (A presentation remote works too - the forward and back buttons on the clicker are the same as) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 547.3 Tm (Page Down and Page Up.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 519.4 Tm (Finishing) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 498.7 Tm (Press Esc.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 478.8 Tm (You come straight back to the library. Going past the last slide does the same thing.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 450.9 Tm (Removing a presentation) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 430.1 Tm (Click Remove on its card, and confirm.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 410.2 Tm (That takes it off this screen. Your download in the browser is not touched, so you can always get) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 395.3 Tm (it back by downloading it again.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 367.4 Tm (Presentations you no longer need) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 346.6 Tm (The computer tidies up after itself, so the library does not slowly fill up with old sessions.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 326.7 Tm (A session you have shown is kept for a month after the last time you showed it. Showing it again) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 311.8 Tm (starts that month over.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 291.9 Tm (A session you downloaded but never showed is kept for six months.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 272.0 Tm (You do not have to do anything about this, and nothing disappears while you are using it.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 244.1 Tm (If a presentation will not open) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 223.3 Tm (Its card says so, and offers only Remove. Usually the file did not download properly.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 203.4 Tm (Download it again. Your original download is still there either way.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
21 0 obj
|
||||
<< /Length 4396 >>
|
||||
stream
|
||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Wonder Ink) Tj ET
|
||||
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
|
||||
BT /F2 10.5 Tf 1 0 0 1 62.0 733.9 Tm (If Wonder Ink is available in your classroom.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 713.0 Tm (Wonder Ink opens the lesson. It is its own application, not the browser, and it opens straight) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 698.0 Tm (into the lesson site.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 670.1 Tm (Using it) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 649.4 Tm (1.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 649.4 Tm (Click Wonder Ink on the bar.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 634.5 Tm (2.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 634.5 Tm (Sign in with your Wonder Ink account.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 614.6 Tm (A lesson often opens several tabs - the lesson, its video, an activity. They appear in a row) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 599.7 Tm (across the top. Click a tab to move between them, and the x on a tab to close it.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 579.7 Tm (There is no address bar, on purpose. Wonder Ink can take you anywhere the lesson needs to go.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 551.8 Tm (Making the lesson bigger or smaller) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 531.1 Tm (Press Ctrl and + to make the lesson bigger, Ctrl and ? to make it smaller, and Ctrl and 0 to go) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 516.2 Tm (back to normal. You can also right-click the lesson and choose Make the lesson bigger. When) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 501.3 Tm (several tabs are open, the ? 100% + buttons at the top right do the same.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 481.4 Tm (The size applies to every tab and is remembered next time.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 453.4 Tm (Printing) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 432.7 Tm (Press Ctrl and P, click Print at the top right, or right-click and choose Print page?. You will) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 417.8 Tm (see the pages exactly as they will print.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 397.9 Tm (-) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 397.9 Tm (Choose Only some pages to print, for example, `2` or `1-3`.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 383.0 Tm (-) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 383.0 Tm (Set Copies for the number of children.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 368.1 Tm (-) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 368.1 Tm (Choose Black and white to save color ink. The preview turns grey, so you see what will come) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 353.1 Tm (out; choose Color to go back.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 333.2 Tm (Printing a picture from the lesson. Right-click the picture and choose Print image?. Only that) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 318.3 Tm (picture prints, not the page around it. This works for pictures you cannot open by themselves.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 298.4 Tm (In the preview for a picture, also under Picture: choose its Size \(for example 50%\), click one of) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 283.5 Tm (the nine Position squares to put it in a corner or the middle, and choose Paper upright or) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 268.6 Tm (sideways. The preview changes as you choose, and shows exactly what will print. Fill the whole) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 253.7 Tm (page makes it cover the paper but cuts off the edges. A picture that is open in a tab by itself) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 238.8 Tm (prints the same way.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 218.9 Tm (If Wonder Ink says a picture *could not be taken from the page*, it had not finished loading:) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 203.9 Tm (close the preview and try again, or choose Print page? to print the whole page instead.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 184.0 Tm (Click Print. Wonder Ink says when the printer has it and goes back to the lesson. Cancel prints) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 169.1 Tm (nothing.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 141.2 Tm (Worksheets and colouring pages) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 120.5 Tm (Click the worksheet link in the lesson. It opens in a new tab, ready to read or print. Any other) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 105.6 Tm (link in a lesson - a slide, a picture, a video - also opens in a new tab with one click, and Wonder) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 90.7 Tm (Ink shows it straight away; the lesson stays open in its own tab. A worksheet's own ? and + at the) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
22 0 obj
|
||||
<< /Length 1446 >>
|
||||
stream
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 780.0 Tm (top of the page make it bigger or smaller.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 760.1 Tm (Worksheets you download are kept separately from everything else on the computer, so they never) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 745.2 Tm (get mixed up with anything.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 717.3 Tm (If Wonder Ink closes by accident) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 696.5 Tm (Open it again. If you used it in the last two hours, your recent tabs come back - the slides,) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 681.6 Tm (pictures and worksheets you had open, in the same order. Wonder Ink's own lesson tab comes back at) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 666.7 Tm (the sign-in page: sign in again and open your lesson.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 646.8 Tm (Your password is never kept. After two hours that nobody has used Wonder Ink, it forgets the) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 631.9 Tm (tabs and the next person starts with a fresh Wonder Ink.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 604.0 Tm (When you have finished) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 583.2 Tm (Close Wonder Ink at the end of your session. Closing it signs you out, so the next teacher in) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 568.3 Tm (this room starts with their own account and not yours. Your recent tabs are kept for two hours in) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 553.4 Tm (case you need them again, but never your sign-in.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
23 0 obj
|
||||
<< /Length 1475 >>
|
||||
stream
|
||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Parent Call) Tj ET
|
||||
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
|
||||
BT /F2 10.5 Tf 1 0 0 1 62.0 733.9 Tm (If your computer has a Parent Call button in the bottom-left corner.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 713.0 Tm (Parent Call asks the sanctuary to fetch a child's grown-up, without you leaving the room.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 685.0 Tm (Using it) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 664.3 Tm (1.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 664.3 Tm (Click Parent Call, in the bottom-left corner of the screen.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 649.4 Tm (2.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 649.4 Tm (Type the child's name.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 634.5 Tm (3.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 634.5 Tm (Click Send.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 614.6 Tm (The card tells you what is happening. Sent to sanctuary means somebody there has it.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 586.7 Tm (What it does and does not promise) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 565.9 Tm (Sent to sanctuary means the message arrived. It does not mean it has been read out yet - somebody) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 551.0 Tm (there decides when.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 531.1 Tm (If it cannot reach the sanctuary it says so plainly. It will never tell you a call went through) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 516.2 Tm (when it did not.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 496.3 Tm (If that happens, send somebody.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
24 0 obj
|
||||
<< /Length 1500 >>
|
||||
stream
|
||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Playing a DVD) Tj ET
|
||||
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
|
||||
BT /F2 10.5 Tf 1 0 0 1 62.0 733.9 Tm (If your computer has a DVD button and a disc drive.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 705.0 Tm (Playing a disc) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 684.2 Tm (1.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 684.2 Tm (Put the disc in the drive.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 669.3 Tm (2.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 669.3 Tm (Wait for DVD Ready to appear.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 654.4 Tm (3.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 654.4 Tm (Click DVD on the bar.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 634.5 Tm (The disc fills the screen and plays on the television. Discs never start on their own - it always) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 619.6 Tm (takes a button press.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 591.7 Tm (While it is playing) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 570.9 Tm (Move the mouse to the very bottom of the screen and a row of controls slides up: previous chapter,) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 556.0 Tm (play and pause, next chapter, the disc menu, eject, and close.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 536.1 Tm (Space also plays and pauses, and the arrow keys move around the disc menu.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 508.2 Tm (Finishing) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 487.4 Tm (Click X on that row to stop, or eject to stop and eject the disc.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 467.5 Tm (Take the disc with you.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
25 0 obj
|
||||
<< /Length 1166 >>
|
||||
stream
|
||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (Printing) Tj ET
|
||||
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 733.9 Tm (Printing goes to the church printer. There is nothing to choose and nothing to set up.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 706.0 Tm (Printing something) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 685.2 Tm (1.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 685.2 Tm (Press Ctrl+P, or use the Print button on the page you are looking at.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 670.3 Tm (2.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 670.3 Tm (Check the number of copies.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 78.0 655.4 Tm (3.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 94.0 655.4 Tm (Click Print.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 627.5 Tm (If nothing comes out) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 606.7 Tm (The printer may be off, out of paper, or somebody else's job may be ahead of yours. Your job waits) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 591.8 Tm (in the queue and prints when the printer is ready again - you do not need to send it twice.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 571.9 Tm (Sending it again two or three times is how ten copies of the same worksheet appear later.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
26 0 obj
|
||||
<< /Length 1766 >>
|
||||
stream
|
||||
BT /F2 17 Tf 1 0 0 1 62.0 774.0 Tm (If something goes wrong) Tj ET
|
||||
0.75 w 0.6 0.6 0.6 RG 62 751.9 m 533 751.9 l S
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 733.9 Tm (Almost nothing here needs a technical person, and nothing you can do from the keyboard will) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 719.0 Tm (break the computer.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 691.0 Tm (The screen is black) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 670.3 Tm (Move the mouse or press a key. If it stays black, check the television is on and showing this) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 655.4 Tm (computer.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 627.5 Tm (A window will not close) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 606.7 Tm (Click its button on the bar to bring it back, and close it normally.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 578.8 Tm (The bar has gone) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 558.1 Tm (Wait a few seconds; it puts itself back.) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 538.2 Tm (If it does not, switch the computer off at the power button, wait ten seconds, and switch it on) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 523.2 Tm (again. It comes back to the same screen it always does.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 495.3 Tm (Something asks for a password) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 474.6 Tm (Nothing a teacher needs to do asks for a password. If a password box appears, you have reached) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 459.7 Tm (something you did not mean to - press Esc or click Cancel, and carry on.) Tj ET
|
||||
BT /F2 12.5 Tf 1 0 0 1 62.0 431.8 Tm (It is still wrong) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 411.0 Tm (Tell the office, and say which room and what you were doing when it happened. That second part is) Tj ET
|
||||
BT /F1 10.5 Tf 1 0 0 1 62.0 396.1 Tm (the one that saves everybody time.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
27 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>
|
||||
endobj
|
||||
28 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>
|
||||
endobj
|
||||
xref
|
||||
0 29
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000187 00000 n
|
||||
0000000326 00000 n
|
||||
0000000465 00000 n
|
||||
0000000604 00000 n
|
||||
0000000743 00000 n
|
||||
0000000882 00000 n
|
||||
0000001021 00000 n
|
||||
0000001160 00000 n
|
||||
0000001300 00000 n
|
||||
0000001440 00000 n
|
||||
0000001580 00000 n
|
||||
0000001720 00000 n
|
||||
0000001860 00000 n
|
||||
0000002677 00000 n
|
||||
0000003980 00000 n
|
||||
0000005441 00000 n
|
||||
0000007188 00000 n
|
||||
0000011325 00000 n
|
||||
0000014467 00000 n
|
||||
0000018916 00000 n
|
||||
0000020415 00000 n
|
||||
0000021943 00000 n
|
||||
0000023496 00000 n
|
||||
0000024715 00000 n
|
||||
0000026534 00000 n
|
||||
0000026632 00000 n
|
||||
trailer
|
||||
<< /Size 29 /Root 1 0 R >>
|
||||
startxref
|
||||
26735
|
||||
%%EOF
|
||||
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 67 KiB |
@@ -154,6 +154,74 @@ that would be a one-way door.
|
||||
|
||||
The original is kept at `/etc/network/interfaces.before-ekklesia-classroom`.
|
||||
|
||||
### What 0.2.5 got wrong, and what 0.2.6 changed
|
||||
|
||||
The prayer-room laptop's Wi-Fi is **hidden** — `ROGC Classroom`, an SSID
|
||||
with a space in it, announced by nothing. `--adopt` built a connection,
|
||||
failed to bring it up, rolled back, and left the adapter belonging to
|
||||
nobody. Four separate defects, all fixed:
|
||||
|
||||
**1. The hidden flag was thrown away.** The stanza said `wpa-scan-ssid 1`
|
||||
and the parser read it — into `options`, where the only accessor ignored
|
||||
it. The generated profile had `802-11-wireless.hidden: no`, and
|
||||
NetworkManager will not associate with an SSID it has not seen in a scan.
|
||||
A hidden one is never in a scan. `credentials()` returned a pair; there
|
||||
is now a `profile()` that returns the whole network, and `add()` writes
|
||||
`802-11-wireless.hidden` in **both** directions rather than only when
|
||||
true — a default that happens to be right is not an answer.
|
||||
|
||||
**2. `ifdown` ran after the stanza was commented out.** ifupdown reads
|
||||
`/etc/network/interfaces` to find out how to bring an interface down.
|
||||
With the stanza gone it says *"Ignoring unknown interface"* and does
|
||||
nothing — so ifupdown's own `wpa_supplicant` and DHCP client were still
|
||||
holding the radio when NetworkManager was asked to associate with it.
|
||||
Two supplicants on one interface is a fight NetworkManager loses. The
|
||||
order is now: **`ifdown` first, while the file still says how; rewrite
|
||||
the file second.**
|
||||
|
||||
**3. The rollback left the adapter unmanaged.** `restore()` ended with
|
||||
`nmcli device set <dev> managed no`, which is right while ifupdown can
|
||||
actually bring the interface up. When `ifup` fails too, those two lines
|
||||
are a wireless adapter neither manager owns: no connection, and a panel
|
||||
that cannot even scan. That is the state the laptop was found in, and it
|
||||
is why `sudo nmcli device set wlp2s0 managed yes` was a manual recovery
|
||||
step. The rollback now escalates — if ifupdown cannot get the network
|
||||
back either, the interface is handed to NetworkManager rather than left
|
||||
with neither, and it says so loudly.
|
||||
|
||||
**4. A `#` in a password was silently truncated.** The parser cut every
|
||||
line at its first `#`. `interfaces(5)` is explicit that only whole-line
|
||||
comments exist — *"end-of-line comments are NOT supported"* — so a
|
||||
pre-shared key containing `#` became a prefix of itself, and the only
|
||||
symptom is an association that fails exactly the way a wrong password
|
||||
fails.
|
||||
|
||||
Two smaller ones went with them. The rollback un-comments its own marked
|
||||
lines in place instead of copying the backup over the whole file, because
|
||||
the backup is written once and never refreshed — a whole-file restore
|
||||
silently reverted any *wired* change an administrator made in between.
|
||||
And `nmcli -t` output is now split on its own escaping rules rather than
|
||||
on `:`, with `LC_ALL=C`, so a network whose name contains a colon cannot
|
||||
make a successful migration look like a failed one.
|
||||
|
||||
`--adopt` also now refuses a network it cannot recreate — WPA-Enterprise
|
||||
has no pre-shared key to carry across — *before* taking the interface
|
||||
away from ifupdown to find out, and reports any `unmanaged-devices` line
|
||||
in NetworkManager's own configuration, which survives the stanza going
|
||||
away and would otherwise leave the interface belonging to nobody after a
|
||||
restart.
|
||||
|
||||
Verified against a real NetworkManager 1.52.1: the keyfile it writes
|
||||
carries `hidden=true`, `ssid=ROGC Classroom` with its space, and a psk
|
||||
containing `#` intact, at 0600 root:root with no `connection.permissions`.
|
||||
`tests/test-classroom-wifi-adopt.sh` drives the whole hand-over against
|
||||
stubbed tools and asserts the ordering, the rollback and the managed
|
||||
state.
|
||||
|
||||
**The prayer-room laptop must not be migrated again.** Its stanza is
|
||||
already gone, so `--adopt` reports "nothing to adopt" and exits 0 without
|
||||
touching anything; there is a test for exactly that.
|
||||
|
||||
After the migration the panel shows the connection at the next login, the
|
||||
room can scan, and the credential protection below applies to it like any
|
||||
other system connection.
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
# Presentations
|
||||
|
||||
Optional per device, off by default. `PRESENTATIONS_ENABLED=true` in
|
||||
`/etc/ekklesia-classroom/device.conf` puts a **Presentations** button on
|
||||
the bar and starts the importer; nothing else in the room changes.
|
||||
|
||||
It depends on nothing else. A machine may have Presentations with no
|
||||
Wonder Ink, no drive, no Parent Call, on Wi-Fi or a cable, as a laptop or
|
||||
a tower. The Bible study laptop is the first installation.
|
||||
|
||||
## What was measured before any of this was designed
|
||||
|
||||
The brief was to use existing presentation software. So the existing
|
||||
presentation software was tried first, on Debian 13 in the Plasma lab,
|
||||
against a ten-slide deck in both PPTX and PDF. **The critical
|
||||
requirement is that a teacher who ends or cancels a slideshow lands back
|
||||
in the library and never in an editor.**
|
||||
|
||||
| Tried | What it actually did |
|
||||
| --- | --- |
|
||||
| `soffice --show deck.pptx` | Starts a real fullscreen slideshow — `app_id=libreoffice-impress`, `"Presenting: session7"`, `fullscreen=1`. But it needs `XDG_SESSION_TYPE=wayland` **and** `libreoffice-gtk3`/`-qt6`, or it dies with *"Failed to open display"*; it shows the LibreOffice splash on the way up; and a **"Tip of the Day: 1/225"** dialog opened on top of the slideshow on first run. Ending the show leaves the Impress editor. |
|
||||
| `okular --presentation deck.pdf` | Opens **three** windows: the presentation, the ordinary Okular document window behind it, and an **"Information — Okular"** dialog. The full viewer UI — File, Open, Settings — sits behind the slides. |
|
||||
| `zathura deck.pdf` | Opens windowed, not fullscreen. A document reader, not a presenter. |
|
||||
| `pdfpc deck.pdf` | Single fullscreen window, the closest of the four. With `--single-screen` it came up windowed, and in the lab it did not respond to Right, Escape or `q`. A presenter-console tool with notes and a timer, and another dependency. |
|
||||
|
||||
None of them returns a teacher to a library, because none of them knows
|
||||
there is one. Every one of them leaves an application on the screen that
|
||||
can open any file on the machine.
|
||||
|
||||
### What was chosen instead
|
||||
|
||||
**LibreOffice converts once, headless, at import. Poppler renders the
|
||||
pages. The classroom shows the pictures.**
|
||||
|
||||
```
|
||||
Session 7.pptx --soffice --headless --convert-to pdf--> slides.pdf
|
||||
slides.pdf --pdftoppm -png -scale-to-x 1920--------> page-001.png ...
|
||||
```
|
||||
|
||||
Measured on the same deck:
|
||||
|
||||
| Step | Time |
|
||||
| --- | --- |
|
||||
| PPTX → PDF, ten slides | 0.34 s |
|
||||
| All ten pages → PNG | 0.03 s |
|
||||
| One page at 1920px | 0.10 s |
|
||||
|
||||
Fonts embed correctly (`BAAAAA+LiberationSans`, subset, embedded) and the
|
||||
text survives, which is where a title comes from when the filename is a
|
||||
hash.
|
||||
|
||||
This is not a new renderer. LibreOffice does exactly the rendering
|
||||
Impress would have done live — it is the same engine — and poppler does
|
||||
what every PDF viewer does. The classroom's own code displays a PNG.
|
||||
Because playback is made from static page images, PowerPoint animations,
|
||||
video, audio and other interactive content are not supported.
|
||||
What it buys:
|
||||
|
||||
* **A teacher can never reach an editor**, because no editor ever runs
|
||||
interactively. `soffice` runs once, with no window, at import.
|
||||
* **Escape is ours.** The presenter is a window of the library's own
|
||||
process, so ending a slideshow is the library raising itself, not a
|
||||
second program being asked to exit.
|
||||
* **Presenting is instant.** The pictures already exist; there is no
|
||||
LibreOffice to start in front of a room.
|
||||
* **PDF is first class.** A session exported from Google Slides as PDF
|
||||
skips the conversion entirely and keeps exactly the appearance Google
|
||||
gave it. PPT and PPTX join that path rather than having one of their
|
||||
own.
|
||||
* **Offline by construction.** Nothing after the download touches the
|
||||
network.
|
||||
|
||||
`libreoffice-gtk3` is deliberately *not* a dependency: nothing
|
||||
interactive runs, so no UI integration package is needed.
|
||||
|
||||
## Where things are
|
||||
|
||||
```
|
||||
~/Downloads/ completed presentations move from here
|
||||
~/Downloads/Wonder Ink/ Wonder Ink's worksheets. NEVER touched.
|
||||
|
||||
~/.local/share/ekklesia-classroom/presentations/
|
||||
imported.json which downloads have been dealt with
|
||||
<id>/
|
||||
presentation.json title, pages, thumbnail slide, timers
|
||||
original/
|
||||
Session 7 - Romans.pptx the one original, name kept
|
||||
slides.pdf what everything is rendered from
|
||||
pages/page-001.png ... one per slide, 1920px wide
|
||||
thumbnail.png the card picture, from the chosen slide
|
||||
```
|
||||
|
||||
## Importing
|
||||
|
||||
`ekklesia-classroom-presentations.service` (user) runs
|
||||
`classroom-presentation-import`, which looks at `~/Downloads` every four
|
||||
seconds. Polling, not inotify: a teacher waits the same few seconds
|
||||
either way, and this cannot miss an event across a restart.
|
||||
|
||||
A file is imported when **all** of these hold:
|
||||
|
||||
* its extension is `.pdf`, `.pptx` or `.ppt`;
|
||||
* it is not inside the `Wonder Ink` directory;
|
||||
* there is no `.crdownload`, `.part`, `.partial`, `.download` or `.tmp`
|
||||
sibling — Chrome writes `Session 7.pptx.crdownload` and renames it when
|
||||
the transfer finishes, so that sibling is the authoritative "still
|
||||
arriving";
|
||||
* it has not changed for six seconds, which catches a copy from a USB
|
||||
stick that has no `.crdownload` to go by;
|
||||
* it is not empty; and
|
||||
* its fingerprint — path, size, modification time — is not already in
|
||||
`imported.json`.
|
||||
|
||||
Two downloads of the same name are two presentations in separate id
|
||||
directories. The second gets `(2)` after its title; neither overwrites the
|
||||
other.
|
||||
|
||||
**Move and crash safety.** On one filesystem the completed download is
|
||||
renamed atomically. Across filesystems it is copied to a hidden temporary,
|
||||
flushed, published atomically, and only then removed from Downloads. The
|
||||
metadata and transaction fingerprint are written first. On startup, an
|
||||
interruption before the source was removed discards the partial destination
|
||||
and retries; an interruption after the move resumes conversion from the
|
||||
managed original. A presentation becomes ready only once every derived file
|
||||
it needs is on disk.
|
||||
|
||||
A file that cannot be converted gets a card saying so, offering only
|
||||
**Remove**. Its managed original remains recoverable even when PDF or page
|
||||
generation fails.
|
||||
|
||||
## Memory (0.2.6.2)
|
||||
|
||||
On a 4 GB Plasma VM the kernel killed `ekklesia-classroom-presentations.service`
|
||||
mid-import and Plasma said *Memory Shortage Avoided*. Measured afterwards,
|
||||
LibreOffice converting one deck with the importer's own code:
|
||||
|
||||
| Deck | PPTX | LibreOffice peak | Rendering peak |
|
||||
| --- | --- | --- | --- |
|
||||
| 10 slides of text | 20 KB | 203 MB, 0.8 s | 38 MB |
|
||||
| 30 slides, 12 photos shared | 23.5 MB | 738 MB, 16 s | 70 MB |
|
||||
| 60 slides, the same 12 photos | 23.6 MB | 754 MB, 28 s | 70 MB |
|
||||
| 30 slides, 30 different 12 MP photos | 58.7 MB | **1.43 GB**, 22 s | 69 MB |
|
||||
| 20 slides, 20 different 27 MP photos | 143 MB | **2.08 GB**, 24 s | 70 MB |
|
||||
|
||||
It grows with the photographs, not the slides, and it is a spike rather
|
||||
than a leak. PDF export with images reduced to 200 dpi saved only 5%,
|
||||
because LibreOffice holds the full images regardless. So the failure was a
|
||||
photo-heavy deck meeting a 4 GB machine with the room's other programs
|
||||
open — and what made it a problem was what happened next. systemd's
|
||||
default `OOMPolicy=stop` took the whole service down with the killed
|
||||
LibreOffice; it restarted, `sweep()` resumed the same conversion with no
|
||||
limit, and the library said *Getting this ready…* throughout.
|
||||
|
||||
Now:
|
||||
|
||||
* **`MemoryMax=40%`** — 1.6 GB on a 4 GB machine, 3.2 GB on 8 GB. Measured
|
||||
in the lab at 1.6 GB, the 2.1 GB deck still imported in 33 s: much of
|
||||
that peak is file-backed and reclaimable, and on a machine with swap the
|
||||
rest is swapped rather than killed (1.4 GB of it at a 200 MB ceiling).
|
||||
* **`OOMScoreAdjust=800`** — under pressure everywhere, LibreOffice goes
|
||||
before the lesson on the screen, the browser or the panel.
|
||||
* **`OOMPolicy=continue`** — a killed LibreOffice is a failed import, not a
|
||||
dead service. Measured with no swap and a 300 MB ceiling: the kernel
|
||||
killed LibreOffice, the watcher kept its PID with no restart, and the
|
||||
import was recorded as `failed`, kind `memory`.
|
||||
* **Two attempts, then stop.** Each attempt is counted before it starts.
|
||||
Measured by killing the whole service during attempt 1 and again during
|
||||
attempt 2: it resumed once, then said "giving up … the teacher can try
|
||||
again" and did not start a third.
|
||||
* **The card says so**, and offers **Try again**: *This needed more memory
|
||||
than the computer had free. Close other programs, then choose Try
|
||||
again.* Try again was pressed in the lab and the deck became ready.
|
||||
A file that simply will not convert still says *This file could not be
|
||||
opened* and offers no Try again, because trying again will not help.
|
||||
* **Never stuck.** An import still "getting ready" 20 minutes after it
|
||||
began — longer than any attempt can take — is shown as the same Try
|
||||
again card, because nothing is working on it.
|
||||
* **One import at a time**, by an exclusive lock as well as the one loop;
|
||||
measured, three decks downloaded together converted one after another
|
||||
with never more than one LibreOffice running.
|
||||
|
||||
What the lab cannot give is a real 4 GB machine's mix of programs; the
|
||||
ceilings were emulated per service.
|
||||
|
||||
## The three things kept apart
|
||||
|
||||
This is the part most worth not breaking.
|
||||
|
||||
| | Changed by | Never changed by |
|
||||
| --- | --- | --- |
|
||||
| **Library cover** | *Use as cover* in Quick Look | presenting, importing |
|
||||
| **Starting slide** | nothing — always slide 1 | choosing a thumbnail |
|
||||
| **Retention** | a slide actually reaching the screen | Quick Look, opening the library, choosing a thumbnail, preview generation, a viewer that failed to start |
|
||||
|
||||
The first slide of a Bible study session is a generic opening shared by
|
||||
every session; the one that names the session is usually the second. So
|
||||
the thumbnail defaults to slide 1 and Quick Look exists to change it, and
|
||||
changing it must not be mistaken for having presented anything.
|
||||
|
||||
`Presenter.show_page` calls `mark_presented()` at the moment the first
|
||||
slide is drawn, and not before. Pressing **Present** is an intention; a
|
||||
picture on the screen is a presentation.
|
||||
|
||||
## Retention
|
||||
|
||||
| State | Kept |
|
||||
| --- | --- |
|
||||
| Never presented | 180 days from import |
|
||||
| Presented | 30 days from the **last** presentation |
|
||||
|
||||
Every presentation restarts the thirty days. No teacher-facing setting.
|
||||
|
||||
The cleanup runs hourly in the importer and:
|
||||
|
||||
* never removes a presentation whose id is in
|
||||
`$XDG_RUNTIME_DIR/ekklesia-classroom-presenting` — the lock the
|
||||
presenter holds while a slide is on the screen. In the runtime
|
||||
directory so a crash cannot make a presentation permanently
|
||||
un-removable;
|
||||
* only ever removes a directory under the library root;
|
||||
* removes the managed original and all derived assets together;
|
||||
* never touches unrelated files in `~/Downloads`, and therefore never a
|
||||
Wonder Ink worksheet;
|
||||
* tolerates a directory that is already half gone.
|
||||
|
||||
## The presenter
|
||||
|
||||
A fullscreen `Gtk.Window` in the library's own process showing one
|
||||
pre-rendered page at a time, letterboxed — never cropped, never
|
||||
stretched.
|
||||
|
||||
| Key | Does |
|
||||
| --- | --- |
|
||||
| Space, →, ↓, Page Down, Return, `n` | Next slide |
|
||||
| ←, ↑, Page Up, Backspace, `p` | Previous slide |
|
||||
| Home / End | First / last slide |
|
||||
| Escape, `q`, F5 | Finish, back to the library |
|
||||
|
||||
Page Up and Page Down are what the clicker in the cupboard sends.
|
||||
|
||||
Going past the last slide finishes, rather than showing a black "click to
|
||||
exit" slide.
|
||||
|
||||
**Fullscreen is re-asserted, not assumed.** The presenter requests
|
||||
fullscreen after its Wayland surface is mapped, and its stable `Presenting`
|
||||
title lets a presenter-only KWin rule apply fullscreen and remove the border
|
||||
without affecting the library or Quick Look. `window-state-event` reasserts
|
||||
the state for as long as the presentation is running and stops the moment
|
||||
Escape is pressed. The library is hidden during playback and restored on
|
||||
exit; the Plasma panel itself is never hidden or reconfigured.
|
||||
|
||||
## Fonts
|
||||
|
||||
The pastor's material comes from Google Slides. These are all in Debian
|
||||
main and install without a question:
|
||||
|
||||
| Package | Covers |
|
||||
| --- | --- |
|
||||
| `fonts-liberation2` | Arial, Times New Roman, Courier New — metric compatible |
|
||||
| `fonts-crosextra-carlito` | Calibri |
|
||||
| `fonts-crosextra-caladea` | Cambria |
|
||||
| `fonts-croscore` | the same three under their Chrome OS names |
|
||||
| `fonts-liberation-sans-narrow` | Arial Narrow |
|
||||
| `fonts-roboto`, `fonts-open-sans`, `fonts-lato`, `fonts-montserrat` | the web fonts Google Slides offers |
|
||||
| `fonts-urw-base35` | the PostScript standard 35 |
|
||||
|
||||
`ttf-mscorefonts-installer` is deliberately **absent**. It is in contrib,
|
||||
it downloads from a third party at install time, and it shows an EULA
|
||||
prompt — which an unattended classroom upgrade cannot answer.
|
||||
|
||||
## Checking a machine
|
||||
|
||||
```
|
||||
classroom-presentation-import --status what the library holds
|
||||
classroom-presentation-import --once one pass, then stop
|
||||
classroom-presentation-import --cleanup apply retention now
|
||||
classroom-manual --features which manual pages this device shows
|
||||
```
|
||||
|
||||
`--status` prints titles, slide counts, the chosen thumbnail slide, both
|
||||
timers and the expiry date. It is the fastest way to answer "why has this
|
||||
gone" and "why has this not appeared".
|
||||
|
||||
## Later
|
||||
|
||||
The library's next round — a maximisable window, a tile grid, click to advance, a hidden pointer — is in [backlog.md](backlog.md).
|
||||
@@ -71,6 +71,50 @@ The boot provisioning command never upgrades or reconfigures anything already in
|
||||
never touches a repository it did not add. Classroom software updates
|
||||
are the OTA channel's job, not this.
|
||||
|
||||
## When a classroom updates
|
||||
|
||||
**Every day at 03:00**, local time, spread over half an hour so the rooms
|
||||
do not all ask at once: `ekklesia-classroom-update.timer`,
|
||||
`OnCalendar=*-*-* 03:00:00`, `RandomizedDelaySec=30m`. It has been so
|
||||
since 0.1.5; the lab's installed stable machine logged its checks at 03:01
|
||||
on 26 September and 03:04 on 27 September.
|
||||
|
||||
**A computer that was switched off at 03:00** checks once, soon after it
|
||||
is next switched on (`Persistent=true`). Measured on the lab's systemd
|
||||
with the machine really stopped across the time: the missed check ran 50 s
|
||||
after it started, once, and not again. Nothing else starts the check —
|
||||
no boot trigger, no repeat — so it cannot loop.
|
||||
|
||||
Each check follows only the device's own `CHANNEL` (a stable room stays
|
||||
on stable), installs only a newer version, and only a package whose
|
||||
SHA-256, name and version are what the manifest says.
|
||||
|
||||
**The reboot after an update waits for nobody to be using the computer**
|
||||
(since 0.2.6.5). It used to happen the moment the update was installed:
|
||||
harmless at 03:00, and a room restarting itself mid-morning after a night
|
||||
switched off. Now `classroom-reboot-when-idle` reboots once no keyboard,
|
||||
mouse, touchpad or touchscreen has been touched for 30 minutes *and* no
|
||||
sound has played for as long — a video to the room is not an idle
|
||||
computer. At 03:00 that is about 03:30. Switched off before then, nothing
|
||||
is lost: the update is installed and the next start is on it. Until the
|
||||
reboot the session carries on as it was; the only other thing an update
|
||||
starts at once, provisioning, does nothing on a machine that is already
|
||||
set up.
|
||||
|
||||
Input is read from the kernel's input devices, whatever the desktop is
|
||||
doing; sound from the kernel's own playback state (`/proc/asound`). Both
|
||||
measured in the lab — a mouse made with `/dev/uinput` kept it from
|
||||
rebooting, and leaving it alone let it — except the sound card, which the
|
||||
lab does not have: that is a check on real hardware.
|
||||
|
||||
The schedule is a file the package installs, not a configuration file,
|
||||
so every upgrade brings the packaged schedule. The release gate checks it
|
||||
after a fresh install and after each upgrade.
|
||||
|
||||
systemctl list-timers ekklesia-classroom-update.timer # next and last check
|
||||
journalctl -u ekklesia-classroom-update # what each check did
|
||||
journalctl -u ekklesia-classroom-reboot-when-idle # a reboot that is waiting
|
||||
|
||||
## Permanent download addresses
|
||||
|
||||
```
|
||||
@@ -110,3 +154,100 @@ curl -fsSL -o c.deb https://updates.riverofglorymn.org/canary/latest.deb
|
||||
sha256sum c.deb
|
||||
curl -fsS https://updates.riverofglorymn.org/canary/manifest.json
|
||||
```
|
||||
|
||||
## Version numbers and releasing
|
||||
|
||||
```
|
||||
0.2.6 a feature release
|
||||
0.2.6.1 the first maintenance release of 0.2.6
|
||||
0.2.6.2 the second
|
||||
0.2.7 the next feature release
|
||||
0.3.0 a larger milestone
|
||||
```
|
||||
|
||||
Three parts for a feature release, four for a maintenance release of one.
|
||||
The leading zero stays until Classroom is feature-complete; 1.0.0 belongs
|
||||
to the wider Ekklesia platform's milestone. Nothing else is a version: no
|
||||
leading zeros (dpkg reads `0.2.06` as `0.2.6`), no `0.2.6.0`, no suffixes.
|
||||
The build, both publish scripts and every classroom's updater refuse
|
||||
anything else, from one pattern held to one list of cases by
|
||||
`tests/test-release-versions.sh`.
|
||||
|
||||
Which of two versions is newer is always `dpkg --compare-versions`, never
|
||||
our own arithmetic, because dpkg is what installs them:
|
||||
`0.2.2 < 0.2.6 < 0.2.6.1 < 0.2.6.2 < 0.2.6.10 < 0.2.7`.
|
||||
|
||||
### A published version is never used again
|
||||
|
||||
0.2.6 was rebuilt with later fixes and uploaded a second time under the
|
||||
same number, replacing the first upload. A room that had installed the
|
||||
first one could never be offered the second: the updater only installs a
|
||||
version newer than its own. So now:
|
||||
|
||||
* `update_canary.sh VERSION` refuses a version that has ever been
|
||||
published — on the server or in `repo/packages` — and one that is not
|
||||
newer than every package ever published. It checks before building and
|
||||
again after the Trixie gate.
|
||||
* Packages are uploaded with `rsync --ignore-existing`, and the checksum on
|
||||
the server is compared with the one built before anything points at it.
|
||||
* A manifest is only written once the server holds exactly the package
|
||||
and checksum it names.
|
||||
* After publishing, the channel is read back over HTTPS the way a classroom
|
||||
reads it: manifest, versioned package, `latest.deb` and the version dpkg
|
||||
reads out of the file must all agree.
|
||||
|
||||
A publish that fails after the local copy into `repo/packages` has spent
|
||||
that number. Use the next one.
|
||||
|
||||
### Canary (Preview)
|
||||
|
||||
Since 0.2.6.6 releases normally go through Gitea: built once, recorded as
|
||||
a Gitea release, published and promoted by workflows that call these same
|
||||
scripts. [releasing.md](releasing.md) has the whole path. By hand:
|
||||
|
||||
```
|
||||
./update_canary.sh 0.2.6.7 dist/ekklesia-classroom_0.2.6.7_amd64.deb # gate and publish THAT file
|
||||
./update_canary.sh 0.2.7 # build, gate and publish
|
||||
```
|
||||
|
||||
The version is always given. With four parts there are two possible next
|
||||
numbers, and which one a release is depends on what is in it. Run with no
|
||||
argument it prints the current canary and both candidates, and builds
|
||||
nothing.
|
||||
|
||||
Given a package, that file is what is gated and published; nothing is
|
||||
rebuilt, because a second build is a second, untested file. The gate is
|
||||
`tools/release-gate.sh`: a fresh install, and the upgrade from whatever
|
||||
canary and stable serve, each on a room of that channel;
|
||||
`RELEASE_GATE_FROM=0.2.2` adds an older release rooms are still on.
|
||||
|
||||
### Stable
|
||||
|
||||
```
|
||||
./update_stable.sh 0.2.6.6 <sha256>
|
||||
```
|
||||
|
||||
Only with the owner's written approval for that release. Stable is never
|
||||
given a package of its own: it is pointed at one canary already published,
|
||||
byte for byte, and refuses a version that is not newer than stable's
|
||||
current one or whose package the server does not hold with the same
|
||||
checksum. With the sha256 — always give it — it refuses any file but the
|
||||
one that was approved.
|
||||
|
||||
### Checking a channel without changing it
|
||||
|
||||
```
|
||||
ROOT=$PWD bash -c 'source tools/release-lib.sh; release_verify_channel stable; release_verify_channel canary'
|
||||
```
|
||||
|
||||
### Rehearsing an upgrade
|
||||
|
||||
`tools/validate-trixie-release.sh NEW.deb [PREVIOUS.deb]` installs into a
|
||||
clean Debian 13 container. `VALIDATION_CHANNEL=stable` rehearses a stable
|
||||
room, for example the desktops' real path:
|
||||
|
||||
```
|
||||
VALIDATION_CHANNEL=stable tools/validate-trixie-release.sh \
|
||||
dist/ekklesia-classroom_0.2.6.1_amd64.deb \
|
||||
repo/packages/ekklesia-classroom_0.2.2_amd64.deb
|
||||
```
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
# Releasing Ekklesia Classroom
|
||||
|
||||
How a change becomes a package, how that package reaches a classroom, and
|
||||
who decides each step. Written so it can be followed months from now by
|
||||
somebody who was not there when it was set up.
|
||||
|
||||
The short version: **Gitea is the record, updates.riverofglorymn.org is
|
||||
the delivery, and a person makes every decision that reaches a room.**
|
||||
|
||||
```
|
||||
pull request ──► CI (checks, package, container gate) automatic, no secrets
|
||||
│
|
||||
merge to master or release/X.Y
|
||||
│
|
||||
Release 1 - build ──► Gitea pre-release vX.Y.Z: .deb, SHA256SUMS, a person runs it
|
||||
│ build-info.json, gate report, notes
|
||||
Release 2 - Preview ──► the same file on canary; read back; recorded a person runs it
|
||||
│
|
||||
physical checks on real hardware (docs/testing.md) a person does them
|
||||
│
|
||||
Release 3 - Stable ──► the same file on stable; read back; recorded a person runs it
|
||||
```
|
||||
|
||||
Nothing is rebuilt after step 1. The file the gate passed is the file the
|
||||
laptop installs and the file the desktops install, byte for byte, and its
|
||||
SHA-256 is written down at every step.
|
||||
|
||||
## The tracks
|
||||
|
||||
| Track | What it is | Where classrooms get it | Who is on it |
|
||||
| --- | --- | --- | --- |
|
||||
| **Stable** | Approved for production: passed the gate, ran on Preview, and passed the physical checks on real hardware | `https://updates.riverofglorymn.org/stable/` | The three classroom desktops |
|
||||
| **Preview** | A finished release candidate, frozen, waiting for its physical checks | `https://updates.riverofglorymn.org/canary/` — Preview **is** the canary channel | The prayer-room laptop |
|
||||
| **Nightly** | Development builds of the next version, for lab machines | not yet — see [Nightly](#nightly-designed-not-yet-active) | Nobody yet; never a classroom |
|
||||
|
||||
"Preview" is a new name for an old channel. The URL, the manifest and the
|
||||
laptop's `CHANNEL=canary` are unchanged: renaming a production path would
|
||||
strand the one machine on it for no gain. The scripts still say canary.
|
||||
|
||||
A Gitea release is a **pre-release** while its version is only on Preview
|
||||
and becomes a full release when it is promoted to Stable.
|
||||
|
||||
## Versions
|
||||
|
||||
As in `tools/release-lib.sh`: `0.2.6` is a feature release, `0.2.6.1` its
|
||||
first maintenance release, `0.2.7` the next feature release. Three or four
|
||||
numbers, no leading zeros, and ordering is always `dpkg --compare-versions`.
|
||||
|
||||
**A version is used once.** Once any file of that name has been on the
|
||||
update server it is never rebuilt or replaced — 0.2.6 was, and no room that
|
||||
had the first one could ever be offered the second. The release tag is
|
||||
`v` + the version (`v0.2.6.6`) and is never moved.
|
||||
|
||||
## Branches
|
||||
|
||||
| Branch | What it holds | Who merges |
|
||||
| --- | --- | --- |
|
||||
| `master` | The integration branch: the next version's development (0.3.0 now). Every change arrives by pull request with CI green | Anyone with write access, after review |
|
||||
| `release/X.Y` | A maintenance line (`release/0.2`): fixes only, each one a pull request, each release built from here. Created when a release has to be maintained while `master` moves on — which is now | Same |
|
||||
| short-lived branches | One change each (`fix/…`, `feat/…`, `docs/…`), deleted after merge | — |
|
||||
| `experiment/*` | Work that may never ship. Not built as releases | — |
|
||||
|
||||
Why this and not more: the repository has always been one line of work on
|
||||
`master`, and what it lacked was only a place for 0.2.x to wait while 0.3.0
|
||||
starts. One maintenance branch per line, created when it is needed, does
|
||||
that. There is no `develop` branch, because `master` already is one, and no
|
||||
branch per channel, because a channel is a manifest pointing at a file, not
|
||||
a line of code.
|
||||
|
||||
**Releases are tags, not branches.** `v0.2.6.6` names the exact commit the
|
||||
package was built from. Stable and Preview are not branches at all.
|
||||
|
||||
**Fixes flow forward.** A fix made on `release/0.2` is merged into `master`
|
||||
afterwards (`git merge release/0.2`, as a pull request), never cherry-picked
|
||||
backwards from `master`. A merge keeps both lines' histories honest and
|
||||
makes it impossible to forget a fix in the newer line. If `master` has moved
|
||||
too far for a clean merge, the fix is re-done on `master` in its own pull
|
||||
request that says so.
|
||||
|
||||
**A frozen candidate.** 0.2.6.6 is tagged `v0.2.6.6` on `release/0.2`. While
|
||||
it waits for its physical checks, 0.3.0 work lands on `master` and cannot
|
||||
touch it. If the checks fail, the fix goes to `release/0.2`, becomes 0.2.6.7,
|
||||
and is merged forward.
|
||||
|
||||
### Protecting the branches (Gitea settings)
|
||||
|
||||
For `master` and `release/*`: no force push, no deletion, and merging only
|
||||
from a pull request whose **CI / checks** and **CI / package and container
|
||||
gate** statuses are green. See [Gitea administration](#gitea-administration-still-to-do).
|
||||
|
||||
## Pull requests and commits: CI
|
||||
|
||||
`.gitea/workflows/ci.yml`, on every push to any branch and every pull
|
||||
request, on the `ekklesia-ci` runner, with **no secrets**:
|
||||
|
||||
1. **checks** — `tools/check.sh --base <where the change started>`:
|
||||
- every script's syntax by its `#!` line, every Python module, every JSON
|
||||
policy and wallpaper metadata file;
|
||||
- the change's hygiene: `git diff --check`, no `.deb`, `dist/`, `repo/` or
|
||||
`packaging/root/` committed, and `VERSION` a valid release version;
|
||||
- the teacher's manual, rebuilt and checked against itself;
|
||||
- every `tests/test-*.sh`: configuration, migration, desktop layout and
|
||||
wallpaper convergence, printing (no paper: the queue, the options, the
|
||||
picture pipeline), Wonder Ink workspace, Presentations, audio, network
|
||||
and Wi-Fi adoption, the updater and its schedule, release versions and
|
||||
the release tooling, deployment support, and the Gitea release record.
|
||||
The log of each is kept as the `check-logs` artifact, with a
|
||||
`summary.md` table.
|
||||
2. **package and container gate** — builds the package the commit's
|
||||
`VERSION` describes, proves it says that version, and installs it on a
|
||||
clean Debian 13 container with `tools/release-gate.sh --ci`: fresh, and as
|
||||
an upgrade from whatever canary and stable serve. Between releases
|
||||
`master`'s `VERSION` equals what canary already serves, so an upgrade
|
||||
that is not possible is recorded as **SKIP**, with the reason, not
|
||||
failed. The package contents and the gate report are kept; **the package
|
||||
itself is not**, because it is not a release and must never be mistaken
|
||||
for one.
|
||||
|
||||
A test added to `tests/` runs in both jobs, and in every release build, with
|
||||
nothing else to edit: `tools/build-release.sh` runs the same
|
||||
`tools/check.sh` before it builds.
|
||||
|
||||
What CI does not do: the GUI lab (`tools/lab/`), which needs the nested
|
||||
Plasma session on the development VM, and anything on real hardware. Those
|
||||
are run by hand before a release, and their results written into the
|
||||
release commit and the Gitea release.
|
||||
|
||||
## Building a release
|
||||
|
||||
1. On `master` (a feature release) or `release/X.Y` (maintenance), make
|
||||
the release commit: `VERSION` set to the new version and the manual
|
||||
rebuilt, nothing else:
|
||||
|
||||
```
|
||||
echo 0.2.6.7 > VERSION && tools/build-manual
|
||||
git commit -m "release: 0.2.6.7, for the canary machine only" VERSION docs/manual/build
|
||||
```
|
||||
|
||||
and merge it by pull request like anything else.
|
||||
2. Run the GUI lab against a local build (`tools/lab/dirty-upgrade`, from
|
||||
the release stable rooms are on and from canary's), and note the result.
|
||||
3. In Gitea: **Actions → Release 1 - build → Run workflow**, on that branch,
|
||||
with the version (and `0.2.2` or whatever older release rooms are still
|
||||
on, to rehearse that upgrade too).
|
||||
|
||||
`tools/release-ci.sh build` then:
|
||||
|
||||
- refuses unless the branch is `master` or `release/*`, the checkout is
|
||||
clean, and `VERSION` already says the version;
|
||||
- refuses a version that has a Gitea release or tag, whose package is on the
|
||||
update server, or that is not newer than what canary and stable serve;
|
||||
- builds with `SOURCE_DATE_EPOCH` set to the commit's time, so the manual's
|
||||
revision date is the commit's and the build changes nothing in the tree —
|
||||
and **fails if it did change anything**, because then the package would not
|
||||
be the commit;
|
||||
- writes `SHA256SUMS` and `build-info.json` (version, package, sha256, full
|
||||
commit, branch, time, the run that built it, and the version dpkg reads
|
||||
from the file);
|
||||
- runs the full gate, `tools/release-gate.sh`, on that file;
|
||||
- creates the Gitea **pre-release** `vX.Y.Z` tagged at the commit, with the
|
||||
package, `SHA256SUMS`, `build-info.json`, the gate report, the check
|
||||
summary, and notes listing the changes since the previous tag.
|
||||
|
||||
The sha256 it prints is the release's name from then on.
|
||||
|
||||
## Publishing to Preview
|
||||
|
||||
**Actions → Release 2 - publish to Preview (canary) → Run workflow**, with the
|
||||
version and its sha256 from the Gitea release. Two jobs:
|
||||
|
||||
- **gate**, on `ekklesia-ci` with no secret: downloads the release's `.deb`,
|
||||
proves it is that sha256, that the release's `SHA256SUMS` says so, and that
|
||||
dpkg reads the version from it; runs the full gate again, against what
|
||||
canary and stable serve *now*; attaches the report to the release under a
|
||||
new name.
|
||||
- **publish**, on `ekklesia-release` with the update server's key:
|
||||
downloads the same file and that report and runs
|
||||
`update_canary.sh VERSION PACKAGE` with the report. `update_canary.sh`:
|
||||
- refuses a version that has ever been published, or is not newer than
|
||||
everything that has (asked of the server itself, over ssh);
|
||||
- accepts the gate report only if it names this file's sha256, passed, and
|
||||
upgraded from exactly the versions canary and stable serve now — a report
|
||||
made before either moved is refused, and the gate has to be run again;
|
||||
- uploads the package with `rsync --ignore-existing` and proves the server's
|
||||
copy is the same bytes, writes the manifest **after** the package, swaps
|
||||
`canary/latest.deb` by rename **after** the manifest, and reads all three
|
||||
back over HTTPS the way a classroom does.
|
||||
|
||||
`tools/release-ci.sh preview` then checks that exactly one file was added
|
||||
to the server's packages and none changed, and records on the Gitea release
|
||||
that the file went to Preview, with its sha256 and the read-back.
|
||||
|
||||
The laptop takes it at its next update check (daily at 03:00, caught up after
|
||||
a night off) and reboots once nobody is using it.
|
||||
|
||||
## Physical approval
|
||||
|
||||
The gate and the lab cannot stand in for a real television, a real lid, a
|
||||
real DVD drive, real speakers or a real reboot. `docs/testing.md` lists what
|
||||
a person checks for each release on real hardware — for 0.2.6.6, the section
|
||||
[Before 0.2.6.6 goes to stable](testing.md#before-0266-goes-to-stable).
|
||||
|
||||
Only a person does these, and only a person records them. Nothing in CI
|
||||
claims a physical check passed.
|
||||
|
||||
## Promoting to Stable
|
||||
|
||||
**Actions → Release 3 - promote to Stable → Run workflow**, with the version,
|
||||
its sha256, the approval (who, when, which checks, on which machine), and the
|
||||
confirmation `PROMOTE X.Y.Z TO STABLE`. On `ekklesia-release`:
|
||||
|
||||
- it refuses unless the Gitea release records **that sha256** going to
|
||||
Preview: only what Preview ran can be promoted;
|
||||
- `update_stable.sh VERSION SHA256` refuses any other file, refuses a version
|
||||
not newer than stable, refuses unless the server already holds exactly that
|
||||
package, then points `stable/manifest.json` and `stable/latest.deb` at it
|
||||
and reads them back over HTTPS. **No build, no upload, no new file**;
|
||||
- it proves no package on the server changed, records the promotion and the
|
||||
approval on the Gitea release, and marks it no longer a pre-release.
|
||||
|
||||
The desktops take it at their next 03:00 check.
|
||||
|
||||
Merging code never promotes anything. There is no path from a push to
|
||||
Stable that does not pass through a person running this workflow.
|
||||
|
||||
## Without a runner: the same steps by hand
|
||||
|
||||
Every workflow step is a script, so a release never depends on a runner
|
||||
being up. From a checkout with the update server in `~/.ssh/config` as
|
||||
`classroom-control` (as the development VM has always had) and a Gitea token
|
||||
with repository write access:
|
||||
|
||||
```
|
||||
export GITEA_URL=https://git.riverofglorymn.org
|
||||
export GITEA_REPO=River_of_Glory_Church/Ekklesia_Classroom
|
||||
export GITEA_TOKEN=… # never committed
|
||||
|
||||
tools/release-ci.sh build 0.2.6.7 0.2.2
|
||||
tools/release-ci.sh gate 0.2.6.7 <sha256>
|
||||
tools/release-ci.sh preview 0.2.6.7 <sha256> <gate-report name>
|
||||
tools/release-ci.sh stable 0.2.6.7 <sha256> "physical checks passed on …, by …, on …"
|
||||
```
|
||||
|
||||
And, as before and still true, the underlying tools alone:
|
||||
|
||||
```
|
||||
./update_canary.sh 0.2.6.7 dist/ekklesia-classroom_0.2.6.7_amd64.deb # gate + publish that file
|
||||
./update_stable.sh 0.2.6.7 <sha256> # promote that file
|
||||
```
|
||||
|
||||
`update_canary.sh VERSION` with no package still builds one first, for
|
||||
compatibility; handing it the file you tested is the better habit.
|
||||
|
||||
## Immutability: three locks
|
||||
|
||||
1. **The update server.** `release_check_new_version` refuses a version whose
|
||||
file name has ever been used or that is not newer than every version ever
|
||||
published; `rsync --ignore-existing` will not overwrite a package even if
|
||||
that check were wrong; every upload is read back by sha256.
|
||||
2. **The Gitea release.** Gitea itself lets anyone with write access delete an
|
||||
asset and upload another of the same name, and Gitea 1.27 has no setting
|
||||
to stop it. `tools/gitea-release` is the only thing the workflows use to
|
||||
write releases, and it uploads an asset only under a name the release does
|
||||
not have; the same bytes again are left alone; different bytes fail
|
||||
loudly and change nothing. A tag is never moved. The record under
|
||||
"## Record" is only appended to. A person with write access can still
|
||||
delete things in the web UI — don't; nothing automated will put them back.
|
||||
3. **The checksum everywhere.** The sha256 is in the Gitea release, its
|
||||
`SHA256SUMS`, `build-info.json`, the gate reports, the update server's
|
||||
manifest and the release commit's follow-up. Preview and Stable both
|
||||
require it as an input and refuse a file that does not match.
|
||||
|
||||
## Gitea Releases and updates.riverofglorymn.org
|
||||
|
||||
| | Gitea release `vX.Y.Z` | updates.riverofglorymn.org |
|
||||
| --- | --- | --- |
|
||||
| Purpose | The permanent record: why, from what, how it was checked, what happened | Delivery to classrooms |
|
||||
| Read by | People | Each classroom's updater, daily |
|
||||
| Holds | The `.deb`, SHA256SUMS, build info, gate reports, notes, the record of Preview and Stable | `packages/*.deb`, and per channel `manifest.json` and `latest.deb` |
|
||||
| Decides what a room installs | No | Yes: the channel's manifest |
|
||||
|
||||
The two always hold the same bytes. The update server stays the only thing
|
||||
a classroom talks to: a Gitea outage, a Gitea upgrade or a Gitea migration
|
||||
never touches a room.
|
||||
|
||||
## Rollback and recovery
|
||||
|
||||
**A classroom never installs an older version.** The updater only takes a
|
||||
version newer than its own (it has to: otherwise two channels could
|
||||
ping-pong a room), and `update_stable.sh` refuses to move stable backwards,
|
||||
because a room would ignore it anyway. So:
|
||||
|
||||
- **A bad release on Preview only** (the laptop): do not promote it. Fix
|
||||
forward: the fix, or a revert, becomes the next version, through the same
|
||||
gate and Preview. The bad file stays on the server — it is a version that
|
||||
was published, and deleting it would only hide what the laptop is running.
|
||||
- **A bad release on Stable**: the same, faster. Build the next version from a
|
||||
revert of the bad change (on `release/X.Y`), gate it, Preview it, and
|
||||
promote it as soon as the specific failure is shown fixed on real hardware.
|
||||
Stable points at a *new* version whose content is the last good one.
|
||||
- **One room that must go back now**, before a fix exists: by hand on that
|
||||
machine, `sudo apt-get install --allow-downgrades ./ekklesia-classroom_<good>_amd64.deb`
|
||||
with the file from its Gitea release, then reboot. It stays there until a
|
||||
newer version is published to its channel.
|
||||
- **A publish interrupted half way**: the manifest is written after the
|
||||
package and `latest.deb` after the manifest, so a room either sees the old
|
||||
release or the whole new one. Re-running the same workflow is safe: the
|
||||
version checks refuse to publish twice, so check the Gitea release's record
|
||||
and `release_verify_channel` before re-running anything.
|
||||
- **A lost Gitea**: the update server still has every package, and the tags
|
||||
are in every clone. `Release - import an existing release` rebuilds the
|
||||
record from the server's files.
|
||||
|
||||
## Secrets, variables and credentials
|
||||
|
||||
None of these are in the repository, and none are given to CI.
|
||||
|
||||
| Name | Kind | Holds | Used by |
|
||||
| --- | --- | --- | --- |
|
||||
| `CLASSROOM_UPDATES_SSH_KEY` | repository **secret** | The private key the release runner uses to publish to the update server | Release 2 (publish job), Release 3 |
|
||||
| `CLASSROOM_UPDATES_ADDRESS` | repository variable | The update server's address from the runner (`10.40.0.2`) | Release 2, 3 |
|
||||
| `CLASSROOM_UPDATES_USER` | repository variable | The account that key logs in as | Release 2, 3 |
|
||||
| `CLASSROOM_UPDATES_KNOWN_HOSTS` | repository variable | The server's ssh host key line(s), so the job refuses any other host | Release 2, 3 |
|
||||
| `RELEASE_API_TOKEN` | repository secret, **optional** | A token that may write releases. Only needed if the automatic Actions token ever cannot; measured on Gitea 1.27.2, it can | Release 1, 2, 3, import |
|
||||
| automatic Actions token | provided by Gitea per job | Creates and records releases | Release 1, 2, 3, import |
|
||||
|
||||
Variables are not secret: a pull request's jobs can read them (measured). Put
|
||||
nothing in a variable that would matter if it were printed.
|
||||
|
||||
**Least privilege for the publish key.** Today the development VM publishes
|
||||
as `media@10.40.0.2`, and `media` is in the `sudo` and `docker` groups on that
|
||||
host — which also runs Gitea, Home Assistant and Vaultwarden. That key must
|
||||
**not** be the one put in Gitea. Before the release workflows are used:
|
||||
|
||||
1. On 10.40.0.2, create an account used for nothing else (say
|
||||
`classroom-publish`), in no group but one that may write
|
||||
`/srv/ekklesia/classroom-updates` — not `sudo`, not `docker`.
|
||||
2. Generate a new key pair for it on a trusted machine; put the public half
|
||||
in its `authorized_keys` with `restrict,from="<release runner address>"`;
|
||||
put the private half in `CLASSROOM_UPDATES_SSH_KEY`; delete the local copy.
|
||||
3. Set the three variables above.
|
||||
|
||||
The scripts need `ls`, `test`, `sha256sum`, `cat`, `ln`, `mv` and `rsync`
|
||||
there, and nothing else.
|
||||
|
||||
## Runners
|
||||
|
||||
| Label | Runs | Needs | Must not have |
|
||||
| --- | --- | --- | --- |
|
||||
| `ekklesia-ci` | CI on every push and pull request; Release 1; the gate job of Release 2; import | Debian 13, Docker (the container gate), git, python3, dpkg-deb, curl, rsync, nodejs (for `actions/checkout`), the fonts and tools the tests use — in practice, a VM set up like the development VM | Any credential for the update server or a classroom; a route to classrooms it does not need |
|
||||
| `ekklesia-release` | Release 2's publish job, Release 3 | ssh, rsync, curl, python3, dpkg-deb, git, nodejs; network to 10.40.0.2 | Docker; any key on its disk; any other label |
|
||||
| `classroom-hw-*` (future) | Hardware checks, by hand only | A real classroom computer in the lab | Anything to do with production rooms |
|
||||
|
||||
**How the separation holds, and where it doesn't** — measured on a local
|
||||
Gitea 1.27.2 with act_runner 0.6.1:
|
||||
|
||||
- A pull request from a **fork** does not run at all until a maintainer
|
||||
approves it. Approved, it gets **no secrets**. It does get the variables
|
||||
and a token, and it runs on whichever runner its workflow names —
|
||||
including `ekklesia-release`, if it asks for that label.
|
||||
- A branch pushed to this repository runs with the repository's secrets.
|
||||
**Anyone with write access can publish**, by writing a workflow that uses the
|
||||
key. Give write access accordingly.
|
||||
|
||||
The release scripts never use a runner's own ssh keys: under Actions the
|
||||
key comes only from the secret, is written to the job's own directory with
|
||||
its own `known_hosts` and an ssh configuration used with `ssh -F` (which
|
||||
ignores `~/.ssh/config`), and is deleted when the job ends. A job without
|
||||
the secret stops. **Never put a runner on a machine that holds the publish
|
||||
key by hand** — the development VM does — in case that rule is ever broken.
|
||||
|
||||
So: the key lives only in Gitea's secret store, never on a runner's disk,
|
||||
where an approved fork job could read it. The `ekklesia-release` runner runs
|
||||
each job in a fresh container (`ekklesia-release:docker://node:22-trixie`,
|
||||
without the Docker socket), so no job can leave anything behind for the
|
||||
next. **Never approve a fork's run that changes `.gitea/workflows/`**
|
||||
without reading it.
|
||||
|
||||
`ekklesia-ci` needs Docker for the container gate, and Docker is root on its
|
||||
host. Treat that host as something any approved pull request can take over:
|
||||
a VM with nothing on it worth taking, and no key to anything else.
|
||||
|
||||
**Registering a runner** (on the runner's host, as the account it runs as):
|
||||
|
||||
```
|
||||
act_runner generate-config > config.yaml
|
||||
# edit runner.labels in config.yaml: [ "ekklesia-ci:host" ]
|
||||
# (measured: labels in the config file OVERRIDE --labels at registration;
|
||||
# a generated config lists ubuntu-latest etc. and the runner then
|
||||
# advertises those, and every job here waits for ever)
|
||||
act_runner register --no-interactive -c config.yaml \
|
||||
--instance https://git.riverofglorymn.org --token <from Gitea> --name ekklesia-ci-1
|
||||
act_runner daemon -c config.yaml # as a systemd service
|
||||
```
|
||||
|
||||
The registration token is under the repository's **Settings → Actions →
|
||||
Runners**. Register at repository level, not instance level, so these
|
||||
runners take no other repository's jobs.
|
||||
|
||||
Artifacts: `actions/upload-artifact@v3` works on Gitea 1.27.2; **v4 fails**
|
||||
("not supported on GHES", measured). The workflows use v3.
|
||||
|
||||
## Nightly (designed, not yet active)
|
||||
|
||||
For installable 0.3.0 development builds, separate from a frozen Preview.
|
||||
|
||||
- **Source:** `master`, a commit whose CI passed.
|
||||
- **When:** once a night if `master` changed since the last nightly, and on
|
||||
demand. Not every commit: a lab machine that reboots into a new build every
|
||||
hour tests nothing.
|
||||
- **Version:** `<next>~nightly.<YYYYMMDD>.<run>`, for example
|
||||
`0.3.0~nightly.20261014.3`. The `~` makes it sort **before** `0.3.0` and
|
||||
after every 0.2.x, and each night after the last — so a lab machine moves
|
||||
to the real 0.3.0 when it ships. The release version rules
|
||||
(`tools/release-lib.sh`, and the same pattern in the updater) refuse `~`
|
||||
today; they must accept it for the nightly channel only.
|
||||
- **Server:** `/nightly/manifest.json`, `/nightly/latest.deb`, and packages in
|
||||
`/nightly/packages/` — **not** `/packages/`. The rule that a release must be
|
||||
newer than everything ever published reads `/packages/`; a nightly there
|
||||
would make 0.2.6.7 "older" than a 0.3.0 nightly and block every
|
||||
maintenance release.
|
||||
- **Retention:** the last 14 nightlies on the server; older ones deleted from
|
||||
`/nightly/packages/` only. No Gitea release per nightly; the build is a
|
||||
workflow artifact, kept as long as Gitea keeps artifacts.
|
||||
- **Opting in:** a lab machine's `device.conf` says `CHANNEL=nightly`, and its
|
||||
first nightly is installed by hand.
|
||||
- **Never a classroom:** the nightly updater refuses `CHANNEL=nightly` unless
|
||||
`/etc/ekklesia-classroom/lab-machine` exists, and a nightly package refuses
|
||||
to install without it. A nightly machine never reads the stable or canary
|
||||
manifest, and it cannot drift onto them: its version is newer than any
|
||||
0.2.x, so pointed at stable it would simply sit until stable passes it.
|
||||
Taking it back to 0.2.x is a reinstall with `--allow-downgrades`, by hand.
|
||||
- **Credential:** its own key, allowed to write `/srv/ekklesia/classroom-updates/nightly`
|
||||
and nothing else (`CLASSROOM_NIGHTLY_SSH_KEY`).
|
||||
|
||||
What activating it needs, which is why it is not active: the updater and
|
||||
release-lib changes above ship **inside the package**, so they are 0.3.0
|
||||
work; plus the server directory, the key, and a `nightly.yml` workflow. That
|
||||
is the next infrastructure task, after the backlog audit decides what 0.3.0
|
||||
is.
|
||||
|
||||
## The hardware lab (designed, not yet built)
|
||||
|
||||
Spare classroom computers, each a runner labelled `classroom-hw-<kind>`
|
||||
(`classroom-hw-desktop`, `classroom-hw-laptop`), registered to this
|
||||
repository, in a lab network with no path to production rooms, on
|
||||
`CHANNEL=nightly` or on whatever a job installs.
|
||||
|
||||
- **Triggered only by hand** (`workflow_dispatch`) with a version and sha256 —
|
||||
never by a pull request or a push.
|
||||
- **Installs the exact file** from its Gitea release through apt, proves dpkg
|
||||
reports that version, then runs checks from `tools/hw/` (to be written):
|
||||
HDMI connect/disconnect (the display count and the desktop's wallpaper
|
||||
before and after), HDMI audio (the sink appears, is chosen, plays),
|
||||
the DVD drive (seen, a disc read), the printer (queue state; a real page
|
||||
only with an explicit input), Wi-Fi recovery (down, up, reconnected with
|
||||
nobody typing), sleep/wake (`rtcwake`, and what is standing after), and
|
||||
reboot/update (a job that reboots the machine and a second job that reads
|
||||
what came back).
|
||||
- **Reports** one line per check into `hardware-report-<machine>-<date>.txt`:
|
||||
|
||||
```
|
||||
AUTO PASS hdmi-hotplug 2 -> 1 -> 2 outputs; desktop 1 kept its wallpaper
|
||||
AUTO FAIL dvd-read no disc in /dev/sr0
|
||||
MANUAL - picture-visible not checked by a machine
|
||||
```
|
||||
|
||||
attached to the Gitea release, with a record line summarising it.
|
||||
`AUTO` lines come only from a script that measured something. `MANUAL`
|
||||
lines are only ever `-` from a machine; a person's result is written by the
|
||||
person, in the Stable workflow's approval, and nowhere else. A machine
|
||||
never ticks a physical check.
|
||||
- **Does not block** CI or releases. Until it exists, physical checks are
|
||||
done by a person from `docs/testing.md`, exactly as now.
|
||||
|
||||
## Gitea administration still to do
|
||||
|
||||
Things only a Gitea administrator can do, none of which this repository can
|
||||
do for itself:
|
||||
|
||||
- [ ] Say which host the existing "Ekklesia" runner is on, and its labels. It
|
||||
is not on 10.40.0.2, and the Gitea API does not show runners to anyone
|
||||
but an administrator. Relabel it `ekklesia-ci:host` (in its config
|
||||
file, see above) if it is fit for that role, or register a new one.
|
||||
- [ ] Set up an `ekklesia-release` runner as above: container mode, no Docker
|
||||
socket, repository level.
|
||||
- [ ] Create the `classroom-publish` account and key on 10.40.0.2, and the
|
||||
secret and three variables above.
|
||||
- [ ] Protect `master` and `release/*`: no force push, no deletion, required
|
||||
status checks **CI / checks** and **CI / package and container gate**.
|
||||
- [ ] Under the repository's Actions settings, keep approval required for
|
||||
runs from forks (the default, measured).
|
||||
- [ ] Run **Release - import an existing release** for 0.2.6.6 (version,
|
||||
sha256 and commit are in the release commit and in
|
||||
`docs/testing.md`) so its Gitea release exists before its promotion.
|
||||
|
||||
## What was measured
|
||||
|
||||
On a throwaway Gitea 1.27.2 with act_runner 0.6.1 (the versions production
|
||||
runs), on the development VM, 2026-09-29:
|
||||
|
||||
| Question | Answer |
|
||||
| --- | --- |
|
||||
| Do all five workflows parse, and plan only on their own events? | Yes: CI on push and pull_request; the release workflows only on a manual run |
|
||||
| Does the CI checks job pass on a real runner? | Yes: all 31 checks, and their logs upload as an artifact |
|
||||
| Does CI's package job build, and run the gate in its CI mode? | Yes: built, the tree unchanged, fresh install and both upgrades passed, the report marked `mode ci` |
|
||||
| Does **Release 1 - build** work end to end? | Yes, at a throwaway version 9.9.9 against what canary (0.2.6.6) and stable (0.2.6.4) really serve: built from the commit with the tree unchanged, full gate passed, pre-release `v9.9.9` tagged at exactly that commit with the package, SHA256SUMS, build-info.json, gate report and check summary |
|
||||
| Is a second build of the same version refused? | Yes: "v9.9.9 already has a Gitea release; a version is built once" |
|
||||
| Does Preview's gate job work? | Yes: it downloaded the release's file, proved it against the sha256 given and SHA256SUMS, passed the full gate, attached a uniquely named report, recorded it, and handed its name to the publish job |
|
||||
| If the key secret is missing, what would the publish job use? | **This machine's own ssh configuration** — on the development VM, the production publish key: a throwaway 9.9.9 would have gone to the real server. The job was cancelled before it ran, and since then a job under Actions without the key refuses, and one with it uses only its own configuration (`ssh -F`), never the runner's |
|
||||
| Does a failing check stop a release build? | Yes: an undocumented workflow left in the tree failed `test-workflows`, and nothing was built |
|
||||
| Can the automatic token create a release, attach an asset, edit the notes? | Yes |
|
||||
| Does `tools/gitea-release` refuse different bytes under a published name, on real Gitea? | Yes |
|
||||
| Do `github.event.inputs.X` and `inputs.X` both work? | Yes |
|
||||
| Do job outputs pass from a job on one runner to a job on another? | Yes |
|
||||
| Do `vars.*` and `secrets.*` reach a job, and is the secret masked? | Yes, and yes |
|
||||
| `upload-artifact@v3` / `@v4`? | v3 works; v4 fails ("not supported on GHES") |
|
||||
| Do labels given to `act_runner register` survive a generated config? | **No**: the config's labels win |
|
||||
| Does a fork's pull request run without approval? | No: held until a maintainer approves |
|
||||
| Approved, does it get secrets? | No. It gets variables, a token, and any runner it names |
|
||||
|
||||
Not measured, because it needs the real update server's key in a real
|
||||
runner: Preview's publish job and Stable's promote job end to end. Their scripts are the ones
|
||||
used by hand since 0.2.6, and `tests/test-release-ci.sh` runs them against a
|
||||
stand-in server and a stand-in Gitea.
|
||||
@@ -149,6 +149,18 @@ nothing else about the room's locks moves.
|
||||
| `notif-13pt`, `notif-15pt` | the two font sizes that were weighed for it |
|
||||
| `pc-typed-layer` | the card gone: the lab's keyboard tool closes it, which is why typing is still a bench test |
|
||||
|
||||
## Wonder Ink printing
|
||||
|
||||
Under the lab's real Plasma session, driven with the pointer. See
|
||||
[wonder-ink.md](../wonder-ink.md), *Printing*.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `pic-preview-default`, `pic-preview-half-topleft` | a picture in a tab of its own, laid out on Letter: as large as fits, then 50% top left (0.2.6.2) |
|
||||
| `print-image-menu` | right-click on a picture inside a lesson: **Print image…** and **Print page…**; on the words there is only Print page (0.2.6.3) |
|
||||
| `print-image-bw-half-topleft` | Print image on a signed-in picture, 50% top left, Black and white — the preview grey, as the job CUPS kept was (0.2.6.3) |
|
||||
| `workspace-restored` | Wonder Ink reopened after an accidental close: the lesson's WonderInk tab at its sign-in page, its slide in front, its PDF behind (0.2.6.4, the real site) |
|
||||
|
||||
## Failures, kept
|
||||
|
||||
| | |
|
||||
|
||||
|
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: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 535 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 906 KiB |
|
After Width: | Height: | Size: 536 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 281 KiB |
@@ -3,6 +3,36 @@
|
||||
Run against the exact artifact published to canary, on the real endpoint,
|
||||
before promoting to stable with `./update_stable.sh`.
|
||||
|
||||
## Before 0.2.6.6 goes to stable
|
||||
|
||||
The container gate and the lab passed both of these. Neither can prove
|
||||
them on real hardware, so a person does, on the prayer-room laptop, which
|
||||
takes 0.2.6.6 from canary. If every box is ticked, 0.2.6.6 is promoted
|
||||
**as the same file**, never rebuilt:
|
||||
|
||||
```
|
||||
ekklesia-classroom_0.2.6.6_amd64.deb
|
||||
sha256 2b972391974265ba220c58ae0a8f287664a42228f062278956b89017bd5a54f7
|
||||
./update_stable.sh 0.2.6.6 2b972391974265ba220c58ae0a8f287664a42228f062278956b89017bd5a54f7
|
||||
```
|
||||
|
||||
**Waiting to reboot (since 0.2.6.5)** — details under [OTA](#ota):
|
||||
|
||||
- [ ] Using the computer after an update has installed: it does not restart
|
||||
- [ ] A video playing with sound, mouse left alone: it does not restart
|
||||
- [ ] Left untouched and silent, it restarts about 30 minutes later, on
|
||||
the new version
|
||||
- [ ] Switched off before that restart: it starts normally next time, on
|
||||
the new version, and does not restart again
|
||||
|
||||
**The television arriving late (0.2.6.6)** — details under
|
||||
[the television switched on after a night-time reboot](#the-television-switched-on-after-a-night-time-reboot):
|
||||
|
||||
- [ ] Rebooted with the television off at the set (if the room allows it),
|
||||
then switched on: the classroom's landscape, not Plasma's purple
|
||||
- [ ] The panel is the room's own: same launchers, same order, clock bottom
|
||||
right
|
||||
|
||||
## Boot
|
||||
|
||||
- [ ] Cold boot reaches the classroom shell with no terminal or window junk
|
||||
@@ -11,6 +41,39 @@ before promoting to stable with `./update_stable.sh`.
|
||||
- [ ] Dock appears, centred
|
||||
- [ ] Clock appears, bottom right
|
||||
|
||||
### The television switched on after a night-time reboot
|
||||
|
||||
This is how Tiny Pebbles lost its wallpaper after the 0.2.2 upgrade, and
|
||||
the lab cannot do it: KWin will not start a session with no screen, and
|
||||
the lab cannot plug one in. The lab proves the same code path through a
|
||||
new activity (`tools/lab/wallpaper-check`); only a real television
|
||||
proves that switching it on takes that path. See
|
||||
[ui-scale.md](ui-scale.md#a-desktop-the-layout-never-saw-0266). Do it once, on a
|
||||
desktop whose television is switched off at the set, not the remote:
|
||||
|
||||
1. [ ] Television **off** at the set. `sudo reboot`, and wait five minutes.
|
||||
2. [ ] Television **on**. The classroom's landscape is on the screen,
|
||||
**not** Plasma's purple wallpaper. The panel is the room's own.
|
||||
3. [ ] From the maintenance terminal (Meta+Ctrl+Alt+Shift+K),
|
||||
`classroom-desktop-wallpaper --check`. Note what it says. If it
|
||||
names a desktop that "had no wallpaper configured", the television
|
||||
really did arrive after the shell, and the screen in step 2 is the
|
||||
fix working
|
||||
4. [ ] `systemctl --user restart plasma-plasmashell.service`: the panel
|
||||
blinks and comes back, open windows stay, the wallpaper is unchanged
|
||||
5. [ ] `classroom-desktop-wallpaper --check` now says "the classroom
|
||||
wallpaper already"
|
||||
6. [ ] Reboot with the television on: the same picture and the same panel
|
||||
|
||||
Tiny Pebbles can confirm the cause **before** it takes the release, because
|
||||
the update reboots it and a login rebuilds the file. If its panel was built
|
||||
with no screen, the panel is `[Containments][1]` next to the desktop's 10:
|
||||
|
||||
```
|
||||
grep -A6 '^\[Containments\]\[[0-9]*\]$' \
|
||||
~/.config/plasma-org.kde.plasma.desktop-appletsrc | grep -E '^\[|^plugin='
|
||||
```
|
||||
|
||||
## Provisioning
|
||||
|
||||
- [ ] `journalctl -u ekklesia-classroom-provision` shows a clean run
|
||||
@@ -323,6 +386,35 @@ DVD_ENABLED=false
|
||||
Nothing about `DEVICE_TYPE=laptop` turns those applications off; they are
|
||||
off because those lines say so.
|
||||
|
||||
### Physical verification — the Wi-Fi hand-over
|
||||
|
||||
No lab can do this one: it needs the laptop's radio. Over SSH, watching,
|
||||
with a way back into the room if it goes wrong:
|
||||
|
||||
1. `classroom-wifi --check` — expect it to name the interface and say
|
||||
ifupdown owns it. Changes nothing.
|
||||
2. `classroom-wifi --adopt --dry-run` — expect the three "would" lines.
|
||||
Changes nothing. `/etc/network/interfaces` must still be identical.
|
||||
3. `classroom-wifi --adopt`. It prints each step. Expect it to end with
|
||||
*"is NetworkManager's now, and the room is online"*.
|
||||
If it fails it puts ifupdown back by itself and says so; the machine
|
||||
should still be reachable. `classroom-wifi --rollback` undoes it later.
|
||||
4. `nmcli device status` — the Wi-Fi device must no longer say
|
||||
`unmanaged`.
|
||||
5. `nmcli networking connectivity` — must say `full`, not `none`.
|
||||
6. `nmcli device wifi list` — must now list networks.
|
||||
7. Log out and back in. The panel must show the Wi-Fi connection.
|
||||
8. Reboot. It must reconnect on its own, with nobody typing a password.
|
||||
9. As the **classroom** account, confirm the password is still
|
||||
unreachable:
|
||||
- `nmcli -s -g 802-11-wireless-security.psk connection show <name>`
|
||||
must be empty
|
||||
- the applet's *Show Network's QR Code* must not produce one
|
||||
- `/etc/NetworkManager/system-connections/*.nmconnection` must be
|
||||
unreadable
|
||||
10. As **tech**, confirm maintenance still works: `sudo nmcli -s ...`
|
||||
returns the key.
|
||||
|
||||
### Physical verification — a real lid and a real television
|
||||
|
||||
The lab has no lid and no HDMI hotplug, so none of this is proven by a
|
||||
@@ -437,10 +529,307 @@ With `LIVE_ENABLED=true`:
|
||||
- [ ] `./update_canary.sh` publishes package first, manifest last
|
||||
- [ ] The endpoint on the canary channel picks the update up
|
||||
- [ ] SHA-256 verification passes
|
||||
- [ ] Install succeeds and the endpoint reboots
|
||||
- [ ] Install succeeds; `systemctl list-timers ekklesia-classroom-update.timer`
|
||||
shows the next check at about 03:00 tomorrow
|
||||
- [ ] (0.2.6.5) Switched on in the morning after a night off, with an update
|
||||
waiting: it installs, `journalctl -u ekklesia-classroom-reboot-when-idle`
|
||||
says it is waiting, and it does **not** restart while you use it
|
||||
- [ ] Play a video with sound and leave the mouse alone for 40 minutes: no
|
||||
restart while it plays; half an hour after it stops, it restarts
|
||||
- [ ] Left untouched with nothing playing, it restarts about 30 minutes after
|
||||
the update, and comes back on the new version
|
||||
- [ ] Everything above still works after the update
|
||||
- [ ] Upgrade from 0.1.36 preserves every existing `device.conf` value
|
||||
- [ ] `LIVE_ENABLED=false` is appended exactly once on upgrade and remains
|
||||
single after reinstall
|
||||
- [ ] `tools/validate-trixie-release.sh` passes against the 0.1.37 package and
|
||||
the 0.1.36 package
|
||||
|
||||
## Presentations — 0.2.6, the prayer-room laptop
|
||||
|
||||
Everything here needs the real television, the real keyboard and the
|
||||
pastor's own session files. The automated tests and the Plasma lab cover
|
||||
the logic and the windows; none of them can tell you that Session 7's
|
||||
text is the right size from the back of the room.
|
||||
|
||||
Enable it first, and reboot:
|
||||
|
||||
```
|
||||
sudo sed -i 's/^PRESENTATIONS_ENABLED=.*/PRESENTATIONS_ENABLED=true/' \
|
||||
/etc/ekklesia-classroom/device.conf
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
### Download
|
||||
|
||||
- [ ] The bar has a **Presentations** button, after the Browser
|
||||
- [ ] Wonder Ink and Parent Call are still absent from this laptop
|
||||
- [ ] Download tonight's session in ordinary Chrome
|
||||
- [ ] It does **not** appear in the library while it is still downloading
|
||||
- [ ] It appears by itself within a few seconds of the download finishing
|
||||
- [ ] The title is readable — not a hash, not a long filename
|
||||
- [ ] The completed original moved out of Downloads into the managed library;
|
||||
there is no duplicate
|
||||
- [ ] Download the **PDF and the PPTX** of the same session: two cards,
|
||||
the second titled `(2)`, neither overwriting the other
|
||||
|
||||
### Quick Look
|
||||
|
||||
- [ ] **Quick Look** opens on the currently selected cover slide
|
||||
- [ ] **Next** and **Previous** move through every slide
|
||||
- [ ] The text and layout are right — check a slide with a Bible verse
|
||||
- [ ] Go to the session-number slide, usually slide 2
|
||||
- [ ] **Use as cover** changes the card and shows **Current cover**
|
||||
- [ ] **Back to Presentations** returns without presenting
|
||||
- [ ] Close the library, open it again: the chosen picture is still there
|
||||
- [ ] Reboot, open it again: still there
|
||||
- [ ] `classroom-presentation-import --status` shows `presented never`
|
||||
- [ ] At 1920×1080 and 1366×768, scale 1.0 and the larger supported scale,
|
||||
the title, slide navigation, cover action, Back and Present all remain
|
||||
above the panel without manually resizing
|
||||
|
||||
That last line is the one that matters. Quick Look, browsing every slide
|
||||
and changing the picture must not have counted as a presentation.
|
||||
|
||||
### Present
|
||||
|
||||
- [ ] **Present** from the card fills the **external television**
|
||||
- [ ] No panel, title bar, border, desktop or other application is visible
|
||||
- [ ] It starts at slide **1**, not at the slide chosen for the picture
|
||||
- [ ] **Present** from inside Quick Look does the same
|
||||
- [ ] Space, the arrow keys and Page Up/Page Down all move slides
|
||||
- [ ] The presentation remote's forward and back buttons work
|
||||
- [ ] Fonts and layout are right on the television, from the back
|
||||
- [ ] **Esc** returns to the library — **not** the Impress editor, not a
|
||||
terminal, not a blank screen
|
||||
- [ ] Going past the last slide does the same
|
||||
- [ ] `--status` now shows a presentation time and a count
|
||||
- [ ] Present it again: the count rises and the expiry moves out 30 days
|
||||
- [ ] Unplug the network and present again: it still works
|
||||
|
||||
### The awkward ones
|
||||
|
||||
- [ ] A PDF and a PPTX of the same session look the same on screen
|
||||
- [ ] A deliberately broken file gives a short message and only
|
||||
**Remove** — no editor, no terminal
|
||||
- [ ] A long session (40+ slides) opens without the library freezing
|
||||
- [ ] Download a Wonder Ink worksheet: it does **not** appear in the
|
||||
library
|
||||
|
||||
### Remove
|
||||
|
||||
Use a **test copy**. Not tomorrow's session.
|
||||
|
||||
- [ ] **Remove** asks first
|
||||
- [ ] The card goes, and its slides and pictures go with it
|
||||
- [ ] Its managed original goes too; unrelated downloads and another similarly
|
||||
named presentation remain
|
||||
- [ ] It does not come back on the next scan or after a reboot
|
||||
|
||||
### Help is temporarily hidden
|
||||
|
||||
- [ ] No **Help** launcher is visible on the teacher-facing bar
|
||||
- [ ] The manual sources, generated HTML/PDF, launcher and feature-aware build
|
||||
pipeline remain installed for the later documentation correction pass
|
||||
- [ ] Presentations and Wonder Ink title bars show only Close, with no
|
||||
double-chevron keep-above control
|
||||
|
||||
### Wi-Fi — look, do not touch
|
||||
|
||||
The laptop's Wi-Fi is already working through NetworkManager and its
|
||||
ifupdown stanza is already gone. **Do not run `--adopt` on it.**
|
||||
|
||||
- [ ] `classroom-wifi --check` says there is no wireless interface under
|
||||
ifupdown's control
|
||||
- [ ] `classroom-wifi --adopt` says "nothing to adopt" and changes nothing
|
||||
- [ ] `nmcli -g 802-11-wireless.hidden connection show "ROGC Classroom"`
|
||||
still reports `yes`
|
||||
- [ ] The laptop still reconnects after a reboot
|
||||
|
||||
## Wonder Ink — 0.2.6.1 to 0.2.6.4, before any goes to stable
|
||||
|
||||
Everything below was done in the lab except what a lab cannot do: paper,
|
||||
a classroom's own graphics, a real keyboard, its network and its screen.
|
||||
Those are these checks. Stable stays on 0.2.2 until they pass.
|
||||
|
||||
Before starting, on each machine: `dpkg-query -W ekklesia-classroom`
|
||||
must say `0.2.6.4`, which carries everything 0.2.6.1 to 0.2.6.3 did. The desktops only get it once stable is moved, so on
|
||||
a desktop install it by hand for the test:
|
||||
|
||||
```
|
||||
curl -fsSL -o c.deb https://updates.riverofglorymn.org/canary/latest.deb
|
||||
sha256sum c.deb # must match .../canary/manifest.json
|
||||
sudo apt-get install -y ./c.deb && sudo reboot
|
||||
```
|
||||
|
||||
This does **not** move the machine to canary (see provisioning.md): its
|
||||
next automatic update follows stable again, which is still 0.2.2 — so
|
||||
after testing, either promote or reinstall 0.2.2 the same way.
|
||||
|
||||
### Little Waders — printing through Elmer
|
||||
|
||||
- [ ] Open Wonder Ink, sign in, open a lesson with a worksheet
|
||||
- [ ] Click the worksheet; it opens as a tab named after the file, with a
|
||||
document icon
|
||||
- [ ] Ctrl+P (or right-click → Print page…): the preview shows the worksheet's
|
||||
own pages, page count right, ‹ › turn them
|
||||
- [ ] Printer shows **Elmer**, preselected
|
||||
- [ ] Print → a notification "Sent to the printer — … — Elmer", Wonder Ink
|
||||
stays open on the lesson
|
||||
- [ ] **The worksheet comes out of Elmer**, every page, right size, in colour
|
||||
- [ ] Pages `2`, Copies `2`, Black and white → the preview turns grey,
|
||||
and exactly two grey copies of page 2 come out
|
||||
- [ ] Print a normal lesson page the same way; it comes out, Letter
|
||||
- [ ] Open the preview and press Cancel → nothing prints
|
||||
- [ ] Print three times in a row → three printouts, no crash
|
||||
- [ ] `lpstat -W completed -o` shows each job; `journalctl --user -b | grep
|
||||
"printed '"` shows Wonder Ink's line for each
|
||||
|
||||
### Pictures (0.2.6.2)
|
||||
|
||||
- [ ] Open a colouring page that is a picture on its own (a PNG or JPEG
|
||||
tab, not a PDF) and press Print: the preview shows it on white — **no
|
||||
black bars** — with a **Picture** section under Color
|
||||
- [ ] Size 50%, the top-left square: the preview shows it there, the
|
||||
rest of the page blank. Print it: **the paper matches the preview**
|
||||
- [ ] Paper *Sideways*, then *Upright*: the preview turns; print one
|
||||
- [ ] *Fill the whole page*: the preview shows the edges cut off; Cancel
|
||||
prints nothing
|
||||
- [ ] A worksheet PDF still prints with no Picture section
|
||||
- [ ] The print dialog says **Color**, not Colour
|
||||
|
||||
### Black and white, and Print image (0.2.6.3)
|
||||
|
||||
The lab measured all of this on the files CUPS kept, and under the real
|
||||
Plasma session for the clipboard. What it cannot show is Wonder Ink's own
|
||||
site (no account), real paper, and a classroom's own keyboard and mouse.
|
||||
|
||||
- [ ] A lesson page with colour on it: Ctrl+P, then **Black and white** →
|
||||
the preview says *Preparing black and white…* for a moment, then the
|
||||
page is **grey**. **Color** → colour again. Back and forth three
|
||||
times: it is instant after the first, and stays on the same page
|
||||
- [ ] Print it in black and white: **the paper matches the grey preview**
|
||||
- [ ] The same with a worksheet PDF (a few pages), in black and white
|
||||
- [ ] Choose Black and white, then Color, then Print → it comes out **in
|
||||
colour**
|
||||
- [ ] In a lesson, **right-click on a picture** (not the words): the menu
|
||||
has **Print image…** and **Print page…**. Right-click on the words:
|
||||
only **Print page…**
|
||||
- [ ] **Print image…** on a picture you cannot open in a tab by itself:
|
||||
the preview shows *that picture* on white, with the **Picture**
|
||||
section — not the lesson page
|
||||
- [ ] Size **50%**, the **top-left** square, Black and white → the preview
|
||||
shows a grey picture in the top-left quarter. Print it: **the paper
|
||||
matches the preview**
|
||||
- [ ] Print image on a picture with a transparent background (a clipart
|
||||
animal, a logo) → white around it on paper, not black
|
||||
- [ ] Print image, then **Cancel** → nothing prints
|
||||
- [ ] Copy some words in the browser, then use Print image in Wonder
|
||||
Ink, then paste in the browser → the words paste, not the picture
|
||||
- [ ] If any picture says *could not be taken from the page*: note which
|
||||
lesson and which picture. Right-click the same picture again once
|
||||
the page has finished loading. If it still says so, that picture is
|
||||
drawn in a way the engine does not treat as a picture — tell us
|
||||
where it is
|
||||
- [ ] If right-clicking an obvious picture offers **only** Print page, the
|
||||
site draws it as a background. Note which lesson — it is the one
|
||||
case Print image cannot reach
|
||||
|
||||
### Size and zoom
|
||||
|
||||
- [ ] A lesson in Wonder Ink is the same size as the same page in the
|
||||
browser on that machine (it was smaller in 0.2.2)
|
||||
- [ ] **Ctrl and +** (the real keyboard), **Ctrl and −**, **Ctrl and 0**
|
||||
change it; a "Lesson size" badge appears
|
||||
- [ ] Open a second tab: it is the same size; the − 100% + and Print
|
||||
buttons are at the top right
|
||||
- [ ] Close Wonder Ink and open it again: the size is remembered
|
||||
- [ ] A worksheet tab's own − / + (in the PDF toolbar) zoom it
|
||||
- [ ] A lesson video still goes fullscreen and comes back with the tabs
|
||||
|
||||
### Loading, and the machine's graphics
|
||||
|
||||
From the maintenance terminal (Meta+Ctrl+Alt+Shift+K), in the session:
|
||||
|
||||
```
|
||||
classroom-wonderink --diagnose
|
||||
```
|
||||
|
||||
- [ ] Note the **GPU line**. A driver's name is right. "none … drawing in
|
||||
software" means QtWebEngine has no GPU on this machine — compare
|
||||
`chrome://gpu` in the browser; if Chrome has one, that is the cause of
|
||||
slow lessons and is the next thing to fix
|
||||
- [ ] Note the sign-in time (the lab: about 2.9 s wired). Much slower here
|
||||
with a driver named above points at the network
|
||||
- [ ] `free -m`: Wonder Ink needs about 610 MB plus 115 MB per extra tab
|
||||
|
||||
### Temporary workspace restore (0.2.6.4) — Little Waders
|
||||
|
||||
Use a **Viewer** account. An Admin sees lessons in WonderInk's editor,
|
||||
where a click on a link shows its Edit/Unlink bubble — WonderInk's design,
|
||||
not Wonder Ink's; Ctrl+click opens it. See wonder-ink.md, *Links in a
|
||||
lesson*.
|
||||
|
||||
1. [ ] Sign in to the real WonderInk site
|
||||
2. [ ] Open three lesson/resource tabs: the lesson, and two of its links —
|
||||
for example *Saul Welcome Question Slide* and a video or worksheet
|
||||
3. [ ] Click the **middle** tab
|
||||
4. [ ] Close Wonder Ink (the × at the top right)
|
||||
5. [ ] Open it again straight away from the panel
|
||||
6. [ ] The WonderInk tab asks you to **sign in** — sign in normally (your
|
||||
password is never kept, so it always will)
|
||||
7. [ ] The **recent tabs are back**: the slide and the other resource
|
||||
8. [ ] **In the same order** as before
|
||||
9. [ ] With the **middle tab in front**
|
||||
10. [ ] Open a worksheet from a lesson, then close and reopen Wonder Ink:
|
||||
the worksheet tab comes back, named after the file
|
||||
11. [ ] Print from a restored tab (the slide or the worksheet) through
|
||||
Elmer: **the paper matches the preview**
|
||||
12. [ ] Links still open with one click in a new tab, as before (Viewer)
|
||||
13. [ ] With the tabs open, **reboot** the computer; within two hours of
|
||||
using Wonder Ink, open it again
|
||||
14. [ ] **The recent tabs are back** (after signing in on the WonderInk tab)
|
||||
15. [ ] **Expiry, without waiting two hours.** Close Wonder Ink. Open the
|
||||
maintenance terminal (Meta+Ctrl+Alt+Shift+K) and run
|
||||
`CLASSROOM_WONDERINK_TEST_WORKSPACE_SECONDS=120 classroom-wonderink`.
|
||||
This is **test-only**: it lasts as long as that one Wonder Ink,
|
||||
can only shorten the time, and nothing in the classroom sets it
|
||||
16. [ ] Open a couple of tabs in it, click in one, then touch nothing for
|
||||
**three minutes** and close it
|
||||
17. [ ] Open Wonder Ink from the panel: it **starts fresh**, one tab on the
|
||||
WonderInk start page, none of the old tabs
|
||||
|
||||
### Presentations under memory pressure (0.2.6.2, the laptop)
|
||||
|
||||
- [ ] Download a large, photo-heavy deck with Wonder Ink and Chrome open:
|
||||
it either becomes ready, or its card says *This needed more memory …
|
||||
choose Try again* — never *Getting this ready…* for more than a few
|
||||
minutes
|
||||
- [ ] Close the other programs and choose **Try again**: it becomes ready
|
||||
- [ ] `systemctl --user status ekklesia-classroom-presentations` is
|
||||
active, and `journalctl --user -u ekklesia-classroom-presentations`
|
||||
shows no restart loop
|
||||
|
||||
### Settings the teacher must not reach (0.2.6.2)
|
||||
|
||||
- [ ] When any notification shows the sliders (Configure) button, clicking
|
||||
it opens only *The module … is disabled*; its Help button does
|
||||
nothing
|
||||
- [ ] Right-click the clock: no *Configure Digital Clock…*; *Adjust Date
|
||||
and Time…* opens an empty System Settings
|
||||
- [ ] Right-click the volume: no *Configure Audio Devices…*
|
||||
- [ ] The maintenance chord (Meta+Ctrl+Alt+Shift+K) still opens a
|
||||
terminal, and `systemsettings` started from it is complete
|
||||
|
||||
### After a reboot, and the other rooms
|
||||
|
||||
- [ ] Reboot Little Waders: it comes up as the classroom, Wonder Ink opens
|
||||
- [ ] On one other desktop, 0.2.6.1 installed: the browser, the DVD player
|
||||
where there is a drive, Parent Call where enabled, sound through the
|
||||
television, the panel order — all as on 0.2.2
|
||||
- [ ] **Presentations is not on the panel** of any desktop
|
||||
(`grep PRESENTATIONS_ENABLED /etc/ekklesia-classroom/device.conf`
|
||||
says `false`)
|
||||
|
||||
If anything here fails, leave the desktops on stable 0.2.2 and fix it in
|
||||
the next maintenance release on canary.
|
||||
|
||||
@@ -47,6 +47,69 @@ package installs it at `/usr/share/backgrounds/ekklesia/classroom-calm.png`, the
|
||||
path used by the packaged Plasma layout. The layout is rebuilt at login, so
|
||||
upgraded classrooms receive the package wallpaper without per-room copying.
|
||||
|
||||
### A desktop the layout never saw (0.2.6.6)
|
||||
|
||||
Tiny Pebbles came through the 0.2.2 → 0.2.6.4 upgrade showing Plasma's own
|
||||
purple wallpaper. Its desktop was containment 10 with no `Wallpaper` group;
|
||||
River Kids' is containment 1 with one. The package and the picture were the
|
||||
same on both.
|
||||
|
||||
The layout sets the wallpaper on the desktops that exist *while it runs*.
|
||||
Plasma makes a desktop for every screen or activity that appears later, and
|
||||
it makes those without the layout. 0.2.2's updater rebooted as soon as it
|
||||
had installed, at 03:00–03:30. A television switched off at the set is
|
||||
usually no screen at all, so the layout would have run with no desktop, and
|
||||
the desktop Plasma made when the television came on was bare. A panel built
|
||||
with no screen takes number 1 and its eight applets 2–9, which is exactly
|
||||
how a desktop ends up as 10. **The television is inferred, not observed**.
|
||||
See the hardware check in [testing.md](testing.md#the-television-switched-on-after-a-night-time-reboot).
|
||||
|
||||
Measured in the lab (Plasma 6.3.6, Debian 13):
|
||||
|
||||
| What was tried | What happened |
|
||||
| --- | --- |
|
||||
| Activity manager delayed 20 s at login | Plasma waited for it; desktop 1, wallpaper right. Not the cause |
|
||||
| A new activity created mid-session | Plasma made desktop 11 with no `Wallpaper` group, the same shape as Tiny Pebbles' 10. The screen showed Plasma's *Next*; the panel was untouched |
|
||||
| Look-and-feel default `Image=` set to a **file path** (0.2.2–0.2.6.5) | Ignored. `DefaultWallpaper` in libkworkspace6 looks the value up as a wallpaper **package** under `wallpapers/<name>`, the way Breeze says `Image=Next` |
|
||||
| The same default set to a **package name**, whose one image links to the file | Desktop 11, still with no `Image`, showed the classroom's picture after a shell restart |
|
||||
| Setting it live over D-Bus (`evaluateScript`, `plasma-apply-wallpaperimage`) | Refused: "Widgets are locked". The classroom's lockdown forbids the scripting console, and it stays forbidden |
|
||||
| Disabling the only screen to reproduce a screenless start | KWin refuses ("Disabling all outputs … is not allowed"), and the nested lab cannot hot-plug a screen |
|
||||
|
||||
So there are two parts, and they do different jobs:
|
||||
|
||||
* **The default is now a package**, `/usr/share/wallpapers/EkklesiaClassroom`.
|
||||
Its image is a link to the file above, and the look and feel names it.
|
||||
Any desktop Plasma makes without the layout shows the classroom's picture
|
||||
with nothing having to run. This is the fix for Tiny Pebbles.
|
||||
* **`classroom-desktop-wallpaper`** writes `wallpaperplugin=org.kde.image` and
|
||||
the classroom `Image` into every desktop containment already in the file,
|
||||
so the configuration says what the screen shows. It finds desktops by
|
||||
plugin (`org.kde.plasma.folder`, `org.kde.desktopcontainment`), never by
|
||||
number. It changes nothing else, and it does not write a file that is
|
||||
already right. It runs as plasmashell's `ExecStartPre`, the one moment
|
||||
the shell does not own the file: every login, every reboot, every restart
|
||||
after a crash. At a login it usually finds nothing, because
|
||||
`classroom-desktop-reset` has just removed the file for the layout to
|
||||
rebuild.
|
||||
|
||||
To apply it in a running session without logging out, from the maintenance
|
||||
terminal:
|
||||
|
||||
```
|
||||
classroom-desktop-wallpaper --check # read-only
|
||||
systemctl --user restart plasma-plasmashell.service # the panel blinks, windows stay
|
||||
```
|
||||
|
||||
Run without `--check` while the shell is up, it refuses and prints that
|
||||
second line rather than edit the file underneath the shell.
|
||||
|
||||
Tests: `tests/test-classroom-desktop-wallpaper.sh` (in the build) covers
|
||||
Tiny Pebbles' own file, a healthy River Kids file, differing numbers,
|
||||
idempotence and the guard. `tools/lab/wallpaper-check` covers real Plasma,
|
||||
reading the colour off the screen, and runs inside `tools/lab/dirty-upgrade`.
|
||||
`tools/validate-trixie-release.sh` covers the installed package when
|
||||
upgrading from a previous release.
|
||||
|
||||
## What the one number reaches
|
||||
|
||||
`classroom-ui-scale` prints it, clamped, and everything that cannot read
|
||||
@@ -55,10 +118,15 @@ the display scale for itself asks that command rather than the file:
|
||||
* **Plasma** — the panel, the clock, notifications, the volume display and
|
||||
every dialog, because `classroom-scale` sets it as the display's scale
|
||||
through `kscreen-doctor` at every login.
|
||||
* **Chrome and Wonder Ink** — `--force-device-scale-factor`, because
|
||||
Chrome on Wayland was measured not to follow the compositor's output
|
||||
scale on its own. It is the same number, not a second scaling: Chrome
|
||||
replaces its detected scale with it.
|
||||
* **Chrome** — `--force-device-scale-factor`, because Chrome on the sway
|
||||
session was measured not to follow the compositor's output scale on its
|
||||
own. **Under Plasma it does**, so this is now a second scaling: measured
|
||||
at 150%, Chrome's pages report `devicePixelRatio` 2.25, not 1.5. Left as
|
||||
it is in 0.2.6.1 because it is the size teachers know; see
|
||||
[wonder-ink.md](wonder-ink.md#how-large-a-lesson-is).
|
||||
* **Wonder Ink** — its window follows the compositor as every Qt
|
||||
application does, and its *pages* start at a zoom of `UI_SCALE`, so a
|
||||
lesson is the size the browser shows it.
|
||||
* **The disc player's own surfaces** — they are measured in the logical
|
||||
pixels the compositor is already scaling, so they follow without being
|
||||
told.
|
||||
|
||||
@@ -71,7 +71,51 @@ wrong a teacher is standing in front of a class.
|
||||
The right-click menu is built rather than filtered, so a future Qt cannot
|
||||
grow view-source or save-page back into it without somebody noticing in
|
||||
the code: back, forward, reload, open in a new tab when there is a link,
|
||||
cut/copy/paste in a text field, print.
|
||||
cut/copy/paste in a text field, print image when there is a picture under
|
||||
the pointer, print page.
|
||||
|
||||
### Links in a lesson (0.2.6.4)
|
||||
|
||||
A link in a lesson opens with one click in a new tab, and Wonder Ink shows
|
||||
that tab; the lesson stays in its own. That is what the site asks for —
|
||||
every link in a lesson's content carries `target="_blank"` — and what the
|
||||
engine does with it (`createWindow`, answered with a tab that takes the
|
||||
focus). Measured on the real site, on the real *Rise and Fall of Saul*
|
||||
lesson: "Saul Welcome Question Slide" is
|
||||
`<a target="_blank" href="…/SaulWelcomeQuestionSlide.png">`, and one click
|
||||
opened the slide in a new tab, at 100%, at 200% and in Full Screen.
|
||||
|
||||
**The URL bubble teachers saw is WonderInk's editor, shown to admins.**
|
||||
Testing 0.2.6.3 found that clicking such a link showed a small bubble with
|
||||
the address and Edit / Remove instead of following it. Found by reading
|
||||
the site's code and confirmed with the account roles:
|
||||
|
||||
* the site shows lesson text in **CKEditor 5** (35.2.1, its
|
||||
`RTE_USE_CKEDITOR_PRO` component). For a **Viewer** the content is
|
||||
read-only and a link is a link; for an **Admin** it is editable, in
|
||||
place;
|
||||
* in an editable CKEditor 5, a plain click on a link is an editing click
|
||||
by design (`LinkEditing._enableLinkOpen` opens a link only with Ctrl, ⌘
|
||||
on a Mac); the editor answers it with its link balloon — the address,
|
||||
*Edit link*, *Unlink*. It is the same code in Chrome;
|
||||
* moving the teacher's account to Viewer made the bubble go away.
|
||||
|
||||
So Wonder Ink does not override it. Doing so would mean catching clicks
|
||||
inside WonderInk's editor, which is how admins edit lessons, to fix
|
||||
something the account's role already decides. An admin who wants to
|
||||
follow a link straight away: **Ctrl+click** it.
|
||||
|
||||
What each kind of link does, as `tools/lab/wonderink-print-check links:*`
|
||||
clicks them on a signed-in lesson page:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| a text link, a linked picture, another site's link (`target="_blank"`) | a new tab, shown |
|
||||
| a worksheet PDF | a new tab in the PDF viewer; Print fetches it and prints it as a worksheet |
|
||||
| a picture | a new tab; Print lays it out as a picture, and right-click offers Print image |
|
||||
| a lesson button that opens a window | a new tab, shown |
|
||||
| a lesson button, an in-page anchor (`#…`) | what the page does; no tab |
|
||||
| a link with no target | followed in the same tab, as in a browser; Back returns |
|
||||
|
||||
Keys: Ctrl+W closes a tab, Alt+Left and Alt+Right navigate, Ctrl+P prints,
|
||||
Ctrl+Tab switches. Ctrl+T, Ctrl+N and Ctrl+L are deliberately unbound.
|
||||
@@ -140,9 +184,8 @@ Alongside it, two Qt defaults are turned off rather than left implicit:
|
||||
`LocalContentCanAccessFileUrls` (on by default, and would let a local
|
||||
document read other local documents) and `LocalContentCanAccessRemoteUrls`.
|
||||
|
||||
The print dialog's **Print to File** option is also removed. Choosing it
|
||||
enables an output path with a "..." button, which is a file chooser, in a
|
||||
room whose whole design is that there is no file chooser.
|
||||
Printing has no **Print to File** and no file chooser of any kind: the
|
||||
preview (below) offers the room's CUPS printers and nothing else.
|
||||
|
||||
### What a teacher still cannot reach
|
||||
|
||||
@@ -182,30 +225,518 @@ is Chrome's business and stays that way.
|
||||
Downloads folder. That is partly tidiness and mostly the security
|
||||
argument: it is the only place a worksheet can come from.
|
||||
|
||||
## Printing
|
||||
|
||||
### The crash (0.2.2, Little Waders)
|
||||
|
||||
Wonder Ink closed completely every time a teacher confirmed a print job:
|
||||
`SIGSEGV in QPrinter::resolution()`, twice, both through QtWebEngine's
|
||||
print-completion path.
|
||||
|
||||
`QWebEngineView.print(printer)` is asynchronous. In Qt 6.8.2
|
||||
(`qwebengineview.cpp`) it stores the raw `QPrinter*` and returns; when
|
||||
Chromium has rendered the page, `didPrintPage()` calls
|
||||
`currentPrinter->resolution()` and hands the printer to a worker thread.
|
||||
0.2.2 made the printer a local variable in `print_view()`, so PyQt deleted
|
||||
it when the function returned and Qt read freed memory. Reproduced in the
|
||||
lab with the 0.2.2 code: `QPrinter::resolution()` ←
|
||||
`QWebEnginePagePrivate::didPrintPage` ← `WebContentsAdapter::didPrintPage`,
|
||||
the Little Waders backtrace, and no job in CUPS.
|
||||
|
||||
Keeping the printer alive until `printFinished` stops the crash — that was
|
||||
built and measured first — and it is still not a working print path:
|
||||
|
||||
| Measured, QtWebEngine 6.8.2, lab queue built from Elmer's PPD | Qt `view.print()` | `printToPdf()` + `lp` |
|
||||
| --- | --- | --- |
|
||||
| A three-page lesson | 24.6 MB of 300 dpi pictures, no text, A4 on a test queue | 18 KB vector PDF with its text, on Letter, 0.43 s through Elmer's filters |
|
||||
| A worksheet PDF tab | **one blank page** | the worksheet file itself |
|
||||
| A PDF a site shows inline | — | `printToPdf` never answers (`CreatePreviewDocument got 0 page count`) — so it is downloaded and printed as a file |
|
||||
| A queue refusing jobs | reported as **printed** | "Refusing did not take the job: Destination … is not accepting jobs." |
|
||||
| Black and white on Elmer | — | IPP `print-color-mode` is **ignored** by the generic PCL 6 driver ("Unknown option", prints colour); `ColorModel=Grayscale` gives `pxlmono` |
|
||||
|
||||
Qt's path also mishandles three things its own source makes plain: a
|
||||
second `print()` on a page that is still printing is refused with no
|
||||
`printFinished`, the worker keeps the printer after the tab has gone, and
|
||||
quitting destroys the printer under that worker.
|
||||
|
||||
So 0.2.6.1 does not use Qt printing at all. **Print** makes a PDF — a
|
||||
worksheet already is one; a lesson page is rendered by the engine onto the
|
||||
printer's own paper with Chrome-like 10 mm margins — shows it, and hands it
|
||||
to CUPS with `lp`, which is how Chrome prints in the same rooms. Nothing
|
||||
asynchronous ever holds a printer, so closing a tab or Wonder Ink cannot
|
||||
break a job: once `lp` returns, the job is CUPS's.
|
||||
|
||||
### The preview
|
||||
|
||||
One dialog, for lessons and worksheets alike:
|
||||
|
||||
* the document as it will print, a whole page at a time, ‹ › to turn;
|
||||
* **Printer** — the CUPS destinations, the system default preselected
|
||||
(Elmer, in every room);
|
||||
* **Pages** — all, or some: `2`, `1-3`, `1, 3, 5-6`, checked against the
|
||||
document with the reason given if wrong;
|
||||
* **Copies** — 1 to 50;
|
||||
* **Color** — colour or black and white, only where the printer's driver
|
||||
has a grey mode, and asked for in that driver's own terms. Since 0.2.6.3
|
||||
the preview turns grey too (below);
|
||||
* **Cancel** sends nothing; **Print** returns to the lesson once CUPS has
|
||||
the job, and a notification says where it went. A refused job keeps the
|
||||
preview open with CUPS's reason.
|
||||
|
||||
Choosing a printer with different paper lays a lesson page out again. A
|
||||
page that closes its own tab while the preview is open is reported, not
|
||||
printed. Print pressed again while a preview is open does nothing, and so
|
||||
does a page calling `window.print()` meanwhile. The PDF viewer's own print
|
||||
button opens the same preview.
|
||||
|
||||
### Pictures (0.2.6.2)
|
||||
|
||||
A picture a lesson opens in a tab of its own — a colouring page as a PNG
|
||||
or JPEG — printed in 0.2.6.1 with **black bars** above and below it.
|
||||
Measured: the bars were neither in the picture nor in the page layout.
|
||||
The engine shows a lone picture on a viewer page whose background is
|
||||
rgb(14, 14, 14), scaled to the window, and Qt prints page backgrounds by
|
||||
default; the dark page went onto the paper around the artwork.
|
||||
|
||||
So a picture is no longer printed as that page. The preview takes the
|
||||
picture itself — drawn onto white, which is what transparent parts are on
|
||||
paper, at up to 3300 pixels (300 dpi across Letter) — and lays it out on
|
||||
the printer's own paper, and the teacher chooses:
|
||||
|
||||
* **Size** — as large as fits, 75%, 50%, 33% or 25% of that;
|
||||
* **Position** — nine squares, like the page itself;
|
||||
* **Paper** — automatic (landscape for a wide picture), upright or
|
||||
sideways;
|
||||
* **Fill the whole page** — covers the printable area and says it cuts the
|
||||
edges off. Nothing is cropped unless this is ticked.
|
||||
|
||||
The preview is the PDF that will print, laid out again after every change.
|
||||
`tools/lab/wonderink-print-check` measures it on the file CUPS actually
|
||||
kept (`PreserveJobFiles`), which is byte-for-byte the preview's: the
|
||||
picture within 1 point of where it was asked for, the paper turned as
|
||||
asked, and no ink anywhere else on the page. Worksheet PDFs and lesson
|
||||
pages have no picture controls; nothing about them changed.
|
||||
|
||||
Page backgrounds are also no longer printed for lesson pages, as in
|
||||
Chrome, where "Background graphics" is off unless asked for. A white-on-
|
||||
black page prints dark grey on white: Blink darkens light text when the
|
||||
background is left off, measured.
|
||||
|
||||
Temporary PDFs live in the teacher's `$XDG_RUNTIME_DIR` and are removed
|
||||
when the preview closes.
|
||||
|
||||
### Black and white is shown as it prints (0.2.6.3)
|
||||
|
||||
Printing through Elmer found that **Black and white** came out grey on
|
||||
paper while the preview stayed in colour. 0.2.6.2 only told the driver:
|
||||
the preview was the colour PDF, CUPS was given the colour PDF, and
|
||||
`ColorModel=Grayscale` turned it grey inside Elmer's filters.
|
||||
|
||||
So black and white is now made before the preview, not after it. When it
|
||||
is chosen, the preview makes a grey copy of the document with
|
||||
Ghostscript, shows that copy, and Print hands CUPS that same file — still
|
||||
with `ColorModel=Grayscale`, so Elmer's generic PCL 6 driver prints it
|
||||
with `pxlmono` as before rather than as a colour job that happens to be
|
||||
grey. Choosing **Color** shows and prints the colour document again.
|
||||
|
||||
Measured before building it (Ghostscript 10.05.1, in the lab):
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| a lesson page, a picture page, a leader guide, a fillable form | grey and only grey; same pages, page sizes and words; text still text; a filled-in form field and a drawn mark kept, in grey |
|
||||
| against what Elmer would have printed | Ghostscript's grey rendering of the colour original and of the grey copy give the **same greys**: within 1 level in 255 on every filled area and picture. Small text can land a fraction of a pixel over — on a real lesson page, 12 of 32,754 8×8 blocks, all on one line of body text, in pairs of opposite sign. Elmer's driver *is* Ghostscript. (poppler's own grey differs on 7% of a coloured page, which is why the copy is not made with poppler) |
|
||||
| how long | 0.05 s for a worksheet, 0.2 s for a text lesson page, 1.3 s for a full-page photograph, 2 s for 42 pages — and **4.4 s** for one lesson page with eleven full-size pictures, under the real Plasma session. `-dUseFastColor` saves 40% and changes the greys, so it is not used |
|
||||
| a PDF Ghostscript cannot read | exits 1 and **still leaves a file behind** |
|
||||
|
||||
From those:
|
||||
|
||||
* the copy is made in the background, the preview says *Preparing black
|
||||
and white…* and Print waits for it — four seconds is long enough to
|
||||
look like a frozen window. It has a minute before it is given up on,
|
||||
not the 20 s a page render has: a lesson of photographs on a slower
|
||||
laptop is slow, not stuck. Cancel stops it at once;
|
||||
* it is made once per document and kept, and each drawn page is kept, so
|
||||
turning Color and Black and white back and forth is instant after the
|
||||
first time, and stays on the page the teacher was looking at;
|
||||
* a conversion is judged by its exit status and its page count, never by
|
||||
whether a file appeared. If it fails the preview says so and offers
|
||||
Color. It **never prints the colour file in its place**: the teacher
|
||||
asked for black and white;
|
||||
* pages are never turned (pdfwrite otherwise rotates a page to match its
|
||||
text), and annotations are drawn into the page as they print, so no
|
||||
later filter can draw them again in colour;
|
||||
* a printer whose driver has no grey mode is not offered black and white,
|
||||
and choosing one returns the preview to colour.
|
||||
|
||||
`tools/lab/wonderink-print-check` shows a colourful lesson in colour,
|
||||
turns it to black and white and back five times on page 2, and prints
|
||||
page 2 twice: every grey preview is grey pixel for pixel, every colour
|
||||
one is not, the grey copy is made once, and the job CUPS kept is
|
||||
**byte-for-byte the file the preview showed**, grey on every page, sent
|
||||
with `ColorModel=Grayscale` through `pxlmono`. The same for a worksheet,
|
||||
a 50% top-left picture, a return to Color (which prints in colour through
|
||||
`pxlcolor`), and Cancel.
|
||||
|
||||
### Print image: a picture from inside a lesson (0.2.6.3)
|
||||
|
||||
Right-clicking a picture in a lesson and choosing Print printed the whole
|
||||
page around it. Some of Wonder Ink's pictures cannot be opened on their
|
||||
own through the site at all, so a teacher had no way to give that
|
||||
picture the picture layout 0.2.6.2 introduced.
|
||||
|
||||
The right-click menu now says what it will print. It asks the engine's
|
||||
own hit test what is under the pointer:
|
||||
|
||||
| Under the pointer | Measured `mediaType` | The menu offers |
|
||||
| --- | --- | --- |
|
||||
| a picture (`<img>`, also inside a link, a frame, from `blob:` or `data:`) | Image | **Print image…** and **Print page…** |
|
||||
| a `<canvas>` | Canvas | **Print image…** and **Print page…** |
|
||||
| words, empty space, a CSS background picture | None | **Print page…** |
|
||||
|
||||
Print page is always there. Ctrl+P and the Print button print the page,
|
||||
as before.
|
||||
|
||||
**How the picture is taken** was measured before anything was built, on
|
||||
a lab site with each kind of picture a lesson might have, a real
|
||||
right-click on each, and the site logging every request:
|
||||
|
||||
| | signed-in picture | hotlink-protected | one-time address | `blob:` / `data:` | another site's | canvas | WebP |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| engine's "download image" | ✓ | ✓ | **404** — fetched again | ✓ | ✓ | **nothing** | a file Qt cannot read |
|
||||
| `page.download(url)` | ✓ | **403** — no Referer | **404** | – | ✓ | – | the same |
|
||||
| `fetch()` from the page | ✓ | ✓ | ✓ | – | **refused** (CORS) | – | the same |
|
||||
| draw it on a canvas | ✓ | ✓ | ✓ | ✓ | **refused** (tainted) | – | ✓ |
|
||||
| **engine's "copy image"** | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
|
||||
Only the engine's own **copy image** hands over the picture in every
|
||||
case, and it is what Wonder Ink uses. It is the bitmap the page has
|
||||
already decoded — full size, transparency kept, any format Chromium
|
||||
reads (Qt itself cannot read WebP here) — and **it fetches nothing**: the
|
||||
site saw no request at all. So a picture behind the teacher's sign-in
|
||||
needs no sign-in again, and a picture the site hands out once is still
|
||||
there. 0.01 to 0.05 s in the lab.
|
||||
|
||||
It goes through the clipboard, which is the teacher's. So what is on it
|
||||
is kept first and put back as soon as the picture arrives — whenever it
|
||||
arrives, even after Cancel. Measured under the real Plasma session
|
||||
(KWin, Wayland) with another program owning the clipboard: saved in
|
||||
0.002 s, the picture in 0.016 s, and that other program then pasted its
|
||||
own text again.
|
||||
|
||||
The picture then goes into exactly the 0.2.6.2 picture preview — drawn on
|
||||
white, capped at 300 dpi across Letter, Size, Position, Paper, Fill the
|
||||
whole page, Color — named *Picture from* the lesson's title.
|
||||
|
||||
**When it cannot be taken.** A picture that never loaded is still a
|
||||
picture to the hit test, and the engine then copies nothing. After three
|
||||
seconds the preview says *This picture could not be taken from the page*
|
||||
and that nothing was printed, with Print off. It does **not** fall back
|
||||
to printing the page; Print page is one right-click away and says what
|
||||
it does.
|
||||
|
||||
What it cannot do, and says nothing about because it cannot know:
|
||||
|
||||
* **a CSS background picture** is not a picture to the engine's hit
|
||||
test — Chrome's own menu has no "Save image" for one either. The menu
|
||||
offers only Print page. If Wonder Ink's site draws its artwork this
|
||||
way, that is the next thing to look at; nothing in the lab says it does;
|
||||
* **a video** frame is not offered;
|
||||
* **the teacher's clipboard is put back into Wonder Ink.** Under Wayland
|
||||
with no clipboard manager — this session has none — the clipboard
|
||||
belongs to whichever program last set it. After a Print image that is
|
||||
Wonder Ink, so text copied in another program before it can still be
|
||||
pasted while Wonder Ink is open, and is gone once Wonder Ink closes.
|
||||
Measured in the lab session. Leaving the picture there instead would
|
||||
lose it at once.
|
||||
|
||||
`tools/lab/wonderink-print-check` runs a signed-in lesson site
|
||||
(`tools/lab/wonderink-lesson-site.py`: session cookie, one-time
|
||||
addresses, a second origin) and right-clicks for real: the words (Print
|
||||
page only, and the page prints), a JPEG, a PNG, a transparent PNG, a
|
||||
one-time picture, another site's, a `blob:`, a WebP, a canvas, a picture
|
||||
inside a link, a JPEG at 50% top left in black and white, a picture that
|
||||
never loaded, and Cancel. Each is judged on the job CUPS kept: where and
|
||||
how large, no ink elsewhere, the corner mark there — and **no request to
|
||||
the site** while it printed, and the teacher's clipboard theirs again.
|
||||
|
||||
`tools/lab/wonderink-print-check` runs all of this against the installed
|
||||
command in the lab, and judges every scenario by what CUPS actually
|
||||
received. `tests/test-classroom-printing.sh` covers the CUPS side at every
|
||||
build. **Neither is paper.** The first print through the real Elmer is a
|
||||
job for somebody standing next to it.
|
||||
|
||||
### Any other error
|
||||
|
||||
An exception escaping any Qt handler makes PyQt6 abort the whole process —
|
||||
measured: a mistake in one slot and the window vanishes. Wonder Ink now
|
||||
logs it to the journal and carries on.
|
||||
|
||||
## What stayed the same
|
||||
|
||||
* **A separate profile from the ordinary browser.** A teacher's Wonder Ink
|
||||
sign-in and the room's shared YouTube account are different sessions in
|
||||
different storage that cannot reach each other. The browser is still
|
||||
Chrome and nothing in this change touches it.
|
||||
* **Cookies live only as long as the window.** This is what the Chrome
|
||||
policy's `CookiesSessionOnlyForUrls` did for wonderink.org, and it is
|
||||
the reason closing Wonder Ink ends the teacher's session rather than
|
||||
leaving the next person signed in as the last one. The whole profile
|
||||
rather than one domain now, because this profile only ever goes where a
|
||||
lesson sends it. If a room ever wants sign-ins to survive, it is the one
|
||||
line `setPersistentCookiesPolicy` in `classroom-wonderink`.
|
||||
* **Cookies live only as long as the window**, as Chrome's
|
||||
`CookiesSessionOnlyForUrls` did for wonderink.org, so closing Wonder Ink
|
||||
signs the teacher out and the next person is not signed in as the last
|
||||
one. WonderInk's sign-in is the teacher's password in a cookie
|
||||
(measured, 0.2.6.4), which is one more reason it never reaches the
|
||||
disk. What does survive a close, for two hours, is the list of tabs —
|
||||
see *Temporary workspace restore*, below.
|
||||
* **The old Chrome profile is left where it is**, at
|
||||
`~/.local/share/ekklesia-classroom/wonderink`. The new one is
|
||||
`wonderink-shell` beside it, so rolling back to 0.2.0 finds the old one
|
||||
intact.
|
||||
* **One scale for the room.** Chrome has to be handed `UI_SCALE` because
|
||||
it was measured not to follow the compositor on Wayland. Qt does follow
|
||||
it — measured at `UI_SCALE=2.0`, where a Qt client on that session
|
||||
reports `devicePixelRatio 2.0` without being told anything — so Wonder
|
||||
Ink is deliberately *not* told, or the scale would be applied twice.
|
||||
* **One scale for the room** — see *How large a lesson is*, below.
|
||||
|
||||
## Temporary workspace restore (0.2.6.4)
|
||||
|
||||
A teacher who closes Wonder Ink by accident, or whose computer crashes or
|
||||
restarts, gets their recent tabs back if they open it again within **two
|
||||
hours of last using it**. After that, Wonder Ink starts fresh and the tabs
|
||||
are forgotten: the next teacher — overnight, next week — never meets the
|
||||
last one's. It is not a sign-in and not a memory of tabs.
|
||||
|
||||
10:00 using Wonder Ink
|
||||
10:45 closed by accident 10:50 opened again: the tabs are back
|
||||
11:00 finished, closed next Sunday: a fresh Wonder Ink
|
||||
|
||||
### Why it is tabs and not the sign-in
|
||||
|
||||
The first design kept the sign-in. It was measured on the real site
|
||||
before it was built, and stopped:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `rrf-auth` | a cookie the site's JavaScript writes: **`Basic ` and the base64 of the teacher's email and password**, sent as a header on every request. Not a token the server issued |
|
||||
| `rrf-session-id`, `rrf_tsec_tok`, `auth` | session cookies alongside it; none survives the window, which is why closing Wonder Ink signs the teacher out |
|
||||
| Local Storage `mddui_local_storage`, Session Storage | screen variables and a screen cache |
|
||||
| the site's start-up | asks the server to authenticate again every time it loads; a reload in the lab ended signed out even with the cookie present |
|
||||
|
||||
Keeping the sign-in would have meant keeping the teacher's password, in a
|
||||
form anyone at the maintenance terminal could reverse, on a shared
|
||||
computer. It is not done. **Closing Wonder Ink still signs the teacher
|
||||
out**, exactly as before; the engine writes no cookie to disk for any site.
|
||||
|
||||
### What comes back
|
||||
|
||||
* **Resource tabs** — a slide, a picture, a worksheet or video the lesson
|
||||
linked to — exactly as they were. WonderInk's files are public (the
|
||||
lesson's slide loads with no sign-in, measured), so they need none.
|
||||
* **WonderInk's own tabs as one.** Every WonderInk page has the same
|
||||
address (`https://app.wonderink.org/?`) whichever lesson it shows — the
|
||||
site draws each screen itself — so a restored WonderInk tab opens the
|
||||
site's start page and asks the teacher to sign in, as it would anyway.
|
||||
Several of them become one, where the first was, so a teacher meets one
|
||||
sign-in page, not five.
|
||||
* **Worksheet tabs**, named and where they were — if the file is still in
|
||||
`~/Downloads/Wonder Ink` and still a PDF. A worksheet that has gone is
|
||||
left out, silently. Nothing outside that folder is ever opened from a
|
||||
kept workspace, whatever the file says.
|
||||
* **The order, and the tab that was in front.** The tab in front loads at
|
||||
once; the others load the first time they are clicked. Measured: a PDF
|
||||
loaded in a tab nobody was looking at was fitted to a window of no size,
|
||||
and showed at 10% when clicked. Until then a tab is kept by the address
|
||||
it will load, so closing Wonder Ink again loses nothing.
|
||||
|
||||
Measured on the real site, with a real Viewer account, under the lab's
|
||||
Plasma session: the *Rise and Fall of Saul* lesson, its welcome slide and
|
||||
its WONDER@HOME PDF, the slide in front; closed and reopened — the three
|
||||
tabs in that order, the slide in front, the WonderInk tab at its sign-in
|
||||
page; and again after rebooting the machine with Wonder Ink open. The kept
|
||||
file held the three addresses and titles, and none of the password, the
|
||||
Basic-auth value or the email address.
|
||||
|
||||
Never: the sign-in, a cookie, a header, a page's storage, a form, the
|
||||
clipboard, a print preview or any of its temporary files.
|
||||
|
||||
### What is kept, and when
|
||||
|
||||
`~/.local/share/ekklesia-classroom/wonderink-workspace.json`, readable only
|
||||
by the classroom account:
|
||||
|
||||
format, last_activity, active, attempts,
|
||||
tabs: [{kind: "web" | "worksheet", where: address or file, title}]
|
||||
|
||||
An address is kept only if it is `http` or `https` and carries no
|
||||
credential — no user and password in it, no query parameter named like a
|
||||
token, key, password, session or signature. `tests/` writes a workspace
|
||||
with such addresses and a Basic-auth value and checks the file holds none
|
||||
of them; the lab signs in on a stand-in whose sign-in is Basic auth, as
|
||||
WonderInk's is, and checks the same.
|
||||
|
||||
It is written a second after the tabs change — opened, closed, dragged
|
||||
into another order, brought to the front, navigated — and the time of
|
||||
last use at most every 30 seconds, whole, to a temporary file renamed
|
||||
into place. A crash or a power cut loses at most half a minute of use and
|
||||
never leaves half a file.
|
||||
|
||||
### What counts as using it
|
||||
|
||||
The teacher's input reaching Wonder Ink: a click, a key, the wheel, a
|
||||
touch — so following a link, scrolling, switching, opening and closing
|
||||
tabs, opening a worksheet, printing and zooming all count. Nothing a page
|
||||
does by itself does: timers, animation, background requests, analytics,
|
||||
advertising, video buffering, service workers — measured with a page
|
||||
making a request every second and animating for 20 s: not activity. A
|
||||
print preview left open untouched is not activity either.
|
||||
|
||||
**Opening Wonder Ink and having the tabs restored is not using it.** The
|
||||
time kept is the teacher's last use, carried over, so closing and
|
||||
reopening cannot keep a workspace alive by itself; the teacher clicking
|
||||
in the restored tabs is using it, and the two hours start again.
|
||||
|
||||
### When it is not restored
|
||||
|
||||
At start, and only then: more than two hours since the last use, a file
|
||||
that cannot be read or is from another version, a clock gone backwards by
|
||||
more than a minute — Wonder Ink starts fresh, removes the file and says
|
||||
why in the journal. No question is asked.
|
||||
|
||||
A restore that crashes Wonder Ink does not make it unusable. Starting from
|
||||
a workspace is counted in the file before any tab loads, and cleared once
|
||||
Wonder Ink has run 20 seconds. After two starts that did not get that
|
||||
far, the workspace is abandoned and Wonder Ink starts fresh, with a line
|
||||
in the journal.
|
||||
|
||||
Two hours is `IDLE_LIMIT_SECONDS` in
|
||||
`lib/classroom_wonderink_workspace.py`, the one place it is decided.
|
||||
|
||||
**Testing without waiting two hours.** From the maintenance terminal in
|
||||
the session (Meta+Ctrl+Alt+Shift+K), with Wonder Ink closed:
|
||||
|
||||
CLASSROOM_WONDERINK_TEST_WORKSPACE_SECONDS=120 classroom-wonderink
|
||||
|
||||
That one Wonder Ink keeps the workspace for two minutes after the last
|
||||
use, and says `TEST ONLY` in the journal. The variable can only shorten
|
||||
the time, nothing in the classroom sets it (a test checks), and it lasts
|
||||
as long as that process.
|
||||
|
||||
### What it cannot do
|
||||
|
||||
* **Reopen a lesson where it was.** WonderInk's screens have no address of
|
||||
their own, so its tab comes back at its start page; after signing in
|
||||
the teacher opens the lesson again. The slides, pictures and worksheets
|
||||
they had open alongside it are already there.
|
||||
* **Know who is at the keyboard.** Two hours is two hours of nobody using
|
||||
Wonder Ink. A teacher who leaves and whose room is used within two hours
|
||||
leaves their tabs — not their sign-in — to the next person.
|
||||
|
||||
`tools/lab/wonderink-workspace-check` runs each of these against the
|
||||
installed command: restored after 5 and 90 minutes; fresh after two hours
|
||||
and a minute, the next day and the next week; reopened twice untouched;
|
||||
activity after a restore; a busy page; an untouched print preview;
|
||||
pressing Print; a closed tab; tabs reordered; a worksheet, and one whose
|
||||
file has gone; killed outright; a restore that crashes twice; a corrupt
|
||||
file, a later format, a half-written file; the privacy of the file; and
|
||||
restored tabs printing. `tests/test-classroom-wonderink-workspace.sh`
|
||||
covers the rules at every build.
|
||||
|
||||
## How large a lesson is
|
||||
|
||||
Teachers found lessons in Wonder Ink too small next to the same site in
|
||||
the ordinary browser. Measured in the lab (Plasma 6 on KWin, Debian 13) by
|
||||
asking each page for `window.devicePixelRatio` and its width in CSS pixels,
|
||||
with the display scale set the way a room sets it (`kscreen-doctor` and
|
||||
`UI_SCALE`):
|
||||
|
||||
| Display scale | Wonder Ink 0.2.6 | Chrome, as `classroom-browser` starts it |
|
||||
| --- | --- | --- |
|
||||
| 100% | dpr 1, 1920 px wide | dpr 1, 1920 px wide |
|
||||
| 150% | dpr 1.5, 1280 px wide | **dpr 2.25**, 854 px wide |
|
||||
| 200% | dpr 2, 960 px wide | **dpr 4**, 500 px wide |
|
||||
|
||||
Wonder Ink was right by the desktop's measure: it follows the display
|
||||
scale exactly, like the panel and every dialog. **Chrome is scaled twice.**
|
||||
Under Plasma it follows the compositor's scale itself, and
|
||||
`classroom-browser` also forces `--force-device-scale-factor=UI_SCALE` — a
|
||||
flag from the sway session, where Chrome was measured *not* following the
|
||||
compositor. So in any room above 100%, every page in the browser is
|
||||
`UI_SCALE` times larger than the desktop around it, and that is the size
|
||||
teachers read lessons at.
|
||||
|
||||
0.2.6.1 does not change the browser — it is the size the rooms know, and
|
||||
changing it everywhere is its own decision. Wonder Ink's pages start at
|
||||
the browser's size instead: **page zoom = `UI_SCALE`**, the room's own
|
||||
clamped number, which is exactly 1.0 in a room at 100%. Wonder Ink's
|
||||
window, tabs and dialogs are not zoomed; Qt already scales them once,
|
||||
correctly. Measured after the change: at 150% the lesson reports dpr 2.25
|
||||
and 853 px, Chrome dpr 2.25 and 854 px.
|
||||
|
||||
**The teacher's own size** goes on top: Ctrl+Plus (and Ctrl+=, and the
|
||||
keypad's), Ctrl+Minus, Ctrl+0 for normal, Ctrl+wheel, or the right-click
|
||||
menu. Chrome's steps, 50% to 300% of the room's normal size, one level for
|
||||
every tab including new ones. It is remembered in
|
||||
`~/.config/ekklesia-classroom/wonderink.conf` as a single `LEVEL=` line —
|
||||
its own file, nowhere near the engine profile, so remembering a size can
|
||||
never mean remembering a sign-in — and relative to the room, so an
|
||||
administrator changing `UI_SCALE` later still gets the right result.
|
||||
|
||||
Measured along the way:
|
||||
|
||||
* the engine keeps a tab's zoom across every navigation, same site or not,
|
||||
so it is set once when the tab is made and never re-applied;
|
||||
* zoom does not change what prints: the same lesson is 3 pages at 100%,
|
||||
200% and 400%;
|
||||
* **page zoom does not reach a worksheet tab.** Chromium's PDF viewer
|
||||
follows the display scale like the window, fits the page to its width,
|
||||
and has its own − / + zoom in its toolbar, which is what a teacher uses
|
||||
there;
|
||||
* two Qt actions on one key are ambiguous and Qt does *neither* — Ctrl+Minus
|
||||
did nothing while it was bound once by name and again as KDE's standard
|
||||
zoom-out key. One action per command now carries all its keys, which
|
||||
also fixed Ctrl+Shift+Tab (bound twice the same way since 0.2.1).
|
||||
|
||||
`tools/lab/wonderink-zoom-check` asks the installed command all of this.
|
||||
The lab's injected keyboard does not reach its nested session, so the
|
||||
check delivers keys through Qt's own key path to the focused page; a
|
||||
physical Ctrl+Plus on a classroom keyboard is a hardware check.
|
||||
|
||||
## Why lessons load slowly
|
||||
|
||||
Measured before anything was changed, and nothing about loading was
|
||||
changed, because the evidence does not point at Wonder Ink.
|
||||
|
||||
In the lab, on the same connection, the same page, timed by each
|
||||
engine's own Navigation Timing until the sign-in form exists (the site
|
||||
is a 3.7 KB shell that builds itself in JavaScript, so its load event is
|
||||
not the moment a teacher can use it):
|
||||
|
||||
| Until the sign-in page is usable | Wonder Ink | Chrome |
|
||||
| --- | --- | --- |
|
||||
| cold cache, 3 runs | 2.86 – 3.00 s | 2.79 – 3.48 s |
|
||||
| warm cache, 3 runs | 2.20 – 2.49 s | 1.96 – 2.15 s |
|
||||
| window on screen after launch | 0.33 s | 0.42 s |
|
||||
|
||||
* **The site is the weight:** 119 – 160 requests and 3.2 MB for a sign-in
|
||||
form, about 430 KB warm. Its console says it loads the Google Maps
|
||||
JavaScript API *twice*, synchronously. That is Wonder Ink's to fix.
|
||||
* Wonder Ink is about a quarter of a second slower warm: it revalidates
|
||||
the page shell (62 – 87 ms to first byte) where Chrome serves it from
|
||||
its cache. Not the "very long time" teachers describe.
|
||||
* Memory: about 610 MB for one tab and 115 MB more for each further tab —
|
||||
eight tabs, 1.4 GB — one renderer each. Idle CPU about 1%: nothing
|
||||
spins in the background.
|
||||
* The network: from the lab, 0.14 – 0.22 s to first byte; from the
|
||||
development host, 0.42 – 0.44 s. Cloudflare in front, uncached.
|
||||
|
||||
What the lab **cannot** show is the machines: it draws in software for
|
||||
both engines. A classroom whose graphics fail in QtWebEngine but work in
|
||||
Chrome would look exactly like the complaint. So there is a command for a
|
||||
technician, run from the maintenance terminal inside the teacher's
|
||||
session (Meta+Ctrl+Alt+Shift+K):
|
||||
|
||||
classroom-wonderink --diagnose
|
||||
|
||||
It prints the engine's versions, the scale, **what WebGL reports as the
|
||||
renderer** — a real driver's name, or "none … drawing in software" — and
|
||||
times the sign-in page from a cold cache in a throwaway profile, beside a
|
||||
running Wonder Ink without touching it. Compare its time with the lab's
|
||||
2.9 s, and its renderer with Chrome's on the same machine (open
|
||||
`chrome://gpu` there).
|
||||
|
||||
|
||||
## Turning it off
|
||||
|
||||
One future device is a laptop that will not have Wonder Ink at all.
|
||||
|
||||
@@ -362,3 +893,7 @@ For completeness, so nobody re-audits them: the managed policy in
|
||||
`/etc/opt/chrome/policies/managed/` sets first-run, sign-in, password
|
||||
and homepage behaviour only. None of it touches media, autoplay,
|
||||
iframes, fullscreen or site permissions.
|
||||
|
||||
## Later
|
||||
|
||||
What testing of 0.2.6.1 asked for next is in [backlog.md](backlog.md).
|
||||
|
||||
@@ -101,6 +101,19 @@ DVD_ENABLED=true
|
||||
# in the corner is then one more thing to explain.
|
||||
PARENT_CALL_ENABLED=true
|
||||
|
||||
# Whether this device offers Presentations.
|
||||
#
|
||||
# A teacher downloads a session in Chrome and it appears here, ready to
|
||||
# show on the television: no file manager, no Impress, nothing to
|
||||
# explain. Off by default, because every existing classroom upgrading
|
||||
# into this release has never had it and a new button nobody asked for
|
||||
# is a new thing to support.
|
||||
#
|
||||
# It depends on nothing else. A machine may have Presentations with no
|
||||
# Wonder Ink, no disc drive, no Parent Call, on Wi-Fi or a cable, as a
|
||||
# laptop or a tower.
|
||||
PRESENTATIONS_ENABLED=false
|
||||
|
||||
# Which network this device is meant to be on.
|
||||
#
|
||||
# wired an Ethernet cable is the intended connection.
|
||||
@@ -212,6 +225,22 @@ DVD_ENABLED=true
|
||||
# A room full of children needs it. A Bible study does not, and the button
|
||||
# in the corner is then one more thing to explain.
|
||||
PARENT_CALL_ENABLED=true
|
||||
""",
|
||||
),
|
||||
(
|
||||
"PRESENTATIONS_ENABLED",
|
||||
"""# Whether this device offers Presentations.
|
||||
#
|
||||
# A teacher downloads a session in Chrome and it appears here, ready to
|
||||
# show on the television: no file manager, no Impress, nothing to
|
||||
# explain. Off by default, because every existing classroom upgrading
|
||||
# into this release has never had it and a new button nobody asked for
|
||||
# is a new thing to support.
|
||||
#
|
||||
# It depends on nothing else. A machine may have Presentations with no
|
||||
# Wonder Ink, no disc drive, no Parent Call, on Wi-Fi or a cable, as a
|
||||
# laptop or a tower.
|
||||
PRESENTATIONS_ENABLED=false
|
||||
""",
|
||||
),
|
||||
(
|
||||
@@ -325,6 +354,12 @@ FEATURES = {
|
||||
"WONDERINK_ENABLED": True,
|
||||
"DVD_ENABLED": True,
|
||||
"PARENT_CALL_ENABLED": True,
|
||||
# The one that defaults to OFF, and the only one that can. Every
|
||||
# existing classroom has Wonder Ink, a drive and Parent Call, so a
|
||||
# room upgrading into this release must come up with all three. None
|
||||
# of them has ever had Presentations, so a room upgrading into this
|
||||
# release must come up without it.
|
||||
"PRESENTATIONS_ENABLED": False,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -36,10 +36,24 @@ import re
|
||||
|
||||
# ifupdown's own spelling. Values may be quoted, and a psk may be a
|
||||
# 64-character hex key rather than a passphrase.
|
||||
SETTINGS = ("wpa-ssid", "wpa-psk", "wpa-key-mgmt", "wpa-conf", "wpa-proto")
|
||||
SETTINGS = ("wpa-ssid", "wpa-psk", "wpa-key-mgmt", "wpa-conf", "wpa-proto",
|
||||
"wpa-scan-ssid")
|
||||
|
||||
# Whether the access point broadcasts its name.
|
||||
#
|
||||
# wpa-scan-ssid 1 is ifupdown's spelling of wpa_supplicant's scan_ssid=1:
|
||||
# probe for this network by name, because it is not in a passive scan.
|
||||
# NetworkManager spells the same thing 802-11-wireless.hidden, and a
|
||||
# migration that drops it produces a connection that is correct in every
|
||||
# visible respect and can never associate — NetworkManager will not
|
||||
# connect to an SSID it has not seen in a scan, and a hidden one is never
|
||||
# in a scan. That is what happened to the prayer-room laptop on 0.2.5.
|
||||
HIDDEN = "wpa-scan-ssid"
|
||||
|
||||
_STANZA = re.compile(r"^\s*(iface|auto|allow-hotplug|source|source-directory)\s+(.*)$")
|
||||
|
||||
TRUTHY = {"1", "yes", "true", "on"}
|
||||
|
||||
|
||||
def _unquote(value):
|
||||
value = (value or "").strip()
|
||||
@@ -63,7 +77,19 @@ def parse(text):
|
||||
current = None
|
||||
|
||||
for number, raw in enumerate(lines):
|
||||
line = raw.split("#", 1)[0].rstrip()
|
||||
# A comment is a WHOLE line, and only a whole line.
|
||||
#
|
||||
# interfaces(5): "Lines starting with '#' are ignored. Note that
|
||||
# end-of-line comments are NOT supported." This used to cut every
|
||||
# line at its first '#', which is a silent corruption of exactly
|
||||
# one value: a pre-shared key containing a '#' was truncated, the
|
||||
# migrated connection got a password that was a prefix of the
|
||||
# real one, and the only symptom is an association that fails the
|
||||
# way a wrong password fails.
|
||||
if raw.lstrip().startswith("#"):
|
||||
continue
|
||||
|
||||
line = raw.rstrip()
|
||||
match = _STANZA.match(line)
|
||||
|
||||
if match:
|
||||
@@ -129,12 +155,57 @@ def credentials(stanza):
|
||||
network inline returns no SSID: that one is not ours to move, and the
|
||||
caller must leave it alone rather than guess.
|
||||
"""
|
||||
found = profile(stanza)
|
||||
|
||||
if found is None:
|
||||
return None, None
|
||||
|
||||
return found["ssid"], found["psk"]
|
||||
|
||||
|
||||
def profile(stanza):
|
||||
"""Everything needed to recreate this stanza in NetworkManager.
|
||||
|
||||
A dict, or None when the stanza is not ours to move. Returning the
|
||||
whole network rather than a pair is the fix for the 0.2.5 failure:
|
||||
`wpa-scan-ssid` was sitting in the parsed options the whole time and
|
||||
the only accessor threw it away, so the hand-over could not have
|
||||
preserved it however carefully it was written.
|
||||
|
||||
`key_mgmt` is "wpa-psk", "none", or None meaning "this file says
|
||||
something this command does not understand". The caller must refuse
|
||||
that last one rather than guess: a WPA-Enterprise network recreated
|
||||
as a PSK network is a migration that cannot work and has already
|
||||
taken the interface away from ifupdown to find out.
|
||||
"""
|
||||
options = stanza.get("options") or {}
|
||||
|
||||
if "wpa-conf" in options and "wpa-ssid" not in options:
|
||||
return None, None
|
||||
return None
|
||||
|
||||
return options.get("wpa-ssid") or None, options.get("wpa-psk") or None
|
||||
ssid = options.get("wpa-ssid") or None
|
||||
|
||||
if not ssid:
|
||||
return None
|
||||
|
||||
psk = options.get("wpa-psk") or None
|
||||
declared = (options.get("wpa-key-mgmt") or "").strip().upper()
|
||||
|
||||
if declared in ("", "WPA-PSK", "WPA-PSK-SHA256", "SAE", "WPA-PSK SAE"):
|
||||
key_mgmt = "wpa-psk" if psk else "none"
|
||||
elif declared == "NONE":
|
||||
key_mgmt = "none"
|
||||
else:
|
||||
# WPA-EAP, IEEE8021X and anything else this has never seen.
|
||||
key_mgmt = None
|
||||
|
||||
return {
|
||||
"ssid": ssid,
|
||||
"psk": psk,
|
||||
# A network that has to be probed for by name. See HIDDEN.
|
||||
"hidden": (options.get(HIDDEN) or "").strip().lower() in TRUTHY,
|
||||
"key_mgmt": key_mgmt,
|
||||
}
|
||||
|
||||
|
||||
def comment_out(text, stanza, marker="# ekklesia-classroom: "):
|
||||
@@ -160,3 +231,31 @@ def comment_out(text, stanza, marker="# ekklesia-classroom: "):
|
||||
break
|
||||
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
def uncomment(text, marker="# ekklesia-classroom: "):
|
||||
"""Undo comment_out, in the file as it stands now.
|
||||
|
||||
Returns (text, count). The rollback uses this rather than copying the
|
||||
backup over the top, because the backup is kept once and never
|
||||
refreshed: an administrator who corrected a static Ethernet address
|
||||
between the migration and the rollback would have had that correction
|
||||
silently reverted by a whole-file restore. This touches only the
|
||||
lines this package marked, so everything else in the file — wired
|
||||
stanzas, source lines, an administrator's own comments — is exactly
|
||||
as the machine left it.
|
||||
"""
|
||||
lines = (text or "").splitlines(keepends=True)
|
||||
restored = 0
|
||||
|
||||
for number, line in enumerate(lines):
|
||||
stripped = line.lstrip()
|
||||
|
||||
if not stripped.startswith(marker):
|
||||
continue
|
||||
|
||||
indent = line[:len(line) - len(stripped)]
|
||||
lines[number] = indent + stripped[len(marker):]
|
||||
restored += 1
|
||||
|
||||
return "".join(lines), restored
|
||||
|
||||
@@ -0,0 +1,898 @@
|
||||
"""The classroom's presentation library.
|
||||
|
||||
What a teacher sees is in classroom-presentations. This is everything
|
||||
underneath it — where the files live, what is known about each one, and
|
||||
the two things that have to be right whoever is asking: when a download
|
||||
has really finished, and when a presentation has really been presented.
|
||||
|
||||
WHY THE ORIGINAL LIVES IN THE MANAGED LIBRARY.
|
||||
|
||||
A teacher downloads a session into ordinary ~/Downloads. Once the download
|
||||
is complete, it is moved into this managed library. These locked-down
|
||||
machines have no general file manager, so leaving another copy in Downloads
|
||||
only wastes space and makes Remove misleading. The moved original is kept
|
||||
beside the generated PDF and page images, which means conversion failure is
|
||||
recoverable and cleanup has one contained directory to remove.
|
||||
|
||||
WHY EVERY FORMAT BECOMES A PDF.
|
||||
|
||||
Measured in the lab, on Debian 13, before any of this was designed —
|
||||
docs/presentations.md has the whole table. The short of it:
|
||||
|
||||
soffice --show starts a real fullscreen slideshow, and leaves
|
||||
the Impress editor behind when it ends. It also
|
||||
wants a first-run dialog dismissed and shows a
|
||||
splash. A teacher who presses Escape must land
|
||||
in the library, never in an application that can
|
||||
open any file on the machine.
|
||||
|
||||
okular --presentation opens THREE windows: the
|
||||
presentation, the ordinary Okular document
|
||||
window behind it, and an information dialog.
|
||||
|
||||
So LibreOffice converts to PDF once, at import, with no window on the
|
||||
screen at all, and poppler renders that PDF to page images. The classroom
|
||||
presents the images. Nothing here renders a slide: LibreOffice and
|
||||
poppler do all of it, which is the same engine Impress would have used
|
||||
live. This is not a slide renderer — it is a picture viewer over pictures
|
||||
somebody else drew.
|
||||
|
||||
That also makes a PDF export a first-class citizen rather than a special
|
||||
case: a PDF is already the thing everything else is converted into, so a
|
||||
session exported from Google Slides as PDF skips a conversion and keeps
|
||||
the appearance Google gave it.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
import unicodedata
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# What a teacher may put in the library.
|
||||
#
|
||||
# Three, and deliberately no more. Every one of them is something the
|
||||
# pastor's material is actually exported as, and each has a conversion
|
||||
# into PDF that Debian packages.
|
||||
SUPPORTED = {
|
||||
".pdf": "pdf",
|
||||
".pptx": "pptx",
|
||||
".ppt": "ppt",
|
||||
}
|
||||
|
||||
# A download that is still arriving.
|
||||
#
|
||||
# Chrome writes "Session 7.pptx.crdownload" and renames it to
|
||||
# "Session 7.pptx" when the transfer completes, so the presence of the
|
||||
# sibling is the authoritative answer to "has this finished". The others
|
||||
# are what a different browser or a copy in progress leaves.
|
||||
PARTIAL_SUFFIXES = (".crdownload", ".part", ".partial", ".download", ".tmp")
|
||||
|
||||
# How long a file has to stop changing before it counts as finished.
|
||||
#
|
||||
# Belt and braces with the .crdownload check above. A file copied onto
|
||||
# the machine from a USB stick has no .crdownload beside it and is still
|
||||
# half written, and a teacher watching a presentation appear mid-copy
|
||||
# would get a library entry that cannot be opened.
|
||||
SETTLE_SECONDS = 6
|
||||
|
||||
# Retention, in days. Neither is a teacher-facing setting in 0.2.6.
|
||||
NEVER_PRESENTED_DAYS = 180
|
||||
AFTER_PRESENTING_DAYS = 30
|
||||
|
||||
# How wide a rendered slide is, in pixels.
|
||||
#
|
||||
# The classroom televisions are 1080p and 4K. 1920 is the 1080p set
|
||||
# exactly and a 4K set scaled up by two, which on static Bible study
|
||||
# slides is indistinguishable at classroom distance and is a quarter of
|
||||
# the pixels to render and store.
|
||||
SLIDE_WIDTH = 1920
|
||||
|
||||
# The library card. Small on purpose: a wall of these has to stay quick.
|
||||
THUMBNAIL_WIDTH = 480
|
||||
|
||||
|
||||
def data_home():
|
||||
return Path(os.environ.get("XDG_DATA_HOME")
|
||||
or Path.home() / ".local" / "share")
|
||||
|
||||
|
||||
def runtime_home():
|
||||
return Path(os.environ.get("XDG_RUNTIME_DIR") or "/tmp")
|
||||
|
||||
|
||||
def library_root():
|
||||
return data_home() / "ekklesia-classroom" / "presentations"
|
||||
|
||||
|
||||
def downloads_root():
|
||||
return Path(os.environ.get("XDG_DOWNLOAD_DIR") or Path.home() / "Downloads")
|
||||
|
||||
|
||||
# Wonder Ink's own downloads, which are not presentations and must never
|
||||
# be imported, moved or removed by any of this. classroom-wonderink puts
|
||||
# a lesson's worksheets here; see the DOWNLOADS comment in that file for
|
||||
# why it is a directory of its own.
|
||||
WONDERINK_DOWNLOADS = "Wonder Ink"
|
||||
|
||||
|
||||
def presenting_lock():
|
||||
"""The file that says a presentation is on the screen right now.
|
||||
|
||||
In the runtime directory, so it cannot outlive a reboot: a lock left
|
||||
behind by a crash would otherwise make a presentation permanently
|
||||
un-removable.
|
||||
"""
|
||||
return runtime_home() / "ekklesia-classroom-presenting"
|
||||
|
||||
|
||||
def now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def stamp(moment=None):
|
||||
return (moment or now()).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def parse_stamp(text):
|
||||
if not text:
|
||||
return None
|
||||
|
||||
try:
|
||||
return datetime.strptime(text, "%Y-%m-%dT%H:%M:%SZ").replace(
|
||||
tzinfo=timezone.utc)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def write_json(path, data):
|
||||
"""Write a JSON file so that a crash cannot leave half of one.
|
||||
|
||||
Everything the library knows is written this way. A presentation whose
|
||||
metadata is truncated is a card a teacher cannot open and cannot
|
||||
remove, which is worse than one that was never imported.
|
||||
"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(path.name + ".new")
|
||||
|
||||
with open(temporary, "w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, indent=2, sort_keys=True)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def read_json(path, default=None):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
except (OSError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
# ------------------------------------------------------------- titles
|
||||
|
||||
# Things a download picks up on its way to the machine, none of which
|
||||
# mean anything to a teacher.
|
||||
_NOISE = re.compile(
|
||||
r"""(
|
||||
\s*\(\d+\)$ # Chrome's " (1)" for a second copy
|
||||
| ^\d{8,}[-_\s]+ # a leading timestamp
|
||||
| [-_\s]*\b[0-9a-f]{8,}\b # a hash or a UUID fragment
|
||||
| \b(final|v\d+|copy|export|download)\b
|
||||
)""",
|
||||
re.IGNORECASE | re.VERBOSE,
|
||||
)
|
||||
|
||||
|
||||
def readable_title(filename, fallback_text=""):
|
||||
"""A title a teacher would recognise, from a filename they did not choose.
|
||||
|
||||
Google Slides exports as "Session 7 - Romans.pptx" and a browser may
|
||||
make that "Session_7_-_Romans%20(1).pptx". Neither a UUID nor a
|
||||
40-character hash is a title, and a teacher scanning a wall of cards
|
||||
for tonight's session should not have to read one.
|
||||
"""
|
||||
stem = Path(filename).stem
|
||||
|
||||
# Percent-escapes a download can carry, decoded only when they are
|
||||
# obviously escapes rather than a literal per cent in a name.
|
||||
if "%20" in stem or "%28" in stem:
|
||||
for escape, character in (("%20", " "), ("%28", "("), ("%29", ")"),
|
||||
("%26", "&"), ("%27", "'")):
|
||||
stem = stem.replace(escape, character)
|
||||
|
||||
stem = unicodedata.normalize("NFC", stem)
|
||||
stem = stem.replace("_", " ").replace("+", " ")
|
||||
# A dash between words is a dash; a dash padding one is a space.
|
||||
stem = re.sub(r"\s*-\s*", " - ", stem)
|
||||
|
||||
cleaned = _NOISE.sub(" ", stem)
|
||||
cleaned = re.sub(r"\s+", " ", cleaned).strip(" -–—")
|
||||
|
||||
if len(cleaned) >= 3 and not re.fullmatch(r"[\W\d_]+", cleaned):
|
||||
# Long enough to be a name and not purely punctuation and digits.
|
||||
return cleaned[:80]
|
||||
|
||||
# The filename told us nothing. The first readable line of the
|
||||
# document itself is a better answer than "a4f39c2e".
|
||||
for line in (fallback_text or "").splitlines():
|
||||
line = re.sub(r"\s+", " ", line).strip()
|
||||
|
||||
if len(line) >= 3:
|
||||
return line[:80]
|
||||
|
||||
return "Presentation"
|
||||
|
||||
|
||||
def unique_title(title, taken):
|
||||
"""A title that is not already on another card.
|
||||
|
||||
Two files of the same name are two presentations, never one
|
||||
overwriting the other, so the second has to be tellable from the
|
||||
first on a wall of cards.
|
||||
"""
|
||||
if title not in taken:
|
||||
return title
|
||||
|
||||
for number in range(2, 100):
|
||||
candidate = f"{title} ({number})"
|
||||
|
||||
if candidate not in taken:
|
||||
return candidate
|
||||
|
||||
return f"{title} ({uuid.uuid4().hex[:6]})"
|
||||
|
||||
|
||||
# -------------------------------------------------------- the library
|
||||
|
||||
class Presentation:
|
||||
"""One entry in the library, and the directory holding it."""
|
||||
|
||||
def __init__(self, directory, data):
|
||||
self.directory = Path(directory)
|
||||
self.data = data
|
||||
|
||||
# -- identity -------------------------------------------------
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
return self.data.get("id") or self.directory.name
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
return self.data.get("title") or "Presentation"
|
||||
|
||||
@property
|
||||
def pages(self):
|
||||
try:
|
||||
return max(0, int(self.data.get("pages") or 0))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
return self.data.get("state") or "importing"
|
||||
|
||||
@property
|
||||
def ready(self):
|
||||
return self.state == "ready" and self.slides.exists()
|
||||
|
||||
@property
|
||||
def failed(self):
|
||||
return self.state == "failed"
|
||||
|
||||
@property
|
||||
def failure(self):
|
||||
"""Why it failed, as one word a teacher's card can be chosen by.
|
||||
|
||||
"memory": the conversion was killed for want of memory, or was
|
||||
interrupted too often to keep trying — worth trying again once
|
||||
other programs are closed. "unreadable": the file itself would not
|
||||
convert, which trying again will not change.
|
||||
"""
|
||||
if self.stalled:
|
||||
return "memory"
|
||||
|
||||
if not self.failed:
|
||||
return None
|
||||
|
||||
return self.data.get("error_kind") or "unreadable"
|
||||
|
||||
@property
|
||||
def stalled(self):
|
||||
"""Still "getting ready" long after any import could have finished.
|
||||
|
||||
The importer's own limits end every attempt within
|
||||
CONVERT_TIMEOUT + RENDER_TIMEOUT. Past that, with no answer, the
|
||||
importer is not running at all — and a card that says "Getting
|
||||
this ready" for ever is the thing a teacher must never be left
|
||||
looking at.
|
||||
"""
|
||||
if self.state not in ("importing", "retry"):
|
||||
return False
|
||||
|
||||
started = parse_stamp(self.data.get("attempt_started_at")
|
||||
or self.data.get("imported_at"))
|
||||
|
||||
if started is None:
|
||||
return False
|
||||
|
||||
return (now() - started).total_seconds() > STALLED_SECONDS
|
||||
|
||||
@property
|
||||
def error(self):
|
||||
return self.data.get("error") or ""
|
||||
|
||||
# -- files ----------------------------------------------------
|
||||
|
||||
@property
|
||||
def slides(self):
|
||||
"""The PDF everything is presented and rendered from."""
|
||||
return self.directory / "slides.pdf"
|
||||
|
||||
@property
|
||||
def pages_directory(self):
|
||||
return self.directory / "pages"
|
||||
|
||||
def page_image(self, number):
|
||||
return self.pages_directory / f"page-{number:03d}.png"
|
||||
|
||||
@property
|
||||
def thumbnail(self):
|
||||
return self.directory / "thumbnail.png"
|
||||
|
||||
@property
|
||||
def managed_copy(self):
|
||||
name = self.data.get("managed_copy")
|
||||
|
||||
return self.directory / name if name else None
|
||||
|
||||
# -- what a teacher chose -------------------------------------
|
||||
|
||||
@property
|
||||
def thumbnail_page(self):
|
||||
"""Which slide is the card's picture.
|
||||
|
||||
Independent of where a presentation starts, and independent of
|
||||
whether it has ever been presented. Changing it is not a
|
||||
presentation and does not touch a retention timer — the three are
|
||||
kept apart on purpose, because the first slide of a Bible study
|
||||
session is a generic opening shared by every session and the one
|
||||
that names the session is usually the second.
|
||||
"""
|
||||
try:
|
||||
chosen = int(self.data.get("thumbnail_page") or 1)
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
return min(max(chosen, 1), self.pages or 1)
|
||||
|
||||
# -- retention ------------------------------------------------
|
||||
|
||||
@property
|
||||
def imported_at(self):
|
||||
return parse_stamp(self.data.get("imported_at"))
|
||||
|
||||
@property
|
||||
def last_presented_at(self):
|
||||
return parse_stamp(self.data.get("last_presented_at"))
|
||||
|
||||
@property
|
||||
def times_presented(self):
|
||||
try:
|
||||
return max(0, int(self.data.get("times_presented") or 0))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
def expires_at(self):
|
||||
"""When the cleanup may remove this, or None if it cannot say.
|
||||
|
||||
Two clocks, and only ever one of them running. A presentation
|
||||
that has never been shown is kept for six months from the day it
|
||||
arrived. One that has been shown is kept for a month from the
|
||||
last time it was actually shown, and every showing starts that
|
||||
month again.
|
||||
"""
|
||||
presented = self.last_presented_at
|
||||
|
||||
if presented is not None:
|
||||
return presented + timedelta(days=AFTER_PRESENTING_DAYS)
|
||||
|
||||
imported = self.imported_at
|
||||
|
||||
if imported is None:
|
||||
return None
|
||||
|
||||
return imported + timedelta(days=NEVER_PRESENTED_DAYS)
|
||||
|
||||
def expired(self, moment=None):
|
||||
due = self.expires_at()
|
||||
|
||||
return due is not None and (moment or now()) >= due
|
||||
|
||||
# -- writing --------------------------------------------------
|
||||
|
||||
@property
|
||||
def metadata_path(self):
|
||||
return self.directory / "presentation.json"
|
||||
|
||||
def save(self):
|
||||
write_json(self.metadata_path, self.data)
|
||||
|
||||
def set_thumbnail_page(self, number):
|
||||
"""Choose the slide the library card shows.
|
||||
|
||||
Deliberately writes nothing else. This is not a presentation, it
|
||||
is not an import, and it must not move a retention timer.
|
||||
"""
|
||||
number = min(max(int(number), 1), self.pages or 1)
|
||||
self.data["thumbnail_page"] = number
|
||||
self.save()
|
||||
|
||||
source = self.page_image(number)
|
||||
|
||||
if source.exists():
|
||||
render_thumbnail(source, self.thumbnail)
|
||||
|
||||
return number
|
||||
|
||||
def mark_presented(self, moment=None):
|
||||
"""Record that this was really shown to a room.
|
||||
|
||||
Called only after the presenter has a slide on the screen. Quick
|
||||
Look, opening the library, generating previews and choosing a
|
||||
thumbnail all deliberately do not call it, and neither does a
|
||||
viewer that failed to start — a presentation that did not happen
|
||||
must not buy another thirty days.
|
||||
"""
|
||||
self.data["last_presented_at"] = stamp(moment)
|
||||
self.data["times_presented"] = self.times_presented + 1
|
||||
self.save()
|
||||
|
||||
|
||||
def load(directory):
|
||||
directory = Path(directory)
|
||||
data = read_json(directory / "presentation.json")
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
return Presentation(directory, data)
|
||||
|
||||
|
||||
def library(root=None):
|
||||
"""Every presentation, newest import first.
|
||||
|
||||
A directory that is half written — an import interrupted by a reboot,
|
||||
say — has no readable metadata and is simply not in the list. The
|
||||
importer clears those up; the library never shows a card it cannot
|
||||
open.
|
||||
"""
|
||||
root = Path(root) if root is not None else library_root()
|
||||
|
||||
if not root.is_dir():
|
||||
return []
|
||||
|
||||
found = []
|
||||
|
||||
for entry in sorted(root.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
|
||||
presentation = load(entry)
|
||||
|
||||
if presentation is not None:
|
||||
found.append(presentation)
|
||||
|
||||
found.sort(key=lambda p: (p.data.get("imported_at") or ""), reverse=True)
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def request_retry(presentation):
|
||||
"""A teacher's Try again: the importer takes it up on its next pass.
|
||||
|
||||
Only a presentation that failed for want of memory, or stalled; a file
|
||||
that will not convert is not going to convert on the second try.
|
||||
"""
|
||||
if presentation.failure != "memory":
|
||||
return False
|
||||
|
||||
presentation.data["state"] = "retry"
|
||||
presentation.data["error"] = None
|
||||
presentation.data["error_kind"] = None
|
||||
presentation.data["attempts"] = 0
|
||||
presentation.data["attempt_started_at"] = stamp()
|
||||
presentation.save()
|
||||
return True
|
||||
|
||||
|
||||
def remove(presentation):
|
||||
"""Delete exactly one managed entry, unless it is on screen now."""
|
||||
try:
|
||||
showing = presenting_lock().read_text().strip()
|
||||
except OSError:
|
||||
showing = ""
|
||||
|
||||
if presentation.id == showing:
|
||||
return False
|
||||
|
||||
shutil.rmtree(presentation.directory, ignore_errors=True)
|
||||
|
||||
return not presentation.directory.exists()
|
||||
|
||||
|
||||
def titles_in_use(root=None, exclude=None):
|
||||
"""Every title already on a card, optionally ignoring one presentation.
|
||||
|
||||
Excluded BY ID rather than by title, which is not a detail. The import
|
||||
being named is already in the library — it is written before the
|
||||
conversion starts so an interruption is recoverable — so its own
|
||||
provisional title has to come out of the set. Taking it out by title
|
||||
removed the other presentation's identical title along with it, a set
|
||||
holding one copy of a repeated string, and two files whose names
|
||||
differ only in their extension both came out called "Session 7 -
|
||||
Romans". Found in the lab, importing a PDF and a PPTX of the same
|
||||
session, which is exactly what tomorrow's test does.
|
||||
"""
|
||||
return {
|
||||
presentation.title
|
||||
for presentation in library(root)
|
||||
if exclude is None or presentation.id != exclude
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------ imports
|
||||
|
||||
def is_partial(path):
|
||||
"""Whether this file is still arriving."""
|
||||
path = Path(path)
|
||||
|
||||
if path.name.startswith("."):
|
||||
return True
|
||||
|
||||
if path.suffix.lower() in PARTIAL_SUFFIXES:
|
||||
return True
|
||||
|
||||
# Chrome's in-progress file sits beside the finished name.
|
||||
for suffix in PARTIAL_SUFFIXES:
|
||||
if path.with_name(path.name + suffix).exists():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def settled(path, seconds=SETTLE_SECONDS):
|
||||
"""Whether the file has stopped being written to."""
|
||||
try:
|
||||
stat = Path(path).stat()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
return (time.time() - stat.st_mtime) >= seconds and stat.st_size > 0
|
||||
|
||||
|
||||
def importable(path):
|
||||
"""Whether this is a finished file the library should take."""
|
||||
path = Path(path)
|
||||
|
||||
if path.suffix.lower() not in SUPPORTED:
|
||||
return False
|
||||
|
||||
if not path.is_file():
|
||||
return False
|
||||
|
||||
return not is_partial(path) and settled(path)
|
||||
|
||||
|
||||
def within_wonderink(path, downloads=None):
|
||||
"""Whether this file belongs to Wonder Ink's own download workflow.
|
||||
|
||||
Wonder Ink puts a lesson's worksheets in a directory of its own
|
||||
inside Downloads. They are worksheets, not presentations: they are
|
||||
not imported, and nothing in the library ever removes one.
|
||||
"""
|
||||
downloads = Path(downloads) if downloads is not None else downloads_root()
|
||||
|
||||
try:
|
||||
relative = Path(path).resolve().relative_to(downloads.resolve())
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
return WONDERINK_DOWNLOADS in relative.parts
|
||||
|
||||
|
||||
def candidates(downloads=None):
|
||||
"""Finished presentation files sitting in the teacher's Downloads.
|
||||
|
||||
The top level only. Nothing recurses into Wonder Ink's directory,
|
||||
and nothing looks anywhere else on the machine: a library that
|
||||
imported from the whole home directory would be a file manager.
|
||||
"""
|
||||
downloads = Path(downloads) if downloads is not None else downloads_root()
|
||||
|
||||
if not downloads.is_dir():
|
||||
return []
|
||||
|
||||
found = []
|
||||
|
||||
try:
|
||||
entries = sorted(downloads.iterdir())
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
for entry in entries:
|
||||
if entry.is_dir():
|
||||
continue
|
||||
|
||||
if within_wonderink(entry, downloads):
|
||||
continue
|
||||
|
||||
if importable(entry):
|
||||
found.append(entry)
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def fingerprint(path):
|
||||
"""What makes this exact download this exact download.
|
||||
|
||||
Path, size and modification time. Enough that re-downloading the same
|
||||
session tomorrow is a new presentation, and that a reboot does not
|
||||
import everything in Downloads all over again.
|
||||
"""
|
||||
try:
|
||||
stat = Path(path).stat()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
return f"{Path(path).resolve()}|{stat.st_size}|{int(stat.st_mtime)}"
|
||||
|
||||
|
||||
def ledger_path(root=None):
|
||||
root = Path(root) if root is not None else library_root()
|
||||
|
||||
return root / "imported.json"
|
||||
|
||||
|
||||
def ledger(root=None):
|
||||
data = read_json(ledger_path(root), default={})
|
||||
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def remember(mark, value, root=None):
|
||||
"""Record that this download has been dealt with.
|
||||
|
||||
The normal successful import moves the source out of Downloads, so it
|
||||
cannot be found by a later scan. The ledger is still the transaction
|
||||
record that makes an interrupted same- or cross-filesystem move safe:
|
||||
startup can tell which half-built directory belongs to which source.
|
||||
"""
|
||||
data = ledger(root)
|
||||
data[mark] = value
|
||||
write_json(ledger_path(root), data)
|
||||
|
||||
|
||||
# ----------------------------------------------------- rendering
|
||||
|
||||
# Everything below shells out. Nothing here draws a slide: LibreOffice
|
||||
# converts a deck to PDF exactly as Impress would have rendered it live,
|
||||
# and poppler turns PDF pages into pictures. The classroom's own code
|
||||
# only ever displays a picture one of them produced.
|
||||
|
||||
SOFFICE = "soffice"
|
||||
PDFTOPPM = "pdftoppm"
|
||||
PDFINFO = "pdfinfo"
|
||||
PDFTOTEXT = "pdftotext"
|
||||
|
||||
# A large deck on a slow machine. Generous, because the alternative to
|
||||
# waiting is a half-converted file presented to a room.
|
||||
CONVERT_TIMEOUT = 300
|
||||
RENDER_TIMEOUT = 600
|
||||
|
||||
# Past this, a presentation still "getting ready" has no importer working
|
||||
# on it; see Presentation.stalled.
|
||||
STALLED_SECONDS = CONVERT_TIMEOUT + RENDER_TIMEOUT + 300
|
||||
|
||||
# What convert_to_pdf and render_pages say when the kernel killed the
|
||||
# program doing the work. Measured: a deck of 30 different photographs
|
||||
# takes LibreOffice 1.4 GB for about twenty seconds, 20 larger ones 2.1 GB,
|
||||
# and the importer's service is capped at 40% of the machine's memory.
|
||||
KILLED = "killed for want of memory"
|
||||
|
||||
|
||||
def killed(returncode):
|
||||
"""Did the kernel end this process? SIGKILL, directly or via a shell."""
|
||||
return returncode in (-9, 137)
|
||||
|
||||
|
||||
def _run(command, timeout, **kwargs):
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
return subprocess.run(
|
||||
command, capture_output=True, text=True, timeout=timeout,
|
||||
check=False, **kwargs)
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
class Failed:
|
||||
returncode = 127
|
||||
stdout = ""
|
||||
stderr = str(error)
|
||||
|
||||
return Failed()
|
||||
|
||||
|
||||
def convert_to_pdf(source, destination):
|
||||
"""Turn a PPT or PPTX into the PDF everything else works from.
|
||||
|
||||
Headless, in a profile of its own, with no window on any screen. A
|
||||
teacher never sees LibreOffice: not its splash, not its first-run
|
||||
dialog, and above all not its editor. It runs once, at import, and
|
||||
then never again for that presentation.
|
||||
|
||||
A separate -env:UserInstallation is not optional. LibreOffice takes
|
||||
an exclusive lock on its user profile, so a conversion running while
|
||||
anything else on the machine uses LibreOffice would fail, and the
|
||||
failure would arrive as a presentation that never finished importing.
|
||||
"""
|
||||
source = Path(source)
|
||||
destination = Path(destination)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
workspace = destination.parent / "convert"
|
||||
profile = destination.parent / "loprofile"
|
||||
|
||||
if workspace.exists():
|
||||
shutil.rmtree(workspace, ignore_errors=True)
|
||||
|
||||
workspace.mkdir(parents=True, exist_ok=True)
|
||||
profile.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
result = _run(
|
||||
[
|
||||
SOFFICE,
|
||||
f"-env:UserInstallation=file://{profile}",
|
||||
"--headless", "--norestore", "--nologo", "--nolockcheck",
|
||||
"--nodefault", "--nofirststartwizard",
|
||||
"--convert-to", "pdf:impress_pdf_Export",
|
||||
"--outdir", str(workspace),
|
||||
str(source),
|
||||
],
|
||||
CONVERT_TIMEOUT,
|
||||
)
|
||||
|
||||
produced = sorted(workspace.glob("*.pdf"))
|
||||
|
||||
if not produced:
|
||||
shutil.rmtree(profile, ignore_errors=True)
|
||||
shutil.rmtree(workspace, ignore_errors=True)
|
||||
|
||||
if killed(result.returncode):
|
||||
return False, KILLED
|
||||
|
||||
return False, (result.stderr or result.stdout or
|
||||
"the file could not be opened").strip()
|
||||
|
||||
os.replace(produced[0], destination)
|
||||
shutil.rmtree(profile, ignore_errors=True)
|
||||
shutil.rmtree(workspace, ignore_errors=True)
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def page_count(pdf):
|
||||
"""How many slides, according to poppler."""
|
||||
result = _run([PDFINFO, str(pdf)], 60)
|
||||
|
||||
for line in (result.stdout or "").splitlines():
|
||||
if line.startswith("Pages:"):
|
||||
try:
|
||||
return int(line.split(":", 1)[1].strip())
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def first_text(pdf, pages=2):
|
||||
"""A little of the document's own words, for a title of last resort."""
|
||||
result = _run([PDFTOTEXT, "-f", "1", "-l", str(pages), str(pdf), "-"], 60)
|
||||
|
||||
return result.stdout or ""
|
||||
|
||||
|
||||
def render_pages(pdf, directory, width=SLIDE_WIDTH, progress=None):
|
||||
"""Every slide as a picture, at the size a television shows it.
|
||||
|
||||
One pdftoppm run for the whole document rather than one per page:
|
||||
measured in the lab at 0.03s for ten pages, against a process launch
|
||||
each time. `progress` is called with the number rendered so far, so a
|
||||
library window can show a large import filling in rather than freeze.
|
||||
"""
|
||||
pdf = Path(pdf)
|
||||
directory = Path(directory)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
import subprocess
|
||||
|
||||
command = [
|
||||
PDFTOPPM, "-png",
|
||||
"-scale-to-x", str(width), "-scale-to-y", "-1",
|
||||
str(pdf), str(directory / "page"),
|
||||
]
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True)
|
||||
except OSError as error:
|
||||
return 0, str(error)
|
||||
|
||||
deadline = time.monotonic() + RENDER_TIMEOUT
|
||||
|
||||
while process.poll() is None:
|
||||
if time.monotonic() > deadline:
|
||||
process.kill()
|
||||
return 0, "rendering took too long"
|
||||
|
||||
if progress is not None:
|
||||
progress(len(list(directory.glob("page-*.png"))))
|
||||
|
||||
time.sleep(0.4)
|
||||
|
||||
if process.returncode != 0:
|
||||
if killed(process.returncode):
|
||||
return 0, KILLED
|
||||
|
||||
return 0, (process.stderr.read() if process.stderr else
|
||||
"the slides could not be rendered")
|
||||
|
||||
# pdftoppm numbers by the page count's width: page-1 for a nine-page
|
||||
# document, page-01 for a ninety-page one. The library asks for
|
||||
# page-001, so they are renamed into one shape and nothing that reads
|
||||
# them has to know how many there were.
|
||||
rendered = sorted(directory.glob("page-*.png"))
|
||||
|
||||
for number, path in enumerate(rendered, start=1):
|
||||
wanted = directory / f"page-{number:03d}.png"
|
||||
|
||||
if path != wanted:
|
||||
os.replace(path, wanted)
|
||||
|
||||
return len(rendered), ""
|
||||
|
||||
|
||||
def render_thumbnail(source, destination, width=THUMBNAIL_WIDTH):
|
||||
"""The small picture on a library card, from an already rendered page."""
|
||||
source = Path(source)
|
||||
destination = Path(destination)
|
||||
|
||||
if not source.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
import gi
|
||||
|
||||
gi.require_version("GdkPixbuf", "2.0")
|
||||
from gi.repository import GdkPixbuf
|
||||
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
||||
str(source), width, -1, True)
|
||||
temporary = destination.with_name(destination.name + ".new")
|
||||
pixbuf.savev(str(temporary), "png", [], [])
|
||||
os.replace(temporary, destination)
|
||||
|
||||
return True
|
||||
except Exception: # noqa: BLE001 - a card without a picture is fine
|
||||
# A thumbnail is a convenience. A library that refused to show a
|
||||
# presentation because it could not shrink a picture would be
|
||||
# worse than one showing a card with no picture on it.
|
||||
return False
|
||||
@@ -0,0 +1,337 @@
|
||||
"""Printing a PDF to the room's printer, and showing what will be printed.
|
||||
|
||||
Wonder Ink's printing, without Qt's printing in it. Every document Wonder
|
||||
Ink prints is a PDF by the time it gets here — a worksheet already is
|
||||
one, and a lesson page is turned into one by the engine — so what is left
|
||||
is the part the rest of the classroom already trusts: CUPS.
|
||||
|
||||
WHY NOT QPRINTER.
|
||||
|
||||
Measured in the lab on Debian 13 (QtWebEngine 6.8.2), against a queue
|
||||
built from Elmer's own PPD with a file in place of the printer:
|
||||
|
||||
QWebEngineView.print() a lesson page arrived as 24.6 MB of
|
||||
300 dpi pictures, no text left in it;
|
||||
a worksheet PDF tab arrived as ONE BLANK
|
||||
PAGE; a queue rejecting jobs was reported
|
||||
as printed successfully; and on 0.2.2 it
|
||||
crashed, because the printer was deleted
|
||||
before Qt had finished with it.
|
||||
|
||||
printToPdf() + lp the same lesson was an 18 KB PDF with its
|
||||
text, on Letter, through Elmer's filters
|
||||
in 0.43 s; a rejected job is an error
|
||||
with CUPS's own words in it.
|
||||
|
||||
The second is what Chrome does, and Chrome is what the rooms already
|
||||
print from without trouble.
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
# lp hands the file to the scheduler and returns; it does not wait for
|
||||
# the printer. A scheduler that has not answered in this long is not
|
||||
# going to, and a teacher is standing at the dialog.
|
||||
SUBMIT_TIMEOUT = 30
|
||||
QUERY_TIMEOUT = 10
|
||||
|
||||
# What a worksheet can reasonably be asked for. A spin box that goes to
|
||||
# 999 is one slip of a finger away from a ream of paper.
|
||||
MAX_COPIES = 50
|
||||
|
||||
# Paper names a driver uses, in the terms Qt's page sizes are named by.
|
||||
# Anything else is Letter, which is what the church's printer is.
|
||||
PAPER = {"Letter": "Letter", "A4": "A4", "Legal": "Legal"}
|
||||
|
||||
# The driver's own words for "no colour", in the order they are tried.
|
||||
# Elmer's generic PCL 6 driver ignores the IPP print-color-mode attribute
|
||||
# altogether — measured, it logs "Unknown option" and prints in colour —
|
||||
# so black and white has to be asked for in the PPD's terms.
|
||||
GRAY_CHOICES = ("Grayscale", "Gray", "Mono", "Monochrome", "BlackWhite")
|
||||
|
||||
|
||||
def _run(command, timeout=QUERY_TIMEOUT):
|
||||
try:
|
||||
return subprocess.run(
|
||||
command, capture_output=True, text=True, timeout=timeout
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
return subprocess.CompletedProcess(command, 1, "", str(error))
|
||||
|
||||
|
||||
def printers():
|
||||
"""(names, default) for every printer CUPS will accept a job for.
|
||||
|
||||
lpstat -e is the list a teacher can print to: in a classroom that is
|
||||
Elmer and nothing else, because discovery is switched off.
|
||||
"""
|
||||
names = [
|
||||
line.strip()
|
||||
for line in _run(["lpstat", "-e"]).stdout.splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
|
||||
default = None
|
||||
match = re.search(
|
||||
r"system default destination:\s*(\S+)", _run(["lpstat", "-d"]).stdout
|
||||
)
|
||||
|
||||
if match and match.group(1) in names:
|
||||
default = match.group(1)
|
||||
elif names:
|
||||
default = names[0]
|
||||
|
||||
return names, default
|
||||
|
||||
|
||||
def options(printer):
|
||||
"""What this printer's driver offers, parsed from lpoptions -l.
|
||||
|
||||
{"paper": "Letter", "gray": ("ColorModel", "Grayscale") or None}
|
||||
"""
|
||||
found = {"paper": "Letter", "gray": None}
|
||||
|
||||
for line in _run(["lpoptions", "-p", printer, "-l"]).stdout.splitlines():
|
||||
if ":" not in line:
|
||||
continue
|
||||
|
||||
key, choices = line.split(":", 1)
|
||||
key = key.split("/", 1)[0].strip()
|
||||
choices = choices.split()
|
||||
default = next((c[1:] for c in choices if c.startswith("*")), None)
|
||||
plain = [c.lstrip("*") for c in choices]
|
||||
|
||||
if key == "PageSize" and default in PAPER:
|
||||
found["paper"] = PAPER[default]
|
||||
|
||||
if key in ("ColorModel", "ColorMode", "OutputMode"):
|
||||
gray = next((c for c in GRAY_CHOICES if c in plain), None)
|
||||
|
||||
if gray is not None and found["gray"] is None:
|
||||
found["gray"] = (key, gray)
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def page_count(pdf):
|
||||
"""How many pages the PDF has, or 0 if it cannot be read."""
|
||||
result = _run(["pdfinfo", str(pdf)])
|
||||
match = re.search(r"^Pages:\s+(\d+)", result.stdout, re.MULTILINE)
|
||||
return int(match.group(1)) if match else 0
|
||||
|
||||
|
||||
def render_page(pdf, number, width):
|
||||
"""PNG bytes of one page, WIDTH pixels wide, or None.
|
||||
|
||||
Rendered into a directory of its own and read back. Measured on
|
||||
poppler 25.03: asked for "-" as its output, pdftoppm does NOT write to
|
||||
stdout — it writes a file called "-.png" (or "--1.png") into whatever
|
||||
directory it was started in, which for Wonder Ink is the teacher's
|
||||
home, and the preview showed nothing at all.
|
||||
"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="wonderink-page-") as directory:
|
||||
root = Path(directory) / "page"
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"pdftoppm", "-f", str(number), "-l", str(number),
|
||||
"-scale-to-x", str(int(width)), "-scale-to-y", "-1",
|
||||
"-png", "-singlefile", "--", str(pdf), str(root),
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=QUERY_TIMEOUT,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
image = root.with_suffix(".png")
|
||||
|
||||
if result.returncode != 0 or not image.is_file():
|
||||
return None
|
||||
|
||||
data = image.read_bytes()
|
||||
return data or None
|
||||
|
||||
|
||||
def parse_pages(text, count):
|
||||
"""A teacher's page list, as CUPS wants it, or ValueError saying why.
|
||||
|
||||
"" or "all" is every page (None). Otherwise numbers and ranges
|
||||
separated by commas — "2", "1-3", "1, 3, 5-6" — each inside the
|
||||
document. Returned normalised: "1-3,5".
|
||||
"""
|
||||
text = (text or "").strip().lower()
|
||||
|
||||
if text in ("", "all"):
|
||||
return None
|
||||
|
||||
parts = []
|
||||
|
||||
for piece in re.split(r"\s*,\s*", text):
|
||||
match = re.fullmatch(r"(\d+)(?:\s*-\s*(\d+))?", piece)
|
||||
|
||||
if not match:
|
||||
raise ValueError(
|
||||
f"“{piece}” is not a page number. "
|
||||
"Use numbers like 2 or 1-3, separated by commas."
|
||||
)
|
||||
|
||||
first = int(match.group(1))
|
||||
last = int(match.group(2) or first)
|
||||
|
||||
if first < 1 or last < first:
|
||||
raise ValueError(f"“{piece}” is not a range of pages.")
|
||||
|
||||
if last > count:
|
||||
raise ValueError(
|
||||
f"This document has {count} page{'s' if count != 1 else ''}."
|
||||
)
|
||||
|
||||
parts.append(str(first) if first == last else f"{first}-{last}")
|
||||
|
||||
return ",".join(parts)
|
||||
|
||||
|
||||
def grayscale_command(source, target):
|
||||
"""The command that writes SOURCE, in black and white, to TARGET.
|
||||
|
||||
Black and white is made here, before the preview, so the preview shows
|
||||
it and CUPS is given the same file — 0.2.6.2 only told the driver, and
|
||||
the preview stayed in colour while the paper came out grey. Measured in
|
||||
the lab (Ghostscript 10.05.1) on a lesson page, a picture page, a
|
||||
worksheet and a fillable form:
|
||||
|
||||
* the result is grey and nothing else, with the same pages, page
|
||||
sizes and words, and the text still text;
|
||||
* Ghostscript's own grey rendering of the colour original and of
|
||||
this file give the same greys — within 1 level in 255 on every
|
||||
filled area and picture; small text can land a fraction of a
|
||||
pixel over. Elmer's driver is Ghostscript, so this is what Elmer
|
||||
would have made of the colour file anyway — poppler's grey is
|
||||
not, and differs on 7% of a coloured page;
|
||||
* 0.05 s for a worksheet, 1.3 s for a full-page photograph, 2 s for
|
||||
42 pages, and 4.4 s for one lesson page with eleven full-size
|
||||
pictures, which is why the preview does it in the background.
|
||||
|
||||
Pages are never turned (pdfwrite otherwise rotates a page by its text)
|
||||
and annotations — a form field a teacher filled in, a mark on a
|
||||
worksheet — are drawn into the page as they print, so nothing is left
|
||||
for a later filter to draw again in colour.
|
||||
"""
|
||||
return [
|
||||
"gs", "-q", "-dSAFER", "-dBATCH", "-dNOPAUSE",
|
||||
"-sDEVICE=pdfwrite",
|
||||
"-sColorConversionStrategy=Gray", "-dProcessColorModel=/DeviceGray",
|
||||
"-dAutoRotatePages=/None",
|
||||
"-dPreserveAnnots=false", "-dPrinted",
|
||||
f"-sOutputFile={target}", "--", str(source),
|
||||
]
|
||||
|
||||
|
||||
def submit(pdf, printer, copies=1, pages=None, gray=None, title="Wonder Ink"):
|
||||
"""Hand a PDF to CUPS. (True, job id) or (False, what went wrong).
|
||||
|
||||
Success means the scheduler has the job and its id — which is the
|
||||
thing Qt's own print path could not say: it reported a rejected job
|
||||
as printed.
|
||||
"""
|
||||
copies = max(1, min(int(copies), MAX_COPIES))
|
||||
command = ["lp", "-d", printer, "-n", str(copies), "-t", title]
|
||||
|
||||
if pages:
|
||||
command += ["-P", pages]
|
||||
|
||||
if gray:
|
||||
command += ["-o", f"{gray[0]}={gray[1]}"]
|
||||
|
||||
command += ["--", str(pdf)]
|
||||
result = _run(command, timeout=SUBMIT_TIMEOUT)
|
||||
match = re.search(r"request id is (\S+)", result.stdout)
|
||||
|
||||
if result.returncode == 0 and match:
|
||||
return True, match.group(1)
|
||||
|
||||
message = (result.stderr or result.stdout).strip()
|
||||
# "lp: The printer or class is not accepting jobs." reads better
|
||||
# without the program's name in front of it.
|
||||
message = re.sub(r"^lp:\s*", "", message) or "The printer did not accept the job."
|
||||
return False, message
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- pictures
|
||||
#
|
||||
# A picture a lesson opens in a tab of its own is not a page: the engine
|
||||
# shows it on a near-black viewer page (body background rgb(14, 14, 14),
|
||||
# measured) scaled to the window, and printing that page printed the black
|
||||
# around the picture — the bars teachers saw above and below the artwork.
|
||||
# So a picture is laid out on the paper here instead, from the picture
|
||||
# itself, and the teacher chooses how large and where.
|
||||
|
||||
# How large, as a share of the largest size that fits the page.
|
||||
PICTURE_SIZES = (100, 75, 50, 33, 25)
|
||||
|
||||
# Where, as (column, row): 0 left or top, 1 centre, 2 right or bottom.
|
||||
PICTURE_POSITIONS = {
|
||||
"top left": (0, 0), "top": (1, 0), "top right": (2, 0),
|
||||
"left": (0, 1), "centre": (1, 1), "right": (2, 1),
|
||||
"bottom left": (0, 2), "bottom": (1, 2), "bottom right": (2, 2),
|
||||
}
|
||||
|
||||
# Portrait or landscape: "auto" follows the picture's own shape.
|
||||
ORIENTATIONS = ("auto", "portrait", "landscape")
|
||||
|
||||
|
||||
def picture_landscape(orientation, width, height):
|
||||
"""Should the page be landscape for this picture?"""
|
||||
if orientation == "landscape":
|
||||
return True
|
||||
|
||||
if orientation == "portrait":
|
||||
return False
|
||||
|
||||
return width > height
|
||||
|
||||
|
||||
def picture_rect(page, margin, picture, size=100, position="centre", fill=False):
|
||||
"""Where a picture goes on a page, as (x, y, width, height).
|
||||
|
||||
PAGE is (width, height) and MARGIN the blank edge, both in the same
|
||||
units; PICTURE is the picture's (width, height) in pixels. Never
|
||||
stretched: the picture keeps its own proportions.
|
||||
|
||||
Fit (the default) is the largest size that shows ALL of the picture
|
||||
inside the margins, times SIZE percent, placed at POSITION. Fill covers
|
||||
the whole printable area and so cuts off whatever overhangs — which a
|
||||
teacher asks for by name, never by default; SIZE and POSITION do not
|
||||
apply to it.
|
||||
"""
|
||||
page_width, page_height = page
|
||||
width, height = picture
|
||||
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("the picture has no size")
|
||||
|
||||
area_x, area_y = margin, margin
|
||||
area_width = max(1.0, page_width - 2 * margin)
|
||||
area_height = max(1.0, page_height - 2 * margin)
|
||||
|
||||
if fill:
|
||||
scale = max(area_width / width, area_height / height)
|
||||
drawn_width, drawn_height = width * scale, height * scale
|
||||
return (area_x + (area_width - drawn_width) / 2,
|
||||
area_y + (area_height - drawn_height) / 2,
|
||||
drawn_width, drawn_height)
|
||||
|
||||
size = max(1, min(100, int(size)))
|
||||
scale = min(area_width / width, area_height / height) * size / 100
|
||||
drawn_width, drawn_height = width * scale, height * scale
|
||||
column, row = PICTURE_POSITIONS.get(position, (1, 1))
|
||||
|
||||
return (area_x + (area_width - drawn_width) * column / 2,
|
||||
area_y + (area_height - drawn_height) * row / 2,
|
||||
drawn_width, drawn_height)
|
||||
@@ -1,9 +1,11 @@
|
||||
"""The classroom stylesheet, resolved at the room's UI scale.
|
||||
"""The classroom's look: its measurements and its palette.
|
||||
|
||||
The dock and the DVD control bar share this so they cannot drift apart:
|
||||
one template, one set of measurements, one look.
|
||||
The DVD control bar, Parent Call and the Presentations library all draw
|
||||
themselves, and they share this so they cannot drift apart: one set of
|
||||
measurements, one palette, one look.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from classroom_config import scaled
|
||||
@@ -34,3 +36,96 @@ def render(scale, template=None):
|
||||
css = css.replace(f"@{token}@", str(scaled(value, scale)))
|
||||
|
||||
return css
|
||||
|
||||
|
||||
# ---------------------------------------------------------- the palette
|
||||
|
||||
def load_colours():
|
||||
"""The session's own palette, read from the colour scheme in use.
|
||||
|
||||
There is one set of design tokens in this classroom, and they live in
|
||||
a Plasma colour scheme. Reading them here rather than repeating them
|
||||
means this card follows classroom-theme light and dark without
|
||||
knowing that either exists.
|
||||
"""
|
||||
palette = {
|
||||
"window": "#141c20", "view": "#0f171a", "text": "#e6edef",
|
||||
"dim": "#93a5aa", "accent": "#4fb3a8", "on_accent": "#0e1518",
|
||||
"negative": "#e36a6a", "neutral": "#e0b15c", "positive": "#6fcf97",
|
||||
"line": "#25333a",
|
||||
}
|
||||
|
||||
scheme = "EkklesiaDark"
|
||||
|
||||
for path in (
|
||||
os.path.expanduser("~/.config/kdeglobals"),
|
||||
"/etc/ekklesia-classroom/xdg/kdeglobals",
|
||||
"/etc/xdg/kdeglobals",
|
||||
):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
section = None
|
||||
for line in handle:
|
||||
line = line.strip()
|
||||
if line.startswith("["):
|
||||
section = line
|
||||
elif section and section.startswith("[General]") and \
|
||||
line.startswith("ColorScheme="):
|
||||
scheme = line.split("=", 1)[1].strip()
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
else:
|
||||
if scheme:
|
||||
break
|
||||
|
||||
def rgb(value):
|
||||
parts = [part.strip() for part in value.split(",")]
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
try:
|
||||
return "#%02x%02x%02x" % tuple(int(part) for part in parts)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(f"/usr/share/color-schemes/{scheme}.colors", "r",
|
||||
encoding="utf-8") as handle:
|
||||
group = None
|
||||
for line in handle:
|
||||
line = line.strip()
|
||||
if line.startswith("["):
|
||||
group = line
|
||||
continue
|
||||
if "=" not in line or group is None:
|
||||
continue
|
||||
key, value = (part.strip() for part in line.split("=", 1))
|
||||
colour = rgb(value)
|
||||
if colour is None:
|
||||
continue
|
||||
if group == "[Colors:Window]":
|
||||
if key == "BackgroundNormal":
|
||||
palette["window"] = colour
|
||||
elif key == "ForegroundNormal":
|
||||
palette["text"] = colour
|
||||
elif key == "ForegroundInactive":
|
||||
palette["dim"] = colour
|
||||
elif key == "ForegroundNegative":
|
||||
palette["negative"] = colour
|
||||
elif key == "ForegroundNeutral":
|
||||
palette["neutral"] = colour
|
||||
elif key == "ForegroundPositive":
|
||||
palette["positive"] = colour
|
||||
elif key == "BackgroundAlternate":
|
||||
palette["line"] = colour
|
||||
elif group == "[Colors:View]" and key == "BackgroundNormal":
|
||||
palette["view"] = colour
|
||||
elif group == "[Colors:Selection]":
|
||||
if key == "BackgroundNormal":
|
||||
palette["accent"] = colour
|
||||
elif key == "ForegroundNormal":
|
||||
palette["on_accent"] = colour
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return palette
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Wonder Ink's recent tabs, kept for two hours: temporary workspace restore.
|
||||
|
||||
A teacher who closes Wonder Ink by accident, or whose computer restarts,
|
||||
gets their tabs back if they open it again soon. A room nobody has used
|
||||
for two hours starts fresh: the next teacher never sees the last one's
|
||||
tabs.
|
||||
|
||||
It is not a sign-in and not a memory of tabs. Measured on the real site
|
||||
(docs/wonder-ink.md, *Temporary workspace restore*): WonderInk's sign-in
|
||||
is the teacher's email and password, Basic-auth encoded in a cookie, so
|
||||
keeping a sign-in would mean keeping a password. Nothing of the sign-in is
|
||||
kept here — no cookie, no header, no storage — and a restored tab that
|
||||
needs the teacher signed in gets WonderInk's own sign-in page.
|
||||
|
||||
WHAT IS KEPT, AND ONLY THIS.
|
||||
|
||||
format the version of this file
|
||||
last_activity wall-clock time the teacher last used Wonder Ink
|
||||
active which tab was in front
|
||||
tabs for each, in order: its kind ("web" or "worksheet"),
|
||||
its address or the worksheet's file, and its title
|
||||
|
||||
Addresses are http or https and nothing else, and one that carries a
|
||||
credential — a user and password in it, or a query parameter named like
|
||||
a token, key, password or signature — is not kept at all.
|
||||
|
||||
WHEN IT IS USED.
|
||||
|
||||
Only when Wonder Ink starts, and only within IDLE_LIMIT_SECONDS of the
|
||||
last time the teacher used it. The time is the teacher's last use, not
|
||||
the last start: opening Wonder Ink and having its tabs restored is not
|
||||
using it, so closing and reopening cannot keep a workspace alive by
|
||||
itself. Anything that cannot be read, is from another version, is in the
|
||||
future by more than a minute (a clock gone backwards), or has failed to
|
||||
restore twice running is discarded, and Wonder Ink starts fresh.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qsl, urlsplit
|
||||
|
||||
# Two hours after the teacher last used Wonder Ink. The one place this is
|
||||
# decided.
|
||||
IDLE_LIMIT_SECONDS = 2 * 60 * 60
|
||||
|
||||
# FOR TESTING ONLY. Checking expiry on a real machine should not take two
|
||||
# hours, so this variable, in the environment of a Wonder Ink started by
|
||||
# hand from the maintenance terminal, shortens it. Nothing in the classroom
|
||||
# sets it; it lasts as long as that one process, and it can only shorten.
|
||||
TEST_OVERRIDE = "CLASSROOM_WONDERINK_TEST_WORKSPACE_SECONDS"
|
||||
TEST_SHORTEST = 10
|
||||
|
||||
# How far the clock may seem to have gone backwards before a workspace is
|
||||
# discarded for it.
|
||||
CLOCK_TOLERANCE_SECONDS = 60
|
||||
|
||||
# A workspace that has been started from this many times without Wonder
|
||||
# Ink then running long enough to say it worked is abandoned: one bad tab
|
||||
# must not make Wonder Ink unusable.
|
||||
RESTORE_ATTEMPTS = 2
|
||||
|
||||
FORMAT = 1
|
||||
KINDS = ("web", "worksheet")
|
||||
|
||||
# WonderInk's own site. Every page of it has the same address — it is one
|
||||
# page that draws each screen itself — so however many of its tabs a
|
||||
# teacher had, one is restored, and that one asks them to sign in.
|
||||
SITE_DOMAIN = "wonderink.org"
|
||||
|
||||
# Query parameters that are, or may be, a credential. A tab whose address
|
||||
# has one is not kept.
|
||||
CREDENTIAL_WORDS = (
|
||||
"password", "passwd", "pwd", "token", "auth", "secret", "key",
|
||||
"signature", "sig", "credential", "session", "jwt", "x-amz-",
|
||||
)
|
||||
|
||||
TITLE_LENGTH = 120
|
||||
|
||||
|
||||
def idle_limit(environ=None):
|
||||
"""Seconds after the teacher's last use that a workspace is kept."""
|
||||
environ = os.environ if environ is None else environ
|
||||
value = environ.get(TEST_OVERRIDE, "").strip()
|
||||
|
||||
if value:
|
||||
try:
|
||||
seconds = int(value)
|
||||
except ValueError:
|
||||
return IDLE_LIMIT_SECONDS
|
||||
|
||||
return max(TEST_SHORTEST, min(seconds, IDLE_LIMIT_SECONDS))
|
||||
|
||||
return IDLE_LIMIT_SECONDS
|
||||
|
||||
|
||||
def is_test_limit(environ=None):
|
||||
return idle_limit(environ) != IDLE_LIMIT_SECONDS
|
||||
|
||||
|
||||
def is_site(host, site=SITE_DOMAIN):
|
||||
host = (host or "").lstrip(".").lower()
|
||||
return host == site or host.endswith("." + site)
|
||||
|
||||
|
||||
def keepable_url(url):
|
||||
"""Is this an address that may be written down? http(s), no credential."""
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
if parts.scheme not in ("http", "https") or not parts.hostname:
|
||||
return False
|
||||
|
||||
if parts.username or parts.password:
|
||||
return False
|
||||
|
||||
for name, _value in parse_qsl(parts.query, keep_blank_values=True):
|
||||
lowered = name.lower()
|
||||
if any(word in lowered for word in CREDENTIAL_WORDS):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def is_site_start(url, site=SITE_DOMAIN):
|
||||
"""Is this one of WonderInk's own pages, which all share one address?"""
|
||||
parts = urlsplit(url)
|
||||
return is_site(parts.hostname, site) and parts.path in ("", "/")
|
||||
|
||||
|
||||
def tab(kind, where, title=""):
|
||||
return {"kind": kind, "where": str(where), "title": str(title or "")[:TITLE_LENGTH]}
|
||||
|
||||
|
||||
def plan(tabs, active, downloads, site=SITE_DOMAIN):
|
||||
"""Which of the saved TABS to open, in order, and which to put in front.
|
||||
|
||||
Returns (tabs, active) with every tab one that may be opened:
|
||||
|
||||
* a web tab only if its address may be kept (see keepable_url);
|
||||
* WonderInk's own tabs as one, where the first of them was;
|
||||
* a worksheet only if it is a file directly inside DOWNLOADS that
|
||||
still exists and still begins as a PDF — a state file cannot send
|
||||
Wonder Ink to any other file on the computer;
|
||||
* the tab in front is the one that was, or the nearest kept before it.
|
||||
"""
|
||||
downloads = Path(downloads).resolve()
|
||||
kept, index_of, site_seen = [], {}, None
|
||||
|
||||
for number, item in enumerate(tabs):
|
||||
if not isinstance(item, dict) or item.get("kind") not in KINDS:
|
||||
continue
|
||||
|
||||
where, title = str(item.get("where", "")), str(item.get("title", ""))[:TITLE_LENGTH]
|
||||
|
||||
if item["kind"] == "web":
|
||||
if not keepable_url(where):
|
||||
continue
|
||||
|
||||
if is_site_start(where, site):
|
||||
if site_seen is not None:
|
||||
index_of[number] = site_seen
|
||||
continue
|
||||
|
||||
site_seen = len(kept)
|
||||
else:
|
||||
if not openable_worksheet(where, downloads):
|
||||
continue
|
||||
|
||||
index_of[number] = len(kept)
|
||||
kept.append(tab(item["kind"], where, title))
|
||||
|
||||
if not kept:
|
||||
return [], 0
|
||||
|
||||
try:
|
||||
active = int(active)
|
||||
except (TypeError, ValueError):
|
||||
active = 0
|
||||
|
||||
for candidate in range(active, -1, -1):
|
||||
if candidate in index_of:
|
||||
return kept, index_of[candidate]
|
||||
|
||||
return kept, 0
|
||||
|
||||
|
||||
def openable_worksheet(path, downloads):
|
||||
try:
|
||||
resolved = Path(path).resolve()
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return False
|
||||
|
||||
if resolved.parent != Path(downloads).resolve() or not resolved.is_file():
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(resolved, "rb") as handle:
|
||||
return handle.read(5) == b"%PDF-"
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def read(path, now=None, limit=None):
|
||||
"""The workspace to restore, or why there is none.
|
||||
|
||||
Returns (data, None) — with "tabs", "active", "last_activity" and
|
||||
"attempts" — or (None, reason). Anything that is not a workspace to
|
||||
restore is removed.
|
||||
"""
|
||||
now = time.time() if now is None else now
|
||||
limit = idle_limit() if limit is None else limit
|
||||
path = Path(path)
|
||||
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
except FileNotFoundError:
|
||||
return None, "none kept"
|
||||
except (OSError, ValueError):
|
||||
return _discard(path, "unreadable")
|
||||
|
||||
try:
|
||||
if data["format"] != FORMAT:
|
||||
return _discard(path, "from another version")
|
||||
|
||||
last = float(data["last_activity"])
|
||||
tabs = list(data["tabs"])
|
||||
active = int(data.get("active", 0))
|
||||
attempts = int(data.get("attempts", 0))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return _discard(path, "incomplete")
|
||||
|
||||
if last > now + CLOCK_TOLERANCE_SECONDS:
|
||||
return _discard(path, "the clock is behind the last use")
|
||||
|
||||
if now - last > limit:
|
||||
return _discard(path, "expired")
|
||||
|
||||
if attempts >= RESTORE_ATTEMPTS:
|
||||
return _discard(path, f"it failed to restore {attempts} times")
|
||||
|
||||
if not tabs:
|
||||
return _discard(path, "no tabs")
|
||||
|
||||
return {"tabs": tabs, "active": active, "last_activity": last, "attempts": attempts}, None
|
||||
|
||||
|
||||
def write(path, tabs, active, last_activity, attempts=0):
|
||||
"""Keep TABS, or remove the file if there is nothing to keep.
|
||||
|
||||
Only what plan() would open again is written. Written whole to a
|
||||
temporary file, flushed to disk, and renamed over the old one, readable
|
||||
only by the classroom account: a crash or a power cut leaves the old
|
||||
file or the new one, never half of either.
|
||||
"""
|
||||
path = Path(path)
|
||||
keepable, front = [], 0
|
||||
|
||||
for number, item in enumerate(tabs):
|
||||
if item.get("kind") == "worksheet" or (
|
||||
item.get("kind") == "web" and keepable_url(item.get("where", ""))
|
||||
):
|
||||
if number <= active:
|
||||
front = len(keepable)
|
||||
keepable.append(tab(item["kind"], item["where"], item.get("title", "")))
|
||||
|
||||
if not keepable or last_activity is None:
|
||||
forget(path)
|
||||
return False
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(path.name + ".new")
|
||||
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
|
||||
with os.fdopen(descriptor, "w") as handle:
|
||||
json.dump({
|
||||
"format": FORMAT,
|
||||
"last_activity": float(last_activity),
|
||||
"active": front,
|
||||
"attempts": int(attempts),
|
||||
"tabs": keepable,
|
||||
}, handle)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
os.replace(temporary, path)
|
||||
return True
|
||||
|
||||
|
||||
def forget(path):
|
||||
"""Remove a kept workspace. Never fails: Wonder Ink must start regardless."""
|
||||
path = Path(path)
|
||||
|
||||
for target in (path, path.with_name(path.name + ".new")):
|
||||
try:
|
||||
target.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _discard(path, reason):
|
||||
forget(path)
|
||||
return None, reason
|
||||
@@ -4,9 +4,9 @@ Section: misc
|
||||
Priority: optional
|
||||
Architecture: amd64
|
||||
Maintainer: Ekklesia
|
||||
Depends: pkexec, polkit-kde-agent-1, plasma-desktop, plasma-workspace, kwin-wayland, plasma-nm, plasma-pa, plasma-widgets-addons, konsole, sddm, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, pulseaudio-utils, cups, cups-client, cups-filters, foomatic-db-compressed-ppds, python3-gi, python3-gi-cairo, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, libnotify-bin, curl, gnupg, ca-certificates, python3-paho-mqtt, python3-pyqt6.qtwebengine, python3-pyqt6
|
||||
Depends: pkexec, polkit-kde-agent-1, plasma-desktop, plasma-workspace, kwin-wayland, plasma-nm, plasma-pa, plasma-widgets-addons, konsole, sddm, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, pulseaudio-utils, cups, cups-client, cups-filters, foomatic-db-compressed-ppds, python3-gi, python3-gi-cairo, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, libnotify-bin, curl, gnupg, ca-certificates, python3-paho-mqtt, python3-pyqt6.qtwebengine, python3-pyqt6, libreoffice-impress, libreoffice-core, poppler-utils, ghostscript, fonts-liberation, fonts-liberation-sans-narrow, fonts-croscore, fonts-crosextra-carlito, fonts-crosextra-caladea, fonts-urw-base35, fonts-roboto, fonts-open-sans, fonts-lato, fonts-montserrat, fonts-noto-extra
|
||||
Recommends: vlc
|
||||
Conflicts: plasma-welcome, plasma-discover, kdeconnect, kup-backup
|
||||
Conflicts: plasma-welcome, plasma-discover, kdeconnect, kup-backup, khelpcenter
|
||||
Description: Ekklesia managed classroom environment
|
||||
A classroom appliance built on Debian's own Plasma desktop. The room's
|
||||
panel, window behaviour, notifications, palette and lockdown are
|
||||
@@ -36,6 +36,41 @@ Description: Ekklesia managed classroom environment
|
||||
of this build. Chrome stays exactly where it was, for the ordinary
|
||||
browser.
|
||||
.
|
||||
libreoffice-impress and poppler-utils are Presentations, and neither is
|
||||
ever shown to a teacher. LibreOffice runs once per import, headless,
|
||||
with no window on any screen, to turn a PPT or PPTX into a PDF; poppler
|
||||
turns that PDF into one picture per slide; the classroom's own library
|
||||
shows the pictures. Measured before it was chosen: `soffice --show`
|
||||
does start a real slideshow and then leaves the Impress editor on
|
||||
screen when it ends, and `okular --presentation` opens its own document
|
||||
window and an information dialog behind the slides. Neither is
|
||||
something a teacher may be left looking at, and converting once at
|
||||
import costs 0.34s for a ten-slide deck and nothing at all afterwards.
|
||||
libreoffice-gtk3 is deliberately NOT here: nothing interactive runs.
|
||||
.
|
||||
ghostscript is Wonder Ink's black and white. It was already on every
|
||||
machine, because cups-filters needs it; it is named here because Wonder
|
||||
Ink now runs it itself, to make the grey copy of a page that the print
|
||||
preview shows and CUPS is given. Measured: it is the same engine Elmer's
|
||||
driver prints with, and its grey differs from what Elmer would have
|
||||
made of the colour page by at most 1 level in 255.
|
||||
.
|
||||
The fonts are what a Google Slides export asks for. The metric
|
||||
compatible families cover the Microsoft names — Liberation for Arial,
|
||||
Times New Roman and Courier New, Carlito for Calibri, Caladea for
|
||||
Cambria, Croscore for the same three under their Chrome OS names — and
|
||||
Roboto, Open Sans, Lato and Montserrat are the web fonts Google Slides
|
||||
itself offers. Every one is in Debian main and installs without a
|
||||
question. ttf-mscorefonts-installer is deliberately absent: it is in
|
||||
contrib, it downloads from a third party at install time and it shows
|
||||
an EULA prompt, and an unattended classroom upgrade cannot answer one.
|
||||
.
|
||||
The Conflicts line is deliberate. A welcome tour, a software centre and
|
||||
a phone pairing daemon are all things plasma-desktop recommends and a
|
||||
room a teacher walks into should not have.
|
||||
.
|
||||
khelpcenter is there for the same reason, found the hard way: a
|
||||
settings window's Help button opens it directly, whatever the Kiosk
|
||||
restrictions say, and it is a browser of the machine's manual pages with
|
||||
file dialogs of its own. plasma-desktop only recommends it, and nothing
|
||||
else installed needs it.
|
||||
|
||||
@@ -62,6 +62,13 @@ systemctl enable sddm.service >/dev/null 2>&1 || true
|
||||
systemctl --global enable ekklesia-classroom-audio.service >/dev/null 2>&1 || true
|
||||
systemctl --global enable ekklesia-classroom-desktop.service >/dev/null 2>&1 || true
|
||||
|
||||
# The presentation watcher. Enabled on every machine and started by every
|
||||
# session, because whether it does anything is device.conf's answer, not
|
||||
# the installer's: with PRESENTATIONS_ENABLED=false — which is the
|
||||
# default, and therefore every existing classroom — it exits immediately
|
||||
# and the room never runs an importer it has no use for.
|
||||
systemctl --global enable ekklesia-classroom-presentations.service >/dev/null 2>&1 || true
|
||||
|
||||
# The panel's disc player is decided before the display manager starts, not
|
||||
# at the same time as it. Enabled rather than started: this install has
|
||||
# just run the same command directly, below.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"""Match the classroom's panel to the machine it is on.
|
||||
|
||||
Which applications a device offers is decided here, once, and written into
|
||||
the three feature lines of the installed layout. The panel is then built
|
||||
the feature lines of the installed layout. The panel is then built
|
||||
from that layout at the next login.
|
||||
|
||||
Each application is its own question, and the shape of the machine is
|
||||
@@ -15,6 +15,11 @@ never one of them:
|
||||
|
||||
Parent Call PARENT_CALL_ENABLED. Same rule.
|
||||
|
||||
Presentations PRESENTATIONS_ENABLED. Same rule again, and it asks
|
||||
the machine nothing at all: a presentation needs a
|
||||
screen, which every device has. Alone among these it
|
||||
defaults to OFF, because no existing room has it.
|
||||
|
||||
the disc DVD_ENABLED *and* a drive being present. The setting can
|
||||
player take the player away from a machine that has a drive;
|
||||
nothing can put it on a machine that has none, because a
|
||||
@@ -39,6 +44,7 @@ again at the next login.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
@@ -61,10 +67,25 @@ FEATURE_LINES = (
|
||||
("WONDERINK", "WONDERINK_ENABLED"),
|
||||
("DVD", "DVD_ENABLED"),
|
||||
("PARENT_CALL", "PARENT_CALL_ENABLED"),
|
||||
("PRESENTATIONS", "PRESENTATIONS_ENABLED"),
|
||||
)
|
||||
|
||||
MARKER = "/* CLASSROOM-FEATURES-BEGIN */"
|
||||
|
||||
# WHAT THE MANUAL IS ALLOWED TO KNOW ABOUT THIS DEVICE.
|
||||
#
|
||||
# This command already decides what the device offers, so it is also the
|
||||
# place to write that answer down where the classroom account can read
|
||||
# it. A list of setting NAMES, and nothing else: no values, no broker
|
||||
# password, no Wi-Fi, no update address, none of the rest of device.conf
|
||||
# — which the manual has no use for and the room should not be handed.
|
||||
#
|
||||
# In /run rather than /etc because it is derived, not configured: it is
|
||||
# rewritten from device.conf at every boot by the same service that
|
||||
# builds the bar, so it cannot go stale behind a setting somebody
|
||||
# changed.
|
||||
MANIFEST = "/run/ekklesia-classroom/features.json"
|
||||
|
||||
# The kernel names optical drives sr0, sr1, ... regardless of bus. Both
|
||||
# views are checked: /dev is what anything opening the drive will use, and
|
||||
# /sys/block is the kernel's own list, which cannot be left behind by a
|
||||
@@ -121,6 +142,32 @@ def rewrite(text, variable, answer):
|
||||
)
|
||||
|
||||
|
||||
def publish(answers, path=MANIFEST):
|
||||
"""Leave the enabled features where classroom-manual can read them.
|
||||
|
||||
Best effort on purpose. A room whose manual shows a page for
|
||||
something it has not got is a small confusion; a panel that failed to
|
||||
build because a directory was read-only is a room with no way of
|
||||
getting anywhere.
|
||||
"""
|
||||
enabled = sorted(
|
||||
setting for variable, setting in FEATURE_LINES if answers.get(variable)
|
||||
)
|
||||
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
temporary = f"{path}.new"
|
||||
|
||||
with open(temporary, "w", encoding="utf-8") as handle:
|
||||
json.dump(enabled, handle)
|
||||
|
||||
os.replace(temporary, path)
|
||||
os.chmod(path, 0o644)
|
||||
say(f"the manual will show {', '.join(enabled) or 'no optional pages'}")
|
||||
except OSError as error:
|
||||
say(f"could not write {path}: {error}")
|
||||
|
||||
|
||||
def main():
|
||||
path = sys.argv[1] if len(sys.argv) > 1 else LAYOUT
|
||||
|
||||
@@ -137,8 +184,11 @@ def main():
|
||||
return 0
|
||||
|
||||
after = before
|
||||
answers = wanted(read_device_config())
|
||||
|
||||
for variable, answer in wanted(read_device_config()).items():
|
||||
publish(answers)
|
||||
|
||||
for variable, answer in answers.items():
|
||||
after = rewrite(after, variable, answer)
|
||||
|
||||
if after == before:
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Put the classroom's wallpaper on every desktop the shell has.
|
||||
|
||||
Tiny Pebbles came up after the 0.2.2 -> 0.2.6.4 upgrade on Plasma's own
|
||||
purple wallpaper, with the right panel and the right package installed.
|
||||
Its desktop was containment 10, with no Wallpaper group at all:
|
||||
|
||||
[Containments][10]
|
||||
plugin=org.kde.plasma.folder
|
||||
wallpaperplugin=org.kde.image
|
||||
[Containments][10][General]
|
||||
positions={"1920x1080":[]}
|
||||
|
||||
The layout script writes the wallpaper, but only onto the desktops that
|
||||
exist while it runs. Plasma makes a desktop for every screen, and every
|
||||
activity, that appears afterwards — and it makes them without the layout.
|
||||
0.2.2's updater rebooted at three in the morning with the television
|
||||
switched off at the set, so the layout ran with no screen and no desktop
|
||||
at all, and the desktop Plasma made when the television came on was
|
||||
bare. Reproduced in the lab through a new activity, which takes the same
|
||||
path through the shell: containment 11, the same four lines, Plasma's
|
||||
wallpaper.
|
||||
|
||||
Two things now close that. The look and feel's default wallpaper is a
|
||||
real wallpaper package, so a desktop Plasma makes on its own comes up
|
||||
with the classroom's picture — that is the fix, and it needs nothing to
|
||||
run. And this, which writes the picture into every desktop the file
|
||||
already has, so that the configuration says what the screen shows.
|
||||
|
||||
WHICH CONTAINMENT IS THE DESKTOP is answered by what it is, never by its
|
||||
number: a top-level containment whose plugin is one of Plasma's desktop
|
||||
containments. Tiny Pebbles' desktop was 10 and River Kids' is 1, and the
|
||||
panel's number is whatever was free when it was built.
|
||||
|
||||
ONLY THE DESKTOPS ARE TOUCHED. Every panel, applet and line that is not a
|
||||
desktop's wallpaper is written back byte for byte, and a file that is
|
||||
already right is not written at all.
|
||||
|
||||
WHEN IT RUNS. Before plasmashell starts, as the shell's own ExecStartPre,
|
||||
because the shell owns this file: editing it underneath a live shell was
|
||||
measured to destroy the panel. That is every start — a login, a reboot,
|
||||
and the restart after a crash — and at a login it usually finds nothing,
|
||||
because classroom-desktop-reset has just removed the file for the layout
|
||||
to rebuild. Run by hand, it refuses while the shell is running and says
|
||||
how to apply it. Live D-Bus scripting is not an option: the classroom
|
||||
locks the desktop and forbids the scripting console, and Plasma answers
|
||||
"Widgets are locked" — measured.
|
||||
|
||||
classroom-desktop-wallpaper repair, if the shell is stopped
|
||||
classroom-desktop-wallpaper --check say what is wrong, change nothing
|
||||
classroom-desktop-wallpaper FILE work on FILE instead
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
WALLPAPER = "/usr/share/backgrounds/ekklesia/classroom-calm.png"
|
||||
|
||||
WALLPAPER_PLUGIN = "org.kde.image"
|
||||
|
||||
# Plasma's two desktop containments: Folder View, which is what the
|
||||
# classroom's desktops are, and the plain Desktop. Panels, the system tray
|
||||
# a 0.2.2-era profile may still carry and anything else are not desktops.
|
||||
DESKTOP_PLUGINS = ("org.kde.plasma.folder", "org.kde.desktopcontainment")
|
||||
|
||||
# What the layout writes onto a desktop, so a repaired desktop and a
|
||||
# freshly built one say the same thing.
|
||||
WALLPAPER_KEYS = (
|
||||
("FillMode", "2"),
|
||||
("Image", f"file://{WALLPAPER}"),
|
||||
)
|
||||
|
||||
APPLETSRC = "plasma-org.kde.plasma.desktop-appletsrc"
|
||||
|
||||
CONTAINMENT = re.compile(r"^\[Containments\]\[(\d+)\]$")
|
||||
|
||||
# key=value, with KConfig's optional [$i]-style flags or [locale] after
|
||||
# the key. The flags are kept when a value is replaced.
|
||||
ENTRY = re.compile(r"^([^=\[\s]+)((?:\[[^\]]*\])*)=(.*)$")
|
||||
|
||||
|
||||
def say(message):
|
||||
print(f"classroom-desktop-wallpaper: {message}", flush=True)
|
||||
|
||||
|
||||
def groups(lines):
|
||||
"""[(name, header, end)]: lines[header] is the group's header line, or
|
||||
-1 for the lines before the first group, and end is exclusive."""
|
||||
found = []
|
||||
name, header = None, -1
|
||||
|
||||
for index, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
|
||||
if stripped.startswith("[") and stripped.endswith("]"):
|
||||
found.append((name, header, index))
|
||||
name, header = stripped, index
|
||||
|
||||
found.append((name, header, len(lines)))
|
||||
return found
|
||||
|
||||
|
||||
def find_group(lines, name):
|
||||
for group, header, end in groups(lines):
|
||||
if group == name:
|
||||
return header, end
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def entry(line):
|
||||
match = ENTRY.match(line.rstrip("\n"))
|
||||
return (match.group(1), match.group(2), match.group(3)) if match else None
|
||||
|
||||
|
||||
def desktops(lines):
|
||||
"""The ids of the desktop containments, whatever they are numbered."""
|
||||
ids = []
|
||||
|
||||
for name, header, end in groups(lines):
|
||||
match = CONTAINMENT.match(name or "")
|
||||
|
||||
if not match:
|
||||
continue
|
||||
|
||||
for line in lines[header + 1:end]:
|
||||
parsed = entry(line)
|
||||
|
||||
if parsed and parsed[0] == "plugin" and parsed[2] in DESKTOP_PLUGINS:
|
||||
ids.append(match.group(1))
|
||||
break
|
||||
|
||||
return ids
|
||||
|
||||
|
||||
def set_entry(lines, group, key, value):
|
||||
"""Make `key=value` true in an existing group. Returns what changed,
|
||||
or None when it already was."""
|
||||
header, end = find_group(lines, group)
|
||||
last = header
|
||||
|
||||
for index in range(header + 1, end):
|
||||
parsed = entry(lines[index])
|
||||
|
||||
if lines[index].strip():
|
||||
last = index
|
||||
|
||||
if parsed and parsed[0] == key:
|
||||
if parsed[2] == value:
|
||||
return None
|
||||
|
||||
lines[index] = f"{key}{parsed[1]}={value}\n"
|
||||
return f"{key} was [{parsed[2]}]"
|
||||
|
||||
lines.insert(last + 1, f"{key}={value}\n")
|
||||
return f"{key} was missing"
|
||||
|
||||
|
||||
def add_group(lines, containment, group):
|
||||
"""A new group, placed after the last of its containment's own."""
|
||||
prefix = f"[Containments][{containment}]"
|
||||
after = None
|
||||
|
||||
for name, header, end in groups(lines):
|
||||
if name == prefix or (name or "").startswith(prefix + "["):
|
||||
after = end
|
||||
|
||||
# Back over the blank lines that separate it from the next group, so
|
||||
# the new one sits with its containment and the spacing stays as
|
||||
# KConfig writes it.
|
||||
while after > 0 and not lines[after - 1].strip():
|
||||
after -= 1
|
||||
|
||||
if after > 0 and not lines[after - 1].endswith("\n"):
|
||||
lines[after - 1] += "\n"
|
||||
|
||||
block = ["\n", f"{group}\n"]
|
||||
block += [f"{key}={value}\n" for key, value in WALLPAPER_KEYS]
|
||||
lines[after:after] = block
|
||||
|
||||
|
||||
def converge(text):
|
||||
"""The file as it should be, and what was changed to make it so."""
|
||||
lines = text.splitlines(keepends=True)
|
||||
changes = []
|
||||
|
||||
for containment in desktops(lines):
|
||||
top = f"[Containments][{containment}]"
|
||||
change = set_entry(lines, top, "wallpaperplugin", WALLPAPER_PLUGIN)
|
||||
|
||||
if change:
|
||||
changes.append(f"desktop {containment}: {change}")
|
||||
|
||||
group = f"{top}[Wallpaper][{WALLPAPER_PLUGIN}][General]"
|
||||
|
||||
if find_group(lines, group) is None:
|
||||
add_group(lines, containment, group)
|
||||
changes.append(f"desktop {containment}: had no wallpaper configured")
|
||||
continue
|
||||
|
||||
for key, value in WALLPAPER_KEYS:
|
||||
change = set_entry(lines, group, key, value)
|
||||
|
||||
if change:
|
||||
changes.append(f"desktop {containment}: {change}")
|
||||
|
||||
return "".join(lines), changes
|
||||
|
||||
|
||||
def default_path():
|
||||
home = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
|
||||
return os.path.join(home, APPLETSRC)
|
||||
|
||||
|
||||
def shell_running(proc="/proc"):
|
||||
"""plasmashell running as this user. Read from /proc rather than asked
|
||||
of systemd, so a shell started any way at all counts, and rather than
|
||||
of pgrep, which nothing guarantees is installed."""
|
||||
uid = os.getuid()
|
||||
|
||||
for pid in os.listdir(proc):
|
||||
if not pid.isdigit():
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(os.path.join(proc, pid, "comm"), encoding="utf-8") as handle:
|
||||
name = handle.read().strip()
|
||||
|
||||
if name == "plasmashell" and os.stat(os.path.join(proc, pid)).st_uid == uid:
|
||||
return True
|
||||
except OSError:
|
||||
# Gone while it was being looked at.
|
||||
continue
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def write(path, text):
|
||||
"""Replace the file whole, so a full disk or a power cut leaves the old
|
||||
one rather than half of a new one."""
|
||||
temporary = f"{path}.classroom-new"
|
||||
mode = os.stat(path).st_mode & 0o7777
|
||||
|
||||
with open(temporary, "w", encoding="utf-8") as handle:
|
||||
handle.write(text)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
os.chmod(temporary, mode)
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def main(argv):
|
||||
check = "--check" in argv
|
||||
paths = [arg for arg in argv if not arg.startswith("--")]
|
||||
path = paths[0] if paths else default_path()
|
||||
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
before = handle.read()
|
||||
except FileNotFoundError:
|
||||
say("no desktop configuration yet; the layout writes the wallpaper")
|
||||
return 0
|
||||
except OSError as error:
|
||||
say(f"cannot read {path}: {error}")
|
||||
return 0
|
||||
|
||||
after, changes = converge(before)
|
||||
found = desktops(before.splitlines(keepends=True))
|
||||
|
||||
if not found:
|
||||
say("no desktop in the configuration yet; Plasma's default is the "
|
||||
"classroom's wallpaper package")
|
||||
return 0
|
||||
|
||||
if not changes:
|
||||
say(f"desktop {', '.join(found)}: the classroom wallpaper already")
|
||||
return 0
|
||||
|
||||
for change in changes:
|
||||
say(change)
|
||||
|
||||
if check:
|
||||
say("not changed (--check)")
|
||||
return 1
|
||||
|
||||
# The shell owns the live file. Anything else is just a file.
|
||||
if not paths and shell_running():
|
||||
say("plasmashell is running, and editing its file underneath it")
|
||||
say("destroys the panel. Apply it with:")
|
||||
say(" systemctl --user restart plasma-plasmashell.service")
|
||||
say("which repairs the file before the shell starts again.")
|
||||
return 1
|
||||
|
||||
try:
|
||||
write(path, after)
|
||||
except OSError as error:
|
||||
say(f"cannot write {path}: {error}")
|
||||
return 1
|
||||
|
||||
say(f"the wallpaper is now configured on desktop {', '.join(found)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Open this computer's own manual.
|
||||
|
||||
classroom-manual open it
|
||||
classroom-manual --features say what this device would show
|
||||
|
||||
One manual is installed on every machine, carrying every page. What makes
|
||||
it THIS room's manual is that the pages for features this device does not
|
||||
have are removed before a teacher ever sees them: a Bible study laptop
|
||||
opens it and reads about the Browser, Presentations and the volume, with
|
||||
no mention of Wonder Ink or Parent Call to wonder about.
|
||||
|
||||
WHAT REACHES THE CLASSROOM ACCOUNT, AND WHAT DOES NOT.
|
||||
|
||||
Only a list of setting NAMES — ["WONDERINK_ENABLED", ...] — and only
|
||||
those the package already defines as optional applications. No values
|
||||
from device.conf, no broker address, no broker password, no update URL,
|
||||
no Wi-Fi anything, no other key an administrator has put in that file.
|
||||
|
||||
That list is prepared by classroom-desktop-layout, which is already the
|
||||
one place that decides what this device offers, and left in the runtime
|
||||
directory where it is readable and carries nothing worth protecting. If
|
||||
it is not there, every page is shown — which is exactly the printed
|
||||
manual, and is never wrong, only less specific.
|
||||
|
||||
Shown through Chrome in application mode: no address bar, no tabs, one
|
||||
window that closes. Chrome is on every classroom machine already, the
|
||||
manual is a local file, and none of it needs the network.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
|
||||
sys.path.insert(
|
||||
0,
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
|
||||
)
|
||||
|
||||
from classroom_config import FEATURES, feature, read_device_config # noqa: E402
|
||||
|
||||
MANUAL = "/usr/local/share/ekklesia-classroom/manual/classroom-manual.html"
|
||||
|
||||
# Written by classroom-desktop-layout at boot. See the module docstring
|
||||
# for what is and is not in it.
|
||||
MANIFEST = "/run/ekklesia-classroom/features.json"
|
||||
|
||||
|
||||
def say(message):
|
||||
print(f"classroom-manual: {message}")
|
||||
|
||||
|
||||
def enabled_features():
|
||||
"""The optional applications this device offers, by setting name."""
|
||||
try:
|
||||
with open(MANIFEST, "r", encoding="utf-8") as handle:
|
||||
listed = json.load(handle)
|
||||
|
||||
if isinstance(listed, list):
|
||||
# Filtered against what this package knows, so a stale or
|
||||
# hand-edited manifest can only ever name a real feature.
|
||||
return [name for name in listed if name in FEATURES]
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
# No manifest. Fall back to asking device.conf directly for the
|
||||
# feature keys and nothing else — the same question, one step less
|
||||
# removed. A root-run --features does this; a classroom session
|
||||
# normally has the manifest.
|
||||
config = read_device_config()
|
||||
|
||||
return [name for name in sorted(FEATURES) if feature(config, name)]
|
||||
|
||||
|
||||
def page(features):
|
||||
"""The installed manual, with this device's features declared in it."""
|
||||
with open(MANUAL, "r", encoding="utf-8") as handle:
|
||||
text = handle.read()
|
||||
|
||||
declaration = (
|
||||
"<script>window.CLASSROOM_FEATURES = "
|
||||
+ json.dumps(sorted(features))
|
||||
+ ";</script>"
|
||||
)
|
||||
|
||||
# Before the viewer script, which reads it. Nothing else in the file
|
||||
# is touched, so what a teacher reads is the file the package
|
||||
# installed.
|
||||
return text.replace("<body>", "<body>" + declaration, 1)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Open this computer's manual.")
|
||||
parser.add_argument("--features", action="store_true",
|
||||
help="say what this device would show; opens nothing")
|
||||
arguments = parser.parse_args()
|
||||
|
||||
features = enabled_features()
|
||||
|
||||
if arguments.features:
|
||||
for name in sorted(FEATURES):
|
||||
print(f" {name:<24} {'shown' if name in features else 'hidden'}")
|
||||
|
||||
return 0
|
||||
|
||||
if not os.path.exists(MANUAL):
|
||||
say(f"the manual is not installed at {MANUAL}")
|
||||
return 1
|
||||
|
||||
# Composed into the teacher's own cache rather than beside the
|
||||
# installed file, which is root's and read-only.
|
||||
cache = os.path.join(
|
||||
os.environ.get("XDG_CACHE_HOME")
|
||||
or os.path.expanduser("~/.cache"),
|
||||
"ekklesia-classroom")
|
||||
os.makedirs(cache, exist_ok=True)
|
||||
|
||||
try:
|
||||
composed = os.path.join(cache, "manual.html")
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
"w", encoding="utf-8", dir=cache, delete=False,
|
||||
suffix=".html") as handle:
|
||||
handle.write(page(features))
|
||||
temporary = handle.name
|
||||
|
||||
os.replace(temporary, composed)
|
||||
except OSError as error:
|
||||
say(f"could not prepare the manual: {error}")
|
||||
return 1
|
||||
|
||||
scale = "1"
|
||||
|
||||
try:
|
||||
scale = subprocess.run(
|
||||
["/usr/local/bin/classroom-ui-scale"], capture_output=True,
|
||||
text=True, timeout=10, check=False).stdout.strip() or "1"
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
try:
|
||||
# Application mode: the manual in a window of its own, with no
|
||||
# address bar for a teacher to end up somewhere else in. The
|
||||
# room's ordinary browser profile is deliberately not used, so
|
||||
# opening Help cannot disturb a lesson that is already open in
|
||||
# Chrome.
|
||||
os.execvp("google-chrome", [
|
||||
"google-chrome",
|
||||
"--ozone-platform=wayland",
|
||||
f"--force-device-scale-factor={scale}",
|
||||
f"--user-data-dir={os.path.join(cache, 'manual-profile')}",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--password-store=basic",
|
||||
"--hide-crash-restore-bubble",
|
||||
f"--app=file://{composed}",
|
||||
])
|
||||
except OSError as error:
|
||||
say(f"could not open the browser: {error}")
|
||||
say(f"the manual itself is at {composed}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -105,95 +105,10 @@ OVERALL_TIMEOUT = 210.0
|
||||
CLOSE_AFTER_SUCCESS = 4.0
|
||||
|
||||
|
||||
def load_colours():
|
||||
"""The session's own palette, read from the colour scheme in use.
|
||||
|
||||
There is one set of design tokens in this classroom, and they live in
|
||||
a Plasma colour scheme. Reading them here rather than repeating them
|
||||
means this card follows classroom-theme light and dark without
|
||||
knowing that either exists.
|
||||
"""
|
||||
palette = {
|
||||
"window": "#141c20", "view": "#0f171a", "text": "#e6edef",
|
||||
"dim": "#93a5aa", "accent": "#4fb3a8", "on_accent": "#0e1518",
|
||||
"negative": "#e36a6a", "neutral": "#e0b15c", "positive": "#6fcf97",
|
||||
"line": "#25333a",
|
||||
}
|
||||
|
||||
scheme = "EkklesiaDark"
|
||||
|
||||
for path in (
|
||||
os.path.expanduser("~/.config/kdeglobals"),
|
||||
"/etc/ekklesia-classroom/xdg/kdeglobals",
|
||||
"/etc/xdg/kdeglobals",
|
||||
):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
section = None
|
||||
for line in handle:
|
||||
line = line.strip()
|
||||
if line.startswith("["):
|
||||
section = line
|
||||
elif section and section.startswith("[General]") and \
|
||||
line.startswith("ColorScheme="):
|
||||
scheme = line.split("=", 1)[1].strip()
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
else:
|
||||
if scheme:
|
||||
break
|
||||
|
||||
def rgb(value):
|
||||
parts = [part.strip() for part in value.split(",")]
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
try:
|
||||
return "#%02x%02x%02x" % tuple(int(part) for part in parts)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(f"/usr/share/color-schemes/{scheme}.colors", "r",
|
||||
encoding="utf-8") as handle:
|
||||
group = None
|
||||
for line in handle:
|
||||
line = line.strip()
|
||||
if line.startswith("["):
|
||||
group = line
|
||||
continue
|
||||
if "=" not in line or group is None:
|
||||
continue
|
||||
key, value = (part.strip() for part in line.split("=", 1))
|
||||
colour = rgb(value)
|
||||
if colour is None:
|
||||
continue
|
||||
if group == "[Colors:Window]":
|
||||
if key == "BackgroundNormal":
|
||||
palette["window"] = colour
|
||||
elif key == "ForegroundNormal":
|
||||
palette["text"] = colour
|
||||
elif key == "ForegroundInactive":
|
||||
palette["dim"] = colour
|
||||
elif key == "ForegroundNegative":
|
||||
palette["negative"] = colour
|
||||
elif key == "ForegroundNeutral":
|
||||
palette["neutral"] = colour
|
||||
elif key == "ForegroundPositive":
|
||||
palette["positive"] = colour
|
||||
elif key == "BackgroundAlternate":
|
||||
palette["line"] = colour
|
||||
elif group == "[Colors:View]" and key == "BackgroundNormal":
|
||||
palette["view"] = colour
|
||||
elif group == "[Colors:Selection]":
|
||||
if key == "BackgroundNormal":
|
||||
palette["accent"] = colour
|
||||
elif key == "ForegroundNormal":
|
||||
palette["on_accent"] = colour
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return palette
|
||||
# The palette lives in classroom_style, so this card, the Presentations
|
||||
# library and anything else the classroom draws cannot drift apart over
|
||||
# what "the accent colour" means.
|
||||
from classroom_style import load_colours # noqa: E402,F401
|
||||
|
||||
|
||||
STYLE = """
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Watch the teacher's downloads, and keep the library tidy.
|
||||
|
||||
A teacher downloads a session in ordinary Chrome and then looks for it in
|
||||
Presentations. Nothing in between is their job: no file manager, no
|
||||
import button, no "where did you save it". This is the in between.
|
||||
|
||||
classroom-presentation-import run as a service, forever
|
||||
classroom-presentation-import --once one pass, for a technician
|
||||
classroom-presentation-import --status what the library holds
|
||||
|
||||
WHAT IT WILL NOT DO.
|
||||
|
||||
It never leaves a duplicate in Downloads. Once a supported download is
|
||||
complete, its one original is moved into the managed library. Generated
|
||||
PDFs and page images live beside it and can always be rebuilt from it.
|
||||
|
||||
It never imports a Wonder Ink worksheet. Wonder Ink downloads into a
|
||||
directory of its own inside Downloads and those are worksheets, not
|
||||
sessions; the cleanup will not remove one either.
|
||||
|
||||
It never imports a file that is still arriving. Chrome writes
|
||||
`Session 7.pptx.crdownload` and renames it when the transfer is
|
||||
done, so a finished name with that sibling beside it is a download in
|
||||
progress, not a presentation.
|
||||
|
||||
It never overwrites a presentation with a later one of the same
|
||||
name. Two downloads called "Bible Study.pdf" are two presentations.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import errno
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
|
||||
sys.path.insert(
|
||||
0,
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
|
||||
)
|
||||
|
||||
import classroom_presentations as library # noqa: E402
|
||||
from classroom_config import feature, read_device_config # noqa: E402
|
||||
|
||||
# How often the downloads directory is looked at.
|
||||
#
|
||||
# Polling rather than inotify, deliberately. A teacher waits a few
|
||||
# seconds either way, this cannot leak a watch descriptor or miss an
|
||||
# event across a restart, and it is the kind of code somebody can still
|
||||
# read in two years.
|
||||
SCAN_SECONDS = 4
|
||||
|
||||
# How often expired presentations are cleared out.
|
||||
CLEANUP_SECONDS = 3600
|
||||
|
||||
|
||||
def say(message):
|
||||
print(f"classroom-presentation-import: {message}", flush=True)
|
||||
|
||||
|
||||
def new_directory(root):
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
directory = root / uuid.uuid4().hex
|
||||
|
||||
directory.mkdir()
|
||||
|
||||
return directory
|
||||
|
||||
|
||||
def sync_directory(directory):
|
||||
"""Best-effort persistence for a rename or unlink transaction."""
|
||||
try:
|
||||
descriptor = os.open(directory, os.O_RDONLY)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def move_download(source, destination):
|
||||
"""Move one completed download without risking it on another filesystem.
|
||||
|
||||
rename is atomic on one filesystem. Across filesystems, copy to a hidden
|
||||
temporary file, flush it, publish it atomically, and only then unlink the
|
||||
source. If unlinking fails, the published copy is removed and the source
|
||||
remains the one original for the next scan.
|
||||
"""
|
||||
source = os.fspath(source)
|
||||
destination = os.fspath(destination)
|
||||
temporary = destination + ".moving"
|
||||
|
||||
os.makedirs(os.path.dirname(destination), exist_ok=True)
|
||||
|
||||
if os.path.exists(destination) or os.path.exists(temporary):
|
||||
raise FileExistsError(destination)
|
||||
|
||||
try:
|
||||
os.replace(source, destination)
|
||||
sync_directory(os.path.dirname(destination))
|
||||
sync_directory(os.path.dirname(source))
|
||||
return
|
||||
except OSError as error:
|
||||
if error.errno != errno.EXDEV:
|
||||
raise
|
||||
|
||||
try:
|
||||
with open(source, "rb") as incoming, open(temporary, "xb") as outgoing:
|
||||
shutil.copyfileobj(incoming, outgoing, length=1024 * 1024)
|
||||
outgoing.flush()
|
||||
os.fsync(outgoing.fileno())
|
||||
|
||||
shutil.copystat(source, temporary, follow_symlinks=True)
|
||||
os.replace(temporary, destination)
|
||||
sync_directory(os.path.dirname(destination))
|
||||
|
||||
try:
|
||||
os.unlink(source)
|
||||
except OSError:
|
||||
# Roll back to the still-complete source rather than leave two
|
||||
# originals or make a failed move look successful.
|
||||
try:
|
||||
os.unlink(destination)
|
||||
sync_directory(os.path.dirname(destination))
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
sync_directory(os.path.dirname(source))
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(temporary)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def forget_presentation(presentation_id, root):
|
||||
"""Remove transaction records for one interrupted presentation."""
|
||||
data = library.ledger(root)
|
||||
changed = False
|
||||
|
||||
for mark, value in list(data.items()):
|
||||
if value == presentation_id:
|
||||
data.pop(mark, None)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
library.write_json(library.ledger_path(root), data)
|
||||
|
||||
|
||||
class NeedsMemory(RuntimeError):
|
||||
"""The kernel killed the converter or renderer for want of memory."""
|
||||
|
||||
|
||||
# An import the service was killed in the middle of is resumed at the next
|
||||
# start — once. Measured in the lab: a deck that needs more memory than
|
||||
# the machine has free is killed again every time, and with no limit the
|
||||
# service converted, died and restarted in a loop while the library said
|
||||
# "Getting this ready…" for ever.
|
||||
MAX_ATTEMPTS = 2
|
||||
|
||||
|
||||
def prepare(presentation):
|
||||
"""Build the derived PDF, pages and cover from a managed original."""
|
||||
data = presentation.data
|
||||
managed = presentation.managed_copy
|
||||
|
||||
# Counted and written BEFORE the work, so that a kill in the middle of
|
||||
# it is still counted when the service comes back.
|
||||
data["attempts"] = int(data.get("attempts") or 0) + 1
|
||||
data["attempt_started_at"] = library.stamp()
|
||||
presentation.save()
|
||||
|
||||
if managed is None or not managed.is_file():
|
||||
raise RuntimeError("the managed original is missing")
|
||||
|
||||
# A restart may find conversion or rendering output from the interrupted
|
||||
# attempt. The managed original is the recovery point; everything else is
|
||||
# derived and is rebuilt cleanly.
|
||||
if presentation.slides != managed:
|
||||
try:
|
||||
presentation.slides.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
shutil.rmtree(presentation.pages_directory, ignore_errors=True)
|
||||
|
||||
try:
|
||||
presentation.thumbnail.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if data["format"] == "pdf":
|
||||
shutil.copy2(managed, presentation.slides)
|
||||
else:
|
||||
converted, error = library.convert_to_pdf(
|
||||
managed, presentation.slides)
|
||||
|
||||
if not converted:
|
||||
if error == library.KILLED:
|
||||
raise NeedsMemory(error)
|
||||
|
||||
raise RuntimeError(error or "the file could not be converted")
|
||||
|
||||
pages, error = library.render_pages(
|
||||
presentation.slides, presentation.pages_directory)
|
||||
|
||||
if not pages:
|
||||
if error == library.KILLED:
|
||||
raise NeedsMemory(error)
|
||||
|
||||
raise RuntimeError(error or "the slides could not be rendered")
|
||||
|
||||
data["pages"] = pages
|
||||
data["title"] = library.unique_title(
|
||||
library.readable_title(
|
||||
data.get("original_name", ""),
|
||||
library.first_text(presentation.slides)),
|
||||
library.titles_in_use(presentation.directory.parent,
|
||||
exclude=presentation.id),
|
||||
)
|
||||
|
||||
library.render_thumbnail(presentation.page_image(1),
|
||||
presentation.thumbnail)
|
||||
|
||||
data["state"] = "ready"
|
||||
data["error"] = None
|
||||
data["error_kind"] = None
|
||||
presentation.save()
|
||||
|
||||
say(f"imported {data['title']!r} ({pages} slides)")
|
||||
|
||||
return presentation
|
||||
|
||||
|
||||
def import_one(source, root=None):
|
||||
"""Take one finished download into the library.
|
||||
|
||||
Ordered so that an interruption is always recoverable. Metadata and the
|
||||
transaction record are written before the move. Startup either retries
|
||||
from Downloads or resumes from the moved managed original. A presentation
|
||||
becomes ready only once every derived file it needs is on disk.
|
||||
"""
|
||||
root = root if root is not None else library.library_root()
|
||||
mark = library.fingerprint(source)
|
||||
|
||||
if mark is None:
|
||||
return None
|
||||
|
||||
directory = new_directory(root)
|
||||
managed = directory / "original" / source.name
|
||||
|
||||
data = {
|
||||
"id": directory.name,
|
||||
"state": "importing",
|
||||
"title": library.readable_title(source.name),
|
||||
"original_name": source.name,
|
||||
"source_path": str(source),
|
||||
"managed_copy": str(managed.relative_to(directory)),
|
||||
"format": library.SUPPORTED.get(source.suffix.lower(), ""),
|
||||
"imported_at": library.stamp(),
|
||||
"last_presented_at": None,
|
||||
"times_presented": 0,
|
||||
"thumbnail_page": 1,
|
||||
"pages": 0,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
presentation = library.Presentation(directory, data)
|
||||
presentation.save()
|
||||
|
||||
# Recorded before the work, so that a file which crashes the
|
||||
# conversion every time cannot be retried in a loop forever. The
|
||||
# startup sweep is what un-records an import interrupted part way.
|
||||
library.remember(mark, directory.name, root)
|
||||
|
||||
say(f"importing {source.name}")
|
||||
|
||||
try:
|
||||
move_download(source, managed)
|
||||
except Exception as error: # noqa: BLE001
|
||||
# A failed move leaves the source in Downloads. Remove the transaction
|
||||
# and retry on the next scan; there is no half-owned file to show.
|
||||
forget_presentation(presentation.id, root)
|
||||
shutil.rmtree(directory, ignore_errors=True)
|
||||
say(f"could not move {source.name}: {error}")
|
||||
return None
|
||||
|
||||
try:
|
||||
return prepare(presentation)
|
||||
except Exception as error: # noqa: BLE001
|
||||
# Conversion and rendering failures keep the managed original. The
|
||||
# teacher can remove it, and a technician can recover or inspect it;
|
||||
# no successfully downloaded file is lost because a derived asset
|
||||
# could not be made.
|
||||
fail(presentation, error)
|
||||
say(f"could not import {source.name}: {error}")
|
||||
|
||||
return presentation
|
||||
|
||||
|
||||
def fail(presentation, error, kind=None):
|
||||
"""Record a failure a teacher's card can say something useful about."""
|
||||
presentation.data["state"] = "failed"
|
||||
presentation.data["error"] = str(error)[:300]
|
||||
presentation.data["error_kind"] = kind or (
|
||||
"memory" if isinstance(error, NeedsMemory) else "unreadable")
|
||||
presentation.save()
|
||||
|
||||
|
||||
def attempt(presentation):
|
||||
"""prepare(), with any failure recorded rather than raised."""
|
||||
try:
|
||||
prepare(presentation)
|
||||
except Exception as error: # noqa: BLE001
|
||||
fail(presentation, error)
|
||||
say(f"could not import {presentation.title!r}: {error}")
|
||||
|
||||
|
||||
def retry_requested(root=None):
|
||||
"""Presentations a teacher pressed Try again on, taken up now."""
|
||||
root = root if root is not None else library.library_root()
|
||||
retried = 0
|
||||
|
||||
if not root.is_dir():
|
||||
return 0
|
||||
|
||||
for presentation in library.library(root):
|
||||
if presentation.state != "retry":
|
||||
continue
|
||||
|
||||
managed = presentation.managed_copy
|
||||
|
||||
if managed is None or not managed.is_file():
|
||||
fail(presentation, RuntimeError("the managed original is missing"), "unreadable")
|
||||
continue
|
||||
|
||||
say(f"trying again: {presentation.title!r}")
|
||||
attempt(presentation)
|
||||
retried += 1
|
||||
|
||||
return retried
|
||||
|
||||
|
||||
def sweep(root=None):
|
||||
"""Recover imports interrupted during a move, conversion or rendering.
|
||||
|
||||
If Downloads still has the source, discard only the incomplete managed
|
||||
copy and let the next scan retry. If the source has already been moved,
|
||||
the managed original is the recovery point and its derived assets are
|
||||
rebuilt in place. This distinction prevents a crash after the move from
|
||||
deleting the only copy of a successful download.
|
||||
"""
|
||||
root = root if root is not None else library.library_root()
|
||||
|
||||
if not root.is_dir():
|
||||
return 0
|
||||
|
||||
stale = 0
|
||||
|
||||
for presentation in library.library(root):
|
||||
if presentation.state != "importing":
|
||||
continue
|
||||
|
||||
source_text = presentation.data.get("source_path")
|
||||
source = Path(source_text) if source_text else None
|
||||
managed = presentation.managed_copy
|
||||
|
||||
if (managed is not None and managed.is_file()
|
||||
and (source is None or not source.exists())):
|
||||
attempts = int(presentation.data.get("attempts") or 0)
|
||||
|
||||
if attempts >= MAX_ATTEMPTS:
|
||||
# Killed in the middle of it every time it was tried.
|
||||
say(f"giving up on {presentation.title!r} after {attempts} "
|
||||
"interrupted attempts; the teacher can try again")
|
||||
fail(presentation, NeedsMemory("interrupted every attempt"))
|
||||
else:
|
||||
say(f"resuming an interrupted import: {presentation.title!r}")
|
||||
attempt(presentation)
|
||||
|
||||
stale += 1
|
||||
continue
|
||||
|
||||
say(f"retrying an interrupted move: {presentation.title!r}")
|
||||
forget_presentation(presentation.id, root)
|
||||
shutil.rmtree(presentation.directory, ignore_errors=True)
|
||||
stale += 1
|
||||
|
||||
return stale
|
||||
|
||||
|
||||
def scan(root=None, downloads=None):
|
||||
"""One look at the downloads directory."""
|
||||
root = root if root is not None else library.library_root()
|
||||
known = library.ledger(root)
|
||||
imported = 0
|
||||
|
||||
for candidate in library.candidates(downloads):
|
||||
mark = library.fingerprint(candidate)
|
||||
|
||||
if mark is None or mark in known:
|
||||
continue
|
||||
|
||||
if import_one(candidate, root) is not None:
|
||||
imported += 1
|
||||
known = library.ledger(root)
|
||||
|
||||
return imported
|
||||
|
||||
|
||||
def cleanup(root=None, moment=None):
|
||||
"""Remove presentations whose time is up.
|
||||
|
||||
Only ever the selected managed directory, including its one original and
|
||||
derived assets, and never the presentation on the screen.
|
||||
"""
|
||||
root = root if root is not None else library.library_root()
|
||||
lock = library.presenting_lock()
|
||||
showing = ""
|
||||
|
||||
try:
|
||||
showing = lock.read_text().strip()
|
||||
except OSError:
|
||||
showing = ""
|
||||
|
||||
removed = 0
|
||||
|
||||
for presentation in library.library(root):
|
||||
if not presentation.expired(moment):
|
||||
continue
|
||||
|
||||
if presentation.id == showing:
|
||||
# A retention timer must never interrupt a room. It will
|
||||
# still be expired in an hour.
|
||||
say(f"{presentation.title!r} is expired but is being presented")
|
||||
continue
|
||||
|
||||
if presentation.last_presented_at is None:
|
||||
why = f"never presented, imported {presentation.data['imported_at']}"
|
||||
else:
|
||||
why = f"last presented {presentation.data['last_presented_at']}"
|
||||
|
||||
say(f"removing {presentation.title!r} — {why}")
|
||||
|
||||
if library.remove(presentation):
|
||||
removed += 1
|
||||
|
||||
return removed
|
||||
|
||||
|
||||
def status(root=None):
|
||||
presentations = library.library(root)
|
||||
|
||||
if not presentations:
|
||||
print("the library is empty")
|
||||
return 0
|
||||
|
||||
for presentation in presentations:
|
||||
expires = presentation.expires_at()
|
||||
print(f"{presentation.title}")
|
||||
print(f" state {presentation.state}"
|
||||
f"{' — ' + presentation.error if presentation.error else ''}")
|
||||
print(f" slides {presentation.pages}"
|
||||
f" (card shows slide {presentation.thumbnail_page})")
|
||||
print(f" imported {presentation.data.get('imported_at')}")
|
||||
presented = presentation.data.get("last_presented_at") or "never"
|
||||
print(f" presented {presented} "
|
||||
f"({presentation.times_presented} times)")
|
||||
print(f" expires {library.stamp(expires) if expires else 'unknown'}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def hold_lock(root=None):
|
||||
"""The importer's lock, held for as long as this process lives."""
|
||||
import fcntl
|
||||
|
||||
root = root if root is not None else library.library_root()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
handle = open(root / ".importer.lock", "a")
|
||||
|
||||
try:
|
||||
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
handle.close()
|
||||
return None
|
||||
|
||||
return handle
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Bring finished downloads into the Presentations library.")
|
||||
parser.add_argument("--once", action="store_true",
|
||||
help="one pass, then stop")
|
||||
parser.add_argument("--status", action="store_true",
|
||||
help="what the library holds; changes nothing")
|
||||
parser.add_argument("--cleanup", action="store_true",
|
||||
help="apply the retention rules now")
|
||||
arguments = parser.parse_args()
|
||||
|
||||
if arguments.status:
|
||||
return status()
|
||||
|
||||
# A device that does not offer Presentations does not run an importer
|
||||
# for them. Said out loud for the technician who started it by hand.
|
||||
if not feature(read_device_config(), "PRESENTATIONS_ENABLED"):
|
||||
say("PRESENTATIONS_ENABLED is false on this device; not starting")
|
||||
return 0
|
||||
|
||||
if arguments.cleanup:
|
||||
removed = cleanup()
|
||||
say(f"removed {removed} expired presentation(s)")
|
||||
return 0
|
||||
|
||||
# One importer at a time, whoever started it. Conversions are the
|
||||
# memory the machine runs short of, so two at once — the service and a
|
||||
# technician's --once, say — must not happen.
|
||||
held = hold_lock()
|
||||
|
||||
if held is None:
|
||||
say("another importer is already running; not starting a second")
|
||||
return 0
|
||||
|
||||
sweep()
|
||||
retry_requested()
|
||||
|
||||
if arguments.once:
|
||||
imported = scan()
|
||||
cleanup()
|
||||
say(f"imported {imported} presentation(s)")
|
||||
return 0
|
||||
|
||||
say(f"watching {library.downloads_root()}")
|
||||
|
||||
last_cleanup = 0.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
retry_requested()
|
||||
scan()
|
||||
|
||||
if time.monotonic() - last_cleanup > CLEANUP_SECONDS:
|
||||
cleanup()
|
||||
last_cleanup = time.monotonic()
|
||||
except Exception as error: # noqa: BLE001
|
||||
# One bad file must not stop the watcher. A teacher whose
|
||||
# next download never appears has no way to find out why.
|
||||
say(f"scan failed: {error}")
|
||||
|
||||
time.sleep(SCAN_SECONDS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,982 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""The Presentations library, as a teacher sees it.
|
||||
|
||||
Three buttons on a card — Present, Quick Look, Remove — and nothing else.
|
||||
No storage locations, no file names, no import controls, no settings, no
|
||||
retention. A teacher who has downloaded a session opens this,
|
||||
recognises tonight's by its picture, and presses Present.
|
||||
|
||||
WHY THE PRESENTER IS IN THIS WINDOW AND NOT A SEPARATE PROGRAM.
|
||||
|
||||
The disc player is a separate process, because a disc plays until
|
||||
somebody stops it and the panel is how you get back to it. A
|
||||
presentation is the opposite: it is a thing a teacher steps into and
|
||||
must step out of, back to the library, reliably, with one key they
|
||||
already know.
|
||||
|
||||
Making the presenter a fullscreen window of this same application is the
|
||||
whole of that guarantee. Escape is handled here, the fullscreen window is
|
||||
taken down here, and the library is already on the screen underneath it.
|
||||
There is no second process to fail to start, no window to be left behind,
|
||||
and no path by which a teacher ends up looking at something that is not
|
||||
the library.
|
||||
|
||||
That is also why nothing here launches Impress, Okular or any other
|
||||
document application. Every one of them that was measured leaves its own
|
||||
editor or viewer on the screen when a slideshow ends — see
|
||||
docs/presentations.md for what each one actually did. The slides are
|
||||
pictures by the time they reach this window, rendered at import by
|
||||
LibreOffice and poppler, and a picture cannot open a file dialog.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
gi.require_version("Gdk", "3.0")
|
||||
gi.require_version("GdkPixbuf", "2.0")
|
||||
|
||||
from gi.repository import Gdk, GdkPixbuf, GLib, Gtk # noqa: E402
|
||||
|
||||
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
|
||||
sys.path.insert(
|
||||
0,
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
|
||||
)
|
||||
|
||||
import classroom_presentations as library # noqa: E402
|
||||
from classroom_config import feature, read_device_config # noqa: E402
|
||||
from classroom_style import load_colours # noqa: E402
|
||||
|
||||
APP_ID = "ekklesia-presentations"
|
||||
|
||||
# How often the library notices a newly imported presentation.
|
||||
#
|
||||
# The importer is a separate service, so a session downloaded while this
|
||||
# window is open appears without the teacher doing anything. Two seconds
|
||||
# is well under the time it takes to walk back to the machine.
|
||||
REFRESH_SECONDS = 2
|
||||
|
||||
CARD_WIDTH = 320
|
||||
|
||||
|
||||
STYLE = """
|
||||
window {{ background-color: {window}; }}
|
||||
.heading {{ color: {text}; font-size: 30px; font-weight: 700; }}
|
||||
.subtle {{ color: {dim}; font-size: 16px; }}
|
||||
.card {{
|
||||
background-color: {view};
|
||||
border: 1px solid {line};
|
||||
border-radius: 18px;
|
||||
padding: 14px;
|
||||
}}
|
||||
.card-title {{ color: {text}; font-size: 19px; font-weight: 600; }}
|
||||
.card-note {{ color: {dim}; font-size: 14px; }}
|
||||
.thumb {{
|
||||
background-color: {window};
|
||||
border: 1px solid {line};
|
||||
border-radius: 10px;
|
||||
}}
|
||||
button {{
|
||||
color: {text};
|
||||
background-image: none;
|
||||
background-color: rgba(255,255,255,0.06);
|
||||
border: 1px solid {line};
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
font-size: 16px;
|
||||
}}
|
||||
button:hover {{ background-color: rgba(255,255,255,0.12); }}
|
||||
button.primary {{
|
||||
color: {on_accent};
|
||||
background-color: {accent};
|
||||
border-color: {accent};
|
||||
font-weight: 700;
|
||||
}}
|
||||
button.danger:hover {{ background-color: {negative}; color: {on_accent}; }}
|
||||
.empty {{ color: {dim}; font-size: 20px; }}
|
||||
.slide-counter {{
|
||||
color: {text};
|
||||
background-color: rgba(0,0,0,0.65);
|
||||
border-radius: 10px;
|
||||
padding: 6px 12px;
|
||||
font-size: 16px;
|
||||
}}
|
||||
.current-cover {{
|
||||
color: {on_accent};
|
||||
background-color: {accent};
|
||||
border-radius: 10px;
|
||||
padding: 6px 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def scaled_pixbuf(path, width, height=-1):
|
||||
try:
|
||||
return GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
||||
str(path), width, height, True)
|
||||
except (GLib.Error, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def monitor_workarea(window=None):
|
||||
"""The usable logical pixels on the monitor, excluding Plasma's panel."""
|
||||
display = Gdk.Display.get_default()
|
||||
|
||||
if display is None:
|
||||
return Gdk.Rectangle(x=0, y=0, width=1280, height=720)
|
||||
|
||||
monitor = None
|
||||
|
||||
if window is not None and window.get_window() is not None:
|
||||
monitor = display.get_monitor_at_window(window.get_window())
|
||||
|
||||
if monitor is None:
|
||||
monitor = display.get_primary_monitor()
|
||||
|
||||
if monitor is None and display.get_n_monitors() > 0:
|
||||
monitor = display.get_monitor(0)
|
||||
|
||||
if monitor is None:
|
||||
return Gdk.Rectangle(x=0, y=0, width=1280, height=720)
|
||||
|
||||
return monitor.get_workarea()
|
||||
|
||||
|
||||
def monitor_geometry(window=None):
|
||||
"""The full logical monitor rectangle, including the panel area."""
|
||||
display = Gdk.Display.get_default()
|
||||
|
||||
if display is None:
|
||||
return Gdk.Rectangle(x=0, y=0, width=1280, height=720)
|
||||
|
||||
monitor = None
|
||||
|
||||
if window is not None and window.get_window() is not None:
|
||||
monitor = display.get_monitor_at_window(window.get_window())
|
||||
|
||||
if monitor is None:
|
||||
monitor = display.get_primary_monitor()
|
||||
|
||||
if monitor is None and display.get_n_monitors() > 0:
|
||||
monitor = display.get_monitor(0)
|
||||
|
||||
if monitor is None:
|
||||
return Gdk.Rectangle(x=0, y=0, width=1280, height=720)
|
||||
|
||||
return monitor.get_geometry()
|
||||
|
||||
|
||||
class Presenter(Gtk.Window):
|
||||
"""The slideshow. A fullscreen window showing one picture at a time.
|
||||
|
||||
Everything a presentation remote and a keyboard can reasonably send
|
||||
is accepted, because a teacher in front of a room does not get to
|
||||
look up which key this one wants. The clicker in the cupboard sends
|
||||
Page Up and Page Down; the keyboard sends arrows and space.
|
||||
|
||||
Escape is the way out and it is the only way out, which is what makes
|
||||
this safe to put in front of a teacher: there is nothing else to
|
||||
press and nowhere else to end up.
|
||||
"""
|
||||
|
||||
def __init__(self, presentation, start_page=1, on_finish=None,
|
||||
display_geometry=None):
|
||||
# A stable title gives KWin a presenter-only match. The library and
|
||||
# Quick Look share this application's Wayland app id, so matching the
|
||||
# app id alone would fullscreen every window.
|
||||
super().__init__(title="Presenting")
|
||||
|
||||
self.presentation = presentation
|
||||
self.on_finish = on_finish
|
||||
self.page = min(max(start_page, 1), presentation.pages or 1)
|
||||
self.counted = False
|
||||
self.finished = False
|
||||
self.display_geometry = display_geometry or monitor_geometry()
|
||||
# How many times fullscreen has been asked for without it
|
||||
# sticking. See _state.
|
||||
self.refused = 0
|
||||
|
||||
self.set_decorated(False)
|
||||
self.set_skip_taskbar_hint(True)
|
||||
self.set_role("classroom-presenter")
|
||||
self.set_default_size(
|
||||
self.display_geometry.width, self.display_geometry.height)
|
||||
self.set_size_request(
|
||||
self.display_geometry.width, self.display_geometry.height)
|
||||
self.fullscreen()
|
||||
# A slideshow is the only thing on the screen while it is running.
|
||||
self.set_keep_above(True)
|
||||
|
||||
black = Gdk.RGBA()
|
||||
black.parse("#000000")
|
||||
self.override_background_color(Gtk.StateFlags.NORMAL, black)
|
||||
|
||||
# Paint an explicit full-window layer behind the image. The old
|
||||
# app-paintable Gtk.Window never drew its own background, so KWin
|
||||
# correctly made it fullscreen while the panel and desktop remained
|
||||
# visible through the transparent space around a portrait slide.
|
||||
self.image = Gtk.Image()
|
||||
self.image.set_halign(Gtk.Align.CENTER)
|
||||
self.image.set_valign(Gtk.Align.CENTER)
|
||||
stage = Gtk.Overlay()
|
||||
stage.set_size_request(
|
||||
self.display_geometry.width, self.display_geometry.height)
|
||||
background = Gtk.DrawingArea()
|
||||
background.connect("draw", self._paint_black)
|
||||
stage.add(background)
|
||||
stage.add_overlay(self.image)
|
||||
self.add(stage)
|
||||
|
||||
self.connect("key-press-event", self._key)
|
||||
self.connect("delete-event", lambda *_: self.finish())
|
||||
self.connect("size-allocate", self._resized)
|
||||
self.connect("window-state-event", self._state)
|
||||
self.connect("map-event", self._mapped)
|
||||
|
||||
self._drawn_at = (0, 0, 0)
|
||||
|
||||
def _paint_black(self, _widget, context):
|
||||
context.set_source_rgb(0, 0, 0)
|
||||
context.paint()
|
||||
return False
|
||||
|
||||
def _mapped(self, *_):
|
||||
# GTK can make the first request before the Wayland surface exists.
|
||||
# Repeat it after mapping; KWin's matching rule is the compositor-side
|
||||
# guarantee, while this keeps the window correct on other desktops.
|
||||
self.fullscreen()
|
||||
GLib.idle_add(self._mapped_fullscreen)
|
||||
return False
|
||||
|
||||
def _mapped_fullscreen(self):
|
||||
if not self.finished:
|
||||
self.fullscreen()
|
||||
return False
|
||||
|
||||
def _state(self, _widget, event):
|
||||
"""Put the slideshow back on the whole screen if it is taken off it.
|
||||
|
||||
A compositor may unfullscreen a window without being asked:
|
||||
measured in the lab, where focusing another window on the same
|
||||
workspace dropped the slideshow to half the screen with the
|
||||
library beside it, which is precisely the "teacher is left
|
||||
looking at something that is not the presentation" this whole
|
||||
design is built to prevent. KWin has its own occasions —
|
||||
a notification, a second output appearing.
|
||||
|
||||
So fullscreen is re-asserted rather than assumed, for as long as
|
||||
the presentation is running. It stops the moment Escape is
|
||||
pressed, so this can never fight the teacher's way out.
|
||||
|
||||
And it gives up after a few goes. A compositor that keeps
|
||||
granting fullscreen and taking it away again would otherwise have
|
||||
this asking forever, in the middle of a Bible study, which is a
|
||||
worse failure than the window being the wrong size. After that
|
||||
the slides are still on the screen and Escape still works — only
|
||||
the insisting stops.
|
||||
"""
|
||||
if self.finished:
|
||||
return False
|
||||
|
||||
if event.new_window_state & Gdk.WindowState.FULLSCREEN:
|
||||
self.refused = 0
|
||||
return False
|
||||
|
||||
if self.refused < 5:
|
||||
self.refused += 1
|
||||
self.fullscreen()
|
||||
|
||||
return False
|
||||
|
||||
# -- showing a slide ------------------------------------------
|
||||
|
||||
def _resized(self, _widget, _allocation):
|
||||
self.show_page()
|
||||
|
||||
def show_page(self):
|
||||
path = self.presentation.page_image(self.page)
|
||||
allocation = self.get_allocation()
|
||||
width = max(allocation.width, self.display_geometry.width)
|
||||
height = max(allocation.height, self.display_geometry.height)
|
||||
want = (self.page, width, height)
|
||||
|
||||
if want == self._drawn_at:
|
||||
return
|
||||
|
||||
if not path.exists():
|
||||
return
|
||||
|
||||
pixbuf = self._fitted(path, width, height)
|
||||
|
||||
if pixbuf is None:
|
||||
return
|
||||
|
||||
self.image.set_from_pixbuf(pixbuf)
|
||||
self._drawn_at = want
|
||||
|
||||
# A SLIDE IS ON THE SCREEN, SO THIS IS A PRESENTATION.
|
||||
#
|
||||
# Counted here and nowhere earlier. Pressing Present is an
|
||||
# intention; a viewer that failed to start, a file that could not
|
||||
# be read and a window that never drew are all not presentations,
|
||||
# and none of them may buy another thirty days of retention.
|
||||
if not self.counted:
|
||||
self.counted = True
|
||||
self.presentation.mark_presented()
|
||||
|
||||
def _fitted(self, path, width, height):
|
||||
if width < 2 or height < 2:
|
||||
return None
|
||||
|
||||
try:
|
||||
original = GdkPixbuf.Pixbuf.new_from_file(str(path))
|
||||
except (GLib.Error, OSError):
|
||||
return None
|
||||
|
||||
# Letterboxed, never cropped and never stretched. A slide with a
|
||||
# verse on it cannot lose its edges to fit a television.
|
||||
ratio = min(width / original.get_width(),
|
||||
height / original.get_height())
|
||||
|
||||
fitted_width = max(1, int(original.get_width() * ratio))
|
||||
fitted_height = max(1, int(original.get_height() * ratio))
|
||||
fitted = original.scale_simple(
|
||||
fitted_width,
|
||||
fitted_height,
|
||||
GdkPixbuf.InterpType.BILINEAR,
|
||||
)
|
||||
|
||||
# Make the entire client buffer opaque. On Plasma Wayland, painting a
|
||||
# container behind a smaller Gtk.Image still left the surface outside
|
||||
# the image transparent: KWin reported a correct 1920x1080 fullscreen
|
||||
# window, but the panel and desktop showed through the letterbox. A
|
||||
# full-size RGB pixbuf makes black part of the presented content.
|
||||
canvas = GdkPixbuf.Pixbuf.new(
|
||||
GdkPixbuf.Colorspace.RGB, False, 8, width, height)
|
||||
canvas.fill(0x000000ff)
|
||||
fitted.copy_area(
|
||||
0, 0, fitted_width, fitted_height, canvas,
|
||||
(width - fitted_width) // 2,
|
||||
(height - fitted_height) // 2,
|
||||
)
|
||||
|
||||
return canvas
|
||||
|
||||
# -- moving about ---------------------------------------------
|
||||
|
||||
def go(self, delta):
|
||||
target = self.page + delta
|
||||
|
||||
if target < 1:
|
||||
return
|
||||
|
||||
if target > self.presentation.pages:
|
||||
# PAST THE LAST SLIDE IS THE END, AND THE END IS THE LIBRARY.
|
||||
#
|
||||
# Not a black "click to exit" slide, and certainly not an
|
||||
# editor. A teacher who clicks once more than there are
|
||||
# slides is finished, and being finished means being back
|
||||
# where they started.
|
||||
self.finish()
|
||||
return
|
||||
|
||||
self.page = target
|
||||
self.show_page()
|
||||
|
||||
def _key(self, _widget, event):
|
||||
key = event.keyval
|
||||
forward = (
|
||||
Gdk.KEY_Right, Gdk.KEY_Down, Gdk.KEY_space, Gdk.KEY_Page_Down,
|
||||
Gdk.KEY_KP_Page_Down, Gdk.KEY_Return, Gdk.KEY_KP_Enter,
|
||||
Gdk.KEY_KP_Right, Gdk.KEY_KP_Down, Gdk.KEY_n,
|
||||
)
|
||||
backward = (
|
||||
Gdk.KEY_Left, Gdk.KEY_Up, Gdk.KEY_Page_Up, Gdk.KEY_KP_Page_Up,
|
||||
Gdk.KEY_BackSpace, Gdk.KEY_KP_Left, Gdk.KEY_KP_Up, Gdk.KEY_p,
|
||||
)
|
||||
|
||||
if key in forward:
|
||||
self.go(1)
|
||||
elif key in backward:
|
||||
self.go(-1)
|
||||
elif key in (Gdk.KEY_Home, Gdk.KEY_KP_Home):
|
||||
self.page = 1
|
||||
self.show_page()
|
||||
elif key in (Gdk.KEY_End, Gdk.KEY_KP_End):
|
||||
self.page = self.presentation.pages
|
||||
self.show_page()
|
||||
elif key in (Gdk.KEY_Escape, Gdk.KEY_q, Gdk.KEY_F5):
|
||||
self.finish()
|
||||
|
||||
return True
|
||||
|
||||
def finish(self):
|
||||
if self.finished:
|
||||
return True
|
||||
|
||||
self.finished = True
|
||||
|
||||
try:
|
||||
library.presenting_lock().unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
on_finish = self.on_finish
|
||||
self.destroy()
|
||||
|
||||
# Map and raise the library only after the fullscreen surface is gone.
|
||||
# Showing it underneath a still-live fullscreen window could leave the
|
||||
# nested KWin session with no newly damaged return surface on Escape.
|
||||
if on_finish is not None:
|
||||
on_finish()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class QuickLook(Gtk.Window):
|
||||
"""Look through the slides before showing them to a room.
|
||||
|
||||
Three things a teacher can do here, and they are kept apart on
|
||||
purpose because conflating any two of them would be wrong:
|
||||
|
||||
browse which slide is on this screen right now;
|
||||
Use as cover which slide the library card shows;
|
||||
Present start the slideshow, from the beginning.
|
||||
|
||||
Browsing is not presenting: coming in here, looking through every
|
||||
slide and leaving again must not mark the session as presented or
|
||||
move its retention timer by a second. Choosing the picture is not
|
||||
presenting either, and it does not change where a presentation
|
||||
starts.
|
||||
"""
|
||||
|
||||
def __init__(self, presentation, parent, on_close=None, on_present=None):
|
||||
super().__init__(title=presentation.title)
|
||||
|
||||
self.presentation = presentation
|
||||
self.on_close = on_close
|
||||
self.on_present = on_present
|
||||
# Open on the familiar card image. Teachers can immediately see what
|
||||
# "cover" means, while Present still starts independently at slide 1.
|
||||
self.page = presentation.thumbnail_page
|
||||
|
||||
self.set_transient_for(parent)
|
||||
self.set_modal(True)
|
||||
workarea = monitor_workarea(parent)
|
||||
window_width = min(1000, max(560, int(workarea.width * 0.86)))
|
||||
# Leave enough room for decorations and a panel even on compositors
|
||||
# that report the full monitor as the work area (the nested Plasma lab
|
||||
# does). At 1366x768 this remains comfortably usable at larger scale.
|
||||
window_height = min(760, max(420, int(workarea.height * 0.75)))
|
||||
self.set_default_size(window_width, window_height)
|
||||
self.preview_width = max(320, window_width - 32)
|
||||
self.preview_height = max(180, window_height - 210)
|
||||
|
||||
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
|
||||
outer.set_border_width(16)
|
||||
self.add(outer)
|
||||
|
||||
heading = Gtk.Label(label=presentation.title, xalign=0)
|
||||
heading.get_style_context().add_class("heading")
|
||||
heading.set_line_wrap(True)
|
||||
outer.pack_start(heading, False, False, 0)
|
||||
|
||||
self.slide = Gtk.Image()
|
||||
frame = Gtk.EventBox()
|
||||
frame.get_style_context().add_class("thumb")
|
||||
frame.add(self.slide)
|
||||
outer.pack_start(frame, True, True, 0)
|
||||
|
||||
# Browsing.
|
||||
browse = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
||||
browse.set_halign(Gtk.Align.CENTER)
|
||||
|
||||
self.back = Gtk.Button(label="Previous")
|
||||
self.back.connect("clicked", lambda *_: self.go(-1))
|
||||
browse.pack_start(self.back, False, False, 0)
|
||||
|
||||
self.counter = Gtk.Label()
|
||||
self.counter.get_style_context().add_class("subtle")
|
||||
browse.pack_start(self.counter, False, False, 12)
|
||||
|
||||
self.forward = Gtk.Button(label="Next")
|
||||
self.forward.connect("clicked", lambda *_: self.go(1))
|
||||
browse.pack_start(self.forward, False, False, 0)
|
||||
|
||||
outer.pack_start(browse, False, False, 0)
|
||||
|
||||
# The three things this window is for.
|
||||
actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
||||
|
||||
self.choose = Gtk.Button(label="Use as cover")
|
||||
self.choose.connect("clicked", self._choose)
|
||||
|
||||
self.cover = Gtk.Label(label="Current cover")
|
||||
self.cover.get_style_context().add_class("current-cover")
|
||||
self.cover_action = Gtk.Stack()
|
||||
self.cover_action.add_named(self.choose, "choose")
|
||||
self.cover_action.add_named(self.cover, "current")
|
||||
actions.pack_start(self.cover_action, False, False, 0)
|
||||
|
||||
actions.pack_start(Gtk.Box(), True, True, 0)
|
||||
|
||||
close = Gtk.Button(label="Back to Presentations")
|
||||
close.connect("clicked", lambda *_: self.close_quietly())
|
||||
actions.pack_start(close, False, False, 0)
|
||||
|
||||
present = Gtk.Button(label="Present")
|
||||
present.get_style_context().add_class("primary")
|
||||
present.connect("clicked", self._present)
|
||||
actions.pack_start(present, False, False, 0)
|
||||
|
||||
outer.pack_start(actions, False, False, 0)
|
||||
|
||||
self.connect("key-press-event", self._key)
|
||||
self.connect("delete-event", lambda *_: self.close_quietly())
|
||||
self.connect("size-allocate", lambda *_: self.show_page(False))
|
||||
|
||||
self._drawn_at = (0, 0, 0)
|
||||
self.show_page()
|
||||
|
||||
def show_page(self, force=True):
|
||||
allocation = self.slide.get_allocation()
|
||||
width = min(self.preview_width,
|
||||
max(1, allocation.width or self.preview_width))
|
||||
height = min(self.preview_height,
|
||||
max(1, allocation.height or self.preview_height))
|
||||
want = (self.page, width, height)
|
||||
|
||||
if want == self._drawn_at and not force:
|
||||
return
|
||||
|
||||
path = self.presentation.page_image(self.page)
|
||||
pixbuf = (scaled_pixbuf(path, width, height)
|
||||
if path.exists() else None)
|
||||
|
||||
if pixbuf is not None:
|
||||
self.slide.set_from_pixbuf(pixbuf)
|
||||
self._drawn_at = want
|
||||
|
||||
total = self.presentation.pages
|
||||
chosen = self.presentation.thumbnail_page
|
||||
self.counter.set_text(f"Slide {self.page} of {total}")
|
||||
|
||||
self.back.set_sensitive(self.page > 1)
|
||||
self.forward.set_sensitive(self.page < total)
|
||||
self.cover_action.set_visible_child_name(
|
||||
"current" if self.page == chosen else "choose")
|
||||
|
||||
def go(self, delta):
|
||||
target = self.page + delta
|
||||
|
||||
if 1 <= target <= self.presentation.pages:
|
||||
self.page = target
|
||||
self.show_page()
|
||||
|
||||
def _choose(self, *_):
|
||||
# Writes the chosen slide and nothing else. Not a presentation,
|
||||
# and not a change to where the slideshow starts.
|
||||
self.presentation.set_thumbnail_page(self.page)
|
||||
self.show_page()
|
||||
|
||||
if self.on_close is not None:
|
||||
self.on_close()
|
||||
|
||||
def _present(self, *_):
|
||||
self.destroy()
|
||||
|
||||
if self.on_present is not None:
|
||||
self.on_present(self.presentation)
|
||||
|
||||
def close_quietly(self):
|
||||
"""Leave without having presented anything."""
|
||||
self.destroy()
|
||||
|
||||
if self.on_close is not None:
|
||||
self.on_close()
|
||||
|
||||
return True
|
||||
|
||||
def _key(self, _widget, event):
|
||||
key = event.keyval
|
||||
|
||||
if key in (Gdk.KEY_Right, Gdk.KEY_Down, Gdk.KEY_Page_Down,
|
||||
Gdk.KEY_space):
|
||||
self.go(1)
|
||||
elif key in (Gdk.KEY_Left, Gdk.KEY_Up, Gdk.KEY_Page_Up):
|
||||
self.go(-1)
|
||||
elif key == Gdk.KEY_Escape:
|
||||
self.close_quietly()
|
||||
else:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class Library(Gtk.Window):
|
||||
"""The window the panel button opens."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(title="Presentations")
|
||||
|
||||
self.set_default_size(680, 420)
|
||||
self.presenter = None
|
||||
self._signature = None
|
||||
|
||||
self._apply_style()
|
||||
|
||||
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||
outer.set_border_width(20)
|
||||
self.add(outer)
|
||||
|
||||
heading = Gtk.Label(label="Presentations", xalign=0)
|
||||
heading.get_style_context().add_class("heading")
|
||||
outer.pack_start(heading, False, False, 0)
|
||||
|
||||
self.note = Gtk.Label(xalign=0)
|
||||
self.note.get_style_context().add_class("subtle")
|
||||
outer.pack_start(self.note, False, False, 0)
|
||||
|
||||
scroller = Gtk.ScrolledWindow()
|
||||
scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||||
outer.pack_start(scroller, True, True, 8)
|
||||
|
||||
self.grid = Gtk.FlowBox()
|
||||
self.grid.set_valign(Gtk.Align.START)
|
||||
self.grid.set_max_children_per_line(4)
|
||||
self.grid.set_min_children_per_line(1)
|
||||
self.grid.set_selection_mode(Gtk.SelectionMode.NONE)
|
||||
self.grid.set_row_spacing(16)
|
||||
self.grid.set_column_spacing(16)
|
||||
self.grid.set_homogeneous(False)
|
||||
scroller.add(self.grid)
|
||||
|
||||
self.connect("key-press-event", self._key)
|
||||
self.connect("destroy", lambda *_: Gtk.main_quit())
|
||||
|
||||
self.refresh()
|
||||
GLib.timeout_add_seconds(REFRESH_SECONDS, self._tick)
|
||||
|
||||
def _apply_style(self):
|
||||
css = Gtk.CssProvider()
|
||||
css.load_from_data(STYLE.format(**load_colours()).encode("utf-8"))
|
||||
Gtk.StyleContext.add_provider_for_screen(
|
||||
self.get_screen(), css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
|
||||
|
||||
# -- the wall of cards ----------------------------------------
|
||||
|
||||
def _tick(self):
|
||||
self.refresh()
|
||||
return True
|
||||
|
||||
def signature(self, presentations):
|
||||
"""What the library looks like, so it is only rebuilt when it changes.
|
||||
|
||||
Rebuilding a FlowBox under a teacher's pointer every two seconds
|
||||
would make the buttons unclickable.
|
||||
"""
|
||||
return tuple(
|
||||
(p.id, p.state, p.pages, p.thumbnail_page, p.title, p.failure)
|
||||
for p in presentations
|
||||
)
|
||||
|
||||
def _fit_window(self, count, tall=False):
|
||||
"""Size to the cards while staying inside the panel's work area."""
|
||||
workarea = monitor_workarea(self)
|
||||
maximum_width = max(420, int(workarea.width * 0.92))
|
||||
maximum_height = max(360, int(workarea.height * 0.90))
|
||||
|
||||
if count == 0:
|
||||
width = min(maximum_width, 640)
|
||||
height = min(maximum_height, 300)
|
||||
else:
|
||||
# FlowBox includes the card's CSS padding and the three buttons'
|
||||
# natural widths when deciding whether another child fits. Budget
|
||||
# that chrome here too, or two cards can wrap into two rows while
|
||||
# the window is sized for one.
|
||||
card_with_gap = CARD_WIDTH + 40
|
||||
available = max(1, (maximum_width - 40) // card_with_gap)
|
||||
columns = min(count, available, 4)
|
||||
rows = (count + columns - 1) // columns
|
||||
width = min(maximum_width, max(420, 40 + columns * card_with_gap))
|
||||
# A card explaining a memory failure carries three lines of
|
||||
# words above its buttons, not one; measured, at 340 its Try
|
||||
# again was cut off below the window's edge.
|
||||
height = min(maximum_height, 130 + rows * (400 if tall else 340))
|
||||
|
||||
self.resize(width, height)
|
||||
|
||||
def refresh(self):
|
||||
presentations = library.library()
|
||||
signature = self.signature(presentations)
|
||||
|
||||
if signature == self._signature:
|
||||
return
|
||||
|
||||
self._signature = signature
|
||||
|
||||
for child in self.grid.get_children():
|
||||
self.grid.remove(child)
|
||||
|
||||
if not presentations:
|
||||
self.note.set_text("")
|
||||
empty = Gtk.Label(
|
||||
label="No presentations yet\n\n"
|
||||
"Download a PowerPoint or PDF to get started.")
|
||||
empty.get_style_context().add_class("empty")
|
||||
empty.set_justify(Gtk.Justification.CENTER)
|
||||
self.grid.add(empty)
|
||||
else:
|
||||
# Three states, and a file that could not be opened is not
|
||||
# the same as one still being got ready. Telling a teacher
|
||||
# something is "being prepared" when it never will be is how
|
||||
# somebody ends up waiting through a Bible study for it.
|
||||
ready = sum(1 for p in presentations if p.ready)
|
||||
again = sum(1 for p in presentations if p.failure == "memory")
|
||||
broken = sum(1 for p in presentations
|
||||
if p.failed and p.failure != "memory")
|
||||
working = len(presentations) - ready - broken - again
|
||||
|
||||
parts = [f"{ready} ready to present"]
|
||||
|
||||
if working:
|
||||
parts.append(f"{working} still being prepared")
|
||||
|
||||
if again:
|
||||
parts.append(f"{again} to try again")
|
||||
|
||||
if broken:
|
||||
parts.append(f"{broken} could not be opened")
|
||||
|
||||
self.note.set_text(", ".join(parts) + ".")
|
||||
|
||||
for presentation in presentations:
|
||||
self.grid.add(self.card(presentation))
|
||||
|
||||
self.grid.show_all()
|
||||
self._fit_window(len(presentations),
|
||||
tall=any(p.failure == "memory" for p in presentations))
|
||||
|
||||
def card(self, presentation):
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
||||
box.get_style_context().add_class("card")
|
||||
box.set_size_request(CARD_WIDTH, -1)
|
||||
|
||||
picture_frame = Gtk.EventBox()
|
||||
picture_frame.get_style_context().add_class("thumb")
|
||||
picture_frame.set_size_request(CARD_WIDTH - 28, 180)
|
||||
|
||||
if presentation.thumbnail.exists():
|
||||
picture = Gtk.Image()
|
||||
pixbuf = scaled_pixbuf(
|
||||
presentation.thumbnail, CARD_WIDTH - 28, 180)
|
||||
if pixbuf is not None:
|
||||
picture.set_from_pixbuf(pixbuf)
|
||||
else:
|
||||
picture = Gtk.Image()
|
||||
picture.set_from_icon_name("view-presentation", Gtk.IconSize.DIALOG)
|
||||
picture.set_pixel_size(96)
|
||||
|
||||
picture_frame.add(picture)
|
||||
box.pack_start(picture_frame, False, False, 0)
|
||||
|
||||
title = Gtk.Label(label=presentation.title, xalign=0)
|
||||
title.get_style_context().add_class("card-title")
|
||||
title.set_line_wrap(True)
|
||||
title.set_max_width_chars(32)
|
||||
title.set_size_request(CARD_WIDTH - 28, -1)
|
||||
box.pack_start(title, False, False, 0)
|
||||
|
||||
note = Gtk.Label(xalign=0)
|
||||
note.get_style_context().add_class("card-note")
|
||||
box.pack_start(note, False, False, 0)
|
||||
|
||||
buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
|
||||
if presentation.ready:
|
||||
note.set_text(f"{presentation.pages} slides")
|
||||
|
||||
present = Gtk.Button(label="Present")
|
||||
present.get_style_context().add_class("primary")
|
||||
present.connect("clicked",
|
||||
lambda *_: self.start_presenting(presentation))
|
||||
buttons.pack_start(present, True, True, 0)
|
||||
|
||||
look = Gtk.Button(label="Quick Look")
|
||||
look.connect("clicked", lambda *_: self.quick_look(presentation))
|
||||
buttons.pack_start(look, True, True, 0)
|
||||
elif presentation.failure == "memory":
|
||||
# The computer ran short of memory getting it ready, or the
|
||||
# importer stopped part way. Worth another go with less open;
|
||||
# never a converter's message, a path or a terminal.
|
||||
note.set_text("This needed more memory than the computer had "
|
||||
"free. Close other programs, then choose "
|
||||
"Try again.")
|
||||
note.set_line_wrap(True)
|
||||
note.set_max_width_chars(34)
|
||||
|
||||
again = Gtk.Button(label="Try again")
|
||||
again.get_style_context().add_class("primary")
|
||||
again.connect("clicked", lambda *_: self.try_again(presentation))
|
||||
buttons.pack_start(again, True, True, 0)
|
||||
elif presentation.failed:
|
||||
# SHORT, AND IN WORDS A TEACHER CAN ACT ON.
|
||||
#
|
||||
# Never the converter's error text, a path or a terminal.
|
||||
# The managed original remains recoverable until Remove.
|
||||
note.set_text("This file could not be opened.")
|
||||
note.set_line_wrap(True)
|
||||
else:
|
||||
note.set_text("Getting this ready…")
|
||||
|
||||
remove = Gtk.Button(label="Remove")
|
||||
remove.get_style_context().add_class("danger")
|
||||
remove.connect("clicked", lambda *_: self.remove(presentation))
|
||||
buttons.pack_start(remove, True, True, 0)
|
||||
|
||||
box.pack_start(buttons, False, False, 0)
|
||||
|
||||
return box
|
||||
|
||||
# -- what the buttons do --------------------------------------
|
||||
|
||||
def start_presenting(self, presentation, start_page=1):
|
||||
if not presentation.ready:
|
||||
return
|
||||
|
||||
first = presentation.page_image(start_page)
|
||||
|
||||
if not first.exists():
|
||||
self.trouble(presentation,
|
||||
"This presentation is not ready yet.")
|
||||
return
|
||||
|
||||
try:
|
||||
lock = library.presenting_lock()
|
||||
lock.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock.write_text(presentation.id)
|
||||
except OSError:
|
||||
# A lock that could not be written only costs the cleanup its
|
||||
# politeness. It must not stop a teacher presenting.
|
||||
pass
|
||||
|
||||
self.presenter = Presenter(
|
||||
presentation, start_page, on_finish=self._presented,
|
||||
display_geometry=monitor_geometry(self))
|
||||
self.hide()
|
||||
self.presenter.show_all()
|
||||
self.presenter.present()
|
||||
self.presenter.fullscreen()
|
||||
|
||||
def _presented(self):
|
||||
self.presenter = None
|
||||
self._signature = None
|
||||
self.refresh()
|
||||
self.present_window()
|
||||
|
||||
def present_window(self):
|
||||
self.show_all()
|
||||
self.present()
|
||||
|
||||
def quick_look(self, presentation):
|
||||
if not presentation.ready:
|
||||
return
|
||||
|
||||
window = QuickLook(
|
||||
presentation, self,
|
||||
on_close=self._looked,
|
||||
on_present=lambda p: self.start_presenting(p),
|
||||
)
|
||||
window.show_all()
|
||||
|
||||
def _looked(self):
|
||||
# Coming back from Quick Look is not a presentation. The only
|
||||
# thing that may have changed is which slide the card shows.
|
||||
self._signature = None
|
||||
self.refresh()
|
||||
|
||||
def try_again(self, presentation):
|
||||
# The importer takes it up within a few seconds; the card says
|
||||
# "Getting this ready" again at the next refresh.
|
||||
if library.request_retry(presentation):
|
||||
self.refresh()
|
||||
|
||||
def remove(self, presentation):
|
||||
dialog = Gtk.MessageDialog(
|
||||
transient_for=self,
|
||||
modal=True,
|
||||
message_type=Gtk.MessageType.QUESTION,
|
||||
buttons=Gtk.ButtonsType.NONE,
|
||||
text="Remove this presentation?",
|
||||
)
|
||||
dialog.format_secondary_text(
|
||||
f"“{presentation.title}”\n\n"
|
||||
"This will delete it from this computer.")
|
||||
dialog.add_button("Cancel", Gtk.ResponseType.CANCEL)
|
||||
dialog.add_button("Remove", Gtk.ResponseType.OK)
|
||||
dialog.set_default_response(Gtk.ResponseType.CANCEL)
|
||||
|
||||
answer = dialog.run()
|
||||
dialog.destroy()
|
||||
|
||||
if answer != Gtk.ResponseType.OK:
|
||||
return
|
||||
|
||||
if not library.remove(presentation):
|
||||
self.trouble(
|
||||
presentation,
|
||||
"Finish presenting before removing this presentation.")
|
||||
return
|
||||
self._signature = None
|
||||
self.refresh()
|
||||
|
||||
def trouble(self, presentation, message):
|
||||
dialog = Gtk.MessageDialog(
|
||||
transient_for=self,
|
||||
modal=True,
|
||||
message_type=Gtk.MessageType.INFORMATION,
|
||||
buttons=Gtk.ButtonsType.OK,
|
||||
text=presentation.title,
|
||||
)
|
||||
dialog.format_secondary_text(message)
|
||||
dialog.run()
|
||||
dialog.destroy()
|
||||
|
||||
def _key(self, _widget, event):
|
||||
if event.keyval == Gdk.KEY_Escape:
|
||||
# Escape in the library closes the library, which is the same
|
||||
# thing it does everywhere else in this room.
|
||||
self.destroy()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
GLib.set_prgname(APP_ID)
|
||||
GLib.set_application_name("Presentations")
|
||||
|
||||
# A device that does not offer Presentations does not open them. The
|
||||
# panel has no button, so nothing should reach here; saying it out
|
||||
# loud is for the technician who ran the command by hand.
|
||||
if not feature(read_device_config(), "PRESENTATIONS_ENABLED"):
|
||||
print("classroom-presentations: PRESENTATIONS_ENABLED is false on "
|
||||
"this device; not starting", flush=True)
|
||||
return 0
|
||||
|
||||
window = Library()
|
||||
window.show_all()
|
||||
|
||||
Gtk.main()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reboot after an update, but only once nobody is using the computer.
|
||||
|
||||
The nightly update check (ekklesia-classroom-update.timer, 03:00, and
|
||||
Persistent so that a computer switched off at 03:00 checks when it is next
|
||||
switched on) used to reboot the moment an update was installed. At 03:00
|
||||
that is harmless. Switched on in the morning after a night off, it meant a
|
||||
classroom computer restarting itself half an hour into the day, in front
|
||||
of a teacher.
|
||||
|
||||
So the updater installs, then starts this, and this reboots only when:
|
||||
|
||||
nothing has touched any keyboard, mouse, touchpad or touchscreen for
|
||||
IDLE_SECONDS — seen here as kernel input events, which is what every
|
||||
one of them produces, whatever the desktop is doing; and
|
||||
|
||||
no sound has played for as long — a video to the room has no input for
|
||||
an hour and is not an idle computer, and the end of it is not the
|
||||
moment to restart either. Seen as the kernel's own playback state
|
||||
(/proc/asound/.../status says RUNNING while a stream plays).
|
||||
|
||||
At 03:00 that is about 03:30. In the middle of a morning it is half an
|
||||
hour after the teacher last touched it. If the computer is switched off
|
||||
first, nothing is lost: the update is installed, and the next start is on
|
||||
it. Until then the running session carries on as it was.
|
||||
|
||||
The time starts when this starts: input from before cannot be seen, so a
|
||||
computer is only called idle after this has watched it be idle.
|
||||
|
||||
classroom-reboot-when-idle [--idle-seconds N] [--reboot-command CMD...]
|
||||
|
||||
The options are for the lab; the service runs it with none.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import os
|
||||
import select
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
IDLE_SECONDS = 30 * 60
|
||||
|
||||
# How often it looks: input is noticed at once, the rest once a minute is
|
||||
# plenty for a half-hour decision.
|
||||
CHECK_SECONDS = 60
|
||||
|
||||
INPUT_GLOB = "/dev/input/event*"
|
||||
ASOUND_ROOT = "/proc/asound"
|
||||
|
||||
|
||||
def log(message):
|
||||
print(f"[classroom-reboot-when-idle] {message}", flush=True)
|
||||
|
||||
|
||||
def audio_playing(root=ASOUND_ROOT):
|
||||
"""Is any sound card playing right now?"""
|
||||
for status in glob.glob(os.path.join(root, "card*", "pcm*p", "sub*", "status")):
|
||||
try:
|
||||
with open(status, encoding="utf-8", errors="replace") as handle:
|
||||
if handle.readline().strip() == "state: RUNNING":
|
||||
return True
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def should_reboot(now, last_input, idle_seconds, playing):
|
||||
"""The decision: long enough without input, and nothing playing."""
|
||||
return not playing and now - last_input >= idle_seconds
|
||||
|
||||
|
||||
class Inputs:
|
||||
"""Every input device the kernel has, reopened as they come and go."""
|
||||
|
||||
def __init__(self, pattern=INPUT_GLOB):
|
||||
self.pattern = pattern
|
||||
self.open = {}
|
||||
|
||||
def rescan(self):
|
||||
present = set(glob.glob(self.pattern))
|
||||
|
||||
for fd, path in list(self.open.items()):
|
||||
if path not in present:
|
||||
os.close(fd)
|
||||
del self.open[fd]
|
||||
|
||||
known = set(self.open.values())
|
||||
|
||||
for path in sorted(present - known):
|
||||
try:
|
||||
self.open[os.open(path, os.O_RDONLY | os.O_NONBLOCK)] = path
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
def wait(self, seconds):
|
||||
"""True if anything happened on any device within SECONDS."""
|
||||
if not self.open:
|
||||
time.sleep(seconds)
|
||||
return False
|
||||
|
||||
ready, _, _ = select.select(list(self.open), [], [], seconds)
|
||||
seen = False
|
||||
|
||||
for fd in ready:
|
||||
try:
|
||||
while os.read(fd, 4096):
|
||||
seen = True
|
||||
except BlockingIOError:
|
||||
pass
|
||||
except OSError:
|
||||
# A device that went away mid-read; the next rescan drops it.
|
||||
pass
|
||||
|
||||
return seen
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--idle-seconds", type=float, default=IDLE_SECONDS)
|
||||
parser.add_argument("--check-seconds", type=float, default=CHECK_SECONDS)
|
||||
parser.add_argument("--input-glob", default=INPUT_GLOB)
|
||||
parser.add_argument("--asound-root", default=ASOUND_ROOT)
|
||||
parser.add_argument("--reboot-command", nargs="+", default=["systemctl", "reboot"])
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
inputs = Inputs(args.input_glob)
|
||||
inputs.rescan()
|
||||
last_input = time.monotonic()
|
||||
log(f"an update is installed; rebooting once nobody has used this computer for "
|
||||
f"{round(args.idle_seconds / 60)} minutes and nothing is playing "
|
||||
f"(watching {len(inputs.open)} input devices)")
|
||||
|
||||
while True:
|
||||
deadline = time.monotonic() + args.check_seconds
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
if inputs.wait(max(0.0, min(5.0, deadline - time.monotonic()))):
|
||||
last_input = time.monotonic()
|
||||
|
||||
inputs.rescan()
|
||||
playing = audio_playing(args.asound_root)
|
||||
|
||||
if playing:
|
||||
# Sound counts as someone there: a 40-minute video ending is
|
||||
# not the moment to restart, so the half hour starts after it.
|
||||
last_input = time.monotonic()
|
||||
|
||||
if should_reboot(time.monotonic(), last_input, args.idle_seconds, playing):
|
||||
log("nobody has used it, and nothing is playing: rebooting")
|
||||
return subprocess.run(args.reboot_command).returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -77,6 +77,11 @@ def nmcli(*args, check=True):
|
||||
result = subprocess.run(
|
||||
["nmcli"] + list(args), capture_output=True, text=True, timeout=60,
|
||||
check=False,
|
||||
# nmcli translates its output and its terse field separator rules
|
||||
# are the only thing here that is parsed. C is the one locale in
|
||||
# which what this reads back is what nmcli is documented to
|
||||
# print, on a machine in any country.
|
||||
env={**os.environ, "LC_ALL": "C"},
|
||||
)
|
||||
|
||||
if check and result.returncode != 0:
|
||||
@@ -86,14 +91,57 @@ def nmcli(*args, check=True):
|
||||
return result
|
||||
|
||||
|
||||
def terse(line):
|
||||
"""One line of `nmcli -t` output, split into its fields.
|
||||
|
||||
nmcli escapes a colon inside a value as `\\:` and a backslash as
|
||||
`\\\\`, so splitting on ":" is wrong for any network whose name
|
||||
contains one. Everything here that compares a connection name to a
|
||||
field goes through this, because getting it wrong does not produce an
|
||||
error — it produces a migration that decides it failed and rolls a
|
||||
working network back.
|
||||
"""
|
||||
fields = []
|
||||
current = []
|
||||
escaped = False
|
||||
|
||||
for character in line:
|
||||
if escaped:
|
||||
current.append(character)
|
||||
escaped = False
|
||||
elif character == "\\":
|
||||
escaped = True
|
||||
elif character == ":":
|
||||
fields.append("".join(current))
|
||||
current = []
|
||||
else:
|
||||
current.append(character)
|
||||
|
||||
fields.append("".join(current))
|
||||
|
||||
return fields
|
||||
|
||||
|
||||
def connections():
|
||||
"""Every saved connection, as (name, uuid, type)."""
|
||||
result = nmcli("-t", "-f", "NAME,UUID,TYPE", "connection", "show",
|
||||
check=False)
|
||||
|
||||
found = []
|
||||
|
||||
for line in (result.stdout or "").splitlines():
|
||||
fields = terse(line)
|
||||
|
||||
if len(fields) >= 3:
|
||||
found.append((fields[0], fields[1], fields[2]))
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def listing():
|
||||
result = nmcli("-t", "-f", "NAME,TYPE,UUID", "connection", "show")
|
||||
|
||||
for line in result.stdout.splitlines():
|
||||
parts = line.split(":")
|
||||
|
||||
if len(parts) >= 2 and "wireless" in parts[1]:
|
||||
print(f" {parts[0]}")
|
||||
for name, _uuid, kind in connections():
|
||||
if "wireless" in kind:
|
||||
print(f" {name}")
|
||||
|
||||
return 0
|
||||
|
||||
@@ -105,7 +153,7 @@ def forget(name):
|
||||
return 0
|
||||
|
||||
|
||||
def add(ssid, password, name=None):
|
||||
def add(ssid, password, name=None, hidden=False):
|
||||
name = name or ssid
|
||||
|
||||
# Deliberately no connection.permissions. That single omission is the
|
||||
@@ -121,6 +169,15 @@ def add(ssid, password, name=None):
|
||||
# Stored in the connection file, which is root-only, rather than
|
||||
# in a login keyring that belongs to whoever is signed in.
|
||||
"802-11-wireless-security.psk-flags", "0",
|
||||
# WHETHER THE ACCESS POINT BROADCASTS ITS NAME.
|
||||
#
|
||||
# Always passed, in both directions, rather than only when it is
|
||||
# true. NetworkManager's default is "no", and a default that
|
||||
# happens to be right is not the same as an answer: this is the
|
||||
# setting the prayer-room laptop's migration left at the default
|
||||
# and could therefore never associate. Written down either way so
|
||||
# that the connection file says what was meant.
|
||||
"802-11-wireless.hidden", "yes" if hidden else "no",
|
||||
]
|
||||
|
||||
if password:
|
||||
@@ -131,9 +188,13 @@ def add(ssid, password, name=None):
|
||||
|
||||
nmcli(*arguments)
|
||||
|
||||
path = os.path.join(CONNECTIONS, f"{name}.nmconnection")
|
||||
if hidden:
|
||||
say(f"{name} is a hidden network; NetworkManager will probe for it "
|
||||
"by name")
|
||||
|
||||
if os.path.exists(path):
|
||||
path = connection_file(name)
|
||||
|
||||
if path and os.path.exists(path):
|
||||
# NetworkManager already writes 0600 root:root. Said out loud and
|
||||
# enforced anyway, because this is the file the whole arrangement
|
||||
# rests on.
|
||||
@@ -148,6 +209,28 @@ def add(ssid, password, name=None):
|
||||
return 0
|
||||
|
||||
|
||||
def connection_file(name):
|
||||
"""Where NetworkManager actually put a connection, or None.
|
||||
|
||||
Asked rather than constructed. NetworkManager escapes characters in
|
||||
the filename it derives from a connection name, so
|
||||
f"{name}.nmconnection" is a guess that happens to be right for a
|
||||
one-word SSID and wrong for "ROGC Classroom" — which is the network
|
||||
this command exists to migrate, and the file whose permissions the
|
||||
whole arrangement rests on.
|
||||
"""
|
||||
result = nmcli("-t", "-f", "NAME,FILENAME", "connection", "show",
|
||||
check=False)
|
||||
|
||||
for line in (result.stdout or "").splitlines():
|
||||
fields = terse(line)
|
||||
|
||||
if len(fields) >= 2 and fields[0] == name and fields[-1]:
|
||||
return fields[-1]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def run(*command):
|
||||
"""Run a command, tolerating one that is not installed.
|
||||
|
||||
@@ -198,16 +281,78 @@ def carrying(name, device):
|
||||
"--active", check=False)
|
||||
|
||||
for line in (result.stdout or "").splitlines():
|
||||
parts = line.rsplit(":", 1)
|
||||
fields = terse(line)
|
||||
|
||||
if len(parts) == 2 and parts[0] == name and parts[1] == device:
|
||||
if len(fields) == 2 and fields[0] == name and fields[1] == device:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def device_state(device):
|
||||
"""NetworkManager's word for what it is doing with this interface."""
|
||||
result = nmcli("-t", "-f", "DEVICE,STATE", "device", "status", check=False)
|
||||
|
||||
for line in (result.stdout or "").splitlines():
|
||||
fields = terse(line)
|
||||
|
||||
if len(fields) >= 2 and fields[0] == device:
|
||||
return fields[1]
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
def unmanaged_by_config(device):
|
||||
"""Configuration files that name this device as unmanaged.
|
||||
|
||||
Separate from the ifupdown plugin, and the reason the prayer-room
|
||||
laptop still needed `nmcli device set wlp2s0 managed yes` by hand
|
||||
after its stanza was gone. A keyfile saying `unmanaged-devices` is
|
||||
not undone by commenting out an interfaces stanza, and a hand-over
|
||||
that does not notice it hands the interface to nobody.
|
||||
"""
|
||||
naming = []
|
||||
candidates = ["/etc/NetworkManager/NetworkManager.conf"]
|
||||
|
||||
for directory in ("/etc/NetworkManager/conf.d",
|
||||
"/usr/lib/NetworkManager/conf.d",
|
||||
"/run/NetworkManager/conf.d"):
|
||||
try:
|
||||
candidates += sorted(
|
||||
os.path.join(directory, entry)
|
||||
for entry in os.listdir(directory)
|
||||
if entry.endswith(".conf")
|
||||
)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
for path in candidates:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
||||
text = handle.read()
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
|
||||
if not stripped.startswith("unmanaged-devices"):
|
||||
continue
|
||||
|
||||
if device in stripped or "except:" in stripped:
|
||||
naming.append(path)
|
||||
break
|
||||
|
||||
return naming
|
||||
|
||||
|
||||
def find_stanza():
|
||||
"""The one wireless stanza ifupdown holds, or None, plus the file."""
|
||||
if not os.path.exists(INTERFACES):
|
||||
# A machine with no ifupdown configuration at all. Nothing is
|
||||
# wrong with that and nothing here has anything to do.
|
||||
return None, ""
|
||||
|
||||
try:
|
||||
with open(INTERFACES, "r", encoding="utf-8") as handle:
|
||||
text = handle.read()
|
||||
@@ -244,20 +389,33 @@ def check():
|
||||
return 0
|
||||
|
||||
device = stanza["name"]
|
||||
ssid, _ = ifupdown.credentials(stanza)
|
||||
found = ifupdown.profile(stanza)
|
||||
|
||||
say(f"NOTE: {device} is configured in {INTERFACES}, so ifupdown owns "
|
||||
"it and")
|
||||
say(" NetworkManager will report it unmanaged — the room's panel")
|
||||
say(" will say there is no connection and will not find networks.")
|
||||
|
||||
if ssid:
|
||||
if found and found["hidden"]:
|
||||
say(" Its access point does not broadcast its name, which "
|
||||
"NetworkManager")
|
||||
say(" has to be told; --adopt carries that across.")
|
||||
|
||||
for path in unmanaged_by_config(device):
|
||||
say(f" {path} also names this device unmanaged; --adopt "
|
||||
"reports it")
|
||||
say(" rather than editing it.")
|
||||
|
||||
if found is None:
|
||||
say(" Its credentials are not in that file, so it has to be "
|
||||
"moved by hand.")
|
||||
elif found["key_mgmt"] is None:
|
||||
say(" Its security is not a pre-shared key, so it has to be "
|
||||
"moved by hand.")
|
||||
else:
|
||||
say(" To move it, while watching, over SSH:")
|
||||
say(" classroom-wifi --adopt --dry-run")
|
||||
say(" classroom-wifi --adopt")
|
||||
else:
|
||||
say(" Its credentials are not in that file, so it has to be "
|
||||
"moved by hand.")
|
||||
|
||||
return 0
|
||||
|
||||
@@ -307,17 +465,37 @@ def adopt(dry_run=False):
|
||||
return 0
|
||||
|
||||
device = stanza["name"]
|
||||
ssid, psk = ifupdown.credentials(stanza)
|
||||
found = ifupdown.profile(stanza)
|
||||
|
||||
if not ssid:
|
||||
if found is None:
|
||||
say(f"{device} is configured through a wpa_supplicant.conf rather "
|
||||
"than named in this file")
|
||||
say("that one is not ours to move; adopt it by hand")
|
||||
return 1
|
||||
|
||||
if found["key_mgmt"] is None:
|
||||
say(f"{device} uses a security this command does not recreate "
|
||||
f"({stanza['options'].get('wpa-key-mgmt')})")
|
||||
say("refusing to guess; a network moved wrong is a network that "
|
||||
"cannot come back")
|
||||
return 1
|
||||
|
||||
ssid = found["ssid"]
|
||||
psk = found["psk"]
|
||||
|
||||
# Never printed. The room's password is not diagnostic output.
|
||||
say(f"{device}: ifupdown holds a wireless network "
|
||||
f"({'with' if psk else 'without'} a saved password)")
|
||||
f"({'with' if psk else 'without'} a saved password"
|
||||
f"{', hidden' if found['hidden'] else ''})")
|
||||
|
||||
# A keyfile naming this device unmanaged survives the stanza going
|
||||
# away, so the hand-over would end with the interface belonging to
|
||||
# nobody. Said before anything is touched, and not edited: a file an
|
||||
# administrator wrote is not this command's to rewrite.
|
||||
for path in unmanaged_by_config(device):
|
||||
say(f"WARNING: {path} names {device} unmanaged")
|
||||
say(" NetworkManager will not keep it after a restart "
|
||||
"until that is changed")
|
||||
|
||||
# Refuse to start a hand-over that cannot be undone. ifdown releases
|
||||
# the interface and ifup is the whole of the rollback; without them
|
||||
@@ -337,17 +515,32 @@ def adopt(dry_run=False):
|
||||
"run would refuse")
|
||||
|
||||
say(f"would create a NetworkManager system connection for {ssid!r}")
|
||||
say(f"would comment the {device} stanza out of {INTERFACES}")
|
||||
say(" 802-11-wireless.hidden: "
|
||||
f"{'yes' if found['hidden'] else 'no'}")
|
||||
say(f" 802-11-wireless-security.key-mgmt: {found['key_mgmt']}")
|
||||
say(f"would run `ifdown {device}` while its stanza is still in "
|
||||
f"{INTERFACES}")
|
||||
say(f"would then comment that stanza out of {INTERFACES}")
|
||||
say("would hand the interface over, and put it back if that failed")
|
||||
return 0
|
||||
|
||||
existing = nmcli("-t", "-f", "NAME", "connection", "show", check=False)
|
||||
already = ssid in (existing.stdout or "").splitlines()
|
||||
already = any(name == ssid for name, _uuid, _kind in connections())
|
||||
|
||||
if already:
|
||||
# Reused as it stands. An existing connection of this name may be
|
||||
# a teacher's own, or one a previous attempt left behind with the
|
||||
# wrong hidden setting — which is exactly the state the
|
||||
# prayer-room laptop was in — so it is corrected rather than
|
||||
# trusted. Every setting the migration decides is written again.
|
||||
say(f"NetworkManager already has a connection named {ssid!r}")
|
||||
say("correcting it to match what ifupdown holds")
|
||||
nmcli("connection", "modify", ssid,
|
||||
"802-11-wireless.hidden", "yes" if found["hidden"] else "no",
|
||||
"connection.autoconnect", "yes",
|
||||
"802-11-wireless-security.psk-flags", "0",
|
||||
check=False)
|
||||
else:
|
||||
if add(ssid, psk) != 0:
|
||||
if add(ssid, psk, hidden=found["hidden"]) != 0:
|
||||
return 1
|
||||
|
||||
backup = f"{INTERFACES}.before-ekklesia-classroom"
|
||||
@@ -361,20 +554,51 @@ def adopt(dry_run=False):
|
||||
# timeout, an interrupt — the file goes back and ifupdown gets the
|
||||
# interface again. Nothing below may raise past this.
|
||||
try:
|
||||
# RELEASE THE INTERFACE BEFORE TAKING ITS STANZA AWAY.
|
||||
#
|
||||
# ifdown reads /etc/network/interfaces to find out how to bring
|
||||
# an interface down. Run after the stanza is commented out it
|
||||
# reports "Ignoring unknown interface" and does nothing at all —
|
||||
# so ifupdown's own wpa_supplicant and DHCP client were still
|
||||
# running, still holding the radio, when NetworkManager was asked
|
||||
# to associate with it. Two supplicants on one interface is a
|
||||
# fight NetworkManager loses, and it is the second half of why
|
||||
# the prayer-room laptop rolled back.
|
||||
#
|
||||
# The order is therefore: down first, while the file still says
|
||||
# how; rewrite the file second. The window in which the machine
|
||||
# is offline is the same either way.
|
||||
result = run("ifdown", device)
|
||||
|
||||
if result is not None and result.returncode != 0:
|
||||
# Not fatal. A machine whose interface is already down is a
|
||||
# perfectly good starting point, and ifdown says so with a
|
||||
# non-zero status.
|
||||
say(f"ifdown {device}: "
|
||||
f"{(result.stderr or result.stdout or '').strip()}")
|
||||
|
||||
say(f"released {device} from ifupdown")
|
||||
|
||||
with open(INTERFACES, "w", encoding="utf-8") as handle:
|
||||
handle.write(ifupdown.comment_out(text, stanza))
|
||||
|
||||
say(f"took {device} out of ifupdown's hands")
|
||||
say(f"took {device} out of ifupdown's hands in {INTERFACES}")
|
||||
|
||||
# NetworkManager only re-reads which interfaces ifupdown claims
|
||||
# when its configuration is reloaded. The link is still
|
||||
# ifupdown's at this point, and NetworkManager does not touch a
|
||||
# device it considers unmanaged, so this cannot drop it.
|
||||
# when its configuration is reloaded.
|
||||
nmcli("general", "reload", "conf", check=False)
|
||||
|
||||
run("ifdown", device)
|
||||
nmcli("device", "set", device, "managed", "yes", check=False)
|
||||
nmcli("connection", "up", ssid, check=False)
|
||||
|
||||
# On the device, by name. `connection up <name>` alone lets
|
||||
# NetworkManager choose an interface, and a machine with two
|
||||
# radios could bring the network up on the wrong one — which
|
||||
# `carrying` would then correctly report as a failure, and the
|
||||
# migration would roll back a network that was working.
|
||||
started = nmcli("connection", "up", ssid, "ifname", device,
|
||||
check=False)
|
||||
|
||||
if started.returncode != 0:
|
||||
say(f"nmcli: {(started.stderr or '').strip()}")
|
||||
|
||||
deadline = time.monotonic() + ADOPT_TIMEOUT
|
||||
|
||||
@@ -382,12 +606,15 @@ def adopt(dry_run=False):
|
||||
if carrying(ssid, device) and online():
|
||||
say(f"{device} is NetworkManager's now, and the room is "
|
||||
"online")
|
||||
say(f"NetworkManager reports {device} "
|
||||
f"{device_state(device)}")
|
||||
say("the panel will show the connection at the next login")
|
||||
return 0
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
say("NetworkManager did not get the network back")
|
||||
say("NetworkManager did not get the network back "
|
||||
f"({device} is {device_state(device)})")
|
||||
except BaseException as error: # noqa: BLE001 - see the comment above
|
||||
say(f"the migration failed part way through: {error}")
|
||||
|
||||
@@ -398,6 +625,48 @@ def adopt(dry_run=False):
|
||||
return 1
|
||||
|
||||
|
||||
def put_interfaces_back():
|
||||
"""Undo this command's edit to /etc/network/interfaces.
|
||||
|
||||
Un-commenting the marked lines where they are, rather than copying
|
||||
the backup over the whole file. The backup is written once, on the
|
||||
first attempt, and never refreshed — so a whole-file restore silently
|
||||
reverts anything an administrator changed about the WIRED network in
|
||||
between. The marker names exactly the lines this command commented
|
||||
out, and nothing else in the file is touched.
|
||||
|
||||
The backup is still the fallback, for a file that has been replaced
|
||||
since rather than edited.
|
||||
"""
|
||||
backup = f"{INTERFACES}.before-ekklesia-classroom"
|
||||
|
||||
try:
|
||||
with open(INTERFACES, "r", encoding="utf-8") as handle:
|
||||
current = handle.read()
|
||||
except OSError as error:
|
||||
say(f"cannot read {INTERFACES}: {error}")
|
||||
current = None
|
||||
|
||||
if current is not None:
|
||||
restored, count = ifupdown.uncomment(current)
|
||||
|
||||
if count:
|
||||
with open(INTERFACES, "w", encoding="utf-8") as handle:
|
||||
handle.write(restored)
|
||||
|
||||
say(f"put {count} line{'' if count == 1 else 's'} back in "
|
||||
f"{INTERFACES}, and changed nothing else in it")
|
||||
return True
|
||||
|
||||
if os.path.exists(backup):
|
||||
shutil.copy2(backup, INTERFACES)
|
||||
say(f"no marked lines to restore; copied {backup} back instead")
|
||||
return True
|
||||
|
||||
say(f"nothing of this command's is in {INTERFACES} to undo")
|
||||
return False
|
||||
|
||||
|
||||
def restore(device, ssid=None):
|
||||
"""Put the interface back under ifupdown.
|
||||
|
||||
@@ -405,11 +674,7 @@ def restore(device, ssid=None):
|
||||
decide what that means for an exit code: for a failed migration it is
|
||||
still a failure, and for `--rollback` it is a success.
|
||||
"""
|
||||
backup = f"{INTERFACES}.before-ekklesia-classroom"
|
||||
|
||||
if os.path.exists(backup):
|
||||
shutil.copy2(backup, INTERFACES)
|
||||
say(f"restored {INTERFACES} from {backup}")
|
||||
put_interfaces_back()
|
||||
|
||||
nmcli("general", "reload", "conf", check=False)
|
||||
|
||||
@@ -418,7 +683,12 @@ def restore(device, ssid=None):
|
||||
|
||||
if device:
|
||||
nmcli("device", "set", device, "managed", "no", check=False)
|
||||
run("ifup", device)
|
||||
result = run("ifup", device)
|
||||
|
||||
if result is not None and result.returncode != 0:
|
||||
say(f"ifup {device}: "
|
||||
f"{(result.stderr or result.stdout or '').strip()}")
|
||||
|
||||
say(f"ifupdown has {device} again")
|
||||
|
||||
for _ in range(10):
|
||||
@@ -428,6 +698,44 @@ def restore(device, ssid=None):
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
# THE ADAPTER IS NEVER LEFT BELONGING TO NOBODY.
|
||||
#
|
||||
# Above, the device has deliberately been handed back to ifupdown and
|
||||
# NetworkManager told to leave it alone. That is right while ifupdown
|
||||
# can actually bring it up. When ifup has failed too, the same two
|
||||
# lines are a wireless adapter that neither manager owns: no
|
||||
# connection, and a panel that cannot even scan for one. That is the
|
||||
# state the prayer-room laptop was found in after 0.2.5, and it is
|
||||
# the state that made `nmcli device set wlp2s0 managed yes` a manual
|
||||
# recovery step.
|
||||
#
|
||||
# So the last resort is to give it to NetworkManager rather than to
|
||||
# leave it with neither. A room able to pick its network from the
|
||||
# panel is recoverable by somebody standing in front of it; an
|
||||
# unmanaged radio is not.
|
||||
if device:
|
||||
say("WARNING: ifupdown did not bring the network back either")
|
||||
say(f" handing {device} to NetworkManager so the adapter "
|
||||
"is not left unmanaged")
|
||||
|
||||
nmcli("device", "set", device, "managed", "yes", check=False)
|
||||
|
||||
if ssid:
|
||||
nmcli("connection", "up", ssid, "ifname", device, check=False)
|
||||
|
||||
for _ in range(5):
|
||||
if online():
|
||||
say("the room is on the network, through NetworkManager")
|
||||
say(f"NOTE: {INTERFACES} still describes {device}, so "
|
||||
"ifupdown will")
|
||||
say(" claim it again at the next boot. Decide which "
|
||||
"one owns it.")
|
||||
return True
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
say(f" {device} is {device_state(device)}")
|
||||
|
||||
say("WARNING: the machine may be offline. The original "
|
||||
f"{INTERFACES} is restored; try `ifup {device}` or a reboot")
|
||||
return False
|
||||
@@ -444,6 +752,8 @@ def main():
|
||||
help="remove a saved connection")
|
||||
parser.add_argument("--open", action="store_true",
|
||||
help="the network has no password")
|
||||
parser.add_argument("--hidden", action="store_true",
|
||||
help="the access point does not broadcast its name")
|
||||
parser.add_argument("--adopt", action="store_true",
|
||||
help="move an installer-configured Wi-Fi from "
|
||||
"ifupdown into NetworkManager")
|
||||
@@ -489,7 +799,8 @@ def main():
|
||||
say("no password given")
|
||||
return 1
|
||||
|
||||
return add(arguments.ssid, password, arguments.name)
|
||||
return add(arguments.ssid, password, arguments.name,
|
||||
hidden=arguments.hidden)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -5,6 +5,7 @@ import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -13,6 +14,22 @@ from urllib.parse import urljoin
|
||||
|
||||
CONFIG = "/etc/ekklesia-classroom/device.conf"
|
||||
LOCKFILE = "/run/ekklesia-classroom-update.lock"
|
||||
REBOOT_WHEN_IDLE = "ekklesia-classroom-reboot-when-idle.service"
|
||||
CACHE_DIR = "/var/cache/ekklesia-classroom"
|
||||
PACKAGE = "ekklesia-classroom"
|
||||
|
||||
# What a published version looks like: 0.2.6 for a feature release,
|
||||
# 0.2.6.1 for a maintenance release of it. Anything else in a manifest is
|
||||
# a mistake on the server, and a classroom does nothing with it. The same
|
||||
# pattern is in tools/release-lib.sh; tests/test-release-versions.sh holds
|
||||
# the two to one list of good and bad versions.
|
||||
#
|
||||
# Only the SHAPE is decided here. Which of two versions is newer is always
|
||||
# dpkg's answer, because dpkg is what installs them.
|
||||
VERSION_PATTERN = re.compile(
|
||||
r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\.[1-9][0-9]*)?$"
|
||||
)
|
||||
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def log(message):
|
||||
@@ -50,13 +67,70 @@ def installed_version():
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def valid_version(version):
|
||||
return isinstance(version, str) and bool(VERSION_PATTERN.match(version))
|
||||
|
||||
|
||||
def version_is_newer(candidate, current):
|
||||
result = subprocess.run(
|
||||
["dpkg", "--compare-versions", candidate, "gt", current]
|
||||
["dpkg", "--compare-versions", candidate, "gt", current],
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def manifest_problem(manifest):
|
||||
"""Why this manifest must not be acted on, or None if it may be.
|
||||
|
||||
A manifest names a version, the package file for exactly that
|
||||
version, and that file's checksum. The package name is checked
|
||||
against the version, not merely read, so a manifest that says 0.2.6.1
|
||||
and points at the 0.2.6 file is refused rather than installed and
|
||||
then offered again at every check.
|
||||
"""
|
||||
if not isinstance(manifest, dict):
|
||||
return "the manifest is not an object"
|
||||
|
||||
version = manifest.get("version")
|
||||
|
||||
if not valid_version(version):
|
||||
return f"the manifest names an invalid version: {version!r}"
|
||||
|
||||
package = manifest.get("package")
|
||||
expected = f"/packages/{PACKAGE}_{version}_amd64.deb"
|
||||
|
||||
if package != expected:
|
||||
return f"the manifest names {package!r} for version {version}"
|
||||
|
||||
checksum = manifest.get("sha256")
|
||||
|
||||
if not isinstance(checksum, str) or not SHA256_PATTERN.match(checksum.lower()):
|
||||
return f"the manifest's sha256 is not a checksum: {checksum!r}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def package_identity(path):
|
||||
"""(Package, Version) as dpkg reads them out of a downloaded file."""
|
||||
result = subprocess.run(
|
||||
["dpkg-deb", "--field", path, "Package", "Version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return None, None
|
||||
|
||||
fields = {}
|
||||
|
||||
for line in result.stdout.splitlines():
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
fields[key.strip()] = value.strip()
|
||||
|
||||
return fields.get("Package"), fields.get("Version")
|
||||
|
||||
|
||||
def fetch_json(url):
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
@@ -134,6 +208,12 @@ def main():
|
||||
log(f"Unable to fetch update manifest: {exc}")
|
||||
return 1
|
||||
|
||||
problem = manifest_problem(manifest)
|
||||
|
||||
if problem:
|
||||
log(f"Ignoring the {channel} manifest: {problem}.")
|
||||
return 1
|
||||
|
||||
candidate = manifest["version"]
|
||||
|
||||
log(f"Available version: {candidate}")
|
||||
@@ -149,10 +229,10 @@ def main():
|
||||
package_url = urljoin(base_url, manifest["package"].lstrip("/"))
|
||||
expected_hash = manifest["sha256"].lower()
|
||||
|
||||
os.makedirs("/var/cache/ekklesia-classroom", exist_ok=True)
|
||||
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||
|
||||
package_path = os.path.join(
|
||||
"/var/cache/ekklesia-classroom",
|
||||
CACHE_DIR,
|
||||
os.path.basename(manifest["package"]),
|
||||
)
|
||||
|
||||
@@ -177,6 +257,18 @@ def main():
|
||||
|
||||
log("SHA-256 verification passed.")
|
||||
|
||||
# The checksum proves the file is the one the manifest describes;
|
||||
# this proves the manifest described the right file. A package
|
||||
# that installs as some other version would be offered again at
|
||||
# every check, and installed again, and the room rebooted, for
|
||||
# ever.
|
||||
name, version = package_identity(temporary_path)
|
||||
|
||||
if name != PACKAGE or version != candidate:
|
||||
log(f"The package is {name} {version}, not {PACKAGE} {candidate}.")
|
||||
os.unlink(temporary_path)
|
||||
return 1
|
||||
|
||||
os.replace(temporary_path, package_path)
|
||||
|
||||
log(f"Installing {candidate}")
|
||||
@@ -200,8 +292,12 @@ def main():
|
||||
if args.no_reboot:
|
||||
log("Update requires reboot, but --no-reboot was specified.")
|
||||
else:
|
||||
log("Update requires reboot. Rebooting now.")
|
||||
subprocess.run(["systemctl", "reboot"])
|
||||
# Not now. At 03:00 nobody is there; after a missed 03:00,
|
||||
# checked when the computer is next switched on, somebody
|
||||
# usually is. classroom-reboot-when-idle waits until nobody
|
||||
# has touched it for half an hour and nothing is playing.
|
||||
log("Update requires reboot. Rebooting once nobody is using this computer.")
|
||||
subprocess.run(["systemctl", "start", "--no-block", REBOOT_WHEN_IDLE])
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Reboot after an Ekklesia Classroom update, once nobody is using the computer
|
||||
Documentation=file:///usr/local/bin/classroom-reboot-when-idle
|
||||
# Started by ekklesia-classroom-update after it installs an update that
|
||||
# needs a reboot; never at boot. A computer switched off before it
|
||||
# reboots simply starts on the new version.
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/classroom-reboot-when-idle
|
||||
@@ -0,0 +1,49 @@
|
||||
[Unit]
|
||||
Description=Ekklesia Classroom presentation import
|
||||
Documentation=file:///usr/local/bin/classroom-presentation-import
|
||||
# Nothing graphical: it copies, converts and renders files. It is a user
|
||||
# service rather than a system one because the downloads it watches and
|
||||
# the library it fills both belong to the classroom account, and a root
|
||||
# service writing into a teacher's home would have to be told about every
|
||||
# one of those permissions.
|
||||
PartOf=graphical-session.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/classroom-presentation-import
|
||||
# It exits 0 immediately on a device with PRESENTATIONS_ENABLED=false, and
|
||||
# on-failure means such a device is left alone rather than restarted in a
|
||||
# loop forever. A device that does have Presentations gets the watcher
|
||||
# back if it ever dies, because a teacher whose download never appears has
|
||||
# no way at all to find out why.
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
Slice=session.slice
|
||||
# LibreOffice converting a large deck must never be what a teacher notices
|
||||
# about the machine. The room's own applications come first; this is the
|
||||
# only thing here that is allowed to take a while.
|
||||
Nice=10
|
||||
IOSchedulingClass=idle
|
||||
|
||||
# MEMORY. Measured in the lab, LibreOffice converting one deck: 203 MB
|
||||
# for ten slides of text, 738 MB for thirty slides sharing twelve photos,
|
||||
# 1.4 GB for thirty different photos and 2.1 GB for twenty very large
|
||||
# ones, for twenty seconds or so. A 4 GB classroom running Plasma and
|
||||
# Wonder Ink does not have 2 GB spare, and in testing the kernel killed
|
||||
# the whole service mid-conversion — which then restarted and tried the
|
||||
# same deck again, while the library said "Getting this ready" for ever.
|
||||
#
|
||||
# At most 40% of this machine's memory: 1.6 GB on 4 GB, 3.2 GB on 8 GB.
|
||||
# A deck that needs more is stopped HERE, inside this service, rather
|
||||
# than pushing the teacher's own windows out of memory.
|
||||
MemoryMax=40%
|
||||
# If memory runs short everywhere, this is what goes first: never the
|
||||
# lesson on the screen, the browser or the panel.
|
||||
OOMScoreAdjust=800
|
||||
# And a killed LibreOffice is a failed import, not a dead service. The
|
||||
# watcher records it — "needed more memory", with Try again — and carries
|
||||
# on. systemd's default stops the whole unit when any one process in it
|
||||
# is killed for memory.
|
||||
OOMPolicy=continue
|
||||
|
||||
[Install]
|
||||
WantedBy=plasma-workspace.target
|
||||
@@ -16,3 +16,9 @@ StartLimitIntervalSec=0
|
||||
[Service]
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
# Every desktop the configuration already has gets the classroom's
|
||||
# wallpaper written into it, in the one moment the shell is not running to
|
||||
# own the file — at a login, and at every restart after a crash. See
|
||||
# classroom-desktop-wallpaper. The "-" is because a panel matters more than
|
||||
# a wallpaper: nothing that script can do is allowed to stop the shell.
|
||||
ExecStartPre=-/usr/local/bin/classroom-desktop-wallpaper
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""A stand-in for the part of Gitea 1.27's API that tools/gitea-release uses.
|
||||
|
||||
Tags, releases and assets in memory. Writes need the token
|
||||
"not-a-real-token", as Gitea needs a real one. Like Gitea, it takes a
|
||||
second asset under a name a release already has: refusing that is
|
||||
tools/gitea-release's job, and the tests check that it does. GET
|
||||
/test/tamper/TAG/NAME changes an asset's bytes, the way somebody could in
|
||||
the web UI. Prints the port it listens on, then serves until killed.
|
||||
"""
|
||||
|
||||
import json, re
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from urllib.parse import urlparse, parse_qs, unquote
|
||||
|
||||
TOKEN = "token not-a-real-token"
|
||||
PREFIX = "/api/v1/repos/church/classroom"
|
||||
state = {"tags": {"v0.1.5": "1" * 40}, "releases": [], "next": 1}
|
||||
|
||||
|
||||
def find(tag):
|
||||
return next((r for r in state["releases"] if r["tag_name"] == tag), None)
|
||||
|
||||
|
||||
class Gitea(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def reply(self, status, body=None, raw=None):
|
||||
data = raw if raw is not None else json.dumps(body).encode() if body is not None else b""
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def authorised(self):
|
||||
if self.headers.get("Authorization") != TOKEN:
|
||||
self.reply(401, {"message": "token is required"})
|
||||
return False
|
||||
return True
|
||||
|
||||
def release_json(self, r):
|
||||
port = self.server.server_port
|
||||
return {**{k: v for k, v in r.items() if k != "assets"},
|
||||
"html_url": f"http://127.0.0.1:{port}/church/classroom/releases/tag/{r['tag_name']}"}
|
||||
|
||||
def do_GET(self):
|
||||
path = urlparse(self.path).path
|
||||
if m := re.fullmatch(PREFIX + r"/releases/tags/(.+)", path):
|
||||
r = find(unquote(m.group(1)))
|
||||
return self.reply(200, self.release_json(r)) if r else self.reply(404, {})
|
||||
if m := re.fullmatch(PREFIX + r"/tags/(.+)", path):
|
||||
sha = state["tags"].get(unquote(m.group(1)))
|
||||
return self.reply(200, {"name": m.group(1), "commit": {"sha": sha}}) if sha else self.reply(404, {})
|
||||
if m := re.fullmatch(PREFIX + r"/releases/(\d+)/assets", path):
|
||||
r = next((r for r in state["releases"] if r["id"] == int(m.group(1))), None)
|
||||
if not r:
|
||||
return self.reply(404, {})
|
||||
port = self.server.server_port
|
||||
return self.reply(200, [{"id": i, "name": n, "size": len(d),
|
||||
"browser_download_url": f"http://127.0.0.1:{port}/download/{r['id']}/{n}"}
|
||||
for i, (n, d) in enumerate(r["assets"].items())])
|
||||
if m := re.fullmatch(r"/download/(\d+)/(.+)", path):
|
||||
r = next((r for r in state["releases"] if r["id"] == int(m.group(1))), None)
|
||||
data = r and r["assets"].get(unquote(m.group(2)))
|
||||
return self.reply(200, raw=data) if data is not None else self.reply(404, {})
|
||||
# For the test: somebody replaces an asset by hand in the web UI.
|
||||
if m := re.fullmatch(r"/test/tamper/(.+)/(.+)", path):
|
||||
find(unquote(m.group(1)))["assets"][unquote(m.group(2))] = b"tampered"
|
||||
return self.reply(200, {})
|
||||
return self.reply(404, {})
|
||||
|
||||
def do_POST(self):
|
||||
if not self.authorised():
|
||||
return
|
||||
url = urlparse(self.path)
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(length)
|
||||
if url.path == PREFIX + "/releases":
|
||||
data = json.loads(body)
|
||||
if find(data["tag_name"]):
|
||||
return self.reply(409, {"message": "release exists"})
|
||||
state["tags"].setdefault(data["tag_name"], data["target_commitish"])
|
||||
r = {"id": state["next"], "tag_name": data["tag_name"], "name": data["name"],
|
||||
"body": data["body"], "prerelease": data["prerelease"], "draft": False, "assets": {}}
|
||||
state["next"] += 1
|
||||
state["releases"].append(r)
|
||||
return self.reply(201, self.release_json(r))
|
||||
if m := re.fullmatch(PREFIX + r"/releases/(\d+)/assets", url.path):
|
||||
r = next(r for r in state["releases"] if r["id"] == int(m.group(1)))
|
||||
name = parse_qs(url.query)["name"][0]
|
||||
boundary = self.headers["Content-Type"].split("boundary=")[1].encode()
|
||||
part = body.split(b"--" + boundary)[1]
|
||||
content = part.split(b"\r\n\r\n", 1)[1].rsplit(b"\r\n", 1)[0]
|
||||
# Gitea itself happily takes a second asset of the same name.
|
||||
r["assets"][name] = content
|
||||
return self.reply(201, {"name": name})
|
||||
return self.reply(404, {})
|
||||
|
||||
def do_PATCH(self):
|
||||
if not self.authorised():
|
||||
return
|
||||
path = urlparse(self.path).path
|
||||
if m := re.fullmatch(PREFIX + r"/releases/(\d+)", path):
|
||||
r = next(r for r in state["releases"] if r["id"] == int(m.group(1)))
|
||||
data = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
||||
r.update({k: v for k, v in data.items() if k in ("body", "prerelease")})
|
||||
return self.reply(200, self.release_json(r))
|
||||
return self.reply(404, {})
|
||||
|
||||
|
||||
server = HTTPServer(("127.0.0.1", 0), Gitea)
|
||||
print(server.server_port, flush=True)
|
||||
server.serve_forever()
|
||||
@@ -85,8 +85,16 @@ check("a desktop can have Wonder Ink taken away",
|
||||
check("and keeps everything else",
|
||||
desktop_no_ink["DVD"] is True and desktop_no_ink["PARENT_CALL"] is True)
|
||||
|
||||
check("an existing room that has never heard of these keeps them all",
|
||||
all(module.wanted({}).values()), str(module.wanted({})))
|
||||
unset = module.wanted({})
|
||||
check("an existing room that has never heard of these keeps all three",
|
||||
unset["WONDERINK"] and unset["DVD"] and unset["PARENT_CALL"],
|
||||
str(unset))
|
||||
# The one that must NOT follow that rule. Every existing classroom has
|
||||
# Wonder Ink, a drive and Parent Call, so an upgrade has to leave all
|
||||
# three on. No existing classroom has Presentations, so an upgrade has to
|
||||
# leave it off — a new button on a bar nobody asked for is a support call.
|
||||
check("but Presentations is off until a device asks for it",
|
||||
unset["PRESENTATIONS"] is False, str(unset))
|
||||
|
||||
|
||||
print("3. a drive can only ever take the player away")
|
||||
@@ -126,13 +134,18 @@ check("and an unconfigured machine keeps every application",
|
||||
print("5. the bar that comes out, evaluated in node")
|
||||
STUB = r"""
|
||||
const fs = require("fs");
|
||||
const captured = {widgets: [], config: {}};
|
||||
const captured = {widgets: [], config: {}, launchers: []};
|
||||
let current = null;
|
||||
function makeWidget(type) {
|
||||
const w = {
|
||||
type: type,
|
||||
currentConfigGroup: [],
|
||||
writeConfig: function (k, v) { captured.config[type + "." + k] = String(v); },
|
||||
writeConfig: function (k, v) {
|
||||
captured.config[type + "." + k] = String(v);
|
||||
// Quick Launch widgets share a config key, so the last one
|
||||
// written would otherwise be the only one anybody could see.
|
||||
if (k === "launcherUrls") { captured.launchers.push(String(v)); }
|
||||
},
|
||||
reloadConfig: function () {}
|
||||
};
|
||||
captured.widgets.push(type);
|
||||
@@ -172,8 +185,15 @@ else:
|
||||
check("a classroom gets Wonder Ink, the browser and the player, in order",
|
||||
every and every["config"].get(LAUNCH) == f"{INK},{WEB},{DISC}",
|
||||
(every or {}).get("config", {}).get(LAUNCH, why))
|
||||
CALL = "file:///usr/share/applications/ekklesia-parent-call.desktop"
|
||||
HELP = "file:///usr/share/applications/ekklesia-manual.desktop"
|
||||
|
||||
check("and Parent Call in the corner",
|
||||
every and "org.kde.plasma.quicklaunch" in every["widgets"])
|
||||
every and CALL in every["launchers"],
|
||||
str((every or {}).get("launchers")))
|
||||
check("and the unfinished Help launcher is hidden",
|
||||
every and HELP not in every["launchers"],
|
||||
str((every or {}).get("launchers")))
|
||||
|
||||
no_ink, why = bar({"WONDERINK_ENABLED": "false"})
|
||||
check("a Bible study desktop has the browser first and no gap",
|
||||
@@ -188,8 +208,49 @@ else:
|
||||
bible and bible["config"].get(LAUNCH) == WEB,
|
||||
(bible or {}).get("config", {}).get(LAUNCH, why))
|
||||
check("with no Parent Call button on it",
|
||||
bible and "org.kde.plasma.quicklaunch" not in bible["widgets"],
|
||||
str((bible or {}).get("widgets")))
|
||||
bible and CALL not in bible["launchers"],
|
||||
str((bible or {}).get("launchers")))
|
||||
# The source and launcher remain installed, but the unfinished digital
|
||||
# Help entry point is deliberately absent from the teacher-facing bar.
|
||||
check("and unfinished Help stays hidden",
|
||||
bible and HELP not in bible["launchers"],
|
||||
str((bible or {}).get("launchers")))
|
||||
SLIDES = "applications:ekklesia-presentations.desktop"
|
||||
|
||||
# THE DOCK ORDER IS THE THING NOT TO BREAK.
|
||||
#
|
||||
# Every existing room has Wonder Ink, the browser and the player in
|
||||
# that order, and teachers have been pressing them in that order for
|
||||
# months. Presentations is appended, so all three keep their place on
|
||||
# every machine that upgrades into this release.
|
||||
module.has_optical_drive = lambda: True
|
||||
slides, why = bar({"PRESENTATIONS_ENABLED": "true"})
|
||||
check("Presentations goes on the END of the row, after the player",
|
||||
slides and slides["config"].get(LAUNCH)
|
||||
== f"{INK},{WEB},{DISC},{SLIDES}",
|
||||
(slides or {}).get("config", {}).get(LAUNCH, why))
|
||||
|
||||
without, why = bar({"PRESENTATIONS_ENABLED": "false"})
|
||||
check("and is absent entirely when the device does not offer it",
|
||||
without and SLIDES not in without["config"].get(LAUNCH, ""),
|
||||
(without or {}).get("config", {}).get(LAUNCH, why))
|
||||
check("which leaves the other three exactly where they were",
|
||||
without and without["config"].get(LAUNCH) == f"{INK},{WEB},{DISC}",
|
||||
(without or {}).get("config", {}).get(LAUNCH, why))
|
||||
|
||||
# The prayer-room laptop: the browser and Presentations, nothing else.
|
||||
module.has_optical_drive = lambda: False
|
||||
study, why = bar({"PRESENTATIONS_ENABLED": "true",
|
||||
"WONDERINK_ENABLED": "false",
|
||||
"PARENT_CALL_ENABLED": "false",
|
||||
"DEVICE_TYPE": "laptop"})
|
||||
check("the Bible study laptop gets the browser and Presentations",
|
||||
study and study["config"].get(LAUNCH) == f"{WEB},{SLIDES}",
|
||||
(study or {}).get("config", {}).get(LAUNCH, why))
|
||||
check("and still no Parent Call on it",
|
||||
study and CALL not in study["launchers"],
|
||||
str((study or {}).get("launchers")))
|
||||
|
||||
check("and the status corner is untouched",
|
||||
bible and "org.kde.plasma.volume" in bible["widgets"]
|
||||
and "org.kde.plasma.digitalclock" in bible["widgets"]
|
||||
@@ -227,12 +288,28 @@ print("7. a disabled application does not start either")
|
||||
# talked into running it by a stale desktop entry or a technician's
|
||||
# muscle memory.
|
||||
for script, setting in (("classroom-wonderink", "WONDERINK_ENABLED"),
|
||||
("classroom-parent-call", "PARENT_CALL_ENABLED")):
|
||||
("classroom-parent-call", "PARENT_CALL_ENABLED"),
|
||||
("classroom-presentations", "PRESENTATIONS_ENABLED"),
|
||||
("classroom-presentation-import",
|
||||
"PRESENTATIONS_ENABLED")):
|
||||
text = (root / "scripts" / script).read_text()
|
||||
check(f"{script} refuses when {setting} is false",
|
||||
setting in text and "not starting" in text)
|
||||
check(f"{script} defaults to starting, so existing rooms are unaffected",
|
||||
'default=True' in text or 'feature(' in text)
|
||||
check(f"{script} asks the shared helper rather than reading the file",
|
||||
"feature(" in text and "read_device_config()" in text)
|
||||
|
||||
# And each of them defaults the way its own room needs, which is not the
|
||||
# same answer for all four. Asserting one rule for all of them would pass
|
||||
# for the wrong reason on the one that differs.
|
||||
from classroom_config import feature as _feature # noqa: E402
|
||||
|
||||
for setting, default, why in (
|
||||
("WONDERINK_ENABLED", True, "every existing classroom has it"),
|
||||
("DVD_ENABLED", True, "every existing classroom has it"),
|
||||
("PARENT_CALL_ENABLED", True, "every existing classroom has it"),
|
||||
("PRESENTATIONS_ENABLED", False, "no existing classroom has it")):
|
||||
check(f"{setting} defaults to {default} — {why}",
|
||||
_feature({}, setting) is default)
|
||||
|
||||
print()
|
||||
print(f"{passed} passed, {failed} failed")
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
#!/bin/bash
|
||||
# Every desktop shows the classroom's wallpaper, whatever it is numbered.
|
||||
#
|
||||
# Tiny Pebbles came through the 0.2.2 upgrade with its desktop as
|
||||
# containment 10 and no wallpaper configured on it, and showed Plasma's own
|
||||
# purple one. These run the real repair against that file, against a
|
||||
# machine that was already right, and against the shapes in between, and
|
||||
# then check that every place naming the wallpaper names the same one.
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
python3 - "$ROOT" <<'PY'
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from importlib.machinery import SourceFileLoader
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
loader = SourceFileLoader("wallpaper", str(root / "scripts/classroom-desktop-wallpaper"))
|
||||
spec = importlib.util.spec_from_loader("wallpaper", loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
|
||||
passed = failed = 0
|
||||
|
||||
|
||||
def check(label, condition, detail=""):
|
||||
global passed, failed
|
||||
if condition:
|
||||
print(f" ok {label}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" FAIL {label}")
|
||||
if detail:
|
||||
print(f" {detail}")
|
||||
failed += 1
|
||||
|
||||
|
||||
IMAGE = "Image=file:///usr/share/backgrounds/ekklesia/classroom-calm.png"
|
||||
|
||||
|
||||
def group_texts(text):
|
||||
"""{group name: its lines}, for saying what did and did not change."""
|
||||
lines = text.splitlines(keepends=True)
|
||||
return {
|
||||
name: "".join(lines[header:end]).rstrip("\n")
|
||||
for name, header, end in module.groups(lines)
|
||||
}
|
||||
|
||||
|
||||
def containment_ids(text):
|
||||
return sorted(re.findall(r"^\[Containments\]\[(\d+)\]$", text, re.M))
|
||||
|
||||
|
||||
# A panel built with no screen present takes id 1 and its applets 2 to 9,
|
||||
# which is how Tiny Pebbles' desktop came to be 10. Its containment is as
|
||||
# it was read off the machine.
|
||||
PANEL_1 = """[ActionPlugins][0]
|
||||
MiddleButton;NoModifier=org.kde.paste
|
||||
RightButton;NoModifier=org.kde.contextmenu
|
||||
|
||||
[ActionPlugins][1]
|
||||
RightButton;NoModifier=org.kde.contextmenu
|
||||
|
||||
[Containments][1]
|
||||
activityId=
|
||||
formfactor=2
|
||||
immutability=1
|
||||
lastScreen=0
|
||||
location=4
|
||||
plugin=org.kde.panel
|
||||
wallpaperplugin=org.kde.image
|
||||
|
||||
[Containments][1][Applets][2]
|
||||
immutability=1
|
||||
plugin=org.kde.plasma.quicklaunch
|
||||
|
||||
[Containments][1][Applets][2][Configuration][General]
|
||||
launcherUrls=file:///usr/share/applications/ekklesia-parent-call.desktop
|
||||
|
||||
[Containments][1][Applets][3]
|
||||
immutability=1
|
||||
plugin=org.kde.plasma.panelspacer
|
||||
|
||||
[Containments][1][Applets][4]
|
||||
immutability=1
|
||||
plugin=org.kde.plasma.icontasks
|
||||
|
||||
[Containments][1][Applets][4][Configuration][General]
|
||||
launchers=applications:ekklesia-wonderink.desktop,applications:google-chrome.desktop,applications:ekklesia-dvd.desktop
|
||||
sortingStrategy=1
|
||||
|
||||
[Containments][1][Applets][5]
|
||||
immutability=1
|
||||
plugin=org.kde.plasma.panelspacer
|
||||
|
||||
[Containments][1][Applets][6]
|
||||
immutability=1
|
||||
plugin=org.kde.plasma.networkmanagement
|
||||
|
||||
[Containments][1][Applets][7]
|
||||
immutability=1
|
||||
plugin=org.kde.plasma.volume
|
||||
|
||||
[Containments][1][Applets][8]
|
||||
immutability=1
|
||||
plugin=org.kde.plasma.notifications
|
||||
|
||||
[Containments][1][Applets][9]
|
||||
immutability=1
|
||||
plugin=org.kde.plasma.digitalclock
|
||||
|
||||
[Containments][1][General]
|
||||
AppletOrder=2;3;4;5;6;7;8;9
|
||||
"""
|
||||
|
||||
TINY_PEBBLES = PANEL_1 + """
|
||||
[Containments][10]
|
||||
activityId=62d3d55d-bd31-4fc8-9a7b-a5b9b2b99b60
|
||||
formfactor=0
|
||||
immutability=1
|
||||
lastScreen=0
|
||||
location=0
|
||||
plugin=org.kde.plasma.folder
|
||||
wallpaperplugin=org.kde.image
|
||||
|
||||
[Containments][10][General]
|
||||
positions={"1920x1080":[]}
|
||||
|
||||
[ScreenMapping]
|
||||
itemsOnDisabledScreens=
|
||||
"""
|
||||
|
||||
# The healthy machine, as the layout builds it with a screen present and
|
||||
# as the lab's own session wrote it: desktop 1, panel 2, applets 3 to 10.
|
||||
# The clock is applet 10 — the number that is Tiny Pebbles' desktop.
|
||||
RIVER_KIDS = """[ActionPlugins][0]
|
||||
MiddleButton;NoModifier=org.kde.paste
|
||||
RightButton;NoModifier=org.kde.contextmenu
|
||||
|
||||
[Containments][1]
|
||||
ItemGeometries-1920x1080=
|
||||
ItemGeometriesHorizontal=
|
||||
activityId=a9db02f6-926f-4650-b05f-de3a9f0b73fe
|
||||
formfactor=0
|
||||
immutability=1
|
||||
lastScreen=0
|
||||
location=0
|
||||
plugin=org.kde.plasma.folder
|
||||
wallpaperplugin=org.kde.image
|
||||
|
||||
[Containments][1][Wallpaper][org.kde.image][General]
|
||||
FillMode=2
|
||||
Image=file:///usr/share/backgrounds/ekklesia/classroom-calm.png
|
||||
SlidePaths=
|
||||
|
||||
[Containments][2]
|
||||
activityId=
|
||||
formfactor=2
|
||||
immutability=1
|
||||
lastScreen=0
|
||||
location=4
|
||||
plugin=org.kde.panel
|
||||
wallpaperplugin=org.kde.image
|
||||
|
||||
[Containments][2][Applets][10]
|
||||
immutability=1
|
||||
plugin=org.kde.plasma.digitalclock
|
||||
|
||||
[Containments][2][Applets][10][Configuration][Appearance]
|
||||
showDate=true
|
||||
|
||||
[Containments][2][Applets][3]
|
||||
immutability=1
|
||||
plugin=org.kde.plasma.quicklaunch
|
||||
|
||||
[Containments][2][Applets][5]
|
||||
immutability=1
|
||||
plugin=org.kde.plasma.icontasks
|
||||
|
||||
[Containments][2][General]
|
||||
AppletOrder=3;4;5;6;7;8;9;10
|
||||
|
||||
[ScreenMapping]
|
||||
itemsOnDisabledScreens=
|
||||
"""
|
||||
|
||||
# What the lab produced when Plasma made a desktop mid-session: the good
|
||||
# one kept its picture, and the new one, 11, had none.
|
||||
LATE_DESKTOP = RIVER_KIDS.replace("[ScreenMapping]", """[Containments][11]
|
||||
activityId=f992593c-2347-4e8d-be82-f80d02e71158
|
||||
formfactor=0
|
||||
immutability=1
|
||||
lastScreen=0
|
||||
location=0
|
||||
plugin=org.kde.plasma.folder
|
||||
wallpaperplugin=org.kde.image
|
||||
|
||||
[Containments][11][General]
|
||||
positions={"1920x1080":[]}
|
||||
|
||||
[ScreenMapping]""")
|
||||
|
||||
|
||||
print("1. Tiny Pebbles: a desktop numbered 10, with no wallpaper at all")
|
||||
check("the desktop is found by what it is, not its number",
|
||||
module.desktops(TINY_PEBBLES.splitlines(True)) == ["10"],
|
||||
str(module.desktops(TINY_PEBBLES.splitlines(True))))
|
||||
after, changes = module.converge(TINY_PEBBLES)
|
||||
groups_before, groups_after = group_texts(TINY_PEBBLES), group_texts(after)
|
||||
wallpaper = groups_after.get("[Containments][10][Wallpaper][org.kde.image][General]", "")
|
||||
check("it gets the classroom's image", IMAGE in wallpaper, wallpaper)
|
||||
check("scaled and cropped, as the layout writes it", "FillMode=2" in wallpaper)
|
||||
check("and it says what it did", changes == ["desktop 10: had no wallpaper configured"],
|
||||
str(changes))
|
||||
check("the wallpaper plugin is still the image one",
|
||||
"wallpaperplugin=org.kde.image" in groups_after["[Containments][10]"])
|
||||
check("one group added and nothing else changed",
|
||||
set(groups_after) - set(groups_before)
|
||||
== {"[Containments][10][Wallpaper][org.kde.image][General]"}
|
||||
and all(groups_after[name] == text for name, text in groups_before.items()))
|
||||
check("every containment keeps its number", containment_ids(after) == ["1", "10"])
|
||||
check("the panel is still containment 1, applets 2 to 9, in order",
|
||||
groups_after["[Containments][1][General]"].endswith("AppletOrder=2;3;4;5;6;7;8;9")
|
||||
and groups_after["[Containments][1]"] == groups_before["[Containments][1]"])
|
||||
check("the new group sits with its own containment",
|
||||
after.index("[Containments][10][General]")
|
||||
< after.index("[Containments][10][Wallpaper]")
|
||||
< after.index("[ScreenMapping]"))
|
||||
again, changes_again = module.converge(after)
|
||||
check("a second run changes nothing", again == after and changes_again == [], str(changes_again))
|
||||
|
||||
|
||||
print("2. River Kids: already right")
|
||||
after, changes = module.converge(RIVER_KIDS)
|
||||
check("the file comes back byte for byte", after == RIVER_KIDS)
|
||||
check("and nothing is reported as changed", changes == [], str(changes))
|
||||
check("applet 10 on the panel is not mistaken for a desktop",
|
||||
module.desktops(RIVER_KIDS.splitlines(True)) == ["1"])
|
||||
|
||||
|
||||
print("3. two desktops, one bare, as the lab made them")
|
||||
after, changes = module.converge(LATE_DESKTOP)
|
||||
groups_before, groups_after = group_texts(LATE_DESKTOP), group_texts(after)
|
||||
check("only the bare one is written",
|
||||
changes == ["desktop 11: had no wallpaper configured"], str(changes))
|
||||
check("the good one is untouched",
|
||||
groups_after["[Containments][1][Wallpaper][org.kde.image][General]"]
|
||||
== groups_before["[Containments][1][Wallpaper][org.kde.image][General]"])
|
||||
check("and the panel, numbered 2, is untouched",
|
||||
all(groups_after[name] == text for name, text in groups_before.items()
|
||||
if name and name.startswith("[Containments][2]")))
|
||||
check("the new one has the image",
|
||||
IMAGE in groups_after.get("[Containments][11][Wallpaper][org.kde.image][General]", ""))
|
||||
|
||||
|
||||
print("4. a desktop some other profile left with another picture")
|
||||
OTHER = """[Containments][4]
|
||||
formfactor=0
|
||||
location=0
|
||||
plugin=org.kde.desktopcontainment
|
||||
wallpaperplugin=org.kde.color
|
||||
|
||||
[Containments][4][Wallpaper][org.kde.image][General]
|
||||
Image[$e]=file:///usr/share/wallpapers/Next/contents/images/1920x1080.png
|
||||
SlidePaths=/usr/share/wallpapers/
|
||||
|
||||
[Containments][7]
|
||||
formfactor=2
|
||||
location=4
|
||||
plugin=org.kde.plasma.private.systemtray
|
||||
|
||||
[Containments][9]
|
||||
formfactor=2
|
||||
location=4
|
||||
plugin=org.kde.panel
|
||||
"""
|
||||
after, changes = module.converge(OTHER)
|
||||
groups_after = group_texts(after)
|
||||
check("the plain Desktop containment counts as a desktop",
|
||||
module.desktops(OTHER.splitlines(True)) == ["4"])
|
||||
check("its plugin becomes the image one",
|
||||
"wallpaperplugin=org.kde.image" in groups_after["[Containments][4]"])
|
||||
wallpaper = groups_after["[Containments][4][Wallpaper][org.kde.image][General]"]
|
||||
check("its image becomes the classroom's, keeping the key's flags",
|
||||
"Image[$e]=file:///usr/share/backgrounds/ekklesia/classroom-calm.png" in wallpaper,
|
||||
wallpaper)
|
||||
check("a missing key is added to the existing group", "FillMode=2" in wallpaper)
|
||||
check("a key the classroom does not set is left alone",
|
||||
"SlidePaths=/usr/share/wallpapers/" in wallpaper)
|
||||
check("a system tray is not a desktop",
|
||||
groups_after["[Containments][7]"] == group_texts(OTHER)["[Containments][7]"])
|
||||
check("nor is a panel",
|
||||
groups_after["[Containments][9]"] == group_texts(OTHER)["[Containments][9]"])
|
||||
check("and it settles", module.converge(after) == (after, []))
|
||||
|
||||
|
||||
print("5. the command, on files")
|
||||
work = Path(tempfile.mkdtemp())
|
||||
|
||||
|
||||
def run(*args):
|
||||
return module.main([str(arg) for arg in args])
|
||||
|
||||
|
||||
target = work / "tiny-pebbles"
|
||||
target.write_text(TINY_PEBBLES)
|
||||
os.chmod(target, 0o600)
|
||||
check("--check says it is wrong", run("--check", target) == 1)
|
||||
check("and changes nothing", target.read_text() == TINY_PEBBLES)
|
||||
check("the repair succeeds", run(target) == 0)
|
||||
check("the file is repaired", IMAGE in target.read_text())
|
||||
check("the file keeps its permissions", (target.stat().st_mode & 0o777) == 0o600)
|
||||
check("no temporary file is left behind", sorted(p.name for p in work.iterdir()) == ["tiny-pebbles"])
|
||||
stamp = (target.stat().st_ino, target.stat().st_mtime_ns)
|
||||
check("a second run succeeds", run(target) == 0)
|
||||
check("and does not write the file at all",
|
||||
(target.stat().st_ino, target.stat().st_mtime_ns) == stamp)
|
||||
check("--check then says it is right", run("--check", target) == 0)
|
||||
|
||||
healthy = work / "river-kids"
|
||||
healthy.write_text(RIVER_KIDS)
|
||||
stamp = (healthy.stat().st_ino, healthy.stat().st_mtime_ns)
|
||||
check("a machine that is right is left alone", run(healthy) == 0
|
||||
and (healthy.stat().st_ino, healthy.stat().st_mtime_ns) == stamp)
|
||||
|
||||
check("no file at all is not an error", run(work / "absent") == 0)
|
||||
check("and none is created", not (work / "absent").exists())
|
||||
|
||||
empty = work / "no-desktop"
|
||||
empty.write_text(PANEL_1)
|
||||
check("a file with only a panel is not an error", run(empty) == 0)
|
||||
check("and is not changed", empty.read_text() == PANEL_1)
|
||||
|
||||
|
||||
print("6. never underneath a running shell")
|
||||
config_home = work / "config"
|
||||
config_home.mkdir()
|
||||
live = config_home / module.APPLETSRC
|
||||
live.write_text(TINY_PEBBLES)
|
||||
os.environ["XDG_CONFIG_HOME"] = str(config_home)
|
||||
check("the default file is the one in XDG_CONFIG_HOME", module.default_path() == str(live))
|
||||
|
||||
proc = work / "proc"
|
||||
for pid, name in (("1", "systemd"), ("812", "kwin_wayland"), ("self", "python3")):
|
||||
(proc / pid).mkdir(parents=True)
|
||||
(proc / pid / "comm").write_text(name + "\n")
|
||||
check("no plasmashell in the process table is no shell",
|
||||
module.shell_running(str(proc)) is False)
|
||||
(proc / "1044").mkdir()
|
||||
(proc / "1044" / "comm").write_text("plasmashell\n")
|
||||
check("this user's plasmashell is found", module.shell_running(str(proc)) is True)
|
||||
check("and the real process table can be read",
|
||||
module.shell_running() in (True, False))
|
||||
|
||||
module.shell_running = lambda: True
|
||||
check("with plasmashell running it refuses", run() == 1)
|
||||
check("and the shell's file is untouched", live.read_text() == TINY_PEBBLES)
|
||||
check("--check still answers while it runs", run("--check") == 1)
|
||||
|
||||
module.shell_running = lambda: False
|
||||
check("with the shell stopped it repairs", run() == 0 and IMAGE in live.read_text())
|
||||
|
||||
|
||||
print("7. one wallpaper, named the same everywhere")
|
||||
layout = (root / "config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js").read_text()
|
||||
defaults = (root / "config/plasma/lookandfeel/contents/defaults").read_text()
|
||||
build = (root / "tools/build-release.sh").read_text()
|
||||
dropin = (root / "systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf").read_text()
|
||||
package = root / "config/wallpapers/EkklesiaClassroom"
|
||||
metadata = json.loads((package / "metadata.json").read_text())
|
||||
|
||||
check("the layout and the repair name the same file",
|
||||
f'var WALLPAPER = "{module.WALLPAPER}";' in layout)
|
||||
check("and the same fill mode", 'desktop.writeConfig("FillMode", 2);' in layout
|
||||
and ("FillMode", "2") in module.WALLPAPER_KEYS)
|
||||
check("the packaged file is where they say", f'"$STAGE{module.WALLPAPER}"' in build)
|
||||
|
||||
default = re.search(r"^\[Wallpaper\]\nImage=(.*)$", defaults, re.M)
|
||||
check("the look and feel's default is a package name, not a path",
|
||||
default is not None and "/" not in default.group(1),
|
||||
default.group(1) if default else "no [Wallpaper] Image")
|
||||
check("the package it names is the classroom's",
|
||||
default is not None and default.group(1) == metadata["KPlugin"]["Id"] == package.name)
|
||||
check("the build installs that package's metadata",
|
||||
f'"$ROOT/config/wallpapers/{package.name}/metadata.json"' in build
|
||||
and f'WALLPAPER_PACKAGE="$STAGE/usr/share/wallpapers/{package.name}"' in build)
|
||||
check("its image is a link to the one file, not a copy",
|
||||
f"ln -s {module.WALLPAPER}" in build)
|
||||
|
||||
check("the repair is packaged",
|
||||
'"$STAGE/usr/local/bin/classroom-desktop-wallpaper"' in build)
|
||||
check("it runs before every shell start",
|
||||
"ExecStartPre=-/usr/local/bin/classroom-desktop-wallpaper" in dropin)
|
||||
check("and a failure in it cannot stop the shell",
|
||||
not re.search(r"^ExecStartPre=/", dropin, re.M))
|
||||
check("the layout still writes the wallpaper for a fresh desktop",
|
||||
'desktop.writeConfig("Image", "file://" + WALLPAPER);' in layout)
|
||||
|
||||
print()
|
||||
print(f"{passed} passed, {failed} failed")
|
||||
sys.exit(1 if failed else 0)
|
||||
PY
|
||||
@@ -0,0 +1,263 @@
|
||||
#!/bin/bash
|
||||
# The teacher's manual: one source, two versions, and no drift.
|
||||
#
|
||||
# Two halves, because the manual has two failure modes and they are not
|
||||
# alike. The source can be wrong — a page for an optional feature with
|
||||
# no conditional sentence makes the PRINTED manual state it
|
||||
# unconditionally, which is wrong in every room that does not have it.
|
||||
# And the built page can be wrong — it can show a teacher a feature
|
||||
# their computer does not have, or hide one it does, or carry something
|
||||
# out of device.conf that the classroom account has no business seeing.
|
||||
#
|
||||
# The second half runs the manual's own viewer script in node against a
|
||||
# stub DOM, the same way test-classroom-network.sh evaluates the polkit
|
||||
# rule: it is the actual shipped code, not a description of it.
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
echo "A. the source, and the release gate"
|
||||
PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY'
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
sys.path.insert(0, str(root / "lib"))
|
||||
|
||||
from classroom_config import FEATURES
|
||||
|
||||
passed = failed = 0
|
||||
|
||||
|
||||
def check(label, condition, detail=""):
|
||||
global passed, failed
|
||||
if condition:
|
||||
print(f" ok {label}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" FAIL {label}")
|
||||
if detail:
|
||||
print(f" {detail}")
|
||||
failed += 1
|
||||
|
||||
|
||||
source = root / "docs" / "manual"
|
||||
build = source / "build"
|
||||
pages = sorted(p for p in source.glob("*.md") if p.name != "README.md")
|
||||
|
||||
check("there is a manual source directory", source.is_dir())
|
||||
check("with pages in it", len(pages) >= 5, str(len(pages)))
|
||||
|
||||
headers = {}
|
||||
|
||||
for path in pages:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
header = {}
|
||||
|
||||
if text.startswith("---\n"):
|
||||
end = text.index("\n---\n", 3)
|
||||
for line in text[4:end].splitlines():
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
header[key.strip()] = value.strip()
|
||||
|
||||
headers[path.name] = header
|
||||
|
||||
for name, header in headers.items():
|
||||
check(f"{name} has a title", bool(header.get("title")))
|
||||
|
||||
# THE RULE THAT KEEPS THE PRINTED MANUAL TRUE.
|
||||
for name, header in headers.items():
|
||||
if header.get("feature"):
|
||||
check(f"{name} says when it applies, for the printed manual",
|
||||
bool(header.get("printed-if")),
|
||||
"without it the printed manual states an optional feature "
|
||||
"as though every room had it")
|
||||
check(f"{name} names a real device.conf feature",
|
||||
header["feature"] in FEATURES,
|
||||
f"{header.get('feature')} is not one of "
|
||||
f"{', '.join(sorted(FEATURES))}")
|
||||
|
||||
documented = {h.get("feature") for h in headers.values() if h.get("feature")}
|
||||
|
||||
for name in FEATURES:
|
||||
check(f"{name} has a manual page", name in documented,
|
||||
"a room would get a button the manual never mentions")
|
||||
|
||||
orders = [h.get("order") for h in headers.values()]
|
||||
check("no two pages claim the same place", len(orders) == len(set(orders)),
|
||||
str(sorted(orders)))
|
||||
|
||||
print(" -- the builder itself")
|
||||
builder = root / "tools" / "build-manual"
|
||||
check("it exists and is runnable", builder.exists()
|
||||
and builder.stat().st_mode & 0o111)
|
||||
|
||||
result = subprocess.run([str(builder)], capture_output=True, text=True,
|
||||
timeout=120)
|
||||
check("it builds without complaint", result.returncode == 0,
|
||||
result.stderr.strip())
|
||||
check("it produced the digital manual",
|
||||
(build / "classroom-manual.html").exists())
|
||||
check("and the printable one", (build / "classroom-manual.pdf").exists())
|
||||
|
||||
pdf = (build / "classroom-manual.pdf").read_bytes()
|
||||
check("the PDF is a real PDF", pdf.startswith(b"%PDF-")
|
||||
and pdf.rstrip().endswith(b"%%EOF"), str(pdf[:8]))
|
||||
check("with more than one page", pdf.count(b"/Type /Page ") > 1,
|
||||
str(pdf.count(b"/Type /Page ")))
|
||||
check("and a revision date on it, so an old printout can be spotted",
|
||||
b"Revision" in pdf)
|
||||
|
||||
# Every conditional sentence has to actually reach the printed manual.
|
||||
for name, header in headers.items():
|
||||
if header.get("printed-if"):
|
||||
words = header["printed-if"].encode("latin-1", "replace")
|
||||
check(f"{name}'s conditional sentence is in the PDF",
|
||||
words in pdf, header["printed-if"])
|
||||
|
||||
check("the check mode passes on what was just built",
|
||||
subprocess.run([str(builder), "--check"], capture_output=True,
|
||||
timeout=120).returncode == 0)
|
||||
|
||||
html = (build / "classroom-manual.html").read_text(encoding="utf-8")
|
||||
|
||||
for name, header in headers.items():
|
||||
if header.get("feature"):
|
||||
check(f"{name} is marked in the HTML with its feature",
|
||||
f'data-feature="{header["feature"]}"' in html)
|
||||
|
||||
check("the digital manual needs nothing from the network",
|
||||
"http://" not in html and "https://" not in html,
|
||||
"it has to work in a building with no internet")
|
||||
|
||||
print()
|
||||
print(f"{passed} passed, {failed} failed")
|
||||
raise SystemExit(1 if failed else 0)
|
||||
PY
|
||||
|
||||
status=$?
|
||||
[[ $status -ne 0 ]] && exit $status
|
||||
|
||||
echo
|
||||
echo "B. what a device actually shows"
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo " SKIP node is not installed; the viewer script was not evaluated"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
node - "$ROOT" <<'JS'
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const root = process.argv[2];
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function check(label, condition, detail) {
|
||||
if (condition) { console.log(" ok " + label); passed++; }
|
||||
else { console.log(" FAIL " + label); if (detail) console.log(" " + detail); failed++; }
|
||||
}
|
||||
|
||||
const html = fs.readFileSync(
|
||||
path.join(root, "docs/manual/build/classroom-manual.html"), "utf8");
|
||||
|
||||
// The shipped viewer, pulled out of the built page and run for real.
|
||||
const script = html.slice(html.lastIndexOf("<script>") + 8,
|
||||
html.lastIndexOf("</script>"));
|
||||
|
||||
// Enough DOM for it. Sections carry the feature they need, exactly as
|
||||
// the builder wrote them.
|
||||
function sectionsOf(text) {
|
||||
const found = [];
|
||||
const re = /<section id="([^"]+)"(?: data-feature="([^"]+)")?>/g;
|
||||
let m;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
found.push({id: m[1], feature: m[2] || null});
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function show(features) {
|
||||
const sections = sectionsOf(html).map(s => ({
|
||||
id: s.id,
|
||||
removed: false,
|
||||
getAttribute: function (name) {
|
||||
return name === "data-feature" ? s.feature : null;
|
||||
},
|
||||
remove: function () { this.removed = true; },
|
||||
querySelector: function () { return {textContent: s.id}; },
|
||||
}));
|
||||
const contents = {children: [], appendChild: function (c) { this.children.push(c); }};
|
||||
const window = {CLASSROOM_FEATURES: features};
|
||||
const document = {
|
||||
querySelectorAll: () => sections,
|
||||
getElementById: () => contents,
|
||||
createElement: () => ({}),
|
||||
};
|
||||
new Function("window", "document", script)(window, document);
|
||||
return sections.filter(s => !s.removed).map(s => s.id);
|
||||
}
|
||||
|
||||
const all = sectionsOf(html).map(s => s.id);
|
||||
const optional = sectionsOf(html).filter(s => s.feature);
|
||||
check("the built manual carries every page",
|
||||
all.length >= 5, all.join(", "));
|
||||
check("and some of them are optional", optional.length >= 3,
|
||||
optional.map(s => s.feature).join(", "));
|
||||
|
||||
console.log(" -- the Bible study laptop: Presentations, and nothing else optional");
|
||||
const laptop = show(["PRESENTATIONS_ENABLED"]);
|
||||
check("the Presentations page is shown",
|
||||
laptop.some(id => id.includes("presentations")), laptop.join(", "));
|
||||
check("the Browser page is shown, because every room has one",
|
||||
laptop.some(id => id.includes("browser")));
|
||||
check("sound and volume are shown", laptop.some(id => id.includes("sound")));
|
||||
check("Wonder Ink is NOT shown",
|
||||
!laptop.some(id => id.includes("wonderink")), laptop.join(", "));
|
||||
check("Parent Call is NOT shown",
|
||||
!laptop.some(id => id.includes("parent-call")), laptop.join(", "));
|
||||
check("the DVD page is NOT shown",
|
||||
!laptop.some(id => id.includes("dvd")), laptop.join(", "));
|
||||
|
||||
console.log(" -- a children's classroom: everything but Presentations");
|
||||
const classroom = show(["WONDERINK_ENABLED", "PARENT_CALL_ENABLED", "DVD_ENABLED"]);
|
||||
check("Wonder Ink is shown", classroom.some(id => id.includes("wonderink")));
|
||||
check("Parent Call is shown", classroom.some(id => id.includes("parent-call")));
|
||||
check("the DVD page is shown", classroom.some(id => id.includes("dvd")));
|
||||
check("Presentations is NOT shown",
|
||||
!classroom.some(id => id.includes("presentations")), classroom.join(", "));
|
||||
|
||||
console.log(" -- a device that could not say what it has");
|
||||
// No manifest: show everything, which is the printed manual and is never
|
||||
// wrong, only less specific.
|
||||
const unknown = show(null);
|
||||
check("every page is shown rather than none",
|
||||
unknown.length === all.length, unknown.length + " of " + all.length);
|
||||
|
||||
console.log(" -- nothing optional at all");
|
||||
const bare = show([]);
|
||||
check("only the common pages remain",
|
||||
bare.length === all.length - optional.length,
|
||||
bare.join(", "));
|
||||
|
||||
console.log(" -- what the page is allowed to know about the device");
|
||||
const manual = fs.readFileSync(path.join(root, "scripts/classroom-manual"), "utf8");
|
||||
check("the manual is handed a list of setting NAMES, not device.conf",
|
||||
/window\.CLASSROOM_FEATURES/.test(manual)
|
||||
&& /json\.dumps\(sorted\(features\)\)/.test(manual));
|
||||
check("and the names are filtered against the features the package knows",
|
||||
/name for name in listed if name in FEATURES/.test(manual),
|
||||
"a stale manifest must not be able to name anything else");
|
||||
const layout = fs.readFileSync(path.join(root, "scripts/classroom-desktop-layout"), "utf8");
|
||||
check("the manifest is written from the enabled features alone",
|
||||
/setting for variable, setting in FEATURE_LINES if answers\.get\(variable\)/.test(layout));
|
||||
check("no value from device.conf is written into it",
|
||||
!/read_device_config\(\)\[/.test(layout));
|
||||
check("it lives in the runtime directory, so it cannot go stale",
|
||||
/\/run\/ekklesia-classroom\/features\.json/.test(layout));
|
||||
|
||||
console.log();
|
||||
console.log(passed + " passed, " + failed + " failed");
|
||||
process.exit(failed ? 1 : 0);
|
||||
JS
|
||||
@@ -166,7 +166,15 @@ check("it refuses to start when it could not undo itself",
|
||||
"shutil.which" in wifi
|
||||
and "leaving the network exactly as it is" in wifi)
|
||||
|
||||
print("9. the upgrade does not do any of this on its own")
|
||||
print("9. a machine with no ifupdown configuration at all")
|
||||
# A container, and any machine installed without ifupdown. Found by the
|
||||
# release validator: --check said nothing at all, because a missing file
|
||||
# was being treated as a read error.
|
||||
check("a missing interfaces file is not an error, it is just nothing to do",
|
||||
"not os.path.exists(INTERFACES)" in wifi
|
||||
and 'return None, ""' in wifi)
|
||||
|
||||
print("10. the upgrade does not do any of this on its own")
|
||||
# Moving a machine's only link during an unattended upgrade is fine until
|
||||
# the once it is not, and the machine it would strand is a laptop in a
|
||||
# locked room.
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
#!/bin/bash
|
||||
# The Presentations library: what it imports, what it refuses, what it
|
||||
# keeps and what it is never allowed to delete.
|
||||
#
|
||||
# No LibreOffice and no poppler here. Those are exercised by the lab and
|
||||
# by the release validator against a real Debian; this is the part that
|
||||
# has to be right whatever is installed — when a download counts as
|
||||
# finished, whose files may be touched, what a teacher's choice of
|
||||
# thumbnail does and does not change, and which of the two retention
|
||||
# clocks is running.
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY'
|
||||
import os
|
||||
import errno
|
||||
import importlib.util
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from importlib.machinery import SourceFileLoader
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
sys.path.insert(0, str(root / "lib"))
|
||||
|
||||
import classroom_presentations as library
|
||||
|
||||
loader = SourceFileLoader(
|
||||
"classroom_presentation_import",
|
||||
str(root / "scripts" / "classroom-presentation-import"),
|
||||
)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
importer = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(importer)
|
||||
|
||||
passed = failed = 0
|
||||
|
||||
|
||||
def check(label, condition, detail=""):
|
||||
global passed, failed
|
||||
if condition:
|
||||
print(f" ok {label}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" FAIL {label}")
|
||||
if detail:
|
||||
print(f" {detail}")
|
||||
failed += 1
|
||||
|
||||
|
||||
work = Path(tempfile.mkdtemp(prefix="classroom-presentations-"))
|
||||
downloads = work / "Downloads"
|
||||
wonderink = downloads / "Wonder Ink"
|
||||
shelf = work / "library"
|
||||
wonderink.mkdir(parents=True)
|
||||
shelf.mkdir(parents=True)
|
||||
|
||||
|
||||
def download(name, contents="x" * 400, age=60):
|
||||
"""A file sitting in Downloads, finished `age` seconds ago."""
|
||||
path = downloads / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(contents)
|
||||
when = time.time() - age
|
||||
os.utime(path, (when, when))
|
||||
return path
|
||||
|
||||
|
||||
def entry(**data):
|
||||
"""A presentation in the library, without running an import."""
|
||||
directory = shelf / data.get("id", "one")
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
base = {
|
||||
"id": directory.name,
|
||||
"state": "ready",
|
||||
"title": "A session",
|
||||
"pages": 10,
|
||||
"thumbnail_page": 1,
|
||||
"imported_at": library.stamp(),
|
||||
"last_presented_at": None,
|
||||
"times_presented": 0,
|
||||
}
|
||||
base.update(data)
|
||||
presentation = library.Presentation(directory, base)
|
||||
presentation.save()
|
||||
(directory / "slides.pdf").write_text("%PDF-1.4")
|
||||
return presentation
|
||||
|
||||
|
||||
print("1. a download that has finished, and ones that have not")
|
||||
done = download("Session 7 - Romans.pptx")
|
||||
check("a settled PowerPoint is importable", library.importable(done))
|
||||
|
||||
arriving = download("Still arriving.pptx")
|
||||
partial = downloads / "Still arriving.pptx.crdownload"
|
||||
partial.write_text("half")
|
||||
check("one with a .crdownload beside it is not",
|
||||
not library.importable(arriving),
|
||||
"Chrome renames the .crdownload away when the transfer completes")
|
||||
|
||||
check("and the .crdownload itself is never a candidate",
|
||||
not library.importable(partial))
|
||||
|
||||
fresh = download("Just landed.pdf", age=0)
|
||||
check("one still being written is not",
|
||||
not library.importable(fresh),
|
||||
"a file copied from a USB stick has no .crdownload to go by")
|
||||
|
||||
check("something that is not a presentation is not",
|
||||
not library.importable(download("notes.txt")))
|
||||
|
||||
empty = download("Nothing.pdf", contents="", age=60)
|
||||
check("a nought-byte file is not", not library.importable(empty))
|
||||
|
||||
print("2. Wonder Ink's worksheets are not presentations")
|
||||
worksheet = wonderink / "Colouring page.pdf"
|
||||
worksheet.write_text("%PDF-1.4" + "x" * 400)
|
||||
os.utime(worksheet, (time.time() - 60, time.time() - 60))
|
||||
check("a file in the Wonder Ink folder is recognised as Wonder Ink's",
|
||||
library.within_wonderink(worksheet, downloads))
|
||||
check("and one beside it in Downloads is not",
|
||||
not library.within_wonderink(done, downloads))
|
||||
|
||||
names = [p.name for p in library.candidates(downloads)]
|
||||
check("so it is not offered for import", "Colouring page.pdf" not in names,
|
||||
str(names))
|
||||
check("the finished session is", "Session 7 - Romans.pptx" in names, str(names))
|
||||
check("and nothing unfinished is",
|
||||
"Still arriving.pptx" not in names and "Just landed.pdf" not in names,
|
||||
str(names))
|
||||
|
||||
print("3. titles a teacher would recognise")
|
||||
check("an ordinary name is kept",
|
||||
library.readable_title("Session 7 - Romans.pptx") == "Session 7 - Romans",
|
||||
library.readable_title("Session 7 - Romans.pptx"))
|
||||
check("underscores become spaces",
|
||||
library.readable_title("Session_7_Romans.pdf") == "Session 7 Romans",
|
||||
library.readable_title("Session_7_Romans.pdf"))
|
||||
check("a browser's duplicate counter goes",
|
||||
library.readable_title("Bible Study (1).pdf") == "Bible Study",
|
||||
library.readable_title("Bible Study (1).pdf"))
|
||||
check("a percent-escaped space is decoded",
|
||||
library.readable_title("Session%207.pdf") == "Session 7",
|
||||
library.readable_title("Session%207.pdf"))
|
||||
hashed = library.readable_title(
|
||||
"a4f39c2e91bb4d7e.pdf", "Session 7 of the autumn study\nmore")
|
||||
check("a hash falls back to the document's own first words",
|
||||
hashed == "Session 7 of the autumn study", hashed)
|
||||
check("and something with no name at all still gets one",
|
||||
library.readable_title("...pdf", "") == "Presentation",
|
||||
library.readable_title("...pdf", ""))
|
||||
|
||||
print("4. two downloads of the same name are two presentations")
|
||||
first = entry(id="first", title="Session 7")
|
||||
second = entry(id="second", title="Session 7")
|
||||
# The bug this replaces: excluding the import being named BY TITLE also
|
||||
# removed the other presentation's identical title from the set, so a
|
||||
# PDF and a PPTX of one session both came out called "Session 7".
|
||||
taken = library.titles_in_use(shelf, exclude="second")
|
||||
check("the other presentation's title is still in the way",
|
||||
"Session 7" in taken, str(taken))
|
||||
check("so the second gets a title of its own",
|
||||
library.unique_title("Session 7", taken) == "Session 7 (2)",
|
||||
library.unique_title("Session 7", taken))
|
||||
check("and a name nothing else has is left alone",
|
||||
library.unique_title("Session 8", taken) == "Session 8")
|
||||
shutil.rmtree(shelf / "first")
|
||||
shutil.rmtree(shelf / "second")
|
||||
|
||||
print("5. the thumbnail is the teacher's choice, and only that")
|
||||
# The first slide of a Bible study session is a generic opening shared by
|
||||
# every session; the one that names the session is usually the second.
|
||||
p = entry(id="thumb", pages=12)
|
||||
before = dict(p.data)
|
||||
p.set_thumbnail_page(2)
|
||||
after = library.load(shelf / "thumb")
|
||||
check("the chosen slide is remembered", after.thumbnail_page == 2,
|
||||
str(after.thumbnail_page))
|
||||
check("it survives being read back from disk, so it survives a restart",
|
||||
library.load(shelf / "thumb").thumbnail_page == 2)
|
||||
check("choosing it did not mark the session as presented",
|
||||
after.last_presented_at is None and after.times_presented == 0)
|
||||
check("and did not move the retention clock",
|
||||
after.data["imported_at"] == before["imported_at"]
|
||||
and after.data.get("last_presented_at") is None)
|
||||
check("a slide past the end is clamped rather than stored",
|
||||
entry(id="thumb2", pages=5, thumbnail_page=99).thumbnail_page == 5)
|
||||
check("and a nonsense one does not crash the card",
|
||||
entry(id="thumb3", pages=5, thumbnail_page="banana").thumbnail_page == 1)
|
||||
|
||||
print("6. the two retention clocks")
|
||||
old = library.now() - timedelta(days=181)
|
||||
recent = library.now() - timedelta(days=100)
|
||||
|
||||
never = entry(id="never", imported_at=library.stamp(old))
|
||||
check("never presented, imported over 180 days ago, is expired",
|
||||
never.expired(), str(never.expires_at()))
|
||||
|
||||
young = entry(id="young", imported_at=library.stamp(recent))
|
||||
check("never presented but only 100 days old is not",
|
||||
not young.expired(), str(young.expires_at()))
|
||||
|
||||
shown = entry(id="shown",
|
||||
imported_at=library.stamp(old),
|
||||
last_presented_at=library.stamp(library.now() - timedelta(days=2)),
|
||||
times_presented=3)
|
||||
check("presented two days ago is kept, however old the import is",
|
||||
not shown.expired(),
|
||||
"presenting has to start the 30-day clock and stop the 180-day one")
|
||||
|
||||
stale = entry(id="stale",
|
||||
imported_at=library.stamp(old),
|
||||
last_presented_at=library.stamp(library.now() - timedelta(days=31)),
|
||||
times_presented=1)
|
||||
check("presented 31 days ago is expired", stale.expired())
|
||||
|
||||
print("7. presenting is the only thing that resets the clock")
|
||||
p = entry(id="counted", pages=4)
|
||||
check("a fresh import has never been presented",
|
||||
p.last_presented_at is None and p.times_presented == 0)
|
||||
p.mark_presented()
|
||||
reread = library.load(shelf / "counted")
|
||||
check("presenting is recorded", reread.last_presented_at is not None)
|
||||
check("and counted", reread.times_presented == 1)
|
||||
first_due = reread.expires_at()
|
||||
time.sleep(1.1)
|
||||
reread.mark_presented()
|
||||
again = library.load(shelf / "counted")
|
||||
check("presenting again starts the thirty days over",
|
||||
again.expires_at() > first_due, f"{first_due} -> {again.expires_at()}")
|
||||
check("and counts again", again.times_presented == 2)
|
||||
|
||||
print("8. an import that was interrupted is not shown to a teacher")
|
||||
half = entry(id="half", state="importing")
|
||||
check("it is in the library's own list",
|
||||
any(x.id == "half" for x in library.library(shelf)))
|
||||
check("but it is not ready, so no card offers Present",
|
||||
not library.load(shelf / "half").ready)
|
||||
broken = shelf / "broken"
|
||||
broken.mkdir()
|
||||
(broken / "presentation.json").write_text("{ this is not json")
|
||||
check("a truncated metadata file is skipped rather than crashing the library",
|
||||
all(x.id != "broken" for x in library.library(shelf)),
|
||||
"a card a teacher cannot open is worse than no card")
|
||||
|
||||
print("9. the presenting lock cannot outlive a reboot")
|
||||
check("it lives in the runtime directory",
|
||||
"/run" in str(library.presenting_lock())
|
||||
or str(library.presenting_lock()).startswith("/tmp"),
|
||||
str(library.presenting_lock()))
|
||||
|
||||
print("10. the ledger identifies a download transaction")
|
||||
mark = library.fingerprint(done)
|
||||
library.remember(mark, "some-id", shelf)
|
||||
check("a fingerprint is path, size and time",
|
||||
mark is not None and str(done.resolve()) in mark, str(mark))
|
||||
check("it is written down", library.ledger(shelf).get(mark) == "some-id")
|
||||
check("so an interrupted move can be matched after a restart",
|
||||
mark in library.ledger(shelf))
|
||||
touched = download("Session 7 - Romans.pptx", contents="y" * 900)
|
||||
check("but re-downloading the same session is a new presentation",
|
||||
library.fingerprint(touched) != mark,
|
||||
"a different size or time is a different download")
|
||||
|
||||
print("11. a completed download is moved into the managed library")
|
||||
original_render_pages = library.render_pages
|
||||
original_render_thumbnail = library.render_thumbnail
|
||||
original_first_text = library.first_text
|
||||
original_convert = library.convert_to_pdf
|
||||
|
||||
|
||||
def fake_render_pages(_pdf, directory, width=library.SLIDE_WIDTH,
|
||||
progress=None):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
(directory / "page-001.png").write_bytes(b"png")
|
||||
return 1, ""
|
||||
|
||||
|
||||
def fake_render_thumbnail(_source, destination,
|
||||
width=library.THUMBNAIL_WIDTH):
|
||||
destination.write_bytes(b"thumbnail")
|
||||
return True
|
||||
|
||||
|
||||
library.render_pages = fake_render_pages
|
||||
library.render_thumbnail = fake_render_thumbnail
|
||||
library.first_text = lambda _pdf, pages=2: ""
|
||||
|
||||
moved = download("Move me.pdf", contents="%PDF-1.4 moved")
|
||||
moved_contents = moved.read_bytes()
|
||||
managed = importer.import_one(moved, shelf)
|
||||
check("the source leaves Downloads after import", not moved.exists())
|
||||
check("the one original is kept in the managed entry",
|
||||
managed is not None and managed.managed_copy.read_bytes() == moved_contents,
|
||||
str(managed.managed_copy if managed else None))
|
||||
check("its PDF and page images are derived assets",
|
||||
managed is not None and managed.ready and managed.slides.exists()
|
||||
and managed.page_image(1).exists())
|
||||
|
||||
print("12. conversion failure does not lose the downloaded original")
|
||||
library.convert_to_pdf = lambda _source, _destination: (False, "broken deck")
|
||||
broken_source = download("Broken deck.pptx", contents="not really a deck")
|
||||
failed_import = importer.import_one(broken_source, shelf)
|
||||
check("the failed source was still moved out of Downloads",
|
||||
not broken_source.exists())
|
||||
check("a failed card retains the managed original",
|
||||
failed_import is not None and failed_import.failed
|
||||
and failed_import.managed_copy.read_text() == "not really a deck")
|
||||
check("the failure did not create a second original",
|
||||
failed_import is not None
|
||||
and list((failed_import.directory / "original").iterdir())
|
||||
== [failed_import.managed_copy])
|
||||
|
||||
print("13. a cross-filesystem move publishes before deleting")
|
||||
cross_source = download("Across filesystems.pdf", contents="cross-device")
|
||||
cross_destination = work / "other-device" / cross_source.name
|
||||
real_replace = importer.os.replace
|
||||
source_text = str(cross_source)
|
||||
destination_text = str(cross_destination)
|
||||
|
||||
|
||||
def cross_replace(source, destination):
|
||||
if os.fspath(source) == source_text and os.fspath(destination) == destination_text:
|
||||
raise OSError(errno.EXDEV, "different filesystems")
|
||||
return real_replace(source, destination)
|
||||
|
||||
|
||||
importer.os.replace = cross_replace
|
||||
try:
|
||||
importer.move_download(cross_source, cross_destination)
|
||||
finally:
|
||||
importer.os.replace = real_replace
|
||||
|
||||
check("the cross-filesystem destination is complete",
|
||||
cross_destination.read_text() == "cross-device")
|
||||
check("only the destination remains after it is durable",
|
||||
not cross_source.exists()
|
||||
and not cross_destination.with_name(cross_destination.name + ".moving").exists())
|
||||
|
||||
print("14. restart recovery never deletes the only moved original")
|
||||
resume_dir = shelf / "resume"
|
||||
resume_original = resume_dir / "original" / "Resume.pdf"
|
||||
resume_original.parent.mkdir(parents=True)
|
||||
resume_original.write_text("%PDF-1.4 resume")
|
||||
resume_data = {
|
||||
"id": "resume",
|
||||
"state": "importing",
|
||||
"title": "Resume",
|
||||
"original_name": "Resume.pdf",
|
||||
"source_path": str(downloads / "Resume.pdf"),
|
||||
"managed_copy": "original/Resume.pdf",
|
||||
"format": "pdf",
|
||||
"imported_at": library.stamp(),
|
||||
"last_presented_at": None,
|
||||
"times_presented": 0,
|
||||
"thumbnail_page": 1,
|
||||
"pages": 0,
|
||||
"error": None,
|
||||
}
|
||||
library.Presentation(resume_dir, resume_data).save()
|
||||
importer.sweep(shelf)
|
||||
resumed = library.load(resume_dir)
|
||||
check("an import interrupted after the move resumes in place",
|
||||
resumed is not None and resumed.ready)
|
||||
check("and its original survives recovery", resume_original.exists())
|
||||
|
||||
print("15. removal is isolated and refuses an active presentation")
|
||||
remove_one = entry(id="remove-one", title="Similar title")
|
||||
keep_one = entry(id="keep-one", title="Similar title (2)")
|
||||
(remove_one.directory / "original").mkdir()
|
||||
(remove_one.directory / "original" / "one.pdf").write_text("one")
|
||||
(keep_one.directory / "original").mkdir()
|
||||
(keep_one.directory / "original" / "two.pdf").write_text("two")
|
||||
check("removing one managed entry succeeds", library.remove(remove_one))
|
||||
check("a similarly named presentation and its files are untouched",
|
||||
keep_one.directory.exists()
|
||||
and (keep_one.directory / "original" / "two.pdf").exists())
|
||||
|
||||
runtime = work / "runtime"
|
||||
runtime.mkdir()
|
||||
old_runtime = os.environ.get("XDG_RUNTIME_DIR")
|
||||
os.environ["XDG_RUNTIME_DIR"] = str(runtime)
|
||||
library.presenting_lock().write_text(keep_one.id)
|
||||
check("an actively presented entry cannot be removed",
|
||||
not library.remove(keep_one) and keep_one.directory.exists())
|
||||
library.presenting_lock().unlink()
|
||||
if old_runtime is None:
|
||||
os.environ.pop("XDG_RUNTIME_DIR", None)
|
||||
else:
|
||||
os.environ["XDG_RUNTIME_DIR"] = old_runtime
|
||||
|
||||
library.render_pages = original_render_pages
|
||||
library.render_thumbnail = original_render_thumbnail
|
||||
library.first_text = original_first_text
|
||||
library.convert_to_pdf = original_convert
|
||||
|
||||
print("16. teacher-facing wording and compositor policy")
|
||||
ui = (root / "scripts" / "classroom-presentations").read_text()
|
||||
layout = (root / "config" / "plasma" / "lookandfeel" / "contents"
|
||||
/ "layouts" / "org.kde.plasma.desktop-layout.js").read_text()
|
||||
kwin = (root / "config" / "plasma" / "xdg" / "kwinrc").read_text()
|
||||
rules = (root / "config" / "plasma" / "xdg" / "kwinrulesrc").read_text()
|
||||
check("the empty state has one concise browser-neutral instruction",
|
||||
'label="No presentations yet\\n\\n"' in ui
|
||||
and "Download a PowerPoint or PDF to get started." in ui
|
||||
and "Download a presentation in Chrome" not in ui)
|
||||
check("cover selection uses the requested plain wording",
|
||||
'label="Use as cover"' in ui and 'label="Current cover"' in ui
|
||||
and "this is the library picture" not in ui)
|
||||
check("Remove no longer promises a copy in Chrome",
|
||||
"This will delete it from this computer." in ui
|
||||
and "Your download in Chrome" not in ui)
|
||||
check("the presenter has a compositor-enforced fullscreen rule",
|
||||
"title=Presenting" in rules and "fullscreen=true" in rules
|
||||
and "fullscreenrule=2" in rules)
|
||||
check("normal title bars contain only Close",
|
||||
"ButtonsOnRight=X" in kwin and "ButtonsOnRight=FX" not in kwin)
|
||||
check("the unfinished Help launcher is absent from the panel",
|
||||
"ekklesia-manual.desktop" not in layout)
|
||||
|
||||
print("17. running out of memory is a state a teacher can act on, never a loop")
|
||||
library.render_pages = fake_render_pages
|
||||
library.render_thumbnail = fake_render_thumbnail
|
||||
library.first_text = lambda _pdf, pages=2: ""
|
||||
# The kernel killing LibreOffice: SIGKILL directly, or 137 through the
|
||||
# soffice shell wrapper.
|
||||
check("a SIGKILL is recognised either way",
|
||||
library.killed(-9) and library.killed(137)
|
||||
and not library.killed(1) and not library.killed(0))
|
||||
|
||||
library.convert_to_pdf = lambda _source, _destination: (False, library.KILLED)
|
||||
heavy = download("Heavy deck.pptx", contents="photographs")
|
||||
starved = importer.import_one(heavy, shelf)
|
||||
check("a conversion killed for memory is a failed import",
|
||||
starved is not None and starved.failed, starved.data if starved else None)
|
||||
check("of the kind a teacher can try again",
|
||||
starved.failure == "memory" and starved.data["error_kind"] == "memory")
|
||||
check("and the downloaded original is kept",
|
||||
starved.managed_copy.read_text() == "photographs")
|
||||
check("the attempt was counted before the work began",
|
||||
starved.data.get("attempts") == 1 and starved.data.get("attempt_started_at"))
|
||||
check("a file that simply will not convert is a different failure",
|
||||
failed_import.failure == "unreadable")
|
||||
check("and cannot be tried again", not library.request_retry(failed_import))
|
||||
|
||||
# A service killed in the middle of an import, found by the next start.
|
||||
library.convert_to_pdf = original_convert
|
||||
|
||||
|
||||
def interrupted(name, attempts):
|
||||
directory = shelf / name
|
||||
(directory / "original").mkdir(parents=True)
|
||||
(directory / "original" / f"{name}.pdf").write_text("%PDF-1.4 " + name)
|
||||
library.Presentation(directory, {
|
||||
"id": name, "state": "importing", "title": name,
|
||||
"original_name": f"{name}.pdf", "source_path": str(downloads / f"{name}.pdf"),
|
||||
"managed_copy": f"original/{name}.pdf", "format": "pdf",
|
||||
"imported_at": library.stamp(), "last_presented_at": None,
|
||||
"times_presented": 0, "thumbnail_page": 1, "pages": 0, "error": None,
|
||||
"attempts": attempts,
|
||||
}).save()
|
||||
return directory
|
||||
|
||||
|
||||
once = interrupted("once", 1)
|
||||
twice = interrupted("twice", 2)
|
||||
importer.sweep(shelf)
|
||||
check("an import interrupted once is resumed",
|
||||
library.load(once).ready)
|
||||
gave_up = library.load(twice)
|
||||
check("an import interrupted twice is not tried a third time",
|
||||
gave_up.failed and gave_up.failure == "memory", gave_up.data)
|
||||
check("and its original is still there to try again from",
|
||||
(twice / "original" / "twice.pdf").exists())
|
||||
|
||||
check("Try again puts it back in the importer's hands",
|
||||
library.request_retry(gave_up) and library.load(twice).state == "retry")
|
||||
check("which counts afresh", library.load(twice).data.get("attempts") == 0)
|
||||
check("and the importer takes it up on its next pass",
|
||||
importer.retry_requested(shelf) == 1 and library.load(twice).ready)
|
||||
|
||||
# The importer not running at all: nothing will ever finish this one.
|
||||
stuck = interrupted("stuck", 0)
|
||||
entry_data = library.load(stuck).data
|
||||
long_ago = library.now() - timedelta(seconds=library.STALLED_SECONDS + 60)
|
||||
entry_data["attempt_started_at"] = library.stamp(long_ago)
|
||||
library.Presentation(stuck, entry_data).save()
|
||||
stuck_card = library.load(stuck)
|
||||
check("an import nobody is working on stops saying Getting this ready",
|
||||
stuck_card.stalled and stuck_card.failure == "memory")
|
||||
check("and offers Try again", library.request_retry(stuck_card))
|
||||
check("a fresh import is not mistaken for a stalled one",
|
||||
not library.load(once).stalled and library.load(once).failure is None)
|
||||
|
||||
held = importer.hold_lock(shelf)
|
||||
check("one importer holds the lock", held is not None)
|
||||
check("a second importer is refused rather than run beside it",
|
||||
importer.hold_lock(shelf) is None)
|
||||
held.close()
|
||||
check("and it is free again once the first has gone",
|
||||
importer.hold_lock(shelf) is not None)
|
||||
check("the lock file is not a presentation",
|
||||
all(p.id != ".importer.lock" for p in library.library(shelf)))
|
||||
|
||||
unit = (root / "systemd" / "user" / "ekklesia-classroom-presentations.service").read_text()
|
||||
check("the service may use at most 40% of the machine's memory",
|
||||
"MemoryMax=40%" in unit)
|
||||
check("a killed converter does not take the watcher with it",
|
||||
"OOMPolicy=continue" in unit)
|
||||
check("and under pressure it goes before the teacher's windows",
|
||||
"OOMScoreAdjust=800" in unit)
|
||||
check("the card says what to do, in a teacher's words",
|
||||
"Close other programs, then choose" in ui and 'label="Try again"' in ui)
|
||||
|
||||
library.render_pages = original_render_pages
|
||||
library.render_thumbnail = original_render_thumbnail
|
||||
library.first_text = original_first_text
|
||||
library.convert_to_pdf = original_convert
|
||||
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
print()
|
||||
print(f"{passed} passed, {failed} failed")
|
||||
raise SystemExit(1 if failed else 0)
|
||||
PY
|
||||
@@ -0,0 +1,319 @@
|
||||
#!/bin/bash
|
||||
# Wonder Ink's printing: which printers, which options, what is sent.
|
||||
#
|
||||
# CUPS is not on the development host, so lpstat, lpoptions, lp and
|
||||
# pdfinfo are stand-ins that answer with the output the real ones gave in
|
||||
# the lab — Elmer's own PPD options included. Whether a job really goes
|
||||
# through Elmer's filters is tools/lab/wonderink-print-check's question.
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
mkdir -p "$WORK/bin"
|
||||
|
||||
cat > "$WORK/bin/lpstat" <<EOF
|
||||
#!/bin/bash
|
||||
[[ -f "$WORK/cups-down" ]] && { echo "lpstat: Scheduler is not running." >&2; exit 1; }
|
||||
case "\$1" in
|
||||
-e) cat "$WORK/printers" 2>/dev/null ;;
|
||||
-d) cat "$WORK/default" 2>/dev/null || echo "no system default destination" ;;
|
||||
esac
|
||||
EOF
|
||||
|
||||
# lpoptions -p NAME -l, as the real one printed it for Elmer's PPD.
|
||||
cat > "$WORK/bin/lpoptions" <<EOF
|
||||
#!/bin/bash
|
||||
cat "$WORK/options-\$2" 2>/dev/null
|
||||
EOF
|
||||
|
||||
cat > "$WORK/bin/lp" <<EOF
|
||||
#!/bin/bash
|
||||
printf '%s\n' "\$@" > "$WORK/lp-args"
|
||||
if [[ -f "$WORK/lp-refuses" ]]; then
|
||||
echo 'lp: Destination "Elmer" is not accepting jobs.' >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "request id is Elmer-42 (1 file(s))"
|
||||
EOF
|
||||
|
||||
cat > "$WORK/bin/pdfinfo" <<'EOF'
|
||||
#!/bin/bash
|
||||
[[ "$(head -c 5 "$1" 2>/dev/null)" == "%PDF-" ]] || { echo "Syntax Error" >&2; exit 1; }
|
||||
echo "Producer: test"
|
||||
echo "Pages: $(grep -c '^page' "$1")"
|
||||
EOF
|
||||
chmod +x "$WORK"/bin/*
|
||||
|
||||
printf 'Elmer\n' > "$WORK/printers"
|
||||
echo "system default destination: Elmer" > "$WORK/default"
|
||||
cat > "$WORK/options-Elmer" <<'EOF'
|
||||
PrintoutMode/Print Quality: Draft Draft.Gray *Normal Normal.Gray High High.Gray
|
||||
PageSize/Page Size: Custom.WIDTHxHEIGHT *Letter A4 11x17 A3 A5 B5 Env10 EnvC5 EnvDL EnvISOB5 EnvMonarch Executive Legal
|
||||
Duplex/Double-Sided Printing: DuplexNoTumble DuplexTumble *None
|
||||
ColorModel/Color Mode: FromPrintoutMode *Color Grayscale
|
||||
PrinterResolution/Resolution: *FromPrintoutMode 300x300dpi 600x600dpi 1200x600dpi 1200x1200dpi
|
||||
EOF
|
||||
cat > "$WORK/options-Office" <<'EOF'
|
||||
PageSize/Media Size: Letter *A4 Legal
|
||||
EOF
|
||||
|
||||
PATH="$WORK/bin:$PATH" WORK="$WORK" PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import classroom_printing as printing
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
work = Path(os.environ["WORK"])
|
||||
passed = failed = 0
|
||||
|
||||
|
||||
def check(label, condition, detail=""):
|
||||
global passed, failed
|
||||
if condition:
|
||||
print(f" ok {label}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" FAIL {label}")
|
||||
if detail:
|
||||
print(f" {detail}")
|
||||
failed += 1
|
||||
|
||||
|
||||
def lp_args():
|
||||
return (work / "lp-args").read_text().splitlines()
|
||||
|
||||
|
||||
print("1. which printers a teacher is offered")
|
||||
check("Elmer, and Elmer is the default", printing.printers() == (["Elmer"], "Elmer"))
|
||||
(work / "printers").write_text("Elmer\nOffice\n")
|
||||
(work / "default").write_text("system default destination: Office\n")
|
||||
check("the system default is preselected, whichever it is",
|
||||
printing.printers() == (["Elmer", "Office"], "Office"))
|
||||
(work / "default").write_text("no system default destination\n")
|
||||
check("with no default, the first printer",
|
||||
printing.printers() == (["Elmer", "Office"], "Elmer"))
|
||||
(work / "default").write_text("system default destination: Gone\n")
|
||||
check("a default that is not in the list is not offered",
|
||||
printing.printers() == (["Elmer", "Office"], "Elmer"))
|
||||
(work / "printers").write_text("")
|
||||
check("no printers at all is an empty list, not an error",
|
||||
printing.printers() == ([], None))
|
||||
(work / "cups-down").touch()
|
||||
check("and so is CUPS not running", printing.printers() == ([], None))
|
||||
(work / "cups-down").unlink()
|
||||
(work / "printers").write_text("Elmer\n")
|
||||
(work / "default").write_text("system default destination: Elmer\n")
|
||||
|
||||
print()
|
||||
print("2. what each printer's driver offers")
|
||||
elmer = printing.options("Elmer")
|
||||
check("Elmer's paper is Letter", elmer["paper"] == "Letter", elmer)
|
||||
# Measured: this driver ignores print-color-mode and prints in colour.
|
||||
check("black and white is asked for in the driver's own terms",
|
||||
elmer["gray"] == ("ColorModel", "Grayscale"), elmer)
|
||||
office = printing.options("Office")
|
||||
check("an A4 printer is A4", office["paper"] == "A4", office)
|
||||
check("a printer with no grey choice offers none", office["gray"] is None, office)
|
||||
unknown = printing.options("Nobody")
|
||||
check("a printer that says nothing is Letter, colour only",
|
||||
unknown == {"paper": "Letter", "gray": None}, unknown)
|
||||
|
||||
print()
|
||||
print("3. a teacher's page list")
|
||||
for text, expected in (("", None), ("all", None), (" ", None), ("2", "2"),
|
||||
("1-3", "1-3"), ("1, 3, 5-6", "1,3,5-6"), ("3-3", "3"),
|
||||
("1 - 2", "1-2"), ("6", "6")):
|
||||
try:
|
||||
got = printing.parse_pages(text, 6)
|
||||
except ValueError as error:
|
||||
got = f"refused: {error}"
|
||||
check(f"{text!r} is {expected!r}", got == expected, got)
|
||||
|
||||
for text in ("7", "0", "3-1", "1-9", "one", "1;lp -d x", "-2", "1--2", "2,", "1.5"):
|
||||
try:
|
||||
printing.parse_pages(text, 6)
|
||||
check(f"{text!r} is refused", False)
|
||||
except ValueError as error:
|
||||
check(f"{text!r} is refused, and says why: {error}", bool(str(error)))
|
||||
|
||||
print()
|
||||
print("4. what is handed to CUPS")
|
||||
pdf = work / "Colour the Ark.pdf"
|
||||
pdf.write_text("%PDF-1.4\npage\npage\n")
|
||||
check("a PDF with two pages has two", printing.page_count(pdf) == 2)
|
||||
check("something that is not a PDF has none",
|
||||
printing.page_count(work / "printers") == 0)
|
||||
|
||||
ok, answer = printing.submit(pdf, "Elmer")
|
||||
args = lp_args()
|
||||
check("a job is accepted, with its id", (ok, answer) == (True, "Elmer-42"), answer)
|
||||
check("to Elmer, one copy, named for the lesson",
|
||||
args[:6] == ["-d", "Elmer", "-n", "1", "-t", "Wonder Ink"], args)
|
||||
check("the file comes after --, so no name can be read as an option",
|
||||
args[-2:] == ["--", str(pdf)], args)
|
||||
check("and nothing else is asked for", "-P" not in args and "-o" not in args, args)
|
||||
|
||||
printing.submit(pdf, "Elmer", copies=3, pages="2-3", gray=("ColorModel", "Grayscale"),
|
||||
title="Noah")
|
||||
args = lp_args()
|
||||
check("copies, pages and black and white arrive as lp options",
|
||||
args[args.index("-n") + 1] == "3"
|
||||
and args[args.index("-P") + 1] == "2-3"
|
||||
and args[args.index("-o") + 1] == "ColorModel=Grayscale"
|
||||
and args[args.index("-t") + 1] == "Noah", args)
|
||||
|
||||
printing.submit(pdf, "Elmer", copies=500)
|
||||
check(f"copies are capped at {printing.MAX_COPIES}",
|
||||
lp_args()[lp_args().index("-n") + 1] == str(printing.MAX_COPIES))
|
||||
printing.submit(pdf, "Elmer", copies=0)
|
||||
check("and never fewer than one", lp_args()[lp_args().index("-n") + 1] == "1")
|
||||
|
||||
(work / "lp-refuses").touch()
|
||||
ok, answer = printing.submit(pdf, "Elmer")
|
||||
check("a refused job is a failure, in CUPS's own words",
|
||||
not ok and answer == 'Destination "Elmer" is not accepting jobs.', answer)
|
||||
|
||||
print()
|
||||
print("4b. where a picture goes on the paper")
|
||||
LETTER, MARGIN = (612, 792), 28.35
|
||||
area = (MARGIN, MARGIN, 612 - 2 * MARGIN, 792 - 2 * MARGIN)
|
||||
close = lambda a, b: all(abs(x - y) < 0.01 for x, y in zip(a, b))
|
||||
|
||||
x, y, w, h = printing.picture_rect(LETTER, MARGIN, (1600, 1000))
|
||||
check("a wide picture fits the width and keeps its shape",
|
||||
abs(w - area[2]) < 0.01 and abs(w / h - 1.6) < 0.001, (x, y, w, h))
|
||||
check("and is centred on the page", abs(y + h / 2 - 396) < 0.01)
|
||||
check("nothing of it falls outside the margins",
|
||||
x >= MARGIN - 0.01 and y >= MARGIN - 0.01
|
||||
and x + w <= 612 - MARGIN + 0.01 and y + h <= 792 - MARGIN + 0.01)
|
||||
|
||||
x, y, w, h = printing.picture_rect(LETTER, MARGIN, (1600, 1000), 50, "top left")
|
||||
check("50% in the top left is half the fitted size, at the margin",
|
||||
close((x, y, w), (MARGIN, MARGIN, area[2] / 2)), (x, y, w, h))
|
||||
x, y, w, h = printing.picture_rect(LETTER, MARGIN, (1600, 1000), 25, "bottom right")
|
||||
check("25% in the bottom right ends at the margin",
|
||||
close((x + w, y + h), (612 - MARGIN, 792 - MARGIN)), (x, y, w, h))
|
||||
x, y, w, h = printing.picture_rect(LETTER, MARGIN, (900, 1400), 100, "right")
|
||||
check("a tall picture fits the height and sits at the right",
|
||||
abs(h - area[3]) < 0.01 and abs(x + w - (612 - MARGIN)) < 0.01, (x, y, w, h))
|
||||
|
||||
x, y, w, h = printing.picture_rect(LETTER, MARGIN, (1600, 1000), fill=True)
|
||||
check("fill covers the whole printable area",
|
||||
x <= MARGIN and y <= MARGIN + 0.01 and x + w >= 612 - MARGIN and y + h >= 792 - MARGIN - 0.01)
|
||||
check("and keeps the picture's shape, cutting off what overhangs",
|
||||
abs(w / h - 1.6) < 0.001 and w > area[2])
|
||||
check("fill ignores size and position",
|
||||
printing.picture_rect(LETTER, MARGIN, (1600, 1000), 25, "top left", True)
|
||||
== printing.picture_rect(LETTER, MARGIN, (1600, 1000), fill=True))
|
||||
check("an unknown position is the centre",
|
||||
printing.picture_rect(LETTER, MARGIN, (1600, 1000), 50, "upside down")
|
||||
== printing.picture_rect(LETTER, MARGIN, (1600, 1000), 50, "centre"))
|
||||
try:
|
||||
printing.picture_rect(LETTER, MARGIN, (0, 10))
|
||||
check("a picture with no size is refused", False)
|
||||
except ValueError:
|
||||
check("a picture with no size is refused", True)
|
||||
|
||||
check("automatic paper is landscape for a wide picture",
|
||||
printing.picture_landscape("auto", 1600, 1000))
|
||||
check("and portrait for a tall or square one",
|
||||
not printing.picture_landscape("auto", 900, 1400)
|
||||
and not printing.picture_landscape("auto", 1000, 1000))
|
||||
check("and the teacher's choice overrides it",
|
||||
not printing.picture_landscape("portrait", 1600, 1000)
|
||||
and printing.picture_landscape("landscape", 900, 1400))
|
||||
check("every position is on a three by three grid",
|
||||
len(printing.PICTURE_POSITIONS) == 9
|
||||
and sorted(printing.PICTURE_POSITIONS.values()) == [(c, r) for c in range(3) for r in range(3)])
|
||||
|
||||
print()
|
||||
print("4c. black and white, made before the preview")
|
||||
command = printing.grayscale_command(work / "in.pdf", work / "out gray.pdf")
|
||||
check("Ghostscript writes a PDF", command[0] == "gs" and "-sDEVICE=pdfwrite" in command, command)
|
||||
check("in grey and nothing else",
|
||||
"-sColorConversionStrategy=Gray" in command and "-dProcessColorModel=/DeviceGray" in command, command)
|
||||
# pdfwrite otherwise turns a page to match its text; a landscape picture
|
||||
# on a portrait sheet would come back turned.
|
||||
check("never turning a page", "-dAutoRotatePages=/None" in command, command)
|
||||
check("annotations drawn in as they print, not left for a filter to draw in colour",
|
||||
"-dPreserveAnnots=false" in command and "-dPrinted" in command, command)
|
||||
check("safely, and without stopping to ask",
|
||||
"-dSAFER" in command and "-dBATCH" in command and "-dNOPAUSE" in command, command)
|
||||
check("to the file asked for, even with a space in its name",
|
||||
f"-sOutputFile={work / 'out gray.pdf'}" in command, command)
|
||||
check("and the source after --, so no name can be read as an option",
|
||||
command[-2:] == ["--", str(work / "in.pdf")], command)
|
||||
|
||||
print()
|
||||
print("5. Wonder Ink prints through this, and only this")
|
||||
ink = (root / "scripts/classroom-wonderink").read_text()
|
||||
code = "\n".join(line for line in ink.splitlines() if not line.lstrip().startswith("#"))
|
||||
# The 0.2.2 crash, and the blank worksheet, both came from Qt's print path.
|
||||
check("no QPrinter", "QPrinter(" not in code and "QPrintDialog" not in code)
|
||||
check("no QWebEngineView.print()", not re.search(r"\bview\.print\(", code))
|
||||
check("a lesson page becomes a PDF by printToPdf", "printToPdf(" in code)
|
||||
check("which is given up on if the engine never answers",
|
||||
"def _wait(self, message, too_long, timeout=PREPARE_TIMEOUT_MS):" in code
|
||||
and "self._timer.start(timeout)" in code)
|
||||
check("black and white has longer, since a page of photographs is slow, not stuck",
|
||||
"GRAY_TIMEOUT_MS = 60_000" in code and " GRAY_TIMEOUT_MS,\n" in code)
|
||||
check("the preview submits with classroom_printing.submit",
|
||||
"classroom_printing.submit(" in code)
|
||||
check("there is no file chooser in it", "QFileDialog" not in code)
|
||||
check("a worksheet tab is printed from its file",
|
||||
"self._pdf_views[view] = resolved" in code)
|
||||
check("an inline PDF is fetched as a file first", "view.page().download(view.url())" in code)
|
||||
check("a page's background is not printed, as in Chrome",
|
||||
"PrintElementBackgrounds, False" in code)
|
||||
check("a picture on its own is laid out, not printed as the viewer page",
|
||||
"dialog.show_picture(image)" in code and "document.contentType" in ink)
|
||||
preview_code = code[code.index("class PrintPreview"):code.index("\ndef notify")]
|
||||
check("the print interface says Color, not Colour",
|
||||
'"Color"' in preview_code and '"Colour"' not in preview_code)
|
||||
check("an exception in a handler is logged, not fatal",
|
||||
"sys.excepthook = keep_going" in code)
|
||||
|
||||
# 0.2.6.3: black and white is shown as it prints.
|
||||
check("black and white is made by classroom_printing.grayscale_command, in the background",
|
||||
"classroom_printing.grayscale_command(" in preview_code and "QProcess(" in preview_code)
|
||||
check("choosing Color or Black and white lays the preview out again",
|
||||
"self._colour.currentIndexChanged.connect" in preview_code)
|
||||
check("a failed conversion is judged by its exit and its pages, never printed in colour",
|
||||
"process.exitCode() != 0" in preview_code
|
||||
and "page_count(target) != expected" in preview_code)
|
||||
check("the driver is still told ColorModel=Grayscale for the grey file",
|
||||
'gray = self._options[printer]["gray"]' in preview_code)
|
||||
check("and nothing prints that is not what is shown",
|
||||
"(self._gray is not None and self._pdf == self._gray[1]) != (gray is not None)" in preview_code)
|
||||
|
||||
# 0.2.6.3: right-click says what it prints.
|
||||
menu_code = code[code.index(" def contextMenuEvent"):code.index("\nclass WonderInk")]
|
||||
check("right-click offers Print page always",
|
||||
'"Print page…"' in menu_code and "print_view(self)" in menu_code)
|
||||
check("and Print image only where the engine's hit test found a picture or a canvas",
|
||||
'"Print image…"' in menu_code and "MediaTypeImage" in menu_code
|
||||
and "MediaTypeCanvas" in menu_code and "print_image(self)" in menu_code)
|
||||
check("the old single Print… is gone from the menu", '"Print…"' not in menu_code)
|
||||
take_code = code[code.index(" def _take_picture"):code.index(" def _show_preview")]
|
||||
check("the picture is the engine's own copy, not fetched again",
|
||||
"WebAction.CopyImageToClipboard" in take_code
|
||||
and "download(" not in take_code and "QNetwork" not in take_code)
|
||||
check("what was on the clipboard is kept and put back",
|
||||
"keep_clipboard(clipboard)" in take_code and "put_back()" in take_code)
|
||||
check("a picture that never arrives is said so, not printed as the page",
|
||||
"TAKE_PICTURE_MS" in take_code and "dialog.fail(" in take_code
|
||||
and "print_view" not in take_code)
|
||||
check("and it goes to the picture layout, on white",
|
||||
"dialog.show_picture(printable_picture(image))" in take_code
|
||||
and "Qt.GlobalColor.white" in code)
|
||||
check("the module is packaged",
|
||||
"lib/classroom_printing.py" in (root / "tools/build-release.sh").read_text())
|
||||
|
||||
print()
|
||||
print(f"{passed} passed, {failed} failed")
|
||||
sys.exit(1 if failed else 0)
|
||||
PY
|
||||