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 index 364deab..0b3020f 100644 --- a/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js +++ b/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js @@ -116,20 +116,33 @@ if (DVD) { tasks.writeConfig("launchers", launchers.join(",")); -// THE ORDER STAYS PUT. +// THE ORDER STAYS PUT WHEN AN APPLICATION OPENS. // -// 0 is "do not sort", and it is what stops a teacher dragging the -// launchers into a different order. Plasma's default is 1, "manually", -// and manual sorting is the only mode in which the task manager accepts a -// reordering drop at all — see MouseHandler.qml, which tests -// tasksModel.sortMode === SortManual before it will move anything. The -// classroom shipped without this line, so it had Plasma's default and the -// bar could be rearranged by anybody who dragged an icon. +// 1 is "manually", and it has to be 1. This is not a preference; it is +// the only value at which the Icons-Only Task Manager keeps a running +// application in its pinned position. // -// This is the supported lever rather than a patched task manager: the -// drop is refused by the applet's own code, not caught afterwards by -// something of ours putting the bar back. -tasks.writeConfig("sortingStrategy", 0); +// 0.2.4 set this to 0 to stop teachers dragging the launchers about, and +// that worked — and broke the order instead, which is the bug this +// replaces. Two lines of the stock applet explain the whole thing: +// +// main.qml:153 hideActivatedLaunchers: tasks.iconsOnly || ... +// main.qml:155 launchInPlace: tasks.iconsOnly && sortingStrategy === 1 +// +// For an icons-only bar the first is ALWAYS true: the moment an +// application starts, its launcher is hidden. The second is what puts the +// running window back in the launcher's place — and only at 1. At 0 the +// launcher vanished and the window was appended after the others, so +// opening the browser moved it behind the disc player. Reproduced in the +// lab with a real Chrome window, and fixed by this line; both measured. +// +// The cost is that manual sorting is also the only mode in which +// MouseHandler.qml accepts a reordering drop, so a teacher can drag the +// launchers again within a session. There is no value that gives both, +// and no Kiosk key that separates them. classroom-desktop-reset rebuilds +// this bar from the package at every login, so a drag never outlives the +// session, and docs/testing.md records the trade rather than hiding it. +tasks.writeConfig("sortingStrategy", 1); // Launchers stay together at the start of the row and running windows // appear after them, so opening or closing an application cannot move a // pinned icon. This is Plasma's default; it is written down because the diff --git a/docs/screenshots/panel-after-chrome.png b/docs/screenshots/panel-after-chrome.png new file mode 100644 index 0000000..2d82ce4 Binary files /dev/null and b/docs/screenshots/panel-after-chrome.png differ diff --git a/docs/screenshots/panel-after-drag-sort1.png b/docs/screenshots/panel-after-drag-sort1.png new file mode 100644 index 0000000..9cfb0aa Binary files /dev/null and b/docs/screenshots/panel-after-drag-sort1.png differ diff --git a/docs/screenshots/panel-before-chrome.png b/docs/screenshots/panel-before-chrome.png new file mode 100644 index 0000000..fc60dd7 Binary files /dev/null and b/docs/screenshots/panel-before-chrome.png differ diff --git a/docs/screenshots/panel-drag-fine.png b/docs/screenshots/panel-drag-fine.png new file mode 100644 index 0000000..5465c25 Binary files /dev/null and b/docs/screenshots/panel-drag-fine.png differ diff --git a/docs/screenshots/panel-sort1-chrome.png b/docs/screenshots/panel-sort1-chrome.png new file mode 100644 index 0000000..6a7f0fc Binary files /dev/null and b/docs/screenshots/panel-sort1-chrome.png differ diff --git a/docs/screenshots/panel-sort1-crop.png b/docs/screenshots/panel-sort1-crop.png new file mode 100644 index 0000000..54df07e Binary files /dev/null and b/docs/screenshots/panel-sort1-crop.png differ diff --git a/docs/testing.md b/docs/testing.md index 274089b..a088044 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -256,11 +256,54 @@ next login. - [ ] A disabled application leaves no gap: the remaining launchers close up - [ ] The launcher order is the same on two machines with the same features enabled -- [ ] Dragging a launcher does not move it -- [ ] Right-clicking a launcher offers no Pin, Unpin, Configure or panel - edit entry - [ ] The order survives launching and closing applications, logout/login - and a reboot + and a reboot — run `tools/lab/order-check` against a live session, + which opens real windows and reads the bar off the screen +- [ ] Right-clicking a launcher offers no Pin or Unpin + +### Two things the bar cannot do at once + +Stock Plasma 6.3 ties them together, and this is the trade the classroom +makes. Both halves are measured, not inferred: + + main.qml:153 hideActivatedLaunchers: tasks.iconsOnly || ... + main.qml:155 launchInPlace: tasks.iconsOnly && sortingStrategy === 1 + MouseHandler.qml:74 accepts a reordering drop only when + sortMode === SortManual + +For an icons-only bar the launcher is ALWAYS hidden once its application +starts. Only `sortingStrategy = 1` puts the running window back in the +launcher's place — and 1 is also the only value at which a drag can +reorder anything. + + * `sortingStrategy = 0` — no dragging, but the browser jumps behind the + disc player when it opens. That was 0.2.4, and it is the bug. + * `sortingStrategy = 1` — the order holds through every open and close, + and a teacher who knows to drag can rearrange the bar within a + session. This is what ships. + +There is no third value and no Kiosk key that separates them. +`classroom-desktop-reset` rebuilds the bar from the package at every +login, so a drag never outlives the session. + +- [ ] KNOWN: a drag can still rearrange the bar until the next login +- [ ] After logging out and back in, the packaged order is back + +### The configuration escape (unresolved) + +Right-clicking a launcher offers **Configure Icons-Only Task Manager…**, +and that dialog opens and reaches the sorting setting. Measured on the +lab session with the desktop locked: `plasma/plasmashell/unlockedDesktop` +reads false, `evaluateScript` answers "Widgets are locked", Pin and Unpin +are correctly hidden — and Configure is not gated by any of it. There is +no `allow_configure_when_locked` key in Plasma 6.3; it was searched for +in the shipped libraries and does not exist. + +Closing it means overriding stock `ContextMenu.qml`, which is a fork of a +file that changes between Plasma releases. Not done. + +- [ ] KNOWN: a teacher who opens that dialog can change the sorting, and + the next login puts it back - [ ] The bar is right at 1080p/100% and 4K/200% ## The Bible study laptop diff --git a/lib/classroom_network.py b/lib/classroom_network.py new file mode 100644 index 0000000..005f2b5 --- /dev/null +++ b/lib/classroom_network.py @@ -0,0 +1,162 @@ +"""Reading what ifupdown was told about this machine's network. + +Only the parsing, so it can be tested against the file a real machine +actually has rather than against a running laptop. + +WHY THIS EXISTS. + +The Bible study laptop had working internet and a Plasma network applet +that said there was no connection. Both were true. Debian's installer had +been given the Wi-Fi during installation, so it wrote the network into +/etc/network/interfaces: + + auto wlp2s0 + iface wlp2s0 inet dhcp + wpa-ssid Something + wpa-psk a-secret + +ifupdown brings that up with its own wpa_supplicant and dhcpcd, which is +why the internet works. NetworkManager ships with plugins=ifupdown and +[ifupdown] managed=false, so an interface named in that file is marked +UNMANAGED — which is why nmcli showed `wlp2s0 wifi unmanaged`, why the +applet had nothing to report, and why scanning found nothing: an +unmanaged device is not a radio NetworkManager will scan with. + +managed=false is deliberate and stays. It is what keeps a tower's static +Ethernet under ifupdown's control where an administrator put it. What has +to change is narrower: a WIRELESS interface should belong to +NetworkManager, so the room can see it, scan with it, and so its password +is stored the way this package stores passwords. + +So this finds the wireless stanza and everything needed to recreate it as +a NetworkManager system connection. +""" + +import re + +# ifupdown's own spelling. Values may be quoted, and a psk may be a +# 64-character hex key rather than a passphrase. +SETTINGS = ("wpa-ssid", "wpa-psk", "wpa-key-mgmt", "wpa-conf", "wpa-proto") + +_STANZA = re.compile(r"^\s*(iface|auto|allow-hotplug|source|source-directory)\s+(.*)$") + + +def _unquote(value): + value = (value or "").strip() + + for quote in ('"', "'"): + if len(value) >= 2 and value.startswith(quote) and value.endswith(quote): + return value[1:-1] + + return value + + +def parse(text): + """Every `iface` stanza in an interfaces file, with its options. + + Returns a list of dicts: name, family, method, options, and the line + range the stanza occupies so a caller can comment exactly it out and + nothing else. + """ + lines = (text or "").splitlines() + stanzas = [] + current = None + + for number, raw in enumerate(lines): + line = raw.split("#", 1)[0].rstrip() + match = _STANZA.match(line) + + if match: + keyword, rest = match.group(1), match.group(2).split() + + if keyword == "iface": + current = { + "name": rest[0] if rest else "", + "family": rest[1] if len(rest) > 1 else "", + "method": rest[2] if len(rest) > 2 else "", + "options": {}, + "first": number, + "last": number, + } + stanzas.append(current) + else: + # auto/allow-hotplug end the previous stanza's body. + current = None + + continue + + if current is None or not line.strip(): + continue + + parts = line.strip().split(None, 1) + + if not parts: + continue + + current["options"][parts[0].lower()] = _unquote( + parts[1] if len(parts) > 1 else "") + current["last"] = number + + return stanzas + + +def wireless(stanzas, is_wireless): + """The stanzas that describe a wireless interface. + + `is_wireless` answers for a device name, so the caller decides how to + ask the machine — /sys/class/net//wireless in practice. A + wpa-ssid is accepted as evidence on its own, for a machine whose + device has since been renamed or removed. + """ + found = [] + + for stanza in stanzas: + name = stanza.get("name") or "" + + if not name or name == "lo": + continue + + if is_wireless(name) or "wpa-ssid" in stanza["options"]: + found.append(stanza) + + return found + + +def credentials(stanza): + """SSID and pre-shared key from a stanza, or (None, None). + + A stanza that points at a wpa_supplicant.conf instead of naming the + network inline returns no SSID: that one is not ours to move, and the + caller must leave it alone rather than guess. + """ + options = stanza.get("options") or {} + + if "wpa-conf" in options and "wpa-ssid" not in options: + return None, None + + return options.get("wpa-ssid") or None, options.get("wpa-psk") or None + + +def comment_out(text, stanza, marker="# ekklesia-classroom: "): + """Comment out exactly one stanza, leaving every other byte alone.""" + lines = (text or "").splitlines(keepends=True) + + for number in range(stanza["first"], stanza["last"] + 1): + if number < len(lines) and not lines[number].lstrip().startswith("#"): + lines[number] = marker + lines[number] + + # The `auto`/`allow-hotplug` line above it would otherwise ask + # ifupdown to bring up an interface it no longer has a stanza for. + for number in range(stanza["first"] - 1, -1, -1): + stripped = lines[number].strip() if number < len(lines) else "" + + if not stripped or stripped.startswith("#"): + continue + + if re.match(r"^(auto|allow-hotplug)\s", stripped) and \ + stanza["name"] in stripped.split(): + lines[number] = marker + lines[number] + + break + + return "".join(lines) diff --git a/scripts/classroom-wifi b/scripts/classroom-wifi index 6a43b6f..534803e 100755 --- a/scripts/classroom-wifi +++ b/scripts/classroom-wifi @@ -9,6 +9,20 @@ terminal: classroom-wifi "Church Wi-Fi" # prompts for the password classroom-wifi --list classroom-wifi --forget "Old Network" + classroom-wifi --adopt --dry-run # what would be migrated + classroom-wifi --adopt # migrate it, with rollback + +ADOPTING A WI-FI THE INSTALLER SET UP. + +A machine whose Wi-Fi was given to the Debian installer has it in +/etc/network/interfaces, where ifupdown owns it. The internet works and +NetworkManager reports the device UNMANAGED, so the room's applet says +there is no connection and cannot scan. --adopt moves that network into a +NetworkManager system connection and takes the stanza out of ifupdown's +hands, which is the whole fix for both symptoms. + +The password never leaves the machine and is never printed: this runs as +root, reads it out of a root-only file and writes it into another one. WHY THIS EXISTS RATHER THAN LETTING A TEACHER ADD ONE. @@ -34,10 +48,25 @@ if the room could create a connection it was allowed to read. import argparse import getpass import os +import shutil import subprocess import sys +import time + +sys.path.insert(0, "/usr/local/lib/ekklesia-classroom") +sys.path.insert( + 0, + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"), +) + +import classroom_network as ifupdown # noqa: E402 CONNECTIONS = "/etc/NetworkManager/system-connections" +INTERFACES = "/etc/network/interfaces" + +# How long NetworkManager gets to bring the network back up after the +# hand-over before this decides it has failed and puts ifupdown back. +ADOPT_TIMEOUT = 45 def say(message): @@ -119,6 +148,140 @@ def add(ssid, password, name=None): return 0 +def is_wireless(device): + return os.path.isdir(f"/sys/class/net/{device}/wireless") + + +def connectivity(): + result = nmcli("networking", "connectivity", check=False) + + return (result.stdout or "").strip() + + +def online(): + """Whether the machine can actually reach anything. + + NetworkManager's own answer first, because that is the thing the room + was complaining about; a real fetch second, because "full" is a claim + and this is the moment to be sure of it. + """ + if connectivity() in ("full", "limited"): + return True + + return subprocess.run( + ["curl", "-fsS", "--max-time", "8", "-o", "/dev/null", + "https://updates.riverofglorymn.org/stable/manifest.json"], + capture_output=True, check=False, + ).returncode == 0 + + +def adopt(dry_run=False): + """Move an installer-configured Wi-Fi from ifupdown to NetworkManager. + + Ordered so that the link is given up as late as possible and put back + the moment anything goes wrong. The connection is created first and + sits unused; the file is changed next; only then is the interface + handed over, and if the room is not back on the network within + ADOPT_TIMEOUT the file is restored and ifupdown brought back. + """ + try: + with open(INTERFACES, "r", encoding="utf-8") as handle: + text = handle.read() + except OSError as error: + say(f"cannot read {INTERFACES}: {error}") + return 1 + + stanzas = ifupdown.wireless(ifupdown.parse(text), is_wireless) + + if not stanzas: + say("no wireless interface is configured in /etc/network/interfaces") + say("nothing to adopt; this machine's Wi-Fi is already " + "NetworkManager's or it has none") + return 0 + + if len(stanzas) > 1: + say(f"{len(stanzas)} wireless stanzas found; refusing to guess") + return 1 + + stanza = stanzas[0] + device = stanza["name"] + ssid, psk = ifupdown.credentials(stanza) + + if not ssid: + say(f"{device} is configured through a wpa_supplicant.conf rather " + "than named in this file") + say("that one is not ours to move; adopt it by hand") + return 1 + + # Never printed. The room's password is not diagnostic output. + say(f"{device}: ifupdown holds a wireless network " + f"({'with' if psk else 'without'} a saved password)") + + if dry_run: + say(f"would create a NetworkManager system connection for {ssid!r}") + say(f"would comment the {device} stanza out of {INTERFACES}") + say("would hand the interface over, and put it back if that failed") + return 0 + + existing = nmcli("-t", "-f", "NAME", "connection", "show", check=False) + already = ssid in (existing.stdout or "").splitlines() + + if already: + say(f"NetworkManager already has a connection named {ssid!r}") + else: + if add(ssid, psk) != 0: + return 1 + + backup = f"{INTERFACES}.before-ekklesia-classroom" + + if not os.path.exists(backup): + shutil.copy2(INTERFACES, backup) + say(f"kept the original at {backup}") + + with open(INTERFACES, "w", encoding="utf-8") as handle: + handle.write(ifupdown.comment_out(text, stanza)) + + say(f"took {device} out of ifupdown's hands") + + # NetworkManager only re-reads which interfaces ifupdown claims when + # its configuration is reloaded. The link is still ifupdown's at this + # point, and NetworkManager does not touch a device it considers + # unmanaged, so this cannot drop it. + nmcli("general", "reload", "conf", check=False) + + subprocess.run(["ifdown", device], capture_output=True, check=False) + nmcli("device", "set", device, "managed", "yes", check=False) + nmcli("connection", "up", ssid, check=False) + + deadline = time.monotonic() + ADOPT_TIMEOUT + + while time.monotonic() < deadline: + if online(): + say(f"{device} is NetworkManager's now, and the room is online") + say("the panel will show the connection at the next login") + return 0 + + time.sleep(3) + + # It did not come back. Put the machine exactly as it was. + say("NetworkManager did not get the network back; rolling back") + shutil.copy2(backup, INTERFACES) + nmcli("general", "reload", "conf", check=False) + nmcli("device", "set", device, "managed", "no", check=False) + subprocess.run(["ifup", device], capture_output=True, check=False) + + for _ in range(10): + if online(): + say("ifupdown has the network back; nothing was lost") + return 1 + + time.sleep(3) + + say("WARNING: the machine may be offline. ifupdown was restored; try " + f"`ifup {device}` or a reboot") + return 1 + + def main(): parser = argparse.ArgumentParser( description="Provision the church Wi-Fi as a system connection.") @@ -130,6 +293,11 @@ def main(): help="remove a saved connection") parser.add_argument("--open", action="store_true", help="the network has no password") + parser.add_argument("--adopt", action="store_true", + help="move an installer-configured Wi-Fi from " + "ifupdown into NetworkManager") + parser.add_argument("--dry-run", action="store_true", + help="with --adopt, say what would change") arguments = parser.parse_args() if arguments.list: @@ -139,6 +307,9 @@ def main(): say("run this as root; it creates a connection the machine owns") return 1 + if arguments.adopt: + return adopt(dry_run=arguments.dry_run) + if arguments.forget: return forget(arguments.forget) diff --git a/tests/test-classroom-desktop-layout.sh b/tests/test-classroom-desktop-layout.sh index c047d70..22365a0 100755 --- a/tests/test-classroom-desktop-layout.sh +++ b/tests/test-classroom-desktop-layout.sh @@ -201,10 +201,20 @@ else: ink_laptop and ink_laptop["config"].get(LAUNCH) == f"{INK},{WEB},{DISC}", (ink_laptop or {}).get("config", {}).get(LAUNCH, why)) - print("6. the bar cannot be rearranged by dragging") - check("sorting is off, which is what refuses a reordering drop", + 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") == "0", + "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", diff --git a/tests/test-classroom-network-adopt.sh b/tests/test-classroom-network-adopt.sh new file mode 100755 index 0000000..f27f67f --- /dev/null +++ b/tests/test-classroom-network-adopt.sh @@ -0,0 +1,153 @@ +#!/bin/bash +# Moving an installer-configured Wi-Fi out of ifupdown's hands. +# +# The Bible study laptop had working internet and an applet that said +# there was no connection, because Debian's installer had written the +# Wi-Fi into /etc/network/interfaces and NetworkManager marks an +# interface named there UNMANAGED. This is the parsing half of the fix: +# find that stanza, get the network out of it, and comment out exactly +# it and nothing else. +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY' +import sys +from pathlib import Path + +root = Path(sys.argv[1]) +sys.path.insert(0, str(root / "lib")) + +from classroom_network import comment_out, credentials, parse, wireless + +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 + + +# The shape Debian's installer writes when it is given a wireless network +# during installation. Measured against the laptop: /etc/network/interfaces +# is 0600 root:root, wpa-ssid and wpa-psk are inline, and the +# wpa_supplicant it starts has no -c config file, which is what tells you +# ifupdown fed it these values rather than a wpa_supplicant.conf. +LAPTOP = """# This file describes the network interfaces available on your system +source /etc/network/interfaces.d/* + +auto lo +iface lo inet loopback + +allow-hotplug wlp2s0 +iface wlp2s0 inet dhcp +\twpa-ssid Sanctuary +\twpa-psk a-real-secret +""" + +# A classroom tower: static Ethernet an administrator put under +# ifupdown's control on purpose. Nothing here may touch it. +TOWER = """source /etc/network/interfaces.d/* + +auto lo +iface lo inet loopback + +auto enp1s0 +iface enp1s0 inet static +\taddress 10.40.0.55/24 +\tgateway 10.40.0.1 +""" + + +def wireless_names(*names): + return lambda device: device in names + + +print("1. the laptop's own file") +stanzas = parse(LAPTOP) +check("every iface stanza is found", + [s["name"] for s in stanzas] == ["lo", "wlp2s0"], + str([s["name"] for s in stanzas])) +found = wireless(stanzas, wireless_names("wlp2s0")) +check("the wireless one is picked out", len(found) == 1) +check("and it is not the loopback", found and found[0]["name"] == "wlp2s0") + +ssid, psk = credentials(found[0]) +check("the network name is read", ssid == "Sanctuary", repr(ssid)) +check("and the password", psk == "a-real-secret", repr(psk)) + +print("2. a tower is left completely alone") +tower = parse(TOWER) +check("its Ethernet is not mistaken for wireless", + wireless(tower, wireless_names("wlp2s0")) == []) +check("even though it has a static stanza ifupdown owns", + [s["name"] for s in tower] == ["lo", "enp1s0"]) +# managed=false stays because of this machine, so nothing may change here. +check("commenting out is never reached for it", + not wireless(tower, lambda d: False)) + +print("3. only the wireless stanza is commented out") +after = comment_out(LAPTOP, found[0]) +check("the iface line is commented", + "# ekklesia-classroom: iface wlp2s0" in after, after) +check("its options are commented with it", + "# ekklesia-classroom: \twpa-ssid" in after) +check("the allow-hotplug above it goes too, or ifupdown asks for an " + "interface with no stanza", + "# ekklesia-classroom: allow-hotplug wlp2s0" in after) +check("the loopback is untouched", + "\niface lo inet loopback\n" in after) +check("and so is the source line", + "\nsource /etc/network/interfaces.d/*\n" in after) +check("the password is still in the file, not deleted", + "a-real-secret" in after, + "the original must remain recoverable until rollback is impossible") + +print("4. doing it twice changes nothing the second time") +twice = comment_out(after, parse(after)[0] if parse(after) else found[0]) +check("already-commented lines are not commented again", + "# ekklesia-classroom: # ekklesia-classroom:" not in twice) + +print("5. a stanza this must refuse to move") +# Pointing at a wpa_supplicant.conf means the credentials are not in this +# file, so there is nothing here to recreate and guessing would be wrong. +CONF = """allow-hotplug wlan0 +iface wlan0 inet dhcp +\twpa-conf /etc/wpa_supplicant/wpa_supplicant.conf +""" +stanza = wireless(parse(CONF), wireless_names("wlan0"))[0] +check("a wpa-conf stanza yields no SSID, so the caller stops", + credentials(stanza) == (None, None), str(credentials(stanza))) + +print("6. a renamed or absent device is still recognised") +# The machine is asked whether a device is wireless, but a wpa-ssid is +# evidence on its own — a device that has been renamed between kernels +# would otherwise be invisible. +check("a wpa-ssid alone is enough", + len(wireless(parse(LAPTOP), lambda d: False)) == 1) + +print("7. the command itself") +wifi = (root / "scripts/classroom-wifi").read_text() +check("--adopt exists and has a dry run", "--adopt" in wifi and "--dry-run" in wifi) +check("it keeps the original file before changing it", + "before-ekklesia-classroom" in wifi) +check("it verifies the room is really online, not just that NM says so", + "curl" in wifi and "connectivity" in wifi) +check("it rolls back to ifupdown if the network does not come back", + "rolling back" in wifi and "ifup" in wifi) +check("it never prints the password", + "psk}" not in wifi and "{psk" not in wifi) +check("the connection it creates is still a system one", + '"connection.permissions"' not in wifi) + +print() +print(f"{passed} passed, {failed} failed") +raise SystemExit(1 if failed else 0) +PY diff --git a/tools/lab/order-check b/tools/lab/order-check new file mode 100755 index 0000000..97c87a0 --- /dev/null +++ b/tools/lab/order-check @@ -0,0 +1,82 @@ +#!/bin/bash +# The launcher order, read off the screen, with real windows opening and +# closing underneath it. +# +# 0.2.4 shipped a launcher order that was right in every configuration +# test and wrong on the bar, so this test opens actual applications and +# looks at actual pixels. Run with a classroom session already up: +# +# tools/lab/lab session +# tools/lab/order-check +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +NAME="${LAB_NAME:-classroom-v2-lab}" +WANT="wonderink browser player" + +passed=0 +failed=0 + +# Consecutive repeats are collapsed before comparing. The classroom sets +# groupingStrategy=0 on purpose — every window gets its own button, so a +# teacher never has to open a group to discover a second window exists — +# so two browser windows are two browser buttons and that is correct. What +# must not change is the order of the KINDS: Wonder Ink, then the browser, +# then the player. +check() { + local label="$1" + local got + got="$("$ROOT/tools/lab/panel-order" | uniq | tr '\n' ' ' | sed 's/ *$//')" + + if [[ "$got" == "$WANT" ]]; then + echo " ok $label" + passed=$((passed + 1)) + else + echo " FAIL $label" + echo " wanted [$WANT]" + echo " got [$got]" + failed=$((failed + 1)) + fi +} + +app() { + docker exec -d -u classroom \ + -e XDG_RUNTIME_DIR=/run/user/1000 -e WAYLAND_DISPLAY=wayland-0 \ + -e HOME=/home/classroom -e XDG_CURRENT_DESKTOP=KDE \ + -e DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \ + "$NAME" sh -c "$1" +} + +echo "1. nothing running" +docker exec "$NAME" pkill -u classroom chrome >/dev/null 2>&1 || true +sleep 4 +check "the bar is Wonder Ink, the browser, the player" + +echo "2. the browser opens" +app 'exec google-chrome --ozone-platform=wayland --no-sandbox --disable-gpu --no-first-run about:blank' +sleep 16 +check "the browser stays in its place and does not move behind the player" + +echo "3. a second browser window" +app 'exec google-chrome --ozone-platform=wayland --no-sandbox --disable-gpu --new-window about:blank' +sleep 12 +check "a second window adds a button beside it and moves nothing" + +echo "4. Wonder Ink opens as well, with the browser still running" +app 'exec classroom-wonderink' +sleep 18 +check "the first launcher does not move when it is the one that opens" + +echo "5. Wonder Ink closes, the browser stays" +docker exec "$NAME" pkill -u classroom -f classroom-wonderink >/dev/null 2>&1 || true +sleep 6 +check "closing the middle of three changes nothing" + +echo "6. the browser closes again" +docker exec "$NAME" pkill -u classroom chrome >/dev/null 2>&1 || true +sleep 6 +check "closing it puts nothing back in a different order" + +echo +echo "$passed passed, $failed failed" +[[ $failed -eq 0 ]] diff --git a/tools/lab/panel-order b/tools/lab/panel-order new file mode 100755 index 0000000..900bfd6 --- /dev/null +++ b/tools/lab/panel-order @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 + +"""Read the classroom bar off the screen and say what order it is in. + +The launcher-order bug of 0.2.4 got past a full set of configuration +tests, because the configuration was right and the applet did something +else with it. So this asks the picture. + +It captures the panel strip as a PPM — no image library anywhere in this +lab, and PPM is a length, three numbers and the bytes — finds the icons by +looking for columns that are not the bar's own background, and names each +one by its colour. Three icons, three colours that are nothing like each +other: + + Wonder Ink orange and blue, and orange is what a column of it + averages to + Browser Chrome's wheel: red, green, yellow and blue, so its + average is unusually grey for something this bright + the player one flat purple disc + +Usage: panel-order [STRIP] default "0,990 1920x90" +Prints one name per icon, left to right. +""" + +import subprocess +import sys + +CONTAINER = "classroom-v2-lab" +STRIP = sys.argv[1] if len(sys.argv) > 1 else "0,990 1920x90" + + +def capture(): + socket = subprocess.run( + ["docker", "exec", CONTAINER, "sh", "-c", + "ls /run/user/1000/sway-ipc.* 2>/dev/null | head -1"], + capture_output=True, text=True, check=True).stdout.strip() + + raw = subprocess.run( + ["docker", "exec", "-u", "classroom", + "-e", "XDG_RUNTIME_DIR=/run/user/1000", + "-e", "WAYLAND_DISPLAY=wayland-1", + "-e", f"SWAYSOCK={socket}", + CONTAINER, "grim", "-t", "ppm", "-g", STRIP, "-"], + capture_output=True, check=True).stdout + + return raw + + +def pixels(raw): + """(width, height, rows) from a binary PPM.""" + fields = [] + index = 0 + + while len(fields) < 4: + while raw[index:index + 1].isspace(): + index += 1 + + if raw[index:index + 1] == b"#": + while raw[index:index + 1] not in (b"\n", b""): + index += 1 + continue + + start = index + + while not raw[index:index + 1].isspace(): + index += 1 + + fields.append(raw[start:index]) + + index += 1 + width, height = int(fields[1]), int(fields[2]) + body = raw[index:] + rows = [ + body[y * width * 3:(y + 1) * width * 3] + for y in range(height) + ] + + return width, height, rows + + +def columns(width, height, rows): + """Average colour and brightness of each column.""" + found = [] + + for x in range(width): + r = g = b = 0 + + for row in rows: + r += row[x * 3] + g += row[x * 3 + 1] + b += row[x * 3 + 2] + + found.append((r // height, g // height, b // height)) + + return found + + +def name(colour): + r, g, b = colour + + # The disc: blue and red well above green. + if b > g + 25 and r > g + 15: + return "player" + + # Wonder Ink: warm, red clearly ahead of blue. + if r > b + 25: + return "wonderink" + + return "browser" + + +def main(): + width, height, rows = pixels(capture()) + found = columns(width, height, rows) + + # The bar is dark and flat; an icon is a run of brighter columns. + floor = sorted(sum(c) for c in found)[len(found) // 2] + 60 + runs = [] + run = None + + for x, colour in enumerate(found): + if sum(colour) > floor: + if run is None: + run = [x, x] + else: + run[1] = x + elif run is not None: + if run[1] - run[0] >= 12: + runs.append(run) + run = None + + if run is not None and run[1] - run[0] >= 12: + runs.append(run) + + # The applications live in the middle third; the corners are Parent + # Call on the left and the status icons on the right. + middle = [ + item for item in runs + if width // 3 < (item[0] + item[1]) // 2 < (width * 2) // 3 + ] + + for first, last in middle: + centre = (first + last) // 2 + window = found[max(first, centre - 8):min(last, centre + 8) + 1] + r = sum(c[0] for c in window) // max(1, len(window)) + g = sum(c[1] for c in window) // max(1, len(window)) + b = sum(c[2] for c in window) // max(1, len(window)) + print(name((r, g, b))) + + return 0 + + +if __name__ == "__main__": + sys.exit(main())