feat: give the classroom a desktop instead of a shell

The whole appliance UI is now configuration for a desktop Debian already
ships: a Plasma 6.3 session with a colour scheme, a look and feel package
that builds the bar, window rules, and KDE's own Kiosk restrictions.

Two things were measured rather than assumed, and both changed a decision.

Plasma restarts a crashed panel three times a minute and then gives up,
which is the failure a classroom has already had. Four lines of systemd
drop-in make it unconditional; the panel then survived eight SIGKILLs a
second apart, back each time in 2.4s.

Plasma draws a minimised task and a background task identically, so the
five states the room needs cannot all be shown. Rather than write a task
manager, the classroom removes minimise: clicking a launcher always means
show me this.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-10 08:35:12 -05:00
co-authored by Claude Opus 5
parent bfc7caf28f
commit 87b55761c6
51 changed files with 1352 additions and 36 deletions
@@ -2,25 +2,20 @@
"DefaultBrowserSettingEnabled": false, "DefaultBrowserSettingEnabled": false,
"PromotionalTabsEnabled": false, "PromotionalTabsEnabled": false,
"MetricsReportingEnabled": false, "MetricsReportingEnabled": false,
"BrowserSignin": 0, "BrowserSignin": 0,
"SyncDisabled": true, "SyncDisabled": true,
"SigninInterceptionEnabled": false, "SigninInterceptionEnabled": false,
"BrowserAddPersonEnabled": false, "BrowserAddPersonEnabled": false,
"BrowserGuestModeEnabled": false, "BrowserGuestModeEnabled": false,
"ProfilePickerOnStartupAvailability": 1, "ProfilePickerOnStartupAvailability": 1,
"PasswordManagerEnabled": false, "PasswordManagerEnabled": false,
"PasswordLeakDetectionEnabled": false, "PasswordLeakDetectionEnabled": false,
"BackgroundModeEnabled": false, "BackgroundModeEnabled": false,
"DisablePrintPreview": true, "DisablePrintPreview": true,
"CookiesSessionOnlyForUrls": [ "CookiesSessionOnlyForUrls": [
"[*.]wonderink.org" "[*.]wonderink.org"
], ],
"ShowHomeButton": true, "ShowHomeButton": true,
"HomepageIsNewTabPage": false, "HomepageIsNewTabPage": false,
"HomepageLocation": "https://app.wonderink.org/" "HomepageLocation": "https://www.youtube.com/"
} }
@@ -1,6 +1,6 @@
{ {
"RestoreOnStartup": 4, "RestoreOnStartup": 4,
"RestoreOnStartupURLs": [ "RestoreOnStartupURLs": [
"https://app.wonderink.org/" "https://www.youtube.com/"
] ]
} }
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Parent Call.
Deliberately not a colour tile. Parent Call is not one of the room's
applications and does not sit in the row with them, so it is drawn in
the interface's own accent as a line mark: present, reachable, and
visibly a different kind of thing from Wonder Ink and the browser. -->
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<!-- Inset so the mark has the same optical size as the filled tiles
beside it in the bar; an outline drawn to the edges reads larger. -->
<g transform="translate(64 64) scale(0.62) translate(-64 -64)"
fill="none" stroke="#4FB3A8" stroke-width="8"
stroke-linecap="round" stroke-linejoin="round">
<path d="M40 58a24 24 0 0 1 48 0c0 18 6 24 9 28H31c3-4 9-10 9-28z"/>
<path d="M55 94a9 9 0 0 0 18 0"/>
<path d="M64 26v-8"/>
<path d="M100 30a30 30 0 0 1 8 20"/>
<path d="M28 30a30 30 0 0 0-8 20"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 992 B

+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Wonder Ink.
A stand-in for the product's own mark, which the classroom does not
have a licensed asset for. It is a filled colour tile rather than a
line drawing on purpose: the room already learned, the hard way, that
a monochrome glyph on a bar is read as a decoration and not as an
application. Somebody asked whether the old browser glyph meant "the
Internet". A tile with its own colour does not get that question. -->
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<defs>
<linearGradient id="tile" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#4FB3A8"/>
<stop offset="0.55" stop-color="#3E93A8"/>
<stop offset="1" stop-color="#7C6FC4"/>
</linearGradient>
</defs>
<rect x="6" y="6" width="116" height="116" rx="28" fill="url(#tile)"/>
<path d="M64 30c0 0 26 28 26 44a26 26 0 0 1-52 0c0-16 26-44 26-44z"
fill="#FFFFFF" fill-opacity="0.94"/>
<path d="M50 76a14 14 0 0 0 10 13" stroke="#3E93A8" stroke-width="5"
stroke-linecap="round" fill="none" opacity="0.55"/>
<g fill="#FFFFFF" opacity="0.9">
<path d="M96 34l2.6 6.4L105 43l-6.4 2.6L96 52l-2.6-6.4L87 43l6.4-2.6z"/>
<circle cx="34" cy="40" r="3.2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,25 @@
[Desktop Entry]
# The room's browser, installed to /usr/local/share/applications, which
# XDG_DATA_DIRS searches before /usr/share. Chrome's own entry is left
# where Chrome's package put it and is simply never the one that is found.
#
# It has to carry Chrome's file name rather than a name of our own. Plasma
# matches a window to its launcher by the application id the window
# reports, and Chrome reports "google-chrome". A launcher under any other
# name gives a teacher two buttons for one browser: a launcher that always
# looks closed, and a window that appears beside it. That was measured, not
# assumed — it is what the first build did.
Type=Application
Name=Browser
GenericName=Web browser
Comment=YouTube and the rest of the web
Icon=google-chrome
Exec=/usr/local/bin/classroom-browser %U
Terminal=false
Categories=Network;WebBrowser;
StartupNotify=true
StartupWMClass=google-chrome
MimeType=text/html;x-scheme-handler/http;x-scheme-handler/https;
# Chrome's own entry offers a new window and an incognito window from the
# launcher's right-click menu. Neither belongs in a shared room.
Actions=
@@ -0,0 +1,13 @@
[Desktop Entry]
Type=Application
Name=DVD
Comment=Play the disc in the drive
Exec=/usr/local/bin/classroom-dvd
Icon=media-optical-dvd
Terminal=false
Categories=AudioVideo;Player;
StartupNotify=true
StartupWMClass=vlc
# Never in a menu or a launcher by itself. The DVD appears when a disc is
# in the drive, through Plasma's own device notifier, and goes away with it.
NoDisplay=true
@@ -0,0 +1,10 @@
[Desktop Entry]
Type=Application
Name=Parent Call
Comment=Ask for a child's parent to come to the room
Exec=/usr/local/bin/classroom-parent-call
Icon=ekklesia-parent-call
Terminal=false
Categories=Utility;
StartupNotify=false
NoDisplay=false
@@ -0,0 +1,15 @@
[Desktop Entry]
Type=Application
Name=Wonder Ink
GenericName=Lesson
Comment=The Wonder Ink lesson for this room
Exec=/usr/local/bin/classroom-wonderink
Icon=ekklesia-wonderink
Terminal=false
Categories=Education;
StartupNotify=true
# How the panel knows that the window on screen belongs to this launcher.
# Chrome derives an app id from the --app URL, and this must match it
# exactly or the launcher and the window become two separate buttons.
StartupWMClass=chrome-app.wonderink.org__-Default
X-GNOME-SingleWindow=true
+155
View File
@@ -0,0 +1,155 @@
# Ekklesia Classroom — dark. The default.
#
# One set of tokens for the whole session: Plasma reads this file for the
# panel, the notifications, the window decorations and every Qt dialog, so
# there is one place to change a colour rather than a stylesheet per surface.
#
# The hues come from the classroom wallpaper — sage, teal, blue, lavender —
# so the desktop sits on the landscape rather than in front of it.
[General]
Name=Ekklesia Classroom Dark
ColorScheme=EkklesiaDark
shadeSortColumn=true
accentActiveTitlebar=false
[ColorEffects:Disabled]
ChangeSelectionColor=
Color=56,66,71
ColorAmount=0
ColorEffect=0
ContrastAmount=0.5
ContrastEffect=1
Enable=
IntensityAmount=0
IntensityEffect=0
[ColorEffects:Inactive]
ChangeSelectionColor=true
Color=112,111,110
ColorAmount=0.025
ColorEffect=2
ContrastAmount=0.1
ContrastEffect=2
Enable=false
IntensityAmount=0
IntensityEffect=0
[Colors:Button]
BackgroundAlternate=30,42,47
BackgroundNormal=25,35,40
DecorationFocus=79,179,168
DecorationHover=79,179,168
ForegroundActive=79,179,168
ForegroundInactive=147,165,170
ForegroundLink=126,196,255
ForegroundNegative=227,106,106
ForegroundNeutral=224,177,92
ForegroundNormal=230,237,239
ForegroundPositive=111,207,151
ForegroundVisited=156,138,222
[Colors:Selection]
BackgroundAlternate=79,179,168
BackgroundNormal=79,179,168
DecorationFocus=79,179,168
DecorationHover=79,179,168
ForegroundActive=14,21,24
ForegroundInactive=14,21,24
ForegroundLink=14,21,24
ForegroundNegative=14,21,24
ForegroundNeutral=14,21,24
ForegroundNormal=10,16,18
ForegroundPositive=14,21,24
ForegroundVisited=14,21,24
[Colors:Tooltip]
BackgroundAlternate=25,35,40
BackgroundNormal=20,28,32
DecorationFocus=79,179,168
DecorationHover=79,179,168
ForegroundActive=79,179,168
ForegroundInactive=147,165,170
ForegroundLink=126,196,255
ForegroundNegative=227,106,106
ForegroundNeutral=224,177,92
ForegroundNormal=230,237,239
ForegroundPositive=111,207,151
ForegroundVisited=156,138,222
[Colors:View]
BackgroundAlternate=20,28,32
BackgroundNormal=15,23,26
DecorationFocus=79,179,168
DecorationHover=79,179,168
ForegroundActive=79,179,168
ForegroundInactive=147,165,170
ForegroundLink=126,196,255
ForegroundNegative=227,106,106
ForegroundNeutral=224,177,92
ForegroundNormal=230,237,239
ForegroundPositive=111,207,151
ForegroundVisited=156,138,222
[Colors:Window]
BackgroundAlternate=25,35,40
BackgroundNormal=20,28,32
DecorationFocus=79,179,168
DecorationHover=79,179,168
ForegroundActive=79,179,168
ForegroundInactive=147,165,170
ForegroundLink=126,196,255
ForegroundNegative=227,106,106
ForegroundNeutral=224,177,92
ForegroundNormal=230,237,239
ForegroundPositive=111,207,151
ForegroundVisited=156,138,222
[Colors:Complementary]
BackgroundAlternate=25,35,40
BackgroundNormal=20,28,32
DecorationFocus=79,179,168
DecorationHover=79,179,168
ForegroundActive=79,179,168
ForegroundInactive=147,165,170
ForegroundLink=126,196,255
ForegroundNegative=227,106,106
ForegroundNeutral=224,177,92
ForegroundNormal=230,237,239
ForegroundPositive=111,207,151
ForegroundVisited=156,138,222
[Colors:Header]
BackgroundAlternate=25,35,40
BackgroundNormal=18,26,30
DecorationFocus=79,179,168
DecorationHover=79,179,168
ForegroundActive=79,179,168
ForegroundInactive=147,165,170
ForegroundLink=126,196,255
ForegroundNegative=227,106,106
ForegroundNeutral=224,177,92
ForegroundNormal=230,237,239
ForegroundPositive=111,207,151
ForegroundVisited=156,138,222
[Colors:Header][Inactive]
BackgroundAlternate=20,28,32
BackgroundNormal=16,23,26
DecorationFocus=79,179,168
DecorationHover=79,179,168
ForegroundActive=79,179,168
ForegroundInactive=125,142,147
ForegroundLink=126,196,255
ForegroundNegative=227,106,106
ForegroundNeutral=224,177,92
ForegroundNormal=170,186,191
ForegroundPositive=111,207,151
ForegroundVisited=156,138,222
[WM]
activeBackground=18,26,30
activeBlend=79,179,168
activeForeground=230,237,239
inactiveBackground=16,23,26
inactiveBlend=147,165,170
inactiveForeground=147,165,170
+158
View File
@@ -0,0 +1,158 @@
# Ekklesia Classroom — light.
#
# GENERATED from EkklesiaDark.colors by tools/build-light-scheme.
# Edit the dark scheme and regenerate; do not edit this file by hand.
#
# One set of tokens for the whole session: Plasma reads this file for the
# panel, the notifications, the window decorations and every Qt dialog, so
# there is one place to change a colour rather than a stylesheet per surface.
#
# The hues come from the classroom wallpaper — sage, teal, blue, lavender —
# so the desktop sits on the landscape rather than in front of it.
[General]
Name=Ekklesia Classroom Light
ColorScheme=EkklesiaLight
shadeSortColumn=true
accentActiveTitlebar=false
[ColorEffects:Disabled]
ChangeSelectionColor=
Color=56,66,71
ColorAmount=0
ColorEffect=0
ContrastAmount=0.5
ContrastEffect=1
Enable=
IntensityAmount=0
IntensityEffect=0
[ColorEffects:Inactive]
ChangeSelectionColor=true
Color=112,111,110
ColorAmount=0.025
ColorEffect=2
ContrastAmount=0.1
ContrastEffect=2
Enable=false
IntensityAmount=0
IntensityEffect=0
[Colors:Button]
BackgroundAlternate=225,231,230
BackgroundNormal=233,238,237
DecorationFocus=46,140,130
DecorationHover=46,140,130
ForegroundActive=46,140,130
ForegroundInactive=96,116,122
ForegroundLink=26,110,180
ForegroundNegative=192,57,57
ForegroundNeutral=166,116,24
ForegroundNormal=22,35,42
ForegroundPositive=38,142,86
ForegroundVisited=104,80,180
[Colors:Selection]
BackgroundAlternate=46,140,130
BackgroundNormal=46,140,130
DecorationFocus=46,140,130
DecorationHover=46,140,130
ForegroundActive=255,255,255
ForegroundInactive=255,255,255
ForegroundLink=255,255,255
ForegroundNegative=255,255,255
ForegroundNeutral=255,255,255
ForegroundNormal=255,255,255
ForegroundPositive=255,255,255
ForegroundVisited=255,255,255
[Colors:Tooltip]
BackgroundAlternate=233,238,237
BackgroundNormal=242,245,244
DecorationFocus=46,140,130
DecorationHover=46,140,130
ForegroundActive=46,140,130
ForegroundInactive=96,116,122
ForegroundLink=26,110,180
ForegroundNegative=192,57,57
ForegroundNeutral=166,116,24
ForegroundNormal=22,35,42
ForegroundPositive=38,142,86
ForegroundVisited=104,80,180
[Colors:View]
BackgroundAlternate=242,245,244
BackgroundNormal=255,255,255
DecorationFocus=46,140,130
DecorationHover=46,140,130
ForegroundActive=46,140,130
ForegroundInactive=96,116,122
ForegroundLink=26,110,180
ForegroundNegative=192,57,57
ForegroundNeutral=166,116,24
ForegroundNormal=22,35,42
ForegroundPositive=38,142,86
ForegroundVisited=104,80,180
[Colors:Window]
BackgroundAlternate=233,238,237
BackgroundNormal=242,245,244
DecorationFocus=46,140,130
DecorationHover=46,140,130
ForegroundActive=46,140,130
ForegroundInactive=96,116,122
ForegroundLink=26,110,180
ForegroundNegative=192,57,57
ForegroundNeutral=166,116,24
ForegroundNormal=22,35,42
ForegroundPositive=38,142,86
ForegroundVisited=104,80,180
[Colors:Complementary]
BackgroundAlternate=233,238,237
BackgroundNormal=242,245,244
DecorationFocus=46,140,130
DecorationHover=46,140,130
ForegroundActive=46,140,130
ForegroundInactive=96,116,122
ForegroundLink=26,110,180
ForegroundNegative=192,57,57
ForegroundNeutral=166,116,24
ForegroundNormal=22,35,42
ForegroundPositive=38,142,86
ForegroundVisited=104,80,180
[Colors:Header]
BackgroundAlternate=233,238,237
BackgroundNormal=236,241,240
DecorationFocus=46,140,130
DecorationHover=46,140,130
ForegroundActive=46,140,130
ForegroundInactive=96,116,122
ForegroundLink=26,110,180
ForegroundNegative=192,57,57
ForegroundNeutral=166,116,24
ForegroundNormal=22,35,42
ForegroundPositive=38,142,86
ForegroundVisited=104,80,180
[Colors:Header][Inactive]
BackgroundAlternate=242,245,244
BackgroundNormal=228,234,233
DecorationFocus=46,140,130
DecorationHover=46,140,130
ForegroundActive=46,140,130
ForegroundInactive=120,138,144
ForegroundLink=26,110,180
ForegroundNegative=192,57,57
ForegroundNeutral=166,116,24
ForegroundNormal=80,99,105
ForegroundPositive=38,142,86
ForegroundVisited=104,80,180
[WM]
activeBackground=236,241,240
activeBlend=46,140,130
activeForeground=22,35,42
inactiveBackground=228,234,233
inactiveBlend=96,116,122
inactiveForeground=96,116,122
@@ -0,0 +1,31 @@
# Everything the classroom's appearance is made of, in one place.
#
# Plasma reads this when the look and feel is applied, so the panel, the
# window decorations, the notifications and every Qt dialog in the session
# are described here rather than in a stylesheet per surface.
[kdeglobals][KDE]
widgetStyle=Breeze
[kdeglobals][General]
ColorScheme=EkklesiaDark
AccentColor=79,179,168
[kdeglobals][Icons]
Theme=breeze-dark
[plasmarc][Theme]
name=default
[kcminputrc][Mouse]
cursorTheme=breeze_cursors
[kwinrc][org.kde.kdecoration2]
library=org.kde.breeze
theme=Breeze
[Wallpaper]
Image=/usr/share/backgrounds/ekklesia/classroom-calm.png
[KSplash]
Theme=None
@@ -0,0 +1,124 @@
// The Ekklesia Classroom desktop.
//
// Plasma runs this once, when a session has no panel yet, and again on
// demand — `plasma-apply-lookandfeel -a org.ekklesia.classroom.desktop
// --resetLayout` rebuilds the whole desktop from this file. That is the
// classroom's recovery path for a damaged shell, and it is Plasma's own
// mechanism rather than something written here.
//
// One full-width bar along the bottom. Left is auxiliary, the middle is
// applications, the right is the small amount of status a teacher needs.
// Nothing else is on the screen.
var WALLPAPER = "/usr/share/backgrounds/ekklesia/classroom-calm.png";
// Matches the 76px strip the classroom has always used, so muscle memory
// and the room's viewing distance both carry over.
var PANEL_HEIGHT = 76;
// The idle desktop: the wallpaper, and nothing on it.
var screens = desktops();
for (var i = 0; i < screens.length; i++) {
var desktop = screens[i];
desktop.wallpaperPlugin = "org.kde.image";
desktop.currentConfigGroup = ["Wallpaper", "org.kde.image", "General"];
desktop.writeConfig("Image", "file://" + WALLPAPER);
desktop.writeConfig("SlidePaths", "");
// 2 is "scaled and cropped": the landscape fills the display at any
// aspect ratio without being stretched out of shape.
desktop.writeConfig("FillMode", 2);
desktop.reloadConfig();
}
var panel = new Panel();
panel.location = "bottom";
panel.height = PANEL_HEIGHT;
panel.hiding = "none";
panel.floating = false;
panel.lengthMode = "fill";
panel.alignment = "center";
panel.opacity = "adaptive";
// LEFT — auxiliary. Parent Call is not one of the room's applications and
// is deliberately not in the row with them.
var parentCall = panel.addWidget("org.kde.plasma.icon");
parentCall.currentConfigGroup = ["General"];
parentCall.writeConfig(
"url",
"file:///usr/share/applications/ekklesia-parent-call.desktop"
);
var leftGap = panel.addWidget("org.kde.plasma.panelspacer");
leftGap.currentConfigGroup = ["General"];
leftGap.writeConfig("expanding", true);
// CENTRE — the applications, and the whole of the running/minimised/active
// story. This applet is the reason the classroom stops writing a dock.
var tasks = panel.addWidget("org.kde.plasma.icontasks");
tasks.currentConfigGroup = ["General"];
tasks.writeConfig("launchers", [
"applications:ekklesia-wonderink.desktop",
"applications:google-chrome.desktop"
].join(","));
// Every window gets its own button. Teachers should never have to open a
// group to find out that a second window exists.
tasks.writeConfig("groupingStrategy", 0);
tasks.writeConfig("showOnlyCurrentDesktop", false);
tasks.writeConfig("showOnlyCurrentActivity", false);
tasks.writeConfig("showOnlyCurrentScreen", false);
// Clicking a button always means "show me this", and never "hide it".
//
// This was measured before it was decided. Plasma 6.3 draws a minimised
// task and a task that is merely behind another window identically: no
// indicator for closed, a grey indicator for running, a teal one for
// active — three marks for four states. Rather than write a task manager
// that draws a fourth, the classroom removes the state. There is nothing
// on the idle desktop for a teacher to want to see, so minimising an
// application in this room has no purpose and only ever loses it.
//
// A window minimised some other way is still shown as running and comes
// back with one click, so it can never be lost even then.
tasks.writeConfig("minimizeActiveTaskOnClick", false);
tasks.writeConfig("fill", false);
tasks.writeConfig("maxStripes", 1);
tasks.writeConfig("indicateAudioStreams", false);
tasks.writeConfig("wheelEnabled", false);
tasks.writeConfig("middleClickAction", "NewInstance");
tasks.writeConfig("showToolTips", true);
tasks.writeConfig("iconSpacing", 2);
var rightGap = panel.addWidget("org.kde.plasma.panelspacer");
rightGap.currentConfigGroup = ["General"];
rightGap.writeConfig("expanding", true);
// RIGHT — status.
//
// Four ordinary applets straight on the bar, and no system tray around
// them. A tray is a container that discovers whatever is installed and
// then hides most of it behind an arrow; measured in the lab it collected
// battery, brightness, clipboard, keyboard layout, camera, vaults and
// screen management on a machine that has none of those to say anything
// about. Naming the four the classroom actually uses is both smaller and
// deterministic, and it leaves nothing for a teacher to expand.
panel.addWidget("org.kde.plasma.networkmanagement");
panel.addWidget("org.kde.plasma.volume");
// Present only while there is something in a drive. Plasma's own Solid
// device notifier is what makes the DVD appear with the disc and leave
// with it, so nothing in the classroom polls for media.
panel.addWidget("org.kde.plasma.devicenotifier");
panel.addWidget("org.kde.plasma.notifications");
var clock = panel.addWidget("org.kde.plasma.digitalclock");
clock.currentConfigGroup = ["Appearance"];
clock.writeConfig("showDate", true);
clock.writeConfig("dateFormat", "custom");
clock.writeConfig("customDateFormat", "ddd d MMM");
clock.writeConfig("showSeconds", 0);
clock.writeConfig("dateDisplayFormat", "BelowTime");
// Left to itself the clock grows to the height of the bar and becomes the
// largest text in the room. It is status, not a headline.
clock.writeConfig("autoFontAndSize", false);
clock.writeConfig("fontSize", 15);
clock.writeConfig("fontWeight", 500);
+17
View File
@@ -0,0 +1,17 @@
{
"KPackageStructure": "Plasma/LookAndFeel",
"KPlugin": {
"Authors": [
{
"Name": "Ekklesia"
}
],
"Category": "",
"Description": "The classroom appliance desktop",
"Id": "org.ekklesia.classroom.desktop",
"License": "GPLv2+",
"Name": "Ekklesia Classroom",
"Website": "https://riverofglorymn.org"
},
"X-Plasma-APIVersion": "2"
}
+13
View File
@@ -0,0 +1,13 @@
[Desktop Entry]
Type=Service
# Solid decides when this exists. Plasma's device notifier offers it while
# a video DVD is in the drive and withdraws it when the disc is ejected, so
# a room without a drive shows nothing and a room with one needs no setting
# changed. Nothing polls.
X-KDE-Solid-Predicate=[[ StorageVolume.ignored == false AND StorageVolume.usage == 'Other' ] AND [ OpticalDisc.availableContent & 'VideoDvd' ]]
Actions=Play;
[Desktop Action Play]
Name=Play the DVD
Icon=media-optical-dvd
Exec=/usr/local/bin/classroom-dvd %f
+60
View File
@@ -0,0 +1,60 @@
# Ekklesia Classroom session policy.
#
# Installed to /etc/xdg/kdeglobals, where it is the bottom of Plasma's
# configuration cascade. Every group is marked [$i] — immutable — so a
# teacher, a stray click or a misbehaving application cannot override it
# from the user's own configuration. This is KDE's own Kiosk mechanism:
# nothing here is achieved by deleting a binary or hiding a menu entry.
#
# Lifting a restriction for maintenance is a one-line edit to this file
# followed by a session restart, which is what makes the appliance
# serviceable rather than merely locked.
[KDE Action Restrictions][$i]
# No way to reach a shell, a command line, or an arbitrary program.
action/kdesktop_rmb=false
action/kwin_rmb=false
action/openwith=false
action/switch_user=false
action/start_new_session=false
action/lock_screen=false
run_command=false
shell_access=false
logout=false
# The desktop and the panel are a fixed part of the appliance.
editable_desktop_icons=false
editable_bookmarks=false
movable_toolbars=false
plasma/plasmashell/unlockedDesktop=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
action/help_about_kde=false
action/help_contents=false
[General][$i]
ColorScheme=EkklesiaDark
AccentColor=79,179,168
# The teacher's own words are not decoration: keep text rendering plain
# and readable rather than styled.
XftAntialias=true
XftHintStyle=hintslight
XftSubPixel=rgb
[KDE][$i]
widgetStyle=Breeze
LookAndFeelPackage=org.ekklesia.classroom.desktop
SingleClick=false
AnimationDurationFactor=0.5
[Icons][$i]
Theme=breeze-dark
[KFileDialog Settings][$i]
Allow Expansion=false
Automatically select filename extension=true
Show Bookmarks=false
Show Full Path=false
Show Inline Previews=false
Show Speedbar=false
Sort directories first=true
+9
View File
@@ -0,0 +1,9 @@
# KRunner is a command line with a nicer face. The classroom has neither.
[General][$i]
Enabled=false
ActivateWhenTypingOnDesktop=false
[Plugins][$i]
shellEnabled=false
baloosearchEnabled=false
krunner_appstreamEnabled=false
krunner_shellEnabled=false
+9
View File
@@ -0,0 +1,9 @@
# A classroom appliance must never present a password prompt to a teacher.
# There is no lock screen, and nothing can bring one up: the action itself
# is disabled in kdeglobals as well, so a keyboard shortcut cannot reach it.
[Daemon][$i]
Autolock=false
LockOnResume=false
LockOnStart=false
LockGrace=0
Timeout=0
+8
View File
@@ -0,0 +1,8 @@
# The room is not somebody's account. Every session starts from the same
# empty desktop, so what the last teacher left open is never the first
# thing the next one sees.
[General][$i]
loginMode=emptySession
confirmLogout=false
offerShutdown=false
shutdownType=0
+102
View File
@@ -0,0 +1,102 @@
# The classroom's window manager.
#
# The shape of this file is the argument for the whole architecture: window
# stacking, minimising, dialog placement and full-screen media are settings
# here, not code somewhere in the classroom.
[Compositing][$i]
Enabled=true
# Let KWin choose between OpenGL and software rendering by itself. A
# classroom machine that loses acceleration after a kernel update should
# come up slower, not blank.
WindowsBlockCompositing=true
AllowTearing=false
LatencyPolicy=Medium
[Desktops][$i]
Number=1
Rows=1
# One desktop, so a teacher cannot land on an empty one and conclude the
# computer has lost their work.
[Windows][$i]
Placement=Centered
FocusPolicy=ClickToFocus
AutoRaise=false
DelayFocus=0
FocusStealingPreventionLevel=1
# A dialog belongs to the window that opened it and stays with it. This is
# what keeps a print dialog in front of the page it is printing without
# anything in the classroom arranging it.
SeparateScreenFocus=false
ActiveMouseScreen=false
TitlebarDoubleClickCommand=Maximize
MaximizeButtonLeftClickCommand=Maximize
MaximizeButtonMiddleClickCommand=Maximize
MaximizeButtonRightClickCommand=Maximize
[MouseBindings][$i]
CommandAllKey=Meta
CommandAll1=Move
CommandAll2=Nothing
CommandAll3=Nothing
CommandTitlebarWheel=Nothing
CommandAllWheel=Nothing
[TabBox][$i]
# No window switcher overlay. The bottom bar is the way between windows.
ShowTabBox=false
[Plugins][$i]
# Effects the classroom uses, and nothing else. Every line that is false is
# an effect that will not be loaded at all.
blurEnabled=true
contrastEnabled=true
kwin4_effect_fadeEnabled=true
kwin4_effect_translucencyEnabled=false
kwin4_effect_dialogparentEnabled=true
kwin4_effect_squashEnabled=true
kwin4_effect_maximizeEnabled=true
kwin4_effect_scaleEnabled=true
kwin4_effect_fullscreenEnabled=true
slidingpopupsEnabled=true
screenedgeEnabled=false
cubeEnabled=false
desktopgridEnabled=false
overviewEnabled=false
windowviewEnabled=false
wobblywindowsEnabled=false
magiclampEnabled=false
slideEnabled=false
zoomEnabled=false
mouseclickEnabled=false
showfpsEnabled=false
thumbnailasideEnabled=false
kwin4_effect_dimscreenEnabled=false
kwin4_effect_morphingpopupsEnabled=false
[Effect-blur][$i]
BlurStrength=8
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.
ButtonsOnLeft=
ButtonsOnRight=FX
ShowToolTips=false
BorderSize=None
BorderSizeAuto=false
[Script-desktopchangeosd][$i]
Enabled=false
[Xwayland][$i]
Scale=1
+48
View File
@@ -0,0 +1,48 @@
# Window rules.
#
# There are three, they are all about size, and none of them is about
# stacking. Dialogs staying in front of the window that opened them,
# secondary windows floating over the main one instead of resizing it, and
# a print dialog being the window in front are all ordinary window-manager
# behaviour here, so there is nothing to write down.
#
# `types=1` is NET::Normal only. Without it a rule that maximises would
# also maximise the dialogs, which is exactly the behaviour the classroom
# is moving away from.
[General]
count=3
rules=classroom-browser,classroom-wonderink,classroom-video
[classroom-browser]
Description=The classroom browser fills the screen
wmclass=google-chrome
wmclassmatch=1
wmclasscomplete=false
types=1
maximizehoriz=true
maximizehorizrule=3
maximizevert=true
maximizevertrule=3
[classroom-wonderink]
Description=Wonder Ink fills the screen
# Chrome derives this from the --app URL. Measured on a running session
# rather than guessed; it is also what the launcher's StartupWMClass says,
# and the two have to agree or the panel shows two buttons for one app.
wmclass=chrome-app.wonderink.org__-Default
wmclassmatch=1
wmclasscomplete=false
types=1
maximizehoriz=true
maximizehorizrule=3
maximizevert=true
maximizevertrule=3
[classroom-video]
Description=Video is an appliance screen, not a window
wmclass=vlc
wmclassmatch=2
wmclasscomplete=false
noborder=true
noborderrule=2
+16
View File
@@ -0,0 +1,16 @@
# Small cards, top right, out of the way of the bottom bar and of anything
# a teacher is reading. Plasma's own notification service does this; the
# classroom does not run a notification daemon of its own.
[Notifications][$i]
PopupPosition=TopRight
PopupTimeout=6000
LowPriorityPopups=false
LowPriorityHistory=false
[Jobs][$i]
PermanentPopups=false
InNotifications=true
[DoNotDisturb][$i]
WhenFullscreen=true
WhenScreensMirrored=false
Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 920 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 919 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

+24 -23
View File
@@ -1,32 +1,33 @@
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
# The dock's Browser button. classroom-window decides whether this is a # The room's ordinary browser: YouTube, and anywhere else a lesson goes.
# launch, a focus, a minimise or a restore.
# #
# Chrome stays a full browser: Wonder Ink is only the starting page, and # It is a full browser with a tab strip and an address bar, in the room's
# the profile in ~/.config/google-chrome is never touched here so logins # own profile, so the church YouTube sign-in survives reboots and updates.
# and cookies survive reboots and updates. # Wonder Ink is a separate application in a separate profile and is not
# reachable from this one.
#
# There is no launch/focus/minimise/restore logic here any more. The panel
# owns that: it knows this window by StartupWMClass and does the right
# thing with a click, including telling the teacher which of those four
# states the application is in.
# #
# --hide-crash-restore-bubble is what suppresses "Restore pages? Chrome # --hide-crash-restore-bubble is what suppresses "Restore pages? Chrome
# didn't shut down correctly" after a reboot. Note the switch name: the # didn't shut down correctly" after a power cut. Note the switch name: the
# older --disable-session-crashed-bubble is not present in Chrome 152 at # older --disable-session-crashed-bubble is not present in Chrome 152 at
# all, so it would have silently done nothing. Verified against the # all, so it would have silently done nothing.
# strings of the installed binary.
HOME_PAGE='https://app.wonderink.org/' # Not Wonder Ink. Wonder Ink is its own application in its own profile,
# and a second way in through this window would put a teacher's lesson
# session in the room's shared browser profile — the exact leak the split
# exists to prevent.
HOME_PAGE='https://www.youtube.com/'
exec /usr/local/bin/classroom-window toggle \ exec google-chrome \
--match 'google-chrome' \ --ozone-platform=wayland \
--match 'chrome' \ --no-first-run \
--match 'chrome-.*' \ --no-default-browser-check \
--match 'google-chrome-.*' \ --password-store=basic \
-- \ --hide-crash-restore-bubble \
google-chrome \ "${@:-$HOME_PAGE}"
--ozone-platform=wayland \
--start-maximized \
--no-first-run \
--no-default-browser-check \
--password-store=basic \
--hide-crash-restore-bubble \
"$HOME_PAGE"
+39
View File
@@ -0,0 +1,39 @@
#!/bin/bash
set -euo pipefail
# Wonder Ink as an application, not a tab.
#
# Two things make it one. It runs in Chrome's app mode, so the window has
# no tab strip and no address bar and reads as a program. And it runs in
# its own Chrome profile, so the teacher signed into Wonder Ink and the
# room's shared YouTube account are separate sessions that cannot reach
# each other:
#
# * closing this window ends the Wonder Ink session, because the
# wonderink.org cookies are session-only by policy and this profile's
# browser process exits with the window;
# * a Google sign-in performed here cannot be used to re-enter the
# previous teacher's Wonder Ink account from the ordinary browser,
# because the ordinary browser's Google session is in a different
# profile;
# * and the church YouTube login in the ordinary browser survives all of
# it, because nothing here touches that profile.
#
# The machine-wide policy in /etc/opt/chrome/policies applies to every
# profile, so this window is governed by exactly the same rules as the
# browser.
HOME_PAGE='https://app.wonderink.org/'
PROFILE="${XDG_DATA_HOME:-$HOME/.local/share}/ekklesia-classroom/wonderink"
mkdir -p "$PROFILE"
exec google-chrome \
--user-data-dir="$PROFILE" \
--app="$HOME_PAGE" \
--ozone-platform=wayland \
--no-first-run \
--no-default-browser-check \
--password-store=basic \
--hide-crash-restore-bubble \
"$@"
@@ -0,0 +1,18 @@
# The panel is the only way a teacher has of getting anywhere, so it is
# never allowed to stay dead.
#
# Plasma's own unit already restarts plasmashell on a crash, but it gives
# up after three restarts in sixty seconds and the panel is then gone until
# somebody reboots the room. That is measurably the failure a classroom
# machine has already had once, with the old hand-written dock, which had
# no supervision at all.
#
# Measured in the lab: with these four lines, plasmashell survived eight
# consecutive SIGKILLs one second apart and came back every time in about
# 2.4 seconds. Without them it was permanently gone on the third.
[Unit]
StartLimitIntervalSec=0
[Service]
Restart=always
RestartSec=2
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Generate the light colour scheme from the dark one.
There is one set of design tokens, not two themes. The light scheme is the
same file with the roles swapped, so a colour can only be changed in one
place and the two can never drift apart.
"""
import pathlib
import re
ROOT = pathlib.Path(__file__).resolve().parent.parent
COLORS = ROOT / "config" / "plasma" / "colors"
# dark value -> light value
ROLES = {
"20,28,32": "242,245,244", # window background
"25,35,40": "233,238,237", # alternate
"15,23,26": "255,255,255", # view background
"18,26,30": "236,241,240", # header
"16,23,26": "228,234,233", # header, inactive
"30,42,47": "225,231,230", # button alternate
"230,237,239": "22,35,42", # normal text
"147,165,170": "96,116,122", # inactive text
"170,186,191": "80,99,105", # inactive header text
"125,142,147": "120,138,144",
"79,179,168": "46,140,130", # accent
"126,196,255": "26,110,180", # link
"227,106,106": "192,57,57", # negative
"224,177,92": "166,116,24", # neutral
"111,207,151": "38,142,86", # positive
"156,138,222": "104,80,180", # visited
"14,21,24": "255,255,255", # text on a selection
"10,16,18": "255,255,255",
}
HEADER = (
"# Ekklesia Classroom — light.\n"
"#\n"
"# GENERATED from EkklesiaDark.colors by tools/build-light-scheme.\n"
"# Edit the dark scheme and regenerate; do not edit this file by hand."
)
def main():
dark = (COLORS / "EkklesiaDark.colors").read_text()
light = dark
for old, new in sorted(ROLES.items(), key=lambda item: -len(item[0])):
light = re.sub(rf"(?<![\d,]){re.escape(old)}(?![\d,])", "\0" + new, light)
light = light.replace("\0", "")
light = light.replace("Ekklesia Classroom Dark", "Ekklesia Classroom Light")
light = light.replace("ColorScheme=EkklesiaDark", "ColorScheme=EkklesiaLight")
light = light.replace("# Ekklesia Classroom — dark. The default.", HEADER)
(COLORS / "EkklesiaLight.colors").write_text(light)
print("wrote", COLORS / "EkklesiaLight.colors")
if __name__ == "__main__":
main()
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Render the classroom's idle desktop background.
This is a STAND-IN. The classroom's chosen wallpaper is an illustrated
landscape that is not in this repository; when it arrives it replaces the
output of this script at the same path and nothing else changes.
It exists so the desktop can be judged in the right palette rather than
against the bright confetti background it is replacing: sage, teal, blue
and lavender, a river leading into the picture, and a large quiet area in
the upper middle where nothing happens.
No image library is installed on the development host, so the PNG is
written by hand — zlib and struct are enough for a truecolour image.
"""
import math
import pathlib
import struct
import zlib
WIDTH = 1920
HEIGHT = 1080
OUT = pathlib.Path(__file__).resolve().parent.parent / "config" / "wallpapers"
def mix(a, b, t):
t = 0.0 if t < 0 else 1.0 if t > 1 else t
return tuple(a[i] + (b[i] - a[i]) * t for i in range(3))
def ease(t):
"""Smoothstep. Bands in a gradient are the one thing that would read as
a rendering fault rather than as a picture."""
t = 0.0 if t < 0 else 1.0 if t > 1 else t
return t * t * (3 - 2 * t)
# The palette, top of the sky downwards.
SKY = [
(0.00, (34, 38, 58)), # deep lavender-slate
(0.30, (58, 68, 96)), # lavender
(0.55, (86, 108, 130)), # blue
(0.78, (139, 165, 168)), # sage haze at the horizon
(1.00, (178, 198, 192)),
]
HORIZON = 0.56 # where the land starts, as a fraction of the height
GLOW_X, GLOW_Y = 0.56, 0.545
GLOW = (232, 232, 214)
RIDGES = [
# (base height, amplitude, wavelength, phase, colour, haze)
(0.575, 0.028, 2.1, 0.4, (74, 96, 110), 0.55),
(0.615, 0.040, 1.5, 2.2, (60, 84, 92), 0.34),
(0.665, 0.052, 1.1, 4.1, (48, 72, 74), 0.18),
]
RIVER_TOP = 0.585
RIVER_COLOUR = (96, 140, 146)
RIVER_LIGHT = (186, 212, 202)
BANK = (34, 52, 48) # the sage foreground the river runs between
def sky_colour(y):
t = y / HEIGHT
for i in range(len(SKY) - 1):
t0, c0 = SKY[i]
t1, c1 = SKY[i + 1]
if t <= t1 or i == len(SKY) - 2:
return mix(c0, c1, ease((t - t0) / (t1 - t0)))
return SKY[-1][1]
def river_edges(y):
"""The river narrows towards the horizon and opens towards the viewer,
which is what makes it lead into the picture rather than across it."""
t = (y / HEIGHT - RIVER_TOP) / (1 - RIVER_TOP)
if t < 0:
return None
t = ease(t)
centre = 0.56 - 0.14 * t * t
half = 0.004 + 0.30 * t * t
return (centre - half) * WIDTH, (centre + half) * WIDTH
def render():
rows = []
for y in range(HEIGHT):
base = sky_colour(y)
row = bytearray()
edges = river_edges(y)
yn = y / HEIGHT
# Ridge heights for this column are cheap to evaluate, so the land
# is computed per pixel rather than filled as polygons.
for x in range(WIDTH):
xn = x / WIDTH
r, g, b = base
# A soft light low in the sky, off centre. It is the only
# bright thing in the picture.
dx = (xn - GLOW_X) * 1.7
dy = yn - GLOW_Y
d = math.sqrt(dx * dx + dy * dy)
glow = math.exp(-(d * d) / 0.055) * 0.55
if glow > 0.002:
r, g, b = mix((r, g, b), GLOW, glow)
land = None
for height, amp, wavelength, phase, colour, haze in RIDGES:
ridge = height - amp * (
0.6 * math.sin(xn * math.pi * 2 * wavelength + phase)
+ 0.4 * math.sin(xn * math.pi * 2 * wavelength * 2.3 + phase * 1.7)
)
if yn >= ridge:
# Nearer ridges are darker and less hazy, which is the
# whole of the depth in this picture.
shade = mix(colour, (r, g, b), haze)
depth = ease((yn - ridge) / 0.16)
land = mix(shade, tuple(c * 0.82 for c in shade), depth)
if land is not None:
r, g, b = land
if edges is not None:
left, right = edges
if left <= x <= right:
width = max(right - left, 1.0)
across = (x - left) / width
# Light gathers down the middle of the water and the
# banks stay dark, so the river reads as a surface.
sheen = math.exp(-((across - 0.5) ** 2) / 0.06)
ripple = 0.5 + 0.5 * math.sin(
yn * 130 + math.sin(xn * 18) * 1.4
)
water = mix(RIVER_COLOUR, RIVER_LIGHT, 0.30 * sheen + 0.10 * ripple)
water = mix(water, (r, g, b), max(0.0, 0.45 - 0.45 * ease((yn - RIVER_TOP) / 0.25)))
edge = min(across, 1 - across)
r, g, b = mix(BANK, water, ease(edge / 0.05))
elif yn > RIVER_TOP + 0.10:
near = ease((yn - RIVER_TOP - 0.10) / 0.34)
r, g, b = mix((r, g, b), BANK, near * 0.85)
# The bottom of the screen carries the bar. Settling it down
# keeps the panel legible without putting a band across the
# picture.
if yn > 0.78:
r, g, b = mix((r, g, b), (16, 24, 28), ease((yn - 0.78) / 0.24) * 0.62)
row += bytes((int(r + 0.5) & 255, int(g + 0.5) & 255, int(b + 0.5) & 255))
rows.append(bytes(row))
return rows
def write_png(path, rows):
raw = b"".join(b"\x00" + row for row in rows)
def chunk(tag, data):
payload = tag + data
return (
struct.pack(">I", len(data))
+ payload
+ struct.pack(">I", zlib.crc32(payload) & 0xFFFFFFFF)
)
png = b"\x89PNG\r\n\x1a\n"
png += chunk(b"IHDR", struct.pack(">IIBBBBB", WIDTH, HEIGHT, 8, 2, 0, 0, 0))
png += chunk(b"IDAT", zlib.compress(raw, 9))
png += chunk(b"IEND", b"")
path.write_bytes(png)
if __name__ == "__main__":
OUT.mkdir(parents=True, exist_ok=True)
target = OUT / "classroom-calm.png"
write_png(target, render())
print(f"wrote {target} ({target.stat().st_size} bytes)")
+20
View File
@@ -50,3 +50,23 @@ CMD ["/sbin/init"]
# sanctioned way to capture a KWin session that has no outer compositor. # sanctioned way to capture a KWin session that has no outer compositor.
RUN apt-get update && apt-get install -y --no-install-recommends kde-spectacle \ RUN apt-get update && apt-get install -y --no-install-recommends kde-spectacle \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# The classroom's browser, from Google's own repository, the same way a
# classroom machine gets it. Testing the browser question on a different
# browser would not answer it.
RUN apt-get update && apt-get install -y --no-install-recommends gnupg \
&& install -d -m 0755 /etc/apt/keyrings \
&& curl -fsS https://dl.google.com/linux/linux_signing_key.pub \
| gpg --dearmor --batch --yes --output /etc/apt/keyrings/google-chrome.gpg \
&& echo 'deb [arch=amd64 signed-by=/etc/apt/keyrings/google-chrome.gpg] https://dl.google.com/linux/chrome/deb/ stable main' \
> /etc/apt/sources.list.d/google-chrome.list \
&& apt-get update && apt-get install -y --no-install-recommends google-chrome-stable \
&& rm -rf /var/lib/apt/lists/*
# VLC, CUPS and PipeWire: the rest of the classroom's runtime, so the
# desktop is judged with the room's real applications in it.
RUN apt-get update && apt-get install -y --no-install-recommends \
vlc vlc-plugin-base cups cups-client cups-filters \
pipewire pipewire-pulse wireplumber pulseaudio-utils \
libnotify-bin \
&& rm -rf /var/lib/apt/lists/*
+70
View File
@@ -0,0 +1,70 @@
#!/bin/bash
# Put the classroom desktop onto a machine, from the repository, at the
# paths the package will use. Run as root inside the lab.
#
# Every destination here is a normal system path that Plasma already reads.
# There is no classroom daemon in this list, and nothing is generated: the
# desktop is a set of files.
set -euo pipefail
SRC="${1:-/srv/classroom}"
install -Dm644 "$SRC/config/plasma/xdg/kdeglobals" /etc/xdg/kdeglobals
install -Dm644 "$SRC/config/plasma/xdg/kwinrc" /etc/xdg/kwinrc
install -Dm644 "$SRC/config/plasma/xdg/kwinrulesrc" /etc/xdg/kwinrulesrc
install -Dm644 "$SRC/config/plasma/xdg/kscreenlockerrc" /etc/xdg/kscreenlockerrc
install -Dm644 "$SRC/config/plasma/xdg/ksmserverrc" /etc/xdg/ksmserverrc
install -Dm644 "$SRC/config/plasma/xdg/krunnerrc" /etc/xdg/krunnerrc
install -Dm644 "$SRC/config/plasma/xdg/plasmanotifyrc" /etc/xdg/plasmanotifyrc
install -Dm644 "$SRC/config/plasma/colors/EkklesiaDark.colors" \
/usr/share/color-schemes/EkklesiaDark.colors
install -Dm644 "$SRC/config/plasma/colors/EkklesiaLight.colors" \
/usr/share/color-schemes/EkklesiaLight.colors
LNF=/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop
rm -rf "$LNF"
install -Dm644 "$SRC/config/plasma/lookandfeel/metadata.json" "$LNF/metadata.json"
install -Dm644 "$SRC/config/plasma/lookandfeel/contents/defaults" \
"$LNF/contents/defaults"
install -Dm644 \
"$SRC/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js" \
"$LNF/contents/layouts/org.kde.plasma.desktop-layout.js"
for entry in "$SRC"/config/plasma/applications/*.desktop; do
install -Dm644 "$entry" "/usr/share/applications/$(basename "$entry")"
done
# Entries that deliberately shadow a vendor one. XDG_DATA_DIRS searches
# /usr/local/share before /usr/share, so these win without touching, and
# without being overwritten by, the package that provides the original.
for entry in "$SRC"/config/plasma/applications-local/*.desktop; do
install -Dm644 "$entry" "/usr/local/share/applications/$(basename "$entry")"
done
install -Dm644 "$SRC/config/plasma/solid/ekklesia-dvd.desktop" \
/usr/share/solid/actions/ekklesia-dvd.desktop
for icon in "$SRC"/config/icons/ekklesia-*.svg; do
[ -e "$icon" ] || continue
install -Dm644 "$icon" \
"/usr/share/icons/hicolor/scalable/apps/$(basename "$icon")"
done
install -Dm644 "$SRC/config/wallpapers/classroom-calm.png" \
/usr/share/backgrounds/ekklesia/classroom-calm.png
install -Dm644 "$SRC/systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf" \
/etc/systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf
for script in classroom-browser classroom-wonderink; do
[ -e "$SRC/scripts/$script" ] || continue
install -Dm755 "$SRC/scripts/$script" "/usr/local/bin/$script"
done
command -v gtk-update-icon-cache >/dev/null 2>&1 \
&& gtk-update-icon-cache -qtf /usr/share/icons/hicolor || true
command -v update-desktop-database >/dev/null 2>&1 \
&& update-desktop-database -q /usr/share/applications || true
echo "classroom desktop installed"
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
# Run a KWin script in the session under test and print what it logged.
#
# KWin's scripting interface is the only way to ask the compositor what it
# actually believes about a window — class, caption, desktop file, geometry,
# minimised, keep-above. Everything this experiment claims about window
# behaviour is measured through here rather than inferred from a screenshot.
set -euo pipefail
NAME="${LAB_NAME:-classroom-v2-lab}"
SCRIPT="${1:?usage: kwin-script FILE}"
ID="labscript$RANDOM"
# /tmp inside the lab is a tmpfs created after the container started, which
# `docker cp` cannot see, so the script is piped in instead.
docker exec -i "$NAME" sh -c "cat > /tmp/$ID.js && chown classroom /tmp/$ID.js" \
< "$SCRIPT"
run() {
docker exec -u classroom \
-e XDG_RUNTIME_DIR=/run/user/1000 \
-e DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \
-e HOME=/home/classroom \
"$NAME" "$@"
}
since=$(docker exec "$NAME" date '+%Y-%m-%d %H:%M:%S')
number=$(run qdbus6 org.kde.KWin /Scripting org.kde.kwin.Scripting.loadScript \
"/tmp/$ID.js" "$ID" 2>/dev/null | tr -d '\r')
run qdbus6 org.kde.KWin "/Scripting/Script$number" org.kde.kwin.Script.run >/dev/null 2>&1
sleep 1
run qdbus6 org.kde.KWin /Scripting org.kde.kwin.Scripting.unloadScript "$ID" >/dev/null 2>&1 || true
# KWin's print() reaches the journal, tagged by the wrapper process.
docker exec "$NAME" journalctl --since "$since" --no-pager -o cat 2>/dev/null \
| grep -a "LAB:" | sed 's/.*LAB://'
+40 -6
View File
@@ -75,8 +75,19 @@ down)
;; ;;
outer-up) outer-up)
# A headless compositor has no input devices, and a wl_seat with no
# pointer capability never gives the session under test a pointer to
# click with — sway's cursor commands then report success and move
# nothing. So the lab runs wlroots' libinput backend as well, over the
# devices the privileged container inherits, with seatd standing in for
# the login session it does not have. Clicks in this experiment are
# real pointer events arriving through libinput.
docker exec "$NAME" sh -c 'pgrep -x seatd >/dev/null || \
(SEATD_VTBOUND=0 setsid seatd -g input > /tmp/seatd.log 2>&1 &)'
docker exec "$NAME" usermod -aG input classroom 2>/dev/null || true
sleep 2
session_env \ session_env \
-e WLR_BACKENDS=headless -e WLR_LIBINPUT_NO_DEVICES=1 \ -e WLR_BACKENDS=headless,libinput -e LIBSEAT_BACKEND=seatd \
-e WLR_RENDERER=pixman -e LIBGL_ALWAYS_SOFTWARE=1 \ -e WLR_RENDERER=pixman -e LIBGL_ALWAYS_SOFTWARE=1 \
-d "$NAME" sh -c 'exec sway -c /srv/classroom/tools/lab/outer-sway.conf \ -d "$NAME" sh -c 'exec sway -c /srv/classroom/tools/lab/outer-sway.conf \
> /tmp/outer-sway.log 2>&1' > /tmp/outer-sway.log 2>&1'
@@ -91,6 +102,7 @@ plasma-up)
session_env \ session_env \
-e WAYLAND_DISPLAY="$OUTER" -e XDG_SESSION_TYPE=wayland \ -e WAYLAND_DISPLAY="$OUTER" -e XDG_SESSION_TYPE=wayland \
-e XDG_CURRENT_DESKTOP=KDE -e LIBGL_ALWAYS_SOFTWARE=1 \ -e XDG_CURRENT_DESKTOP=KDE -e LIBGL_ALWAYS_SOFTWARE=1 \
-e QT_LOGGING_RULES="kwin_scripting.debug=true" \
-d "$NAME" sh -c 'exec startplasma-wayland > /tmp/plasma.log 2>&1' -d "$NAME" sh -c 'exec startplasma-wayland > /tmp/plasma.log 2>&1'
for _ in $(seq 1 60); do for _ in $(seq 1 60); do
docker exec "$NAME" pgrep -x plasmashell >/dev/null 2>&1 && break docker exec "$NAME" pgrep -x plasmashell >/dev/null 2>&1 && break
@@ -114,27 +126,45 @@ session)
;; ;;
shot) shot)
# shot NAME [--delay SECONDS] # shot NAME [GEOMETRY] — geometry as grim wants it, e.g. "0,980 1920x100"
name="${2:?usage: lab shot NAME}" name="${2:?usage: lab shot NAME}"
outer grim /tmp/lab-shot.png >/dev/null if [[ -n "${3:-}" ]]; then
outer grim -g "$3" /tmp/lab-shot.png >/dev/null
else
outer grim /tmp/lab-shot.png >/dev/null
fi
mkdir -p "$SHOTS" mkdir -p "$SHOTS"
docker exec "$NAME" cat /tmp/lab-shot.png > "$SHOTS/$name.png" docker exec "$NAME" cat /tmp/lab-shot.png > "$SHOTS/$name.png"
echo "$SHOTS/$name.png" echo "$SHOTS/$name.png"
;; ;;
click) click)
# click X Y — a real pointer press in the session under test # Absolute, through sway's own seat. This only works because the seat
# has a pointer capability at all, which is what the uinput device in
# `outer-up` is for.
outer swaymsg seat - cursor set "$2" "$3" >/dev/null outer swaymsg seat - cursor set "$2" "$3" >/dev/null
sleep 0.3 sleep 0.4
outer swaymsg seat - cursor press button1 >/dev/null outer swaymsg seat - cursor press button1 >/dev/null
sleep 0.1 sleep 0.15
outer swaymsg seat - cursor release button1 >/dev/null outer swaymsg seat - cursor release button1 >/dev/null
sleep 0.5
;; ;;
move) move)
outer swaymsg seat - cursor set "$2" "$3" >/dev/null outer swaymsg seat - cursor set "$2" "$3" >/dev/null
;; ;;
drag)
outer swaymsg seat - cursor set "$2" "$3" >/dev/null
sleep 0.3
outer swaymsg seat - cursor press button1 >/dev/null
sleep 0.3
outer swaymsg seat - cursor set "$4" "$5" >/dev/null
sleep 0.3
outer swaymsg seat - cursor release button1 >/dev/null
sleep 0.4
;;
key) key)
shift shift
outer wtype "$@" outer wtype "$@"
@@ -148,6 +178,8 @@ sh)
run) shift; session_env "$NAME" "$@" ;; run) shift; session_env "$NAME" "$@" ;;
irun) shift; session_env -it "$NAME" "$@" ;; irun) shift; session_env -it "$NAME" "$@" ;;
inner) shift; inner "$@" ;; inner) shift; inner "$@" ;;
inner-d) shift; session_env -d -e WAYLAND_DISPLAY=wayland-0 -e XDG_CURRENT_DESKTOP=KDE \
-e QT_QPA_PLATFORM=wayland "$NAME" "$@" ;;
inner-it) shift; session_env -it -e WAYLAND_DISPLAY=wayland-0 -e XDG_CURRENT_DESKTOP=KDE "$NAME" "$@" ;; inner-it) shift; session_env -it -e WAYLAND_DISPLAY=wayland-0 -e XDG_CURRENT_DESKTOP=KDE "$NAME" "$@" ;;
outer) shift; outer "$@" ;; outer) shift; outer "$@" ;;
@@ -164,9 +196,11 @@ usage: tools/lab/lab COMMAND
plasma-down stop the Plasma session plasma-down stop the Plasma session
shot NAME capture the screen to docs/screenshots/NAME.png shot NAME capture the screen to docs/screenshots/NAME.png
click X Y press the pointer at a screen position click X Y press the pointer at a screen position
drag X1 Y1 X2 Y2 drag from one position to another
move X Y move the pointer move X Y move the pointer
key ARGS... wtype, aimed at the session key ARGS... wtype, aimed at the session
inner CMD... run CMD as a client of the Plasma session inner CMD... run CMD as a client of the Plasma session
inner-d CMD... the same, detached
outer CMD... run CMD against the lab compositor outer CMD... run CMD against the lab compositor
run CMD... run CMD as the classroom user, no display run CMD... run CMD as the classroom user, no display
sh root shell in the lab sh root shell in the lab