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]>
163 lines
5.3 KiB
Python
163 lines
5.3 KiB
Python
"""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)
|