build: keep the DVD and MQTT helpers, and count the deletion honestly

Building the package showed what reading the tree did not: classroom-dvd
still calls classroom-window, classroom-mqtt still calls classroom-notify,
and the DVD overlay still imports the icon and stylesheet helpers the dock
shared. They go back in, and the write-up now counts what was actually
removed rather than what could be.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-10 09:03:26 -05:00
co-authored by Claude Opus 5
parent 6e4786f448
commit 4c3f19d599
17 changed files with 171 additions and 1778 deletions
-314
View File
@@ -1,314 +0,0 @@
#!/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
# The system print dialog, matched by its exact title.
#
# This is the one place a title is used, and only to recognise a single
# known dialog. It is not classifying Chrome windows — creation order
# still does that. The dialog and the window that rendered the document
# for it are otherwise identical to sway, and if the render window ends
# up in front the teacher cannot print at all.
PRINT_TITLE = "Print"
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, arriving=True):
"""Float one exact container; never apply a broad Chrome float rule."""
target = criterion(identifier)
command(f"{target} floating enable")
command(f"{target} border none")
if not arriving:
# Only restoring the floating role to a window that already
# existed. Never move it: that would undo wherever the teacher
# dragged it. Never focus it: that would raise the render window
# over the print dialog sitting above it.
return
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 is_print_dialog(window):
return (window or {}).get("name") == PRINT_TITLE
class ActiveSecondary:
"""The one secondary window the dock offers to close.
Deliberately sticky. This used to be whichever secondary happened to
be focused, which made the button impossible to click: moving the
pointer down to the dock moves focus away from the popup, so the
button disappeared on the way to it. Nobody could ever reach it.
It is remembered instead. Losing focus changes nothing. It only
changes when the window it names goes away, or when another
secondary takes over by being focused.
"""
def __init__(self):
self.target = None
def update(self, secondary, chrome):
# A focused secondary takes over. Focus on the dock, the print
# dialog, the main browser or any layer-shell surface is not a
# secondary, so none of them disturb this.
for identifier in secondary:
window = chrome.get(identifier)
if window and window["focused"] and not window["hidden"]:
self.target = identifier
break
# Never anything that has closed, and never the main browser:
# `secondary` cannot contain the primary.
if self.target is not None and self.target not in secondary:
self.target = None
# If the remembered window closed, fall back to another one that
# is still open, so cancelling a print dialog leaves the render
# window still closeable from the dock.
if self.target is None:
live = [
identifier
for identifier in secondary
if identifier in chrome and not chrome[identifier]["hidden"]
]
self.target = live[-1] if live else None
return self.target
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, active, 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"])
# The print dialog is configured last, because configuring a window
# focuses it and whichever is done last ends up in front. Both can
# arrive between two events, and the tree does not promise to list
# them in the order they were created.
arriving = sorted(
changes["new_secondary"],
key=lambda identifier: is_print_dialog(chrome.get(identifier)),
)
for identifier in arriving:
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, arriving=False)
keep_print_in_front(roles.secondary, chrome)
publish(active.update(roles.secondary, chrome), path)
def keep_print_in_front(secondary, chrome):
"""The print dialog stays the obvious foreground window.
The window that rendered the document is a secondary like any other
and will raise itself over the dialog given the chance, which leaves
the teacher looking at a document they cannot print.
Focus is only ever taken back from another Chrome popup. Clicking
the main browser, the dock or anything else is the teacher's
business and is left alone.
"""
dialogs = [
identifier
for identifier in secondary
if identifier in chrome
and is_print_dialog(chrome[identifier])
and not chrome[identifier]["hidden"]
]
if not dialogs:
return
dialog = dialogs[-1]
if chrome[dialog]["focused"]:
return
intruder = next(
(
identifier
for identifier in secondary
if identifier in chrome
and chrome[identifier]["focused"]
and identifier != dialog
),
None,
)
if intruder is None:
return
command(f"[con_id={dialog}] focus")
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()
active = ActiveSecondary()
try:
while True:
# Whatever happened while there was no subscription.
reconcile(roles, active, 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, active, 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())
-754
View File
@@ -1,754 +0,0 @@
#!/usr/bin/env python3
import math
import os
import subprocess
import sys
from datetime import datetime
from pathlib import Path
import gi
gi.require_version("Gtk", "3.0")
gi.require_version("GtkLayerShell", "0.1")
from gi.repository import Gio, Gtk, Gdk, GdkPixbuf, GLib, GtkLayerShell
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
import classroom_style as style # noqa: E402
from classroom_config import ( # noqa: E402
SHELL_SCALE,
dock_items,
read_device_config,
scaled,
)
from classroom_icons import ScalableIcon # noqa: E402
# Optional on purpose. The dock is the one thing the classroom cannot
# afford to lose, so a missing DVD helper simply disables disc detection
# instead of taking the whole shell down.
try:
import classroom_optical as optical
except ImportError:
optical = None
# Only used for the busy feedback on the dock buttons, so a missing
# module costs a nicety rather than the shell.
try:
import classroom_sway as sway
except ImportError:
sway = None
# Used to draw the throbber. Without it the busy state falls back to a
# stock GtkSpinner, which works but is smaller than the icon language.
try:
import cairo
except ImportError:
cairo = None
ICON_DIR = Path("/etc/ekklesia-classroom/icons")
DVD_POLL_SECONDS = 2
# Every measurement in the shell, at UI_SCALE=1.0. The CSS template
# carries the same numbers; these are the ones GTK needs in Python.
SHELL_HEIGHT = 76
DOCK_SPACING = 10
CLOCK_EDGE_PADDING = 20
BROWSER_ICON_SIZE = 28
DVD_ICON_SIZE = 40
SANCTUARY_ICON_SIZE = 38
# How Chrome appears in the sway tree, so the dock can tell a launch from
# a restore and stop spinning once the window is really there.
BROWSER_WINDOW = (
r"google-chrome",
r"chrome",
r"chrome-.*",
r"google-chrome-.*",
)
# The throbber is drawn at the same size as the icon it stands in for,
# so the button does not change shape when an application is starting.
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
# that a launch which never happens does not spin all morning.
BUSY_TIMEOUT_SECONDS = 30
def launch(command):
subprocess.Popen(command, start_new_session=True)
def notify(title, message):
launch([
"notify-send",
"--app-name=Ekklesia Classroom",
title,
message,
])
def themed_icon(*names):
theme = Gtk.IconTheme.get_default()
for name in names:
if theme.has_icon(name):
return Gtk.Image.new_from_icon_name(
name,
Gtk.IconSize.DIALOG,
)
return Gtk.Image.new_from_icon_name(
"application-x-executable",
Gtk.IconSize.DIALOG,
)
def custom_icon(filename, size):
"""A classroom SVG icon, drawn at the display's real pixel density."""
icon = ScalableIcon(filename, size, ICON_DIR)
return icon if icon.usable else None
def throbber_frames(size, scale):
"""A white arc, drawn once per frame of one revolution.
Drawn at the display's real density like the icons, so the busy
state is not the one soft thing in a sharp dock. The surface carries
the device scale, so everything below is in logical units.
"""
if cairo is None:
return None
stroke = max(2, round(size / 9))
radius = size / 2 - stroke / 2 - 1
frames = []
for index in range(THROBBER_FRAMES):
surface = cairo.ImageSurface(
cairo.FORMAT_ARGB32,
size * scale,
size * scale,
)
surface.set_device_scale(scale, scale)
context = cairo.Context(surface)
context.set_source_rgba(1, 1, 1, 1)
context.set_line_width(stroke)
context.set_line_cap(cairo.LINE_CAP_ROUND)
start = index * 2 * math.pi / THROBBER_FRAMES
context.arc(size / 2, size / 2, radius, start, start + math.pi * 1.4)
context.stroke()
frames.append(surface)
return frames
class Throbber(Gtk.Image):
"""The busy state of a dock button."""
def __init__(self, size):
super().__init__()
self.size = size
self.frames = throbber_frames(size, 1)
self.drawn_at = 1
self.frame = 0
self.timer = None
if self.frames:
self.set_from_surface(self.frames[0])
self.connect("realize", lambda *_: self.rebuild())
self.connect("notify::scale-factor", lambda *_: self.rebuild())
def rebuild(self):
scale = self.get_scale_factor() or 1
if scale == self.drawn_at:
return
frames = throbber_frames(self.size, scale)
if not frames:
return
self.frames = frames
self.drawn_at = scale
self.set_from_surface(self.frames[self.frame])
def usable(self):
return bool(self.frames)
def start(self):
if not self.frames or self.timer is not None:
return
self.timer = GLib.timeout_add(THROBBER_INTERVAL_MS, self.advance)
def stop(self):
if self.timer is not None:
GLib.source_remove(self.timer)
self.timer = None
def advance(self):
self.frame = (self.frame + 1) % len(self.frames)
self.set_from_surface(self.frames[self.frame])
return True
class ClassroomShell(Gtk.Window):
def __init__(self):
super().__init__()
screen = self.get_screen()
visual = screen.get_rgba_visual()
if visual is not None:
self.set_visual(visual)
self.set_app_paintable(True)
self.set_name("classroom-shell")
self.set_decorated(False)
self.set_resizable(False)
self.config = read_device_config()
self.scale = SHELL_SCALE
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(
self,
GtkLayerShell.Layer.TOP,
)
GtkLayerShell.set_anchor(
self,
GtkLayerShell.Edge.BOTTOM,
True,
)
GtkLayerShell.set_anchor(
self,
GtkLayerShell.Edge.LEFT,
True,
)
GtkLayerShell.set_anchor(
self,
GtkLayerShell.Edge.RIGHT,
True,
)
shell_height = scaled(SHELL_HEIGHT, self.scale)
GtkLayerShell.set_exclusive_zone(self, shell_height)
GtkLayerShell.set_keyboard_interactivity(
self,
False,
)
layout = Gtk.Overlay()
layout.set_size_request(-1, shell_height)
dock = self.build_dock()
dock_align = Gtk.Alignment(
xalign=0.5,
yalign=0.5,
xscale=0,
yscale=0,
)
dock_align.add(dock)
layout.add(dock_align)
self.clock = Gtk.Label()
self.clock.get_style_context().add_class("clock")
clock_align = Gtk.Alignment(
xalign=1.0,
yalign=0.5,
xscale=0,
yscale=0,
)
clock_align.set_padding(
0,
0,
0,
scaled(CLOCK_EDGE_PADDING, self.scale),
)
clock_align.add(self.clock)
layout.add_overlay(clock_align)
layout.set_overlay_pass_through(clock_align, True)
self.add(layout)
self.update_time()
GLib.timeout_add_seconds(
15,
self.update_time,
)
def build_dock(self):
dock = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL,
spacing=scaled(DOCK_SPACING, self.scale),
)
dock.get_style_context().add_class("dock")
items = dock_items(self.config)
dock.pack_start(
self.make_button(
"browser",
self.browser_icon(),
self.open_browser,
),
False,
False,
0,
)
if "dvd" in items:
dock.pack_start(
self.make_button(
"dvd",
self.dvd_icon(),
lambda *_: launch(
["/usr/local/bin/classroom-dvd", "toggle"]
),
),
False,
False,
0,
)
self.start_disc_watch()
if "live" in items:
dock.pack_start(
self.make_button(
"sanctuary",
self.sanctuary_icon(),
lambda *_: notify(
"Sanctuary",
"Sanctuary live view is being prepared.",
),
),
False,
False,
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)
button.get_style_context().add_class(
"dock-button"
)
button.add(image)
button.connect("clicked", callback)
self.buttons[name] = {
"button": button,
"image": image,
"source": None,
"spinner": None,
"waited": 0,
}
return button
# -- busy feedback ----------------------------------------------
#
# A click has to be visibly acknowledged even when the thing it
# starts takes several seconds to appear. The button the finger
# landed on becomes a spinner until the window shows up in sway.
def window_open(self, patterns):
if sway is None:
return True
return any(
sway.matches(window, patterns) for window in sway.windows()
)
def open_browser(self, *_):
# Only a real launch is worth spinning over. Focusing or
# restoring an open window is immediate and should feel it.
starting = not self.window_open(BROWSER_WINDOW)
launch(["/usr/local/bin/classroom-browser"])
if starting:
self.start_busy(
"browser",
BROWSER_WINDOW,
"Browser",
"The browser did not start. Please try again.",
)
def start_busy(self, name, patterns, title, message):
entry = self.buttons.get(name)
if sway is None or entry is None or entry["source"] is not None:
return
spinner = Throbber(scaled(BROWSER_ICON_SIZE, self.scale))
if not spinner.usable():
# No cairo: the stock spinner is smaller than the icon
# language would like, but it still says "working".
spinner = Gtk.Spinner()
size = scaled(BUSY_SPINNER_SIZE, self.scale)
spinner.set_size_request(size, size)
entry["button"].remove(entry["image"])
entry["button"].add(spinner)
spinner.show()
spinner.start()
entry["spinner"] = spinner
entry["waited"] = 0
entry["source"] = GLib.timeout_add(
BUSY_POLL_MS,
self.check_busy,
name,
patterns,
title,
message,
)
self.set_busy_cursor(True)
def check_busy(self, name, patterns, title, message):
entry = self.buttons.get(name)
if entry is None or entry["source"] is None:
return GLib.SOURCE_REMOVE
entry["waited"] += BUSY_POLL_MS / 1000
if self.window_open(patterns):
self.stop_busy(name)
return GLib.SOURCE_REMOVE
if entry["waited"] >= BUSY_TIMEOUT_SECONDS:
self.stop_busy(name)
notify(title, message)
return GLib.SOURCE_REMOVE
return GLib.SOURCE_CONTINUE
def stop_busy(self, name):
entry = self.buttons.get(name)
if entry is None or entry["source"] is None:
return
entry["source"] = None
spinner = entry["spinner"]
if spinner is not None:
spinner.stop()
entry["button"].remove(spinner)
entry["spinner"] = None
entry["button"].add(entry["image"])
entry["image"].show()
self.set_busy_cursor(
any(other["source"] is not None for other in self.buttons.values())
)
def set_busy_cursor(self, busy):
"""A waiting pointer, for as long as it is over the dock.
Wayland gives the cursor to whichever client the pointer is over,
and sway offers no way to set one globally, so this is only
honoured on our own surface. That is where the pointer is when a
dock button has just been pressed, which is the moment that
matters.
"""
window = self.get_window()
if window is None:
return
cursor = None
if busy:
cursor = Gdk.Cursor.new_from_name(
window.get_display(),
"progress",
)
window.set_cursor(cursor)
def browser_icon(self):
size = scaled(BROWSER_ICON_SIZE, self.scale)
image = custom_icon(
"browser.svg",
size,
)
if image is not None:
return image
image = themed_icon(
"web-browser-symbolic",
"internet-web-browser-symbolic",
)
image.set_pixel_size(size)
return image
def dvd_icon(self):
size = scaled(DVD_ICON_SIZE, self.scale)
image = custom_icon(
"dvd.svg",
size,
)
if image is not None:
self.dvd_image = image
return image
image = themed_icon(
"media-optical-symbolic",
"media-optical",
)
image.set_pixel_size(size)
return image
def sanctuary_icon(self):
is_live = enabled(
self.config,
"SANCTUARY_LIVE",
False,
)
filename = (
"live.svg"
if is_live
else "live-off.svg"
)
size = scaled(SANCTUARY_ICON_SIZE, self.scale)
image = custom_icon(
filename,
size,
)
if image is not None:
return image
image = themed_icon(
"video-display-symbolic",
"video-display",
)
image.set_pixel_size(size)
return image
def start_disc_watch(self):
if optical is None:
return
self.check_disc()
GLib.timeout_add_seconds(
DVD_POLL_SECONDS,
self.check_disc,
)
def check_disc(self):
ready = optical.drive_status() == optical.CDS_DISC_OK
# The disc indicator: the DVD logo sits back when the drive is
# empty or unplugged and comes up to full strength once a disc is
# ready, the same gray/colour language the LIVE icon uses.
if self.dvd_image is not None:
self.dvd_image.set_opacity(1.0 if ready else 0.55)
# The first poll only records what is already in the drive, so a
# disc left in over the week does not announce itself at boot.
if self.disc_ready is not None and ready and not self.disc_ready:
notify(
"DVD Ready",
"A disc is ready to play.",
)
self.disc_ready = ready
return True
def update_time(self):
self.clock.set_text(
datetime.now().strftime("%-I:%M %p")
)
return True
def load_css(scale):
# Styling is worth losing before the dock is. A stylesheet that will
# not parse leaves an unstyled but working shell rather than no shell.
try:
provider = Gtk.CssProvider()
provider.load_from_data(
style.render(scale).encode("utf-8")
)
except (OSError, GLib.Error) as error:
print(
f"classroom-dock: stylesheet not applied: {error}",
file=sys.stderr,
)
return
screen = Gdk.Screen.get_default()
Gtk.StyleContext.add_provider_for_screen(
screen,
provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
)
def main():
load_css(SHELL_SCALE)
shell = ClassroomShell()
shell.connect(
"destroy",
Gtk.main_quit,
)
shell.show_all()
Gtk.main()
if __name__ == "__main__":
main()
-74
View File
@@ -1,74 +0,0 @@
#!/usr/bin/env python3
"""Starts mako with the classroom styling at the room's UI scale.
mako only reads ~/.config, and its measurements have to follow UI_SCALE,
so the packaged template is resolved into the session's runtime directory
and mako is pointed at the result.
"""
import os
import sys
from pathlib import Path
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
from classroom_config import SHELL_SCALE, scaled # noqa: E402
MAKO = "/usr/bin/mako"
TEMPLATE = Path("/etc/ekklesia-classroom/mako/config.in")
# The same measurements the template carries, at UI_SCALE=1.0.
MEASUREMENTS = {
"font-size": 12,
"radius": 18,
"padding-v": 14,
"padding-h": 18,
"margin": 18,
"width": 380,
"height": 160,
"icon-size": 48,
}
def runtime_directory():
base = os.environ.get("XDG_RUNTIME_DIR") or "/tmp"
directory = Path(base) / "ekklesia-classroom"
directory.mkdir(parents=True, exist_ok=True)
return directory
def render(scale):
config = TEMPLATE.read_text()
for token, value in MEASUREMENTS.items():
config = config.replace(
f"@{token}@",
str(scaled(value, scale)),
)
return config
def main():
scale = SHELL_SCALE
try:
path = runtime_directory() / "mako.config"
path.write_text(render(scale))
config_argument = str(path)
except OSError as error:
# Better plain notifications than none at all.
print(
f"classroom-notifications: falling back to the unscaled template: {error}",
file=sys.stderr,
)
config_argument = str(TEMPLATE)
os.execv(MAKO, [MAKO, "--config", config_argument])
if __name__ == "__main__":
main()
+23 -21
View File
@@ -11,12 +11,12 @@ set -euo pipefail
# Used by classroom-notify-test to send a sample, and by classroom-mqtt
# to put messages from the church broker on the screen.
#
# The classroom desktop runs as the classroom user, and mako is attached
# to that user's D-Bus session. An administrator over SSH sits in a
# different session where nothing owns org.freedesktop.Notifications, so
# a plain notify-send there asks D-Bus to start a second mako, which has
# no display to open and times out with NoReply. Find the running desktop
# session and deliver into it instead.
# The classroom desktop runs as the classroom user, and the notification
# service is part of that user's Plasma session. An administrator over SSH
# sits in a different session where nothing owns
# org.freedesktop.Notifications, so a plain notify-send there asks D-Bus to
# start a service that has no display to open, and times out with NoReply.
# Find the running desktop session and deliver into it instead.
CLASSROOM_USER=classroom
@@ -80,7 +80,7 @@ environ_value() {
printf '%s\n' "$1" | sed -n "s/^$2=//p" | head -n 1
}
# sway holds its own wayland-N.lock open, so the display name can be read
# The compositor holds its own wayland-N.lock open, so the display name can be read
# back off the process instead of being guessed at.
display_from_lock() {
local link target
@@ -115,17 +115,19 @@ if [[ "$current_uid" == "$classroom_uid" &&
-n "${WAYLAND_DISPLAY:-}" &&
-n "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then
# Already inside the classroom desktop session.
sway_pid="n/a"
session_pid="n/a"
runtime_dir="${XDG_RUNTIME_DIR:-}"
bus="$DBUS_SESSION_BUS_ADDRESS"
wayland_display="$WAYLAND_DISPLAY"
else
session="the $CLASSROOM_USER desktop session"
sway_pid="$(pgrep -u "$CLASSROOM_USER" -x sway | head -n 1 || true)"
# The compositor is the session: it owns the Wayland display and the
# environment every other part of the session inherited.
session_pid="$(pgrep -u "$CLASSROOM_USER" -x kwin_wayland | head -n 1 || true)"
[[ -n "$sway_pid" ]] ||
fail "no classroom desktop session is running: there is no sway process for user $CLASSROOM_USER."
[[ -n "$session_pid" ]] ||
fail "no classroom desktop session is running: there is no kwin_wayland process for user $CLASSROOM_USER."
# Checked before anything that needs to look inside the session, so
# an administrator gets told what to do rather than a permission
@@ -134,12 +136,12 @@ else
fail "run this as root (sudo classroom-notify) or as the $CLASSROOM_USER user."
fi
environment_block="$(read_environ "$sway_pid")"
environment_block="$(read_environ "$session_pid")"
if [[ -n "$environment_block" ]]; then
source_note="read from sway (pid $sway_pid)"
source_note="read from the compositor (pid $session_pid)"
else
source_note="sway's environment was unreadable; using session defaults"
source_note="the compositor's environment was unreadable; using session defaults"
fi
runtime_dir="$(environ_value "$environment_block" XDG_RUNTIME_DIR)"
@@ -164,7 +166,7 @@ else
wayland_display="$(environ_value "$environment_block" WAYLAND_DISPLAY)"
if [[ -z "$wayland_display" ]]; then
wayland_display="$(display_from_lock "$sway_pid")"
wayland_display="$(display_from_lock "$session_pid")"
fi
environment=(
@@ -186,15 +188,15 @@ else
fi
fi
# Deliver to the mako that is already running. Sending into a session
# without one would have D-Bus start a second mako, which is exactly the
# failure this script exists to avoid.
pgrep -u "$CLASSROOM_USER" -x mako >/dev/null 2>&1 ||
fail "mako is not running in the classroom session, and this will not start it. Check with: pgrep -u $CLASSROOM_USER -a mako"
# Deliver to the notification service that is already running. Sending into
# a session without one would have D-Bus start something with no display to
# draw on, which is exactly the failure this script exists to avoid.
pgrep -u "$CLASSROOM_USER" -x plasmashell >/dev/null 2>&1 ||
fail "the classroom shell is not running, and this will not start it. Check with: pgrep -u $CLASSROOM_USER -a plasmashell"
if [[ "$dry_run" == true ]]; then
echo "Would notify $session:"
echo " sway pid: $sway_pid"
echo " compositor pid: $session_pid"
echo " session environment: $source_note"
echo " XDG_RUNTIME_DIR: $runtime_dir"
echo " DBUS_SESSION_BUS_ADDRESS: $bus"