diff --git a/config/chrome/policies/managed/ekklesia-classroom.json b/config/chrome/policies/managed/ekklesia-classroom.json index 80d99ff..7402b50 100644 --- a/config/chrome/policies/managed/ekklesia-classroom.json +++ b/config/chrome/policies/managed/ekklesia-classroom.json @@ -2,25 +2,20 @@ "DefaultBrowserSettingEnabled": false, "PromotionalTabsEnabled": false, "MetricsReportingEnabled": false, - "BrowserSignin": 0, "SyncDisabled": true, "SigninInterceptionEnabled": false, - "BrowserAddPersonEnabled": false, "BrowserGuestModeEnabled": false, "ProfilePickerOnStartupAvailability": 1, - "PasswordManagerEnabled": false, "PasswordLeakDetectionEnabled": false, "BackgroundModeEnabled": false, "DisablePrintPreview": true, - "CookiesSessionOnlyForUrls": [ "[*.]wonderink.org" ], - "ShowHomeButton": true, "HomepageIsNewTabPage": false, - "HomepageLocation": "https://app.wonderink.org/" + "HomepageLocation": "https://www.youtube.com/" } diff --git a/config/chrome/policies/recommended/ekklesia-classroom.json b/config/chrome/policies/recommended/ekklesia-classroom.json index a8e3ec9..ef344a6 100644 --- a/config/chrome/policies/recommended/ekklesia-classroom.json +++ b/config/chrome/policies/recommended/ekklesia-classroom.json @@ -1,6 +1,6 @@ { "RestoreOnStartup": 4, "RestoreOnStartupURLs": [ - "https://app.wonderink.org/" + "https://www.youtube.com/" ] } diff --git a/config/icons/ekklesia-parent-call.svg b/config/icons/ekklesia-parent-call.svg new file mode 100644 index 0000000..f4661b8 --- /dev/null +++ b/config/icons/ekklesia-parent-call.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + diff --git a/config/icons/ekklesia-wonderink.svg b/config/icons/ekklesia-wonderink.svg new file mode 100644 index 0000000..eef7aa1 --- /dev/null +++ b/config/icons/ekklesia-wonderink.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + diff --git a/config/plasma/applications-local/google-chrome.desktop b/config/plasma/applications-local/google-chrome.desktop new file mode 100644 index 0000000..8acee4e --- /dev/null +++ b/config/plasma/applications-local/google-chrome.desktop @@ -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= diff --git a/config/plasma/applications/ekklesia-dvd.desktop b/config/plasma/applications/ekklesia-dvd.desktop new file mode 100644 index 0000000..cda48f8 --- /dev/null +++ b/config/plasma/applications/ekklesia-dvd.desktop @@ -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 diff --git a/config/plasma/applications/ekklesia-parent-call.desktop b/config/plasma/applications/ekklesia-parent-call.desktop new file mode 100644 index 0000000..1a32b1e --- /dev/null +++ b/config/plasma/applications/ekklesia-parent-call.desktop @@ -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 diff --git a/config/plasma/applications/ekklesia-wonderink.desktop b/config/plasma/applications/ekklesia-wonderink.desktop new file mode 100644 index 0000000..56c7c4e --- /dev/null +++ b/config/plasma/applications/ekklesia-wonderink.desktop @@ -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 diff --git a/config/plasma/colors/EkklesiaDark.colors b/config/plasma/colors/EkklesiaDark.colors new file mode 100644 index 0000000..afcd446 --- /dev/null +++ b/config/plasma/colors/EkklesiaDark.colors @@ -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 diff --git a/config/plasma/colors/EkklesiaLight.colors b/config/plasma/colors/EkklesiaLight.colors new file mode 100644 index 0000000..28e7a52 --- /dev/null +++ b/config/plasma/colors/EkklesiaLight.colors @@ -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 diff --git a/config/plasma/lookandfeel/contents/defaults b/config/plasma/lookandfeel/contents/defaults new file mode 100644 index 0000000..08cde14 --- /dev/null +++ b/config/plasma/lookandfeel/contents/defaults @@ -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 diff --git a/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js b/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js new file mode 100644 index 0000000..5b511a3 --- /dev/null +++ b/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js @@ -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); diff --git a/config/plasma/lookandfeel/metadata.json b/config/plasma/lookandfeel/metadata.json new file mode 100644 index 0000000..99cf8dc --- /dev/null +++ b/config/plasma/lookandfeel/metadata.json @@ -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" +} diff --git a/config/plasma/solid/ekklesia-dvd.desktop b/config/plasma/solid/ekklesia-dvd.desktop new file mode 100644 index 0000000..d4e7811 --- /dev/null +++ b/config/plasma/solid/ekklesia-dvd.desktop @@ -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 diff --git a/config/plasma/xdg/kdeglobals b/config/plasma/xdg/kdeglobals new file mode 100644 index 0000000..7fb90b8 --- /dev/null +++ b/config/plasma/xdg/kdeglobals @@ -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 diff --git a/config/plasma/xdg/krunnerrc b/config/plasma/xdg/krunnerrc new file mode 100644 index 0000000..a3ce7c6 --- /dev/null +++ b/config/plasma/xdg/krunnerrc @@ -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 diff --git a/config/plasma/xdg/kscreenlockerrc b/config/plasma/xdg/kscreenlockerrc new file mode 100644 index 0000000..f49e422 --- /dev/null +++ b/config/plasma/xdg/kscreenlockerrc @@ -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 diff --git a/config/plasma/xdg/ksmserverrc b/config/plasma/xdg/ksmserverrc new file mode 100644 index 0000000..533c7e2 --- /dev/null +++ b/config/plasma/xdg/ksmserverrc @@ -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 diff --git a/config/plasma/xdg/kwinrc b/config/plasma/xdg/kwinrc new file mode 100644 index 0000000..14df027 --- /dev/null +++ b/config/plasma/xdg/kwinrc @@ -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 diff --git a/config/plasma/xdg/kwinrulesrc b/config/plasma/xdg/kwinrulesrc new file mode 100644 index 0000000..d46f6fa --- /dev/null +++ b/config/plasma/xdg/kwinrulesrc @@ -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 diff --git a/config/plasma/xdg/plasmanotifyrc b/config/plasma/xdg/plasmanotifyrc new file mode 100644 index 0000000..06e4006 --- /dev/null +++ b/config/plasma/xdg/plasmanotifyrc @@ -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 diff --git a/config/wallpapers/classroom-calm.png b/config/wallpapers/classroom-calm.png new file mode 100644 index 0000000..04f8463 Binary files /dev/null and b/config/wallpapers/classroom-calm.png differ diff --git a/docs/screenshots/click-probe.png b/docs/screenshots/click-probe.png new file mode 100644 index 0000000..f25d534 Binary files /dev/null and b/docs/screenshots/click-probe.png differ diff --git a/docs/screenshots/click-test.png b/docs/screenshots/click-test.png new file mode 100644 index 0000000..24739ec Binary files /dev/null and b/docs/screenshots/click-test.png differ diff --git a/docs/screenshots/labelled-tasks.png b/docs/screenshots/labelled-tasks.png new file mode 100644 index 0000000..69bd3e9 Binary files /dev/null and b/docs/screenshots/labelled-tasks.png differ diff --git a/docs/screenshots/m-closed.png b/docs/screenshots/m-closed.png new file mode 100644 index 0000000..8f3d400 Binary files /dev/null and b/docs/screenshots/m-closed.png differ diff --git a/docs/screenshots/m-minimized.png b/docs/screenshots/m-minimized.png new file mode 100644 index 0000000..36d4945 Binary files /dev/null and b/docs/screenshots/m-minimized.png differ diff --git a/docs/screenshots/recovery-after-8-kills.png b/docs/screenshots/recovery-after-8-kills.png new file mode 100644 index 0000000..b8922e0 Binary files /dev/null and b/docs/screenshots/recovery-after-8-kills.png differ diff --git a/docs/screenshots/recovery-after-kwin-kill.png b/docs/screenshots/recovery-after-kwin-kill.png new file mode 100644 index 0000000..1c26bd5 Binary files /dev/null and b/docs/screenshots/recovery-after-kwin-kill.png differ diff --git a/docs/screenshots/state-1-closed-crop.png b/docs/screenshots/state-1-closed-crop.png new file mode 100644 index 0000000..165bb36 Binary files /dev/null and b/docs/screenshots/state-1-closed-crop.png differ diff --git a/docs/screenshots/state-1-closed.png b/docs/screenshots/state-1-closed.png new file mode 100644 index 0000000..c090901 Binary files /dev/null and b/docs/screenshots/state-1-closed.png differ diff --git a/docs/screenshots/state-2-active.png b/docs/screenshots/state-2-active.png new file mode 100644 index 0000000..1be3a8d Binary files /dev/null and b/docs/screenshots/state-2-active.png differ diff --git a/docs/screenshots/state-3-behind.png b/docs/screenshots/state-3-behind.png new file mode 100644 index 0000000..f94e655 Binary files /dev/null and b/docs/screenshots/state-3-behind.png differ diff --git a/docs/screenshots/state-3-zoom.png b/docs/screenshots/state-3-zoom.png new file mode 100644 index 0000000..91126af Binary files /dev/null and b/docs/screenshots/state-3-zoom.png differ diff --git a/docs/screenshots/state-4-minimized.png b/docs/screenshots/state-4-minimized.png new file mode 100644 index 0000000..36d4945 Binary files /dev/null and b/docs/screenshots/state-4-minimized.png differ diff --git a/docs/screenshots/state-5-restored.png b/docs/screenshots/state-5-restored.png new file mode 100644 index 0000000..f94e655 Binary files /dev/null and b/docs/screenshots/state-5-restored.png differ diff --git a/docs/screenshots/v2-browser-running.png b/docs/screenshots/v2-browser-running.png new file mode 100644 index 0000000..2f059b5 Binary files /dev/null and b/docs/screenshots/v2-browser-running.png differ diff --git a/docs/screenshots/v2-idle-first.png b/docs/screenshots/v2-idle-first.png new file mode 100644 index 0000000..b6cb769 Binary files /dev/null and b/docs/screenshots/v2-idle-first.png differ diff --git a/docs/screenshots/v2-idle.png b/docs/screenshots/v2-idle.png new file mode 100644 index 0000000..8e473cc Binary files /dev/null and b/docs/screenshots/v2-idle.png differ diff --git a/docs/screenshots/v2-minimized-full.png b/docs/screenshots/v2-minimized-full.png new file mode 100644 index 0000000..6720f50 Binary files /dev/null and b/docs/screenshots/v2-minimized-full.png differ diff --git a/docs/screenshots/v2-two-apps.png b/docs/screenshots/v2-two-apps.png new file mode 100644 index 0000000..1cd5c45 Binary files /dev/null and b/docs/screenshots/v2-two-apps.png differ diff --git a/docs/screenshots/v2-wonderink.png b/docs/screenshots/v2-wonderink.png new file mode 100644 index 0000000..0c166ac Binary files /dev/null and b/docs/screenshots/v2-wonderink.png differ diff --git a/scripts/classroom-browser b/scripts/classroom-browser index 015325d..3afccfc 100755 --- a/scripts/classroom-browser +++ b/scripts/classroom-browser @@ -1,32 +1,33 @@ #!/bin/bash set -euo pipefail -# The dock's Browser button. classroom-window decides whether this is a -# launch, a focus, a minimise or a restore. +# The room's ordinary browser: YouTube, and anywhere else a lesson goes. # -# Chrome stays a full browser: Wonder Ink is only the starting page, and -# the profile in ~/.config/google-chrome is never touched here so logins -# and cookies survive reboots and updates. +# It is a full browser with a tab strip and an address bar, in the room's +# own profile, so the church YouTube sign-in survives 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 -# 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 -# all, so it would have silently done nothing. Verified against the -# strings of the installed binary. +# all, so it would have silently done nothing. -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 \ - --match 'google-chrome' \ - --match 'chrome' \ - --match 'chrome-.*' \ - --match 'google-chrome-.*' \ - -- \ - google-chrome \ - --ozone-platform=wayland \ - --start-maximized \ - --no-first-run \ - --no-default-browser-check \ - --password-store=basic \ - --hide-crash-restore-bubble \ - "$HOME_PAGE" +exec google-chrome \ + --ozone-platform=wayland \ + --no-first-run \ + --no-default-browser-check \ + --password-store=basic \ + --hide-crash-restore-bubble \ + "${@:-$HOME_PAGE}" diff --git a/scripts/classroom-wonderink b/scripts/classroom-wonderink new file mode 100755 index 0000000..0412094 --- /dev/null +++ b/scripts/classroom-wonderink @@ -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 \ + "$@" diff --git a/systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf b/systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf new file mode 100644 index 0000000..240e77f --- /dev/null +++ b/systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf @@ -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 diff --git a/tools/build-light-scheme b/tools/build-light-scheme new file mode 100755 index 0000000..46a896e --- /dev/null +++ b/tools/build-light-scheme @@ -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"(? 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)") diff --git a/tools/lab/Dockerfile.plasma b/tools/lab/Dockerfile.plasma index 58c6acd..c9c84ae 100644 --- a/tools/lab/Dockerfile.plasma +++ b/tools/lab/Dockerfile.plasma @@ -50,3 +50,23 @@ CMD ["/sbin/init"] # 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 \ && 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/* diff --git a/tools/lab/install-desktop b/tools/lab/install-desktop new file mode 100755 index 0000000..a5c542c --- /dev/null +++ b/tools/lab/install-desktop @@ -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" diff --git a/tools/lab/kwin-script b/tools/lab/kwin-script new file mode 100755 index 0000000..5a6fe91 --- /dev/null +++ b/tools/lab/kwin-script @@ -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://' + diff --git a/tools/lab/lab b/tools/lab/lab index 714865c..d435406 100755 --- a/tools/lab/lab +++ b/tools/lab/lab @@ -75,8 +75,19 @@ down) ;; 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 \ - -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 \ -d "$NAME" sh -c 'exec sway -c /srv/classroom/tools/lab/outer-sway.conf \ > /tmp/outer-sway.log 2>&1' @@ -91,6 +102,7 @@ plasma-up) session_env \ -e WAYLAND_DISPLAY="$OUTER" -e XDG_SESSION_TYPE=wayland \ -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' for _ in $(seq 1 60); do docker exec "$NAME" pgrep -x plasmashell >/dev/null 2>&1 && break @@ -114,27 +126,45 @@ session) ;; shot) - # shot NAME [--delay SECONDS] + # shot NAME [GEOMETRY] — geometry as grim wants it, e.g. "0,980 1920x100" 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" docker exec "$NAME" cat /tmp/lab-shot.png > "$SHOTS/$name.png" echo "$SHOTS/$name.png" ;; 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 - sleep 0.3 + sleep 0.4 outer swaymsg seat - cursor press button1 >/dev/null - sleep 0.1 + sleep 0.15 outer swaymsg seat - cursor release button1 >/dev/null + sleep 0.5 ;; move) 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) shift outer wtype "$@" @@ -148,6 +178,8 @@ sh) run) shift; session_env "$NAME" "$@" ;; irun) shift; session_env -it "$NAME" "$@" ;; 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" "$@" ;; outer) shift; outer "$@" ;; @@ -164,9 +196,11 @@ usage: tools/lab/lab COMMAND plasma-down stop the Plasma session shot NAME capture the screen to docs/screenshots/NAME.png 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 key ARGS... wtype, aimed at the 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 run CMD... run CMD as the classroom user, no display sh root shell in the lab