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:
ekklesia
2026-09-21 19:34:15 -05:00
co-authored by Claude Opus 5
parent 0fa26f398d
commit a31fa75d55
14 changed files with 807 additions and 19 deletions
+162
View File
@@ -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)