On the laptop the browser jumped behind the disc player the moment it was
opened. 0.2.4 caused that, and the cause is two lines of the stock applet:
main.qml:153 hideActivatedLaunchers: tasks.iconsOnly || ...
main.qml:155 launchInPlace: tasks.iconsOnly && sortingStrategy === 1
For an icons-only bar the first is always true — a launcher is hidden the
instant its application starts — and the second is what puts the running
window back where the launcher was. It only does that at sortingStrategy
1. 0.2.4 set it to 0 to stop teachers dragging the icons about, so the
launcher vanished and the window was appended after the others.
Reproduced in the lab with a real Chrome window before changing anything,
and the bar read off the screen: Wonder Ink, the player, then the
browser. With 1 it is Wonder Ink, the browser, the player, through
opening, a second window, another application opening beside it, and
every close.
The launcher identity was suspected and cleared: only one browser button
appears, because google-chrome.desktop already carries Chrome's own file
name and StartupWMClass for exactly this reason.
THE COST, AND IT IS REAL. Manual sorting is also the only mode in which
MouseHandler.qml accepts a reordering drop, so a teacher who knows to drag
can rearrange the bar again. There is no value that gives both and no
Kiosk key that separates them. classroom-desktop-reset already rebuilds
the bar from the package at every login, so a drag never outlives the
session. docs/testing.md states the trade rather than burying it, and the
configuration escape is written down there too as still open.
tools/lab/order-check is the test this needed and did not have. 0.2.4
passed a full set of configuration assertions while the bar did something
else, so this one opens real application windows and reads the panel
strip as pixels — there is no image library in the lab, so it captures
PPM and names each icon by colour. Six checks, all of them behaviour.
Co-Authored-By: Claude Opus 5 <[email protected]>
241 lines
9.2 KiB
Bash
Executable File
241 lines
9.2 KiB
Bash
Executable File
#!/bin/bash
|
|
# Which applications a device offers, and the bar that comes out of it.
|
|
#
|
|
# Two halves. The first is the decision — one setting per application, and
|
|
# never the shape of the machine. The second actually RUNS the layout
|
|
# script the package ships, in node, with Plasma's API stubbed, so the
|
|
# assertions are about the real file rather than about a copy of its text
|
|
# that could drift away from it.
|
|
set -uo pipefail
|
|
|
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
|
|
PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY'
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from importlib.machinery import SourceFileLoader
|
|
from pathlib import Path
|
|
|
|
root = Path(sys.argv[1])
|
|
loader = SourceFileLoader("layout", str(root / "scripts/classroom-desktop-layout"))
|
|
spec = importlib.util.spec_from_loader("layout", loader)
|
|
module = importlib.util.module_from_spec(spec)
|
|
loader.exec_module(module)
|
|
|
|
LAYOUT = root / "config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js"
|
|
|
|
passed = failed = 0
|
|
|
|
|
|
def check(label, condition, detail=""):
|
|
global passed, failed
|
|
if condition:
|
|
print(f" ok {label}")
|
|
passed += 1
|
|
else:
|
|
print(f" FAIL {label}")
|
|
if detail:
|
|
print(f" {detail}")
|
|
failed += 1
|
|
|
|
|
|
BLOCK = """/* CLASSROOM-FEATURES-BEGIN */
|
|
var WONDERINK = true;
|
|
var DVD = true;
|
|
var PARENT_CALL = true;
|
|
/* CLASSROOM-FEATURES-END */"""
|
|
|
|
|
|
print("1. one line per application, rewritten in place")
|
|
check("a feature can be switched off",
|
|
"var WONDERINK = false;" in module.rewrite(BLOCK, "WONDERINK", False))
|
|
check("and back on again",
|
|
"var WONDERINK = true;"
|
|
in module.rewrite(module.rewrite(BLOCK, "WONDERINK", False),
|
|
"WONDERINK", True))
|
|
check("switching one leaves the others alone",
|
|
"var DVD = true;" in module.rewrite(BLOCK, "WONDERINK", False)
|
|
and "var PARENT_CALL = true;" in module.rewrite(BLOCK, "WONDERINK", False))
|
|
check("rewriting twice changes nothing the second time",
|
|
module.rewrite(module.rewrite(BLOCK, "DVD", False), "DVD", False)
|
|
== module.rewrite(BLOCK, "DVD", False))
|
|
|
|
|
|
print("2. the shape of the machine never decides what it is for")
|
|
# This is the architecture rule, and it is the one worth a test: a laptop
|
|
# must be able to run children's church and a tower must be able to be a
|
|
# Bible study.
|
|
module.has_optical_drive = lambda: True
|
|
|
|
laptop_with_ink = module.wanted({"DEVICE_TYPE": "laptop"})
|
|
check("a laptop still offers Wonder Ink unless told otherwise",
|
|
laptop_with_ink["WONDERINK"] is True, str(laptop_with_ink))
|
|
check("a laptop still offers Parent Call unless told otherwise",
|
|
laptop_with_ink["PARENT_CALL"] is True)
|
|
|
|
desktop_no_ink = module.wanted(
|
|
{"DEVICE_TYPE": "desktop", "WONDERINK_ENABLED": "false"})
|
|
check("a desktop can have Wonder Ink taken away",
|
|
desktop_no_ink["WONDERINK"] is False)
|
|
check("and keeps everything else",
|
|
desktop_no_ink["DVD"] is True and desktop_no_ink["PARENT_CALL"] is True)
|
|
|
|
check("an existing room that has never heard of these keeps them all",
|
|
all(module.wanted({}).values()), str(module.wanted({})))
|
|
|
|
|
|
print("3. a drive can only ever take the player away")
|
|
module.has_optical_drive = lambda: False
|
|
check("no drive means no player, whatever the setting says",
|
|
module.wanted({"DVD_ENABLED": "true"})["DVD"] is False)
|
|
module.has_optical_drive = lambda: True
|
|
check("a drive plus DVD_ENABLED=false is still no player",
|
|
module.wanted({"DVD_ENABLED": "false"})["DVD"] is False)
|
|
check("a drive plus DVD_ENABLED=true is the player",
|
|
module.wanted({"DVD_ENABLED": "true"})["DVD"] is True)
|
|
|
|
|
|
print("4. the script rewrites the layout the package actually ships")
|
|
module.has_optical_drive = lambda: True
|
|
|
|
def run(config):
|
|
"""Run the real script over a copy of the real layout."""
|
|
work = Path(tempfile.mkdtemp(prefix="classroom-layout-"))
|
|
copy = work / "layout.js"
|
|
shutil.copy(LAYOUT, copy)
|
|
|
|
module.read_device_config = lambda: config
|
|
module.main.__globals__["sys"].argv = ["classroom-desktop-layout", str(copy)]
|
|
module.main()
|
|
|
|
return copy.read_text(), work
|
|
|
|
text, work = run({})
|
|
check("the shipped layout carries the feature block",
|
|
module.MARKER in text)
|
|
check("and an unconfigured machine keeps every application",
|
|
"var WONDERINK = true;" in text and "var DVD = true;" in text
|
|
and "var PARENT_CALL = true;" in text)
|
|
|
|
|
|
print("5. the bar that comes out, evaluated in node")
|
|
STUB = r"""
|
|
const fs = require("fs");
|
|
const captured = {widgets: [], config: {}};
|
|
let current = null;
|
|
function makeWidget(type) {
|
|
const w = {
|
|
type: type,
|
|
currentConfigGroup: [],
|
|
writeConfig: function (k, v) { captured.config[type + "." + k] = String(v); },
|
|
reloadConfig: function () {}
|
|
};
|
|
captured.widgets.push(type);
|
|
return w;
|
|
}
|
|
globalThis.desktops = () => [makeWidget("desktop")];
|
|
globalThis.Panel = function () {
|
|
this.addWidget = makeWidget;
|
|
return this;
|
|
};
|
|
eval(fs.readFileSync(process.argv[2], "utf8"));
|
|
console.log(JSON.stringify(captured));
|
|
"""
|
|
|
|
def bar(config):
|
|
text, work = run(config)
|
|
stub = work / "stub.js"
|
|
stub.write_text(STUB)
|
|
result = subprocess.run(
|
|
["node", str(stub), str(work / "layout.js")],
|
|
capture_output=True, text=True, timeout=60,
|
|
)
|
|
if result.returncode != 0:
|
|
return None, result.stderr
|
|
return json.loads(result.stdout), ""
|
|
|
|
|
|
if shutil.which("node") is None:
|
|
print(" SKIP node is not installed; the layout was not evaluated")
|
|
else:
|
|
LAUNCH = "org.kde.plasma.icontasks.launchers"
|
|
INK = "applications:ekklesia-wonderink.desktop"
|
|
WEB = "applications:google-chrome.desktop"
|
|
DISC = "applications:ekklesia-dvd.desktop"
|
|
|
|
every, why = bar({})
|
|
check("a classroom gets Wonder Ink, the browser and the player, in order",
|
|
every and every["config"].get(LAUNCH) == f"{INK},{WEB},{DISC}",
|
|
(every or {}).get("config", {}).get(LAUNCH, why))
|
|
check("and Parent Call in the corner",
|
|
every and "org.kde.plasma.quicklaunch" in every["widgets"])
|
|
|
|
no_ink, why = bar({"WONDERINK_ENABLED": "false"})
|
|
check("a Bible study desktop has the browser first and no gap",
|
|
no_ink and no_ink["config"].get(LAUNCH) == f"{WEB},{DISC}",
|
|
(no_ink or {}).get("config", {}).get(LAUNCH, why))
|
|
|
|
module.has_optical_drive = lambda: False
|
|
bible, why = bar({"WONDERINK_ENABLED": "false",
|
|
"PARENT_CALL_ENABLED": "false",
|
|
"DEVICE_TYPE": "laptop"})
|
|
check("the laptop's bar is the browser alone",
|
|
bible and bible["config"].get(LAUNCH) == WEB,
|
|
(bible or {}).get("config", {}).get(LAUNCH, why))
|
|
check("with no Parent Call button on it",
|
|
bible and "org.kde.plasma.quicklaunch" not in bible["widgets"],
|
|
str((bible or {}).get("widgets")))
|
|
check("and the status corner is untouched",
|
|
bible and "org.kde.plasma.volume" in bible["widgets"]
|
|
and "org.kde.plasma.digitalclock" in bible["widgets"]
|
|
and "org.kde.plasma.networkmanagement" in bible["widgets"])
|
|
|
|
module.has_optical_drive = lambda: True
|
|
ink_laptop, why = bar({"DEVICE_TYPE": "laptop"})
|
|
check("a laptop taking children's church gets the same bar as a tower",
|
|
ink_laptop and ink_laptop["config"].get(LAUNCH) == f"{INK},{WEB},{DISC}",
|
|
(ink_laptop or {}).get("config", {}).get(LAUNCH, why))
|
|
|
|
print("6. the order survives an application opening")
|
|
# 1, "manually", is the ONLY value at which the Icons-Only Task
|
|
# Manager keeps a running application in its pinned position:
|
|
# main.qml gates launchInPlace on sortingStrategy === 1, and for an
|
|
# icons-only bar the launcher is always hidden once its application
|
|
# starts. 0.2.4 set this to 0 and the browser jumped behind the disc
|
|
# player the moment it opened.
|
|
#
|
|
# This assertion is the mechanism. The behaviour itself is checked
|
|
# against real windows by tools/lab/order-check, because a
|
|
# configuration test is exactly what 0.2.4 passed.
|
|
check("sorting is manual, which is what keeps a launcher in place",
|
|
every and every["config"].get(
|
|
"org.kde.plasma.icontasks.sortingStrategy") == "1",
|
|
str((every or {}).get("config", {}).get(
|
|
"org.kde.plasma.icontasks.sortingStrategy")))
|
|
check("and launchers stay ahead of running windows",
|
|
every and every["config"].get(
|
|
"org.kde.plasma.icontasks.separateLaunchers") == "true")
|
|
|
|
print("7. a disabled application does not start either")
|
|
# Taking the launcher off the bar is half of it. The command itself has
|
|
# to refuse, so a device that does not offer an application cannot be
|
|
# talked into running it by a stale desktop entry or a technician's
|
|
# muscle memory.
|
|
for script, setting in (("classroom-wonderink", "WONDERINK_ENABLED"),
|
|
("classroom-parent-call", "PARENT_CALL_ENABLED")):
|
|
text = (root / "scripts" / script).read_text()
|
|
check(f"{script} refuses when {setting} is false",
|
|
setting in text and "not starting" in text)
|
|
check(f"{script} defaults to starting, so existing rooms are unaffected",
|
|
'default=True' in text or 'feature(' in text)
|
|
|
|
print()
|
|
print(f"{passed} passed, {failed} failed")
|
|
raise SystemExit(1 if failed else 0)
|
|
PY
|