fix: the bar keeps its order when an application opens
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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0fa26f398d
commit
a31fa75d55
@@ -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
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
+47
-4
@@ -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
|
||||
|
||||
@@ -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/<name>/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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Executable
+153
@@ -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
|
||||
Executable
+82
@@ -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 ]]
|
||||
Executable
+154
@@ -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())
|
||||
Reference in New Issue
Block a user