diff --git a/VERSION b/VERSION index 528bd04..37f868f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.39 +0.1.41 diff --git a/config/sway/config b/config/sway/config index 0e58998..37971d7 100644 --- a/config/sway/config +++ b/config/sway/config @@ -16,6 +16,16 @@ exec --no-startup-id /usr/local/bin/classroom-scale # the room's UI scale and execs mako, so the process is still just mako. exec --no-startup-id /usr/local/bin/classroom-notifications +# The first Chrome top-level stays tiled; later indistinguishable Chrome +# containers are floated. Which is which depends on creation order, so this +# has to be stateful Sway IPC logic rather than a broad for_window rule. +# The close affordance for a popup lives in the dock. +exec --no-startup-id /usr/local/bin/classroom-chrome-windows + +# Popups are borderless like the main browser, so they have no title bar to +# drag. Chrome's own tab strip moves them, and Super+drag always works. +floating_modifier $mod normal + # Ekklesia Classroom dock exec --no-startup-id /usr/local/bin/classroom-dock diff --git a/docs/browser.md b/docs/browser.md index 79d75ff..d179740 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -41,6 +41,30 @@ None of this touches website logins. Wonder Ink, Google and YouTube sign-ins are ordinary site sessions and are unaffected by `BrowserSignin: 0`, which governs signing in to the *browser*. +## Secondary Chrome windows + +Chrome gives the main browser and additional top-level windows the same +`google-chrome` app ID, the same browser PID, and no reliable distinguishing +properties. Page titles are site content and are deliberately not used. + +`classroom-chrome-windows` follows the Sway tree during the session and assigns +roles by creation order: + +- the first Chrome top-level is the main classroom browser and remains tiled, + maximised and borderless; +- every later Chrome top-level floats at 76% of the logical workspace, centred + above the main browser; +- a red **Close ×** button is placed at its top-right and kills only that + secondary container; +- when the secondary closes, the still-tiled main browser underneath has not + been moved or resized. + +The Sway `for_window` rule still only removes Chrome borders. There is no broad +Chrome floating rule: the session helper issues commands against the exact +`con_id` of each additional container. If the main window is closed first, the +oldest remaining Chrome window is promoted back to the tiled, borderless main +role. + ## What the policies do not remove The avatar button stays in the toolbar. Verified by screenshotting @@ -97,3 +121,51 @@ These two requirements genuinely conflict, and which one applies depends on how teachers actually sign in. Worth knowing before Sunday. If teachers use Wonder Ink email-and-password accounts, there is no conflict at all. + + +## Extra browser windows + +The first Chrome top-level window is the classroom browser: tiled, +borderless, filling the desktop above the dock. Every later Chrome +top-level floats instead — centred, about three quarters of the output, +borderless — so it sits over the browser rather than tiling beside it +and shoving the lesson to half width. + +Which window is which can only be creation order. Verified on a real +session: every Chrome top-level has the same `app_id`, the same +`shell=xdg_shell`, the same PID, a null `transient_for`, and sway +exposes no `window_properties`, `window_role` or `window_type` for +Wayland toplevels at all. Page titles are deliberately not used. + +`classroom-chrome-windows` follows sway's own window events rather than +polling, so dragging a popup costs nothing. + +### Closing one + +The Close button is **in the dock**, and only while a secondary window +is focused. Clicking it runs `[con_id=N] kill` on that exact container: +never the main browser, never a Chrome process. + +An earlier attempt anchored a close button to the popup's own corner as +a separate layer-shell surface. On real hardware it chased the window +and flickered, because every position change meant tearing a surface +down and building another. The dock does not move, so it cannot chase +anything. + +Note that a stock sway title bar has no close button, so putting the +border back would not have solved closing either. + +### Printing + +A site's own Print button opens a Chrome window to render the document +and then a print dialog on top of it. Verified in a real session: that +is **three** top-level windows — the browser, the render window, and a +separate `"Print"` dialog — and the render window is +indistinguishable from a genuine popup by anything except its title. + +No special case was added, because there is no signal to special-case +on. The generic treatment already produces the right result: the render +window and the print dialog both float centred and borderless over an +untouched main browser, the dialog is the obvious foreground window, and +anything left behind is one dock click away. The render window used to +be the one thing on screen wearing a sway title bar; it no longer is. diff --git a/docs/printing.md b/docs/printing.md index f0ebc07..8dcfd3b 100644 --- a/docs/printing.md +++ b/docs/printing.md @@ -4,39 +4,67 @@ The package installs and enables CUPS, its client/filter stack, and Debian's compressed Foomatic PPD database. Chrome is configured to open the system print dialog, which shows the queues configured in CUPS. -The target Ricoh Aficio MP C3502 supports PCL 5c and PCL 6 as standard; -PostScript 3 is optional. Debian Trixie does not provide a model-specific -C3502 PPD in the installed driver set, so the classroom helper uses Trixie's -recommended generic color PCL 6/PCL XL driver. This works with the printer's -standard language and does not require a vendor binary. +The Ricoh Aficio MP C3502 supports PCL 5c and PCL 6 as standard; PostScript 3 +is optional. Debian Trixie does not provide a model-specific C3502 PPD in the +installed driver set, so the classroom uses Trixie's recommended generic color +PCL 6/PCL XL driver. This uses the printer's standard language and requires no +vendor binary. -## Configure the church color printer +## Automatic church printer -Give the helper the printer's IP address or DNS name: +Every provisioning pass runs: ``` -sudo classroom-printer-setup 10.20.30.40 +classroom-printer-setup ``` -This creates or updates one queue named `Classroom_Color`, points it directly -to `socket://10.20.30.40:9100`, enables it, accepts jobs, and makes it the -system default. The printer's address is never stored in the package. +It creates or updates this site-wide queue: -An explicit URI is also accepted when deployment requires another supported -transport: +| Setting | Value | +| --- | --- | +| Queue and display name | `Elmer` | +| Printer | Ricoh Aficio MP C3502 | +| Address | `10.10.0.155` | +| Hardware address | `00:26:73:4F:E7:C9` | +| URI | `socket://10.10.0.155:9100` | +| Default | `Elmer` | +| Print quality | `PrintoutMode=Normal` | +| Color | `ColorModel=Color` | + +Those two print options are set explicitly. The generic driver otherwise chose +`Normal.Gray` on the physical printer, which silently made jobs grayscale. + +If an older release created `Classroom_Color`, provisioning removes it before +creating Elmer. A correct subsequent run reports that Elmer is already +configured and does not rewrite the queue. + +The helper can still take an explicit address for diagnostics or future site +changes: ``` -sudo classroom-printer-setup ipp://10.20.30.40/ipp/print -sudo classroom-printer-setup --no-default socket://10.20.30.40:9100 +sudo classroom-printer-setup 10.10.0.155 +sudo classroom-printer-setup ipp://10.10.0.155/ipp/print +sudo classroom-printer-setup --no-default socket://10.10.0.155:9100 ``` -The command is idempotent: running it again updates the same queue. It does not -contact the printer while configuring CUPS, and an unreachable printer only -leaves jobs queued; it never affects boot. +The command does not contact the printer while configuring CUPS. An +unreachable printer only leaves jobs queued; the provisioning call is also +capped at 30 seconds, so it cannot affect boot. + +## Network printer discovery Automatic remote queue browsing is disabled. `cups-browsed` is stopped and -disabled if another package installed it, so unrelated advertised label and -check-in printers are not turned into local classroom queues. +disabled, and both `avahi-daemon.service` and `avahi-daemon.socket` are stopped, +disabled and masked. No other classroom component uses Avahi. + +This removes the direct DNS-SD destinations that `lpstat -e` can show even when +cups-browsed is inactive, including the Brother check-in printer and the +duplicate discovered Ricoh. The intended output is only: + +``` +$ lpstat -e +Elmer +``` ## Administration @@ -47,31 +75,49 @@ lpinfo -m lpinfo -m | grep -i -E 'Ricoh|PCL|PostScript|IPP Everywhere' ``` -Show the classroom queue, its URI, and the system default: +Show the queue, URI, system default and print defaults: ``` -lpstat -p Classroom_Color -v +lpstat -p Elmer -v lpstat -d +lpoptions -p Elmer -l | grep -E '^(PrintoutMode|ColorModel)/' ``` Set the default manually: ``` -sudo lpadmin -d Classroom_Color +sudo lpadmin -d Elmer ``` Print CUPS's test page: ``` -lp -d Classroom_Color /usr/share/cups/data/testprint +lp -d Elmer /usr/share/cups/data/testprint ``` Inspect or cancel queued jobs: ``` -lpstat -o Classroom_Color -cancel -a Classroom_Color +lpstat -o Elmer +cancel -a Elmer ``` The printer language choice is based on Ricoh's published [MP C3002/C3502 specifications](https://assets.ricoh-usa.com/j2jqn9lauv41/1CzJla6qwny2d9oBVHHlID/5bfa366a01cf82162ea24287f99c8af9/aficiompc3002.pdf). + + +## What the teacher sees + +The queue is `Elmer`, its Description is `Elmer`, and its **Location is +deliberately blank**. CUPS prints the Location under the queue name in +every print dialog, so anything there becomes a second line the teacher +has to read past. The printer's teacher-facing identity is one word. + +`classroom-printer-setup` enforces this idempotently, and its +already-configured check includes the Location, so a machine set up +before this change is corrected on the next run rather than being left +alone as "close enough". + + lpstat -l -p Elmer # Description: Elmer, Location: (blank) + lpstat -p -d # Elmer, and the system default + lpstat -e # Elmer, and nothing else diff --git a/docs/provisioning.md b/docs/provisioning.md index 6936bd7..75408b8 100644 --- a/docs/provisioning.md +++ b/docs/provisioning.md @@ -9,10 +9,10 @@ them. It is a `oneshot` unit that nothing else waits on, so it never holds the classroom up, and **it is not allowed to fail the boot**: a machine with no DVD codecs is still a working classroom. -The package `postinst` enables the service but deliberately does not -start it. Every OTA update reboots the moment the install finishes, and -an apt run killed by that reboot would leave dpkg half-configured. The -next boot is a few seconds away and is a clean place to work. +The package `postinst` enables the service and schedules a delayed start after +the outer apt transaction has had time to release its lock. It never runs +provisioning synchronously inside dpkg. If an OTA reboot happens before the +delayed job, the enabled service runs normally on the next boot. To provision without waiting for a reboot: @@ -60,6 +60,11 @@ requires Internet access or hardware to be present. - Wi-Fi administration: [networking.md](networking.md) - Church color printer setup: [printing.md](printing.md) +The printer helper is part of every provisioning pass. It is capped at 30 +seconds and only configures the local scheduler, so an offline Ricoh cannot +hold boot or provisioning open. Failures are logged and retried on the next +boot. A correctly configured queue is a quick no-op. + ## What provisioning does not do The boot provisioning command never upgrades or reconfigures anything already installed, and it diff --git a/docs/testing.md b/docs/testing.md index 8376da7..ec9bbaa 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -35,15 +35,35 @@ before promoting to stable with `./update_stable.sh`. - [ ] `cups.service` is enabled and `lpstat -r` reports that the scheduler runs - [ ] `lpinfo -m` contains the generic color PCL 6/PCL XL driver documented in [printing.md](printing.md) -- [ ] `sudo classroom-printer-setup ` creates only - `Classroom_Color`, enables it, and makes it the system default -- [ ] Repeating the helper updates the same queue without creating a duplicate -- [ ] Chrome opens the system print dialog and shows the configured queue -- [ ] Unrelated label/check-in printers are not automatically added +- [ ] `sudo classroom-printer-setup` creates only `Elmer`, enables it, and + makes it the system default +- [ ] Provisioning creates Elmer automatically at + `socket://10.10.0.155:9100` with description `Elmer` +- [ ] `lpoptions -p Elmer -l` shows `*Normal` for `PrintoutMode` and `*Color` + for `ColorModel` +- [ ] An upgrade removes the old `Classroom_Color` queue +- [ ] Repeating provisioning reports Elmer already configured and does not + rewrite the queue +- [ ] Chrome opens the system print dialog and shows only Elmer +- [ ] `lpstat -e` prints only `Elmer`; neither the Brother label printer nor a + discovered duplicate Ricoh appears +- [ ] `avahi-daemon.service` and `avahi-daemon.socket` are masked - [ ] The CUPS test page prints in color on the Ricoh MP C3502 - [ ] With the Ricoh powered off, the classroom still boots normally and a job simply remains queued +## Secondary Chrome windows + +- [ ] The first Chrome top-level remains maximised, tiled and borderless +- [ ] A second `google-chrome` top-level floats instead of splitting the main + browser +- [ ] It is centred at roughly three quarters of the logical display +- [ ] Its red Close button is visible and mouse-accessible +- [ ] Close removes only the secondary and reveals the untouched main browser +- [ ] Changing either page title does not change the main/secondary roles +- [ ] A third Chrome top-level also floats and Close targets the newest one +- [ ] Chrome minimise/restore and the dock Browser button still behave normally + ## UI scale UI_SCALE sets sway's output scale, so it scales the whole session. @@ -92,7 +112,7 @@ into separate layer-shell windows. - [ ] The Home button returns to Wonder Ink - [ ] Fullscreen YouTube video uses the whole display - [ ] Leaving fullscreen restores the correct geometry above the dock -- [ ] Chrome has **no sway title bar** — the window starts at Chrome's +- [ ] The main Chrome window has **no sway title bar** — it starts at Chrome's own tab strip, and Chrome's tabs and address bar are still there - [ ] Chrome still maximises and minimises as before - [ ] After a reboot, no "Restore pages? Chrome didn't shut down diff --git a/lib/classroom_chrome.py b/lib/classroom_chrome.py new file mode 100644 index 0000000..1b0fc08 --- /dev/null +++ b/lib/classroom_chrome.py @@ -0,0 +1,66 @@ +"""Stable roles for Chrome's otherwise indistinguishable top-level windows.""" + +from classroom_sway import matches + + +CHROME_PATTERNS = ( + r"google-chrome", + r"chrome", + r"chrome-.*", + r"google-chrome-.*", +) + + +def chrome_windows(current): + """Chrome containers in Sway tree order, without using page titles.""" + return [window for window in current if matches(window, CHROME_PATTERNS)] + + +class ChromeRoles: + """Remember the first Chrome container and classify every later one.""" + + def __init__(self): + self.primary = None + self.secondary = [] + + def update(self, current): + identifiers = [window["id"] for window in chrome_windows(current)] + previous_primary = self.primary + previous_secondary = list(self.secondary) + + promoted = None + + if self.primary not in identifiers: + self.primary = identifiers[0] if identifiers else None + + if self.primary is not None and previous_primary is not None: + promoted = self.primary + + self.secondary = [ + identifier + for identifier in previous_secondary + if identifier in identifiers and identifier != self.primary + ] + + new_secondary = [] + + for identifier in identifiers: + if identifier == self.primary or identifier in self.secondary: + continue + + self.secondary.append(identifier) + new_secondary.append(identifier) + + removed = [ + identifier + for identifier in previous_secondary + if identifier not in identifiers + ] + + return { + "primary": self.primary, + "promoted": promoted, + "new_secondary": new_secondary, + "removed": removed, + "active_secondary": self.secondary[-1] if self.secondary else None, + } diff --git a/lib/classroom_sway.py b/lib/classroom_sway.py index 8a5e37a..591a481 100644 --- a/lib/classroom_sway.py +++ b/lib/classroom_sway.py @@ -51,7 +51,7 @@ def query(kind): return None -def collect(node, workspace, found): +def collect(node, workspace, found, floating=False): """Flatten the sway tree into the windows the classroom cares about.""" if node.get("type") == "workspace": workspace = node.get("name") @@ -71,11 +71,15 @@ def collect(node, workspace, found): "fullscreen": bool(node.get("fullscreen_mode")), "workspace": workspace, "hidden": workspace in HIDDEN_WORKSPACES, + "floating": floating, } ) - for child in node.get("nodes", []) + node.get("floating_nodes", []): - collect(child, workspace, found) + for child in node.get("nodes", []): + collect(child, workspace, found, floating) + + for child in node.get("floating_nodes", []): + collect(child, workspace, found, True) return found diff --git a/packaging/debian/postinst b/packaging/debian/postinst index 6baebfc..2a33da6 100755 --- a/packaging/debian/postinst +++ b/packaging/debian/postinst @@ -18,6 +18,7 @@ chown classroom:classroom /home/classroom/.bash_profile chmod 0644 /home/classroom/.bash_profile chmod 0755 /usr/local/bin/classroom-browser +chmod 0755 /usr/local/bin/classroom-chrome-windows chmod 0755 /usr/local/bin/classroom-window chmod 0755 /usr/local/bin/classroom-dvd chmod 0755 /usr/local/bin/classroom-dvd-session @@ -68,6 +69,11 @@ systemctl start NetworkManager.service || true # every advertised printer on the church network if installed independently. systemctl enable cups.socket cups.service || true systemctl disable --now cups-browsed.service || true +# Direct DNS-SD destinations still appear through Avahi even when +# cups-browsed is inactive. Nothing else in this appliance uses Avahi, so stop +# both activation paths and keep unrelated church printers out of print lists. +systemctl disable --now avahi-daemon.socket avahi-daemon.service || true +systemctl mask avahi-daemon.socket avahi-daemon.service || true systemctl start cups.socket || true systemctl enable ekklesia-classroom-update.timer || true diff --git a/scripts/classroom-chrome-windows b/scripts/classroom-chrome-windows new file mode 100755 index 0000000..86d4d5f --- /dev/null +++ b/scripts/classroom-chrome-windows @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 + +"""Keep Chrome's first window tiled and make later windows usable popups. + +Chrome gives the main browser and its secondary top-level windows the same +app_id, the same process and often nothing else to tell them apart. The only +stable fact is creation order, so this remembers the first Chrome container +and treats every later one as secondary. Page titles are deliberately ignored. + +The close affordance for a secondary window lives in the dock. An earlier +attempt anchored a layer-shell button to the popup's own corner and moved it +whenever the window moved; on real hardware it chased the window and +flickered, because every position change meant tearing a surface down and +building another one. The dock is already on screen, already owns the +classroom's visual language, and does not move. This publishes which window +the dock's Close button should act on, and the dock does the rest. + +Driven by Sway's own window events rather than a poll, so a window that is +being dragged costs nothing. +""" + +import fcntl +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, "/usr/local/lib/ekklesia-classroom") +sys.path.insert( + 0, + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"), +) + +from classroom_chrome import ChromeRoles, chrome_windows # noqa: E402 +from classroom_sway import command, windows # noqa: E402 + +SECONDARY_SIZE_PERCENT = 76 + +# Chrome renegotiates the size of a popup that was opened with explicit +# dimensions, and does it after the resize returns. Centring before that +# settles leaves the window visibly off to one side, so it is placed +# once more a moment later. Only ever at creation: after that the window +# is the teacher's to move, and nothing here touches it again. +SETTLE_SECONDS = 0.35 + +# Everything else a window event can report — a title change, an urgency +# hint, a mark — cannot change which window is which, and Chrome retitles +# constantly. +INTERESTING = frozenset( + ("new", "close", "focus", "move", "floating", "fullscreen_mode") +) + +RESUBSCRIBE_SECONDS = 2 + +STATE_NAME = "chrome-close-target" + + +def state_directory(): + runtime = Path(os.environ.get("XDG_RUNTIME_DIR") or "/tmp") + directory = runtime / "ekklesia-classroom" + directory.mkdir(parents=True, exist_ok=True) + + return directory + + +def criterion(identifier): + return f"[con_id={identifier}]" + + +def configure_primary(identifier): + """A promoted remaining window becomes the ordinary classroom browser.""" + command(f"{criterion(identifier)} floating disable") + command(f"{criterion(identifier)} border none") + command(f"{criterion(identifier)} focus") + + +def place(target): + command( + f"{target} resize set width {SECONDARY_SIZE_PERCENT} ppt " + f"height {SECONDARY_SIZE_PERCENT} ppt" + ) + command(f"{target} move position center") + + +def configure_secondary(identifier): + """Float one exact container; never apply a broad Chrome float rule.""" + target = criterion(identifier) + command(f"{target} floating enable") + command(f"{target} border none") + place(target) + command(f"{target} focus") + + # Let Chrome have its say about the size, then place it again. + time.sleep(SETTLE_SECONDS) + place(target) + + +def focused_secondary(secondary, chrome): + """The secondary window the dock's Close button should act on. + + Only ever a focused one. If the teacher is in the main browser there is + nothing to close, and the dock shows no Close button at all. + """ + for identifier in secondary: + window = chrome.get(identifier) + + if window and window["focused"] and not window["hidden"]: + return identifier + + return None + + +def publish(target, path): + """Tell the dock which window to offer to close, atomically.""" + temporary = path.with_suffix(".tmp") + + try: + temporary.write_text("" if target is None else f"{target}\n") + os.replace(temporary, path) + except OSError as error: + print(f"[classroom-chrome-windows] could not publish state: {error}", + flush=True) + + +def reconcile(roles, path): + current = windows() + chrome = {window["id"]: window for window in chrome_windows(current)} + changes = roles.update(current) + + if changes["promoted"] is not None: + configure_primary(changes["promoted"]) + + for identifier in changes["new_secondary"]: + configure_secondary(identifier) + + # Reassert the role if something put a secondary back into the tiling + # layout — a restore, or an application-initiated geometry change. + # Hidden windows are left parked quietly. + for identifier in roles.secondary: + window = chrome.get(identifier) + + if window and not window["hidden"] and not window["floating"]: + configure_secondary(identifier) + + publish(focused_secondary(roles.secondary, chrome), path) + + +def subscribe(): + try: + return subprocess.Popen( + ["swaymsg", "-t", "subscribe", "-m", "-r", '["window"]'], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ) + except (OSError, subprocess.SubprocessError): + return None + + +def watch(): + directory = state_directory() + path = directory / STATE_NAME + + with (directory / "chrome-windows.lock").open("w") as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return 0 + + roles = ChromeRoles() + + try: + while True: + # Whatever happened while there was no subscription. + reconcile(roles, path) + + stream = subscribe() + + if stream is None or stream.stdout is None: + time.sleep(RESUBSCRIBE_SECONDS) + continue + + for line in stream.stdout: + try: + event = json.loads(line) + except ValueError: + continue + + if event.get("change") in INTERESTING: + reconcile(roles, path) + + # Sway went away or restarted; wait, then pick it back up. + stream.wait() + time.sleep(RESUBSCRIBE_SECONDS) + finally: + publish(None, path) + + +def main(): + return watch() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/classroom-dock b/scripts/classroom-dock index dd34ca9..44bf967 100755 --- a/scripts/classroom-dock +++ b/scripts/classroom-dock @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import math +import os import subprocess import sys from datetime import datetime @@ -11,7 +12,7 @@ import gi gi.require_version("Gtk", "3.0") gi.require_version("GtkLayerShell", "0.1") -from gi.repository import Gtk, Gdk, GdkPixbuf, GLib, GtkLayerShell +from gi.repository import Gio, Gtk, Gdk, GdkPixbuf, GLib, GtkLayerShell sys.path.insert(0, "/usr/local/lib/ekklesia-classroom") @@ -76,6 +77,11 @@ BUSY_SPINNER_SIZE = 38 THROBBER_FRAMES = 24 THROBBER_INTERVAL_MS = 80 +# Which secondary Chrome window the Close button acts on, published by +# classroom-chrome-windows. A file rather than a socket because the dock +# only ever reads it, and Gio can watch it without a poll. +CLOSE_TARGET_NAME = "chrome-close-target" + BUSY_POLL_MS = 500 # Long enough for a cold Chrome start on classroom hardware, short enough @@ -231,6 +237,8 @@ class ClassroomShell(Gtk.Window): self.dvd_image = None self.disc_ready = None self.buttons = {} + self.close_target = None + self.close_monitor = None GtkLayerShell.init_for_window(self) GtkLayerShell.set_layer( @@ -355,8 +363,100 @@ class ClassroomShell(Gtk.Window): 0, ) + # Only ever on screen while a secondary browser window is focused, + # so the ordinary dock is exactly what it has always been. + close = self.make_button( + "close", + custom_icon("close.svg", scaled(BROWSER_ICON_SIZE, self.scale)) + or themed_icon("window-close-symbolic"), + self.close_secondary, + ) + close.set_no_show_all(True) + close.set_visible(False) + + # show_all() does not recurse into a no-show-all widget, so the + # icon has to be shown by hand or the button appears empty when + # it is finally revealed. + self.buttons["close"]["image"].show() + + dock.pack_start(close, False, False, 0) + + self.watch_close_target() + return dock + # -- the close affordance for secondary browser windows --------- + # + # The button lives here rather than on the window because a surface + # anchored to a moving window chases it and flickers. The dock does + # not move. + + def watch_close_target(self): + directory = Path( + os.environ.get("XDG_RUNTIME_DIR") or "/tmp" + ) / "ekklesia-classroom" + + try: + directory.mkdir(parents=True, exist_ok=True) + + self.close_path = directory / CLOSE_TARGET_NAME + + # Watching the directory rather than the file: the state is + # published by rename, so the file itself is replaced. + self.close_monitor = Gio.File.new_for_path( + str(directory) + ).monitor_directory(Gio.FileMonitorFlags.NONE, None) + + self.close_monitor.connect( + "changed", + self.on_close_target_changed, + ) + except (OSError, GLib.Error) as error: + print( + f"classroom-dock: not watching for browser popups: {error}", + file=sys.stderr, + ) + self.close_path = None + return + + self.refresh_close_target() + + def on_close_target_changed(self, monitor, changed, other, event): + if changed is not None and changed.get_basename() != CLOSE_TARGET_NAME: + return + + self.refresh_close_target() + + def refresh_close_target(self): + target = None + + if self.close_path is not None: + try: + text = self.close_path.read_text().strip() + except OSError: + text = "" + + if text.isdigit(): + target = int(text) + + if target == self.close_target: + return + + self.close_target = target + + entry = self.buttons.get("close") + + if entry is not None: + entry["button"].set_visible(target is not None) + + def close_secondary(self, *_): + if self.close_target is None or sway is None: + return + + # Closes exactly this container. Never the main browser, and + # never a Chrome process. + sway.command(f"[con_id={self.close_target}] kill") + def make_button(self, name, image, callback): button = Gtk.Button() button.set_relief(Gtk.ReliefStyle.NONE) diff --git a/scripts/classroom-printer-setup b/scripts/classroom-printer-setup index 44fddf7..dbd4248 100755 --- a/scripts/classroom-printer-setup +++ b/scripts/classroom-printer-setup @@ -1,24 +1,34 @@ #!/bin/bash set -euo pipefail -# Configure the one explicitly managed classroom printer. The Ricoh Aficio -# MP C3502 supports PCL 6 as standard; Debian Trixie's compressed Foomatic +# Configure the one explicitly managed church printer. The Ricoh Aficio MP +# C3502 supports PCL 6 as standard; Debian Trixie's compressed Foomatic # database supplies this generic color PCL 6/PCL XL path without vendor code. -QUEUE="Classroom_Color" +QUEUE="Elmer" +LEGACY_QUEUE="Classroom_Color" +DEFAULT_URI="socket://10.10.0.155:9100" DRIVER="foomatic-db-compressed-ppds:0/ppd/foomatic-ppd/Generic-PCL_6_PCL_XL_Printer-pxlcolor.ppd" -MAKE_MODEL="Ricoh Aficio MP C3502 (Generic PCL 6 Color)" -LOCATION="Ekklesia Classroom" +DESCRIPTION="Elmer" +# Blank on purpose. CUPS shows Location under the queue name in every +# print dialog, and the teacher-facing identity of this printer is +# simply "Elmer". +LOCATION="" MAKE_DEFAULT=true +COMMAND_TIMEOUT=10 usage() { cat <<'USAGE' -Usage: classroom-printer-setup [--no-default] PRINTER-IP-OR-URI +Usage: classroom-printer-setup [--no-default] [PRINTER-IP-OR-URI] Examples: - sudo classroom-printer-setup 10.20.30.40 + sudo classroom-printer-setup + sudo classroom-printer-setup 10.10.0.155 sudo classroom-printer-setup socket://printer.example.org:9100 +With no address, configures the church Ricoh as Elmer at +socket://10.10.0.155:9100. + An IP address or hostname uses JetDirect/AppSocket on port 9100. Explicit socket://, ipp://, ipps:// and lpd:// URIs are also accepted. USAGE @@ -34,7 +44,7 @@ fail() { } if [[ "${CLASSROOM_PRINTER_SETUP_TESTING:-false}" != "true" && $EUID -ne 0 ]]; then - fail "run this command as root: sudo classroom-printer-setup " + fail "run this command as root: sudo classroom-printer-setup" fi if [[ "${1:-}" == "--no-default" ]]; then @@ -42,13 +52,13 @@ if [[ "${1:-}" == "--no-default" ]]; then shift fi -if [[ $# -ne 1 || "$1" == "-h" || "$1" == "--help" ]]; then +if [[ $# -gt 1 || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then usage - [[ $# -eq 1 ]] && exit 0 + [[ $# -le 1 ]] && exit 0 exit 2 fi -address="$1" +address="${1:-$DEFAULT_URI}" case "$address" in socket://*|ipp://*|ipps://*|lpd://*) @@ -65,20 +75,78 @@ case "$address" in ;; esac -for command in lpadmin lpinfo lpstat cupsenable cupsaccept; do +for command in timeout lpadmin lpinfo lpoptions lpstat cupsenable cupsaccept; do command -v "$command" >/dev/null 2>&1 || \ fail "$command is missing; reinstall the ekklesia-classroom package" done -if ! lpstat -r >/dev/null 2>&1; then +run_bounded() { + timeout \ + --signal=TERM \ + --kill-after=2s \ + "${COMMAND_TIMEOUT}s" \ + "$@" +} + +disable_discovery() { + command -v systemctl >/dev/null 2>&1 || return 0 + + # cups-browsed creates local queues for advertised printers. CUPS can also + # enumerate DNS-SD destinations through Avahi even without cups-browsed, + # so disable both Avahi activation paths on this single-purpose appliance. + run_bounded systemctl disable --now cups-browsed.service \ + >/dev/null 2>&1 || true + run_bounded systemctl disable --now \ + avahi-daemon.socket avahi-daemon.service >/dev/null 2>&1 || true + run_bounded systemctl mask \ + avahi-daemon.socket avahi-daemon.service >/dev/null 2>&1 || true +} + +disable_discovery + +if ! run_bounded lpstat -r >/dev/null 2>&1; then command -v systemctl >/dev/null 2>&1 && \ - systemctl start cups.service >/dev/null 2>&1 || true + run_bounded systemctl start cups.service >/dev/null 2>&1 || true + + # systemctl can return as the service begins starting. Give the local + # scheduler a short, bounded chance to create its socket. + for _ in {1..20}; do + run_bounded lpstat -r >/dev/null 2>&1 && break + sleep 0.25 + done fi -lpstat -r >/dev/null 2>&1 || \ +run_bounded lpstat -r >/dev/null 2>&1 || \ fail "CUPS is not running; check: systemctl status cups" -models="$(lpinfo -m)" || fail "CUPS could not list installed printer drivers" +if run_bounded lpstat -p "$LEGACY_QUEUE" >/dev/null 2>&1; then + log "removing legacy ${LEGACY_QUEUE} queue" + run_bounded lpadmin -x "$LEGACY_QUEUE" || \ + fail "CUPS could not remove legacy queue ${LEGACY_QUEUE}" +fi + +device="$(run_bounded lpstat -v "$QUEUE" 2>/dev/null || true)" +status="$(run_bounded lpstat -p "$QUEUE" 2>/dev/null || true)" +accepting="$(run_bounded lpstat -a "$QUEUE" 2>/dev/null || true)" +default="$(run_bounded lpstat -d 2>/dev/null || true)" +details="$(run_bounded lpstat -l -p "$QUEUE" 2>/dev/null || true)" +options="$(run_bounded lpoptions -p "$QUEUE" -l 2>/dev/null || true)" + +if grep -Fq ": ${uri}" <<< "$device" \ + && grep -Fq "printer ${QUEUE}" <<< "$status" \ + && grep -Fq " enabled " <<< " ${status} " \ + && grep -Fq "${QUEUE} accepting requests" <<< "$accepting" \ + && grep -Fq "system default destination: ${QUEUE}" <<< "$default" \ + && grep -Fq "Description: ${DESCRIPTION}" <<< "$details" \ + && grep -Eq '^[[:space:]]*Location:[[:space:]]*$' <<< "$details" \ + && grep -Eq '^PrintoutMode/.*:.*\*Normal([[:space:]]|$)' <<< "$options" \ + && grep -Eq '^ColorModel/.*:.*\*Color([[:space:]]|$)' <<< "$options"; then + log "${QUEUE} is already correctly configured." + exit 0 +fi + +models="$(run_bounded lpinfo -m)" || \ + fail "CUPS could not list installed printer drivers" if ! grep -q "^${DRIVER} " <<< "$models"; then fail "the Debian generic color PCL 6 driver is not installed" @@ -88,18 +156,34 @@ log "configuring ${QUEUE} at ${uri}" # Repeating this command updates the same named queue; it never creates a # second destination. None of these operations connects to the printer. -lpadmin \ +run_bounded lpadmin \ -p "$QUEUE" \ -v "$uri" \ -m "$DRIVER" \ - -D "$MAKE_MODEL" \ + -D "$DESCRIPTION" \ -L "$LOCATION" || fail "CUPS could not configure ${QUEUE}" -cupsenable "$QUEUE" || fail "CUPS could not enable ${QUEUE}" -cupsaccept "$QUEUE" || fail "CUPS could not accept jobs for ${QUEUE}" +# CUPS on Trixie does not reliably retain PPD choices supplied in the same +# request that selects the driver. Apply the physically verified defaults in a +# second request after the PPD exists. +run_bounded lpadmin \ + -p "$QUEUE" \ + -o PrintoutMode=Normal \ + -o ColorModel=Color || fail "CUPS could not set the color defaults for ${QUEUE}" + +options="$(run_bounded lpoptions -p "$QUEUE" -l 2>/dev/null || true)" + +grep -Eq '^PrintoutMode/.*:.*\*Normal([[:space:]]|$)' <<< "$options" \ + || fail "CUPS did not retain PrintoutMode=Normal for ${QUEUE}" +grep -Eq '^ColorModel/.*:.*\*Color([[:space:]]|$)' <<< "$options" \ + || fail "CUPS did not retain ColorModel=Color for ${QUEUE}" + +run_bounded cupsenable "$QUEUE" || fail "CUPS could not enable ${QUEUE}" +run_bounded cupsaccept "$QUEUE" || fail "CUPS could not accept jobs for ${QUEUE}" if [[ "$MAKE_DEFAULT" == true ]]; then - lpadmin -d "$QUEUE" || fail "CUPS could not make ${QUEUE} the default" + run_bounded lpadmin -d "$QUEUE" || \ + fail "CUPS could not make ${QUEUE} the default" fi log "${QUEUE} is configured, enabled and accepting jobs." @@ -108,4 +192,5 @@ if [[ "$MAKE_DEFAULT" == true ]]; then log "${QUEUE} is the system default printer." fi +log "Print defaults: PrintoutMode=Normal, ColorModel=Color." log "The printer may remain offline until it is reachable; this does not affect boot." diff --git a/scripts/classroom-provision b/scripts/classroom-provision index efe7fc7..5476ed5 100755 --- a/scripts/classroom-provision +++ b/scripts/classroom-provision @@ -37,6 +37,18 @@ else log "WARNING: sudo classroom-dvd-css-setup" fi +# The site printer is configured entirely through the local CUPS scheduler; +# its being powered off or unreachable does not matter. Still bound the whole +# step so a broken scheduler can never hold classroom provisioning open. +if timeout --signal=TERM --kill-after=5s 30s \ + /usr/local/bin/classroom-printer-setup; then + : +else + log "WARNING: the Elmer printer queue is not configured." + log "WARNING: Provisioning will try again at the next boot, or run:" + log "WARNING: sudo classroom-printer-setup" +fi + log "finished" # Provisioning is best-effort by design. @@ -46,4 +58,4 @@ if [[ "$chrome_ok" != true ]]; then exit 1 fi -exit 0 \ No newline at end of file +exit 0 diff --git a/tests/test-classroom-chrome-windows.sh b/tests/test-classroom-chrome-windows.sh new file mode 100755 index 0000000..f0cdb35 --- /dev/null +++ b/tests/test-classroom-chrome-windows.sh @@ -0,0 +1,188 @@ +#!/bin/bash +set -uo pipefail + +# Secondary Chrome windows: which window is which, how a popup is placed, +# and which one the dock offers to close. +# +# tests/test-classroom-chrome-windows.sh + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY' +import importlib.machinery +import importlib.util +import json +import os +import sys +import tempfile +from pathlib import Path + +from classroom_chrome import ChromeRoles, chrome_windows + +root = Path(sys.argv[1]) +path = root / "scripts/classroom-chrome-windows" +loader = importlib.machinery.SourceFileLoader("classroom_chrome_windows", str(path)) +spec = importlib.util.spec_from_loader(loader.name, loader) +script = importlib.util.module_from_spec(spec) +loader.exec_module(script) + +source = path.read_text() +dock_source = (root / "scripts/classroom-dock").read_text() + +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 + + +def window(identifier, title, floating=False, hidden=False, focused=False): + return { + "id": identifier, + "app_id": "google-chrome", + "class": "", + "instance": "", + "pid": 4242, + "name": title, + "focused": focused, + "floating": floating, + "hidden": hidden, + } + + +print("1. creation order decides which window is the browser") +roles = ChromeRoles() +main = window(5, "Main browser - Google Chrome") +popup = window(8, "about:blank - Google Chrome") + +change = roles.update([main]) +check("the first Chrome window is the browser", change["primary"] == 5) +check("it is not a popup", change["new_secondary"] == []) + +change = roles.update([main, popup]) +check("a later Chrome window is a popup", change["new_secondary"] == [8]) +check("the browser stays the browser", change["primary"] == 5) + +renamed_main = window(5, "Something else entirely") +renamed_popup = window(8, "A different page title") +change = roles.update([renamed_main, renamed_popup]) +check("titles never change a role", + change["primary"] == 5 and not change["new_secondary"]) +check("a shared PID never confuses roles", + len(chrome_windows([renamed_main, renamed_popup])) == 2) + +print() +print("2. several popups at once") +third = window(9, "Third window") +change = roles.update([main, popup, third]) +check("a third top-level is also a popup", change["new_secondary"] == [9]) +change = roles.update([main, popup]) +check("closing one leaves the other a popup", roles.secondary == [8]) +check("the closed one is reported", change["removed"] == [9]) + +print() +print("3. how a popup is placed") +commands = [] +script.command = lambda argument: commands.append(argument) + +script.SETTLE_SECONDS = 0 +script.configure_secondary(8) +check("floats exactly one container", commands[0] == "[con_id=8] floating enable") +check("borderless, like the main browser", commands[1] == "[con_id=8] border none") +check("about three quarters of the output", + "width 76 ppt height 76 ppt" in commands[2], commands) +check("centred", commands[3] == "[con_id=8] move position center") +check("focused", commands[4] == "[con_id=8] focus") +# Chrome resizes a popup after the fact, so the placement is repeated +# once the size has settled or the window ends up off to one side. +check("placed again after Chrome settles", + commands[-1] == "[con_id=8] move position center" + and "width 76 ppt" in commands[-2], commands) +check("placed exactly twice, never on a loop", + commands.count("[con_id=8] move position center") == 2, commands) +check("never matches all Chrome windows at once", + all("app_id" not in item for item in commands)) + +commands.clear() +script.configure_primary(8) +check("a promoted window goes back to tiled", + commands[0] == "[con_id=8] floating disable") +check("and borderless", commands[1] == "[con_id=8] border none") + +print() +print("4. which popup the dock offers to close") +chrome = {5: window(5, "main", focused=True), 8: window(8, "popup")} +check("none while the main browser is focused", + script.focused_secondary([8], chrome) is None) + +chrome = {5: window(5, "main"), 8: window(8, "popup", focused=True)} +check("the focused popup", script.focused_secondary([8], chrome) == 8) + +chrome = {8: window(8, "one"), 9: window(9, "two", focused=True)} +check("the focused one of several", script.focused_secondary([8, 9], chrome) == 9) + +chrome = {8: window(8, "one", hidden=True, focused=True)} +check("never a minimised one", script.focused_secondary([8], chrome) is None) + +check("nothing focused means nothing to close", + script.focused_secondary([8], {8: window(8, "p")}) is None) + +print() +print("5. the state the dock reads") +work = Path(tempfile.mkdtemp()) +state = work / "chrome-close-target" + +script.publish(9, state) +check("publishes the container id", state.read_text().strip() == "9") +check("published by rename, not left half written", + not (work / "chrome-close-target.tmp").exists()) + +script.publish(None, state) +check("empty when there is nothing to close", state.read_text().strip() == "") + +print() +print("6. the dock owns the button, not the window") +check("no layer-shell surface in the window helper", + "GtkLayerShell" not in source) +check("no second GTK process spawned for a button", + "Gtk.main" not in source and "show_close_button" not in source) +check("no geometry maths chasing a window", + "output_rect" not in source and "button_margins" not in source) +check("the dock has a close action", '"close"' in dock_source) +check("the dock closes exactly one container", + '[con_id={self.close_target}] kill' in dock_source) +check("the dock never kills Chrome processes", + "pkill" not in dock_source and "killall" not in dock_source) +check("the dock watches rather than polls for it", + "monitor_directory" in dock_source) + +print() +print("7. driven by Sway events, not a poll") +check("subscribes to window events", '"-t", "subscribe"' in source) +check("ignores title churn", + "title" not in script.INTERESTING and "new" in script.INTERESTING) +check("reacts to focus and movement", + {"focus", "move", "close"} <= set(script.INTERESTING)) +check("no polling loop constant", "POLL_SECONDS" not in source) + +print() +print("8. the session still starts it") +config = (root / "config/sway/config").read_text() +check("started with the session", + "exec --no-startup-id /usr/local/bin/classroom-chrome-windows" in config) +check("no broad Chrome floating rule", + not any("floating enable" in line and "google-chrome" in line + for line in config.splitlines())) + +print() +print(f"passed: {passed} failed: {failed}") +sys.exit(1 if failed else 0) +PY diff --git a/tests/test-classroom-printer-setup.sh b/tests/test-classroom-printer-setup.sh index c4fca9d..4b29f28 100755 --- a/tests/test-classroom-printer-setup.sh +++ b/tests/test-classroom-printer-setup.sh @@ -35,18 +35,119 @@ check_absent() { fi } -mkdir -p "$WORK/bin" +mkdir -p "$WORK/bin" "$WORK/state" -for command in lpadmin cupsenable cupsaccept; do - cat > "$WORK/bin/$command" <<'SHIM' +cat > "$WORK/bin/lpadmin" <<'SHIM' #!/bin/bash -echo "$(basename "$0") $*" >> "$CUPS_LOG" -SHIM +echo "lpadmin $*" >> "$CUPS_LOG" + +if [[ "$1" == "-x" ]]; then + rm -f "$CUPS_STATE/legacy" + exit 0 +fi + +if [[ "$1" == "-d" ]]; then + echo "$2" > "$CUPS_STATE/default" + exit 0 +fi + +queue="" +uri="" +description="" +location="" +location_given=false +printout="" +color="" +model=false + +while [[ $# -gt 0 ]]; do + case "$1" in + -p) queue="$2"; shift 2 ;; + -v) uri="$2"; shift 2 ;; + -m) model=true; shift 2 ;; + -D) description="$2"; shift 2 ;; + -L) location="$2"; location_given=true; shift 2 ;; + -o) + [[ "$2" == PrintoutMode=* ]] && printout="${2#*=}" + [[ "$2" == ColorModel=* ]] && color="${2#*=}" + shift 2 + ;; + *) shift ;; + esac done +[[ -n "$queue" ]] || exit 0 +[[ -z "$uri" ]] || echo "$uri" > "$CUPS_STATE/uri" +[[ -z "$description" ]] || echo "$description" > "$CUPS_STATE/description" +[[ "$location_given" == false ]] || printf '%s' "$location" > "$CUPS_STATE/location" + +# Match CUPS on Trixie: PPD choices sent while selecting the model are not +# retained. The production helper must set them in a follow-up request. +if [[ "$model" == false ]]; then + [[ -z "$printout" ]] || echo "$printout" > "$CUPS_STATE/printout" + [[ -z "$color" ]] || echo "$color" > "$CUPS_STATE/color" +fi + +touch "$CUPS_STATE/queue" +SHIM + +cat > "$WORK/bin/cupsenable" <<'SHIM' +#!/bin/bash +echo "cupsenable $*" >> "$CUPS_LOG" +touch "$CUPS_STATE/enabled" +SHIM + +cat > "$WORK/bin/cupsaccept" <<'SHIM' +#!/bin/bash +echo "cupsaccept $*" >> "$CUPS_LOG" +touch "$CUPS_STATE/accepting" +SHIM + cat > "$WORK/bin/lpstat" <<'SHIM' #!/bin/bash -[[ "${FAKE_CUPS_RUNNING:-true}" == "true" ]] +case "$1" in + -r) + [[ "${FAKE_CUPS_RUNNING:-true}" == "true" ]] + ;; + -v) + [[ -f "$CUPS_STATE/queue" ]] || exit 1 + echo "device for Elmer: $(cat "$CUPS_STATE/uri")" + ;; + -a) + [[ -f "$CUPS_STATE/accepting" ]] || exit 1 + echo "Elmer accepting requests since now" + ;; + -d) + [[ -f "$CUPS_STATE/default" ]] || exit 1 + echo "system default destination: $(cat "$CUPS_STATE/default")" + ;; + -l) + [[ -f "$CUPS_STATE/queue" ]] || exit 1 + echo "printer Elmer is idle. enabled since now" + echo " Description: $(cat "$CUPS_STATE/description")" + echo " Location: $(cat "$CUPS_STATE/location" 2>/dev/null || true)" + ;; + -p) + if [[ "$2" == "Classroom_Color" ]]; then + [[ -f "$CUPS_STATE/legacy" ]] || exit 1 + echo "printer Classroom_Color is idle. enabled since now" + else + [[ -f "$CUPS_STATE/queue" ]] || exit 1 + enabled=disabled + [[ -f "$CUPS_STATE/enabled" ]] && enabled=enabled + echo "printer Elmer is idle. $enabled since now" + fi + ;; +esac +SHIM + +cat > "$WORK/bin/lpoptions" <<'SHIM' +#!/bin/bash +[[ -f "$CUPS_STATE/queue" ]] || exit 1 +printout="$(cat "$CUPS_STATE/printout")" +color="$(cat "$CUPS_STATE/color")" +echo "PrintoutMode/Printout Mode: Draft *${printout} High" +echo "ColorModel/Color Mode: Gray *${color}" SHIM cat > "$WORK/bin/lpinfo" <<'SHIM' @@ -58,37 +159,71 @@ else fi SHIM +cat > "$WORK/bin/systemctl" <<'SHIM' +#!/bin/bash +echo "systemctl $*" >> "$SERVICE_LOG" +SHIM + chmod +x "$WORK/bin"/* export PATH="$WORK/bin:$PATH" export CUPS_LOG="$WORK/cups.log" +export SERVICE_LOG="$WORK/services.log" +export CUPS_STATE="$WORK/state" export CLASSROOM_PRINTER_SETUP_TESTING=true run() { "$SCRIPT" "$@" 2>&1 } -echo "1. creates the explicit classroom queue" +echo "1. provisions the site printer exactly" : > "$CUPS_LOG" -out="$(run 10.20.30.40)" +: > "$SERVICE_LOG" +touch "$CUPS_STATE/legacy" +out="$(run)" log="$(cat "$CUPS_LOG")" -check "uses AppSocket by IP" "-v socket://10.20.30.40:9100" "$log" +services="$(cat "$SERVICE_LOG")" +check "uses the verified site AppSocket URI" "-v socket://10.10.0.155:9100" "$log" +check "creates the Elmer queue" "lpadmin -p Elmer" "$log" +check "description is Elmer" "-D Elmer" "$log" check "uses Trixie's generic color PCL 6 driver" \ "Generic-PCL_6_PCL_XL_Printer-pxlcolor.ppd" "$log" -check "uses one stable queue name" "lpadmin -p Classroom_Color" "$log" -check "enables the queue" "cupsenable Classroom_Color" "$log" -check "accepts jobs" "cupsaccept Classroom_Color" "$log" -check "defaults the queue" "lpadmin -d Classroom_Color" "$log" -check "reports offline-safe behavior" "may remain offline" "$out" +check "forces normal quality" "-o PrintoutMode=Normal" "$log" +check "forces color" "-o ColorModel=Color" "$log" +check "sets driver defaults after selecting the PPD" \ + "lpadmin -p Elmer -o PrintoutMode=Normal -o ColorModel=Color" "$log" +check "enables Elmer" "cupsenable Elmer" "$log" +check "accepts jobs" "cupsaccept Elmer" "$log" +check "defaults Elmer" "lpadmin -d Elmer" "$log" +check "removes the legacy queue" "lpadmin -x Classroom_Color" "$log" +check "disables Avahi service and socket" "disable --now avahi-daemon.socket avahi-daemon.service" "$services" +check "masks Avahi service and socket" "mask avahi-daemon.socket avahi-daemon.service" "$services" +check "reports color defaults" "PrintoutMode=Normal, ColorModel=Color" "$out" +# The teacher-facing identity is just "Elmer". CUPS prints Location under +# the queue name in every print dialog, so it has to be empty. +check "passes an empty Location to CUPS" "-D Elmer -L" "$log" +check "no location text is written" "0" \ + "$(grep -c 'Ekklesia Classroom' "$CUPS_LOG")" +check "the queue is left with no location" "" \ + "$(cat "$CUPS_STATE/location" 2>/dev/null)" echo -echo "2. repeated setup updates the same queue" -run 10.20.30.40 >/dev/null -configs="$(grep -c '^lpadmin -p Classroom_Color' "$CUPS_LOG")" -check "two runs configured the same queue twice" "2" "$configs" -check_absent "no generated queue suffix" "Classroom_Color_" "$(cat "$CUPS_LOG")" +echo "2. a correct repeated run is a quick no-op" +: > "$CUPS_LOG" +out="$(run)" +check "reports already configured" "Elmer is already correctly configured" "$out" +check "does not rewrite the queue" "0" "$(wc -l < "$CUPS_LOG")" echo -echo "3. explicit URIs and non-default setup" +echo "2b. a machine configured the old way is corrected" +printf 'Ekklesia Classroom' > "$CUPS_STATE/location" +: > "$CUPS_LOG" +out="$(run)" +check "notices the stale location" "configuring Elmer" "$out" +check "clears it" "" "$(cat "$CUPS_STATE/location" 2>/dev/null)" +check "and then settles" "Elmer is already correctly configured" "$(run)" + +echo +echo "3. explicit URI remains available for administration" : > "$CUPS_LOG" run --no-default ipp://printer.example.test/ipp/print >/dev/null log="$(cat "$CUPS_LOG")" @@ -104,7 +239,8 @@ else fi export FAKE_DRIVER_PRESENT=false -if out="$(run 10.20.30.40)"; then +rm -f "$CUPS_STATE/queue" +if out="$(run)"; then check "missing driver fails" "failure" "unexpected success" else check "missing driver is explained" "generic color PCL 6 driver is not installed" "$out" diff --git a/tests/test-deployment-support.sh b/tests/test-deployment-support.sh index bcd2297..30c06d6 100755 --- a/tests/test-deployment-support.sh +++ b/tests/test-deployment-support.sh @@ -15,6 +15,9 @@ postinst = (root / "packaging/debian/postinst").read_text() provision = (root / "scripts/classroom-provision").read_text() units = "\n".join(path.read_text() for path in (root / "systemd").rglob("*.service")) canary = (root / "update_canary.sh").read_text() +validation_script = (root / "tools/validate-trixie-release.sh").read_text() +sway = (root / "config/sway/config").read_text() +build = (root / "tools/build-release.sh").read_text() policy = json.loads( (root / "config/chrome/policies/managed/ekklesia-classroom.json").read_text() ) @@ -56,12 +59,31 @@ print("3. printing service behavior") check("CUPS service and socket are enabled", "systemctl enable cups.socket cups.service || true" in postinst) check("CUPS start is best effort", "systemctl start cups.socket || true" in postinst) check("automatic remote queue browsing is disabled", "systemctl disable --now cups-browsed.service || true" in postinst) -check("printer setup is never a boot service", "classroom-printer-setup" not in units) -check("printer setup is never provisioning", "classroom-printer-setup" not in provision) +check("Avahi service and socket are disabled", "systemctl disable --now avahi-daemon.socket avahi-daemon.service || true" in postinst) +check("Avahi service and socket are masked", "systemctl mask avahi-daemon.socket avahi-daemon.service || true" in postinst) +check("printer setup is not a blocking boot dependency", "classroom-printer-setup" not in units) +check("printer setup runs automatically in provisioning", "/usr/local/bin/classroom-printer-setup" in provision) +check("printer provisioning is bounded", "--kill-after=5s 30s" in provision) check("Chrome uses the system print dialog", policy.get("DisablePrintPreview") is True) print() -print("4. canary release gate") +print("4. secondary Chrome windows") +check("stateful Chrome helper starts with Sway", "/usr/local/bin/classroom-chrome-windows" in sway) +check("Chrome helper is packaged", '"$ROOT/scripts/classroom-chrome-windows"' in build) +check("Chrome role module is packaged", '"$ROOT/lib/classroom_chrome.py"' in build) +check("no broad Chrome floating rule", not any( + "google-chrome" in line and "floating enable" in line + for line in sway.splitlines() +)) + +print() +print("5. bounded Trixie validation") +check("container has an outer deadline", "720s" in validation_script) +check("package installs have deadlines", "candidate-install 360" in validation_script) +check("container service startup is suppressed", "/usr/sbin/policy-rc.d" in validation_script and "exit 101" in validation_script) + +print() +print("6. canary release gate") validation = canary.index('"$ROOT/tools/validate-trixie-release.sh"') package_copy = canary.index('cp "$package_path"') first_rsync = canary.index("rsync -av") diff --git a/tools/build-release.sh b/tools/build-release.sh index ba72bbf..f28db58 100755 --- a/tools/build-release.sh +++ b/tools/build-release.sh @@ -97,6 +97,10 @@ install -Dm755 \ "$ROOT/scripts/classroom-browser" \ "$STAGE/usr/local/bin/classroom-browser" +install -Dm755 \ + "$ROOT/scripts/classroom-chrome-windows" \ + "$STAGE/usr/local/bin/classroom-chrome-windows" + install -Dm755 \ "$ROOT/scripts/classroom-window" \ "$STAGE/usr/local/bin/classroom-window" @@ -154,6 +158,10 @@ install -Dm644 \ "$ROOT/lib/classroom_config.py" \ "$STAGE/usr/local/lib/ekklesia-classroom/classroom_config.py" +install -Dm644 \ + "$ROOT/lib/classroom_chrome.py" \ + "$STAGE/usr/local/lib/ekklesia-classroom/classroom_chrome.py" + install -Dm644 \ "$ROOT/lib/classroom_icons.py" \ "$STAGE/usr/local/lib/ekklesia-classroom/classroom_icons.py" diff --git a/tools/validate-trixie-release.sh b/tools/validate-trixie-release.sh index 5a70e88..164740e 100755 --- a/tools/validate-trixie-release.sh +++ b/tools/validate-trixie-release.sh @@ -29,23 +29,63 @@ if [[ -n "$previous" ]]; then docker_args+=( -v "$previous:/packages/previous.deb:ro" ) fi -docker "${docker_args[@]}" debian:trixie-slim \ +timeout --signal=TERM --kill-after=15s 720s \ + docker "${docker_args[@]}" debian:trixie-slim \ bash -s -- "${previous:+/packages/previous.deb}" <<'CONTAINER' -set -euo pipefail +set -Eeuo pipefail + +validation_failure() { + status=$? + echo "Validation failed at line ${BASH_LINENO[0]}: ${BASH_COMMAND}" >&2 + + if command -v lpstat >/dev/null 2>&1; then + lpstat -t >&2 || true + lpoptions -p Elmer -l >&2 || true + fi + + [[ ! -f /tmp/provision-first.log ]] || cat /tmp/provision-first.log >&2 + [[ ! -f /tmp/provision-second.log ]] || cat /tmp/provision-second.log >&2 + exit "$status" +} + +trap validation_failure ERR export DEBIAN_FRONTEND=noninteractive -apt-get update -qq + +# Package validation runs in a container with no init system. Do not let +# maintainer scripts try to start CUPS, Avahi or other irrelevant daemons; +# services are inspected and CUPS is started explicitly when the test needs it. +cat > /usr/sbin/policy-rc.d <<'POLICY' +#!/bin/sh +exit 101 +POLICY +chmod 0755 /usr/sbin/policy-rc.d + +run_logged() { + label="$1" + seconds="$2" + shift 2 + + if ! timeout --signal=TERM --kill-after=10s "${seconds}s" "$@" \ + >"/tmp/${label}.log" 2>&1; then + cat "/tmp/${label}.log" >&2 + echo "${label} exceeded ${seconds}s or failed" >&2 + exit 1 + fi +} + +run_logged apt-update 120 apt-get update -qq previous="${1:-}" if [[ -n "$previous" ]]; then - apt-get install -y -qq "$previous" >/tmp/previous-install.log + run_logged previous-install 360 apt-get install -y -qq "$previous" cat > /etc/ekklesia-classroom/device.conf <<'CONFIG' # Configuration carried forward from 0.1.36 UPDATE_URL=https://updates.riverofglorymn.org CHANNEL=canary -DVD_ENABLED=true +DVD_ENABLED=false UI_SCALE=1.25 MQTT_ENABLED=true MQTT_HOST=broker.example.test @@ -56,7 +96,7 @@ CUSTOM_SETTING=preserve-me-too CONFIG fi -apt-get install -y -qq /packages/candidate.deb >/tmp/candidate-install.log +run_logged candidate-install 360 apt-get install -y -qq /packages/candidate.deb command -v nmtui >/dev/null command -v lpadmin >/dev/null @@ -78,22 +118,40 @@ sleep 1 driver='foomatic-db-compressed-ppds:0/ppd/foomatic-ppd/Generic-PCL_6_PCL_XL_Printer-pxlcolor.ppd' lpinfo -m | grep -q "^${driver} " -classroom-printer-setup 192.0.2.10 >/tmp/printer-first.log -classroom-printer-setup 192.0.2.10 >/tmp/printer-second.log -lpstat -p Classroom_Color -d -v | grep -q 'socket://192.0.2.10:9100' +# Simulate the queue an older release may have left behind, then run the real +# boot provisioning path. Chrome is stubbed as already installed and DVD is +# disabled so the test remains entirely local and deterministic. +lpadmin -p Classroom_Color -v socket://192.0.2.10:9100 -m "$driver" +install -m755 /bin/true /usr/local/bin/google-chrome + +timeout --signal=TERM --kill-after=5s 45s classroom-provision \ + >/tmp/provision-first.log + +! lpstat -p Classroom_Color >/dev/null 2>&1 +lpstat -p Elmer -d -v | grep -q 'socket://10.10.0.155:9100' +lpstat -l -p Elmer | grep -q 'Description: Elmer' +lpoptions -p Elmer -l | grep -Eq '^PrintoutMode/.*:.*\*Normal([[:space:]]|$)' +lpoptions -p Elmer -l | grep -Eq '^ColorModel/.*:.*\*Color([[:space:]]|$)' +[[ "$(lpstat -e)" == "Elmer" ]] +[[ "$(systemctl is-enabled avahi-daemon.service 2>/dev/null || true)" == "masked" ]] +[[ "$(systemctl is-enabled avahi-daemon.socket 2>/dev/null || true)" == "masked" ]] + +timeout --signal=TERM --kill-after=5s 45s classroom-provision \ + >/tmp/provision-second.log +grep -q 'Elmer is already correctly configured' /tmp/provision-second.log if [[ -n "$previous" ]]; then grep -q '^CHANNEL=canary$' /etc/ekklesia-classroom/device.conf - grep -q '^DVD_ENABLED=true$' /etc/ekklesia-classroom/device.conf + grep -q '^DVD_ENABLED=false$' /etc/ekklesia-classroom/device.conf grep -q '^UI_SCALE=1.25$' /etc/ekklesia-classroom/device.conf grep -q '^MQTT_PASSWORD=preserve-me$' /etc/ekklesia-classroom/device.conf grep -q '^CUSTOM_SETTING=preserve-me-too$' /etc/ekklesia-classroom/device.conf [[ "$(grep -c '^LIVE_ENABLED=' /etc/ekklesia-classroom/device.conf)" -eq 1 ]] - apt-get install --reinstall -y -qq /packages/candidate.deb \ - >/tmp/candidate-reinstall.log + run_logged candidate-reinstall 360 \ + apt-get install --reinstall -y -qq /packages/candidate.deb [[ "$(grep -c '^LIVE_ENABLED=' /etc/ekklesia-classroom/device.conf)" -eq 1 ]] fi -echo "Trixie package, upgrade, networking, printing and reinstall checks passed." +echo "Trixie package, upgrade, bounded install, networking, Elmer printing and reinstall checks passed." CONTAINER