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:
co-authored by
Claude Opus 5
parent
6e4786f448
commit
4c3f19d599
@@ -1,3 +0,0 @@
|
|||||||
if [ -z "$WAYLAND_DISPLAY" ] && [ "${XDG_VTNR:-}" = "1" ]; then
|
|
||||||
exec sway -c /etc/ekklesia-classroom/sway/config
|
|
||||||
fi
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
/* Ekklesia Classroom dock
|
|
||||||
*
|
|
||||||
* A template, not plain CSS. classroom-dock substitutes the bracketed
|
|
||||||
* measurements at startup from UI_SCALE in device.conf, so a room can
|
|
||||||
* be tuned for its TV without anyone editing this file. At UI_SCALE=1.0
|
|
||||||
* every token resolves to the value it had before scaling existed.
|
|
||||||
*/
|
|
||||||
|
|
||||||
window#classroom-dock,
|
|
||||||
window#classroom-clock {
|
|
||||||
background-color: transparent;
|
|
||||||
background-image: none;
|
|
||||||
border: none;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main floating dock */
|
|
||||||
.dock {
|
|
||||||
background-image: none;
|
|
||||||
background-color: rgba(34, 38, 50, 0.62);
|
|
||||||
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: @dock-radius@px;
|
|
||||||
|
|
||||||
padding: @dock-padding-v@px @dock-padding-h@px;
|
|
||||||
|
|
||||||
/* Important: no shadow here.
|
|
||||||
GTK was clipping it against the rectangular window edge. */
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.dock-button {
|
|
||||||
color: white;
|
|
||||||
|
|
||||||
background-image: none;
|
|
||||||
background-color: rgba(255, 255, 255, 0.05);
|
|
||||||
|
|
||||||
border: none;
|
|
||||||
border-radius: @button-radius@px;
|
|
||||||
|
|
||||||
padding: @button-padding@px;
|
|
||||||
margin: 0;
|
|
||||||
|
|
||||||
min-width: @button-size@px;
|
|
||||||
min-height: @button-size@px;
|
|
||||||
|
|
||||||
box-shadow: none;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.dock-button:hover {
|
|
||||||
background-image: none;
|
|
||||||
background-color: rgba(70, 75, 90, 0.85);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.dock-button:active {
|
|
||||||
background-image: none;
|
|
||||||
background-color: rgba(85, 90, 108, 0.90);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.dock-button:focus {
|
|
||||||
background-image: none;
|
|
||||||
background-color: rgba(255, 255, 255, 0.08);
|
|
||||||
box-shadow: none;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.dock-button image {
|
|
||||||
color: white;
|
|
||||||
-gtk-icon-effect: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Shown in place of the icon while an application is starting. */
|
|
||||||
button.dock-button spinner {
|
|
||||||
color: white;
|
|
||||||
opacity: 1;
|
|
||||||
|
|
||||||
min-width: @button-spinner@px;
|
|
||||||
min-height: @button-spinner@px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Clock */
|
|
||||||
|
|
||||||
.clock {
|
|
||||||
color: white;
|
|
||||||
|
|
||||||
background-image: none;
|
|
||||||
background-color: rgba(34, 38, 50, 0.62);
|
|
||||||
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: @clock-radius@px;
|
|
||||||
|
|
||||||
padding: @clock-padding-v@px @clock-padding-h@px;
|
|
||||||
|
|
||||||
font-size: @clock-font@px;
|
|
||||||
font-weight: 600;
|
|
||||||
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 640 640"><path fill="#ffffff" d="M161.423 279.11L69.816 120.474C128.458 47.068 218.708-.012 319.976-.012c117.168 0 219.57 62.965 275.33 156.888H334.175c-4.69-.39-9.438-.638-14.245-.638-76.158 0-140.352 52.288-158.553 122.848l.048.024zm273.121-75.958H617.96c14.197 36.202 22.04 75.64 22.04 116.872C640 495.55 498.668 638.04 323.626 640l130.88-226.692c18.449-26.492 29.244-58.642 29.244-93.284 0-45.71-18.839-87.12-49.205-116.872zM203.742 320.024c0-64.099 52.158-116.234 116.234-116.234 64.087 0 116.246 52.159 116.246 116.234 0 64.088-52.158 116.246-116.246 116.246-64.076 0-116.234-52.158-116.234-116.246zm160.112 157.762l-91.596 158.695C118.17 613.438 0 480.515 0 320.024c0-56.988 14.918-110.505 41.044-156.876l130.643 226.278c26.162 55.678 82.808 94.372 148.277 94.372 15.19 0 29.87-2.091 43.879-5.965l.012-.047z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,48 +0,0 @@
|
|||||||
# Ekklesia Classroom notifications
|
|
||||||
# Managed by the ekklesia-classroom package.
|
|
||||||
#
|
|
||||||
# Matched to the dock: same dark slate (#222632), same hairline white
|
|
||||||
# border, same rounded corners, white text. Kept slightly more opaque
|
|
||||||
# than the dock so text stays readable over the wallpaper and over video.
|
|
||||||
#
|
|
||||||
# A template, not a finished config. classroom-notifications resolves the
|
|
||||||
# bracketed measurements from UI_SCALE in device.conf and starts mako on
|
|
||||||
# the result. At UI_SCALE=1.0 they resolve to the values they had before
|
|
||||||
# scaling existed.
|
|
||||||
|
|
||||||
font=DejaVu Sans @font-size@
|
|
||||||
|
|
||||||
background-color=#222632e6
|
|
||||||
text-color=#ffffffff
|
|
||||||
border-color=#ffffff21
|
|
||||||
progress-color=over #464b5ae6
|
|
||||||
|
|
||||||
border-size=1
|
|
||||||
border-radius=@radius@
|
|
||||||
|
|
||||||
padding=@padding-v@,@padding-h@
|
|
||||||
margin=@margin@
|
|
||||||
|
|
||||||
width=@width@
|
|
||||||
height=@height@
|
|
||||||
|
|
||||||
anchor=top-right
|
|
||||||
layer=overlay
|
|
||||||
sort=-time
|
|
||||||
max-visible=3
|
|
||||||
|
|
||||||
markup=1
|
|
||||||
format=<b>%s</b>\n%b
|
|
||||||
|
|
||||||
icons=1
|
|
||||||
max-icon-size=@icon-size@
|
|
||||||
|
|
||||||
default-timeout=6000
|
|
||||||
|
|
||||||
[urgency=low]
|
|
||||||
default-timeout=4000
|
|
||||||
|
|
||||||
[urgency=critical]
|
|
||||||
background-color=#3a2632f0
|
|
||||||
border-color=#ff8a8a59
|
|
||||||
default-timeout=0
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# Ekklesia Classroom
|
|
||||||
# Managed by the ekklesia-classroom package.
|
|
||||||
|
|
||||||
set $mod Mod4
|
|
||||||
|
|
||||||
# Wallpaper
|
|
||||||
output * bg /usr/share/backgrounds/ekklesia/classroom-default.png fill
|
|
||||||
|
|
||||||
# UI scale first, so every surface below comes up at the right size
|
|
||||||
# instead of being resized underneath itself. This scales the whole
|
|
||||||
# session — Chrome, VLC and the dock alike — not just our own windows.
|
|
||||||
exec --no-startup-id /usr/local/bin/classroom-scale
|
|
||||||
|
|
||||||
# Notifications, started before the dock so nothing can race D-Bus into
|
|
||||||
# activating an unstyled mako first. The wrapper resolves the styling at
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# Keep the interface intentionally minimal.
|
|
||||||
# Additional classroom controls are added explicitly by Ekklesia Classroom.
|
|
||||||
|
|
||||||
include /etc/sway/config.d/*
|
|
||||||
|
|
||||||
# Chrome draws its own tab strip with window controls in it, so sway's
|
|
||||||
# title bar above that is a second, redundant one. Verified on the
|
|
||||||
# endpoint: Chrome is an xdg_shell client with app_id "google-chrome",
|
|
||||||
# and sway was giving it border "normal". The class rule covers the case
|
|
||||||
# where Chrome falls back to XWayland.
|
|
||||||
#
|
|
||||||
# This removes the compositor's decoration only. Chrome keeps its tabs
|
|
||||||
# and address bar, stays a normal window, and still maximises and
|
|
||||||
# minimises exactly as before.
|
|
||||||
for_window [app_id="google-chrome"] border none
|
|
||||||
for_window [class="(?i)google-chrome"] border none
|
|
||||||
|
|
||||||
# The DVD player is an appliance screen, not a desktop window. VLC takes
|
|
||||||
# the whole display itself; this removes the title bar sway would
|
|
||||||
# otherwise draw above the video. classroom-dvd-session covers the
|
|
||||||
# wallpaper with black until VLC is up, so there is never a gap.
|
|
||||||
for_window [class="(?i)vlc"] border none
|
|
||||||
for_window [app_id="(?i)vlc"] border none
|
|
||||||
|
|
||||||
# Minimize and restore.
|
|
||||||
#
|
|
||||||
# classroom-window parks the window on a hidden workspace as an ordinary
|
|
||||||
# tiled container, so restoring brings it back maximised instead of as a
|
|
||||||
# floating, draggable window the way the scratchpad did.
|
|
||||||
bindsym $mod+m exec /usr/local/bin/classroom-window minimize
|
|
||||||
bindsym $mod+Shift+m exec /usr/local/bin/classroom-window restore-last
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"layer": "top",
|
|
||||||
"position": "bottom",
|
|
||||||
"height": 56,
|
|
||||||
"spacing": 12,
|
|
||||||
|
|
||||||
"modules-left": [
|
|
||||||
"custom/browser",
|
|
||||||
"custom/dvd",
|
|
||||||
"custom/sanctuary"
|
|
||||||
],
|
|
||||||
|
|
||||||
"modules-right": [
|
|
||||||
"clock"
|
|
||||||
],
|
|
||||||
|
|
||||||
"custom/browser": {
|
|
||||||
"format": "Browser",
|
|
||||||
"on-click": "/usr/local/bin/classroom-browser"
|
|
||||||
},
|
|
||||||
|
|
||||||
"custom/dvd": {
|
|
||||||
"format": "DVD",
|
|
||||||
"on-click": "true"
|
|
||||||
},
|
|
||||||
|
|
||||||
"custom/sanctuary": {
|
|
||||||
"format": "Sanctuary",
|
|
||||||
"on-click": "true"
|
|
||||||
},
|
|
||||||
|
|
||||||
"clock": {
|
|
||||||
"format": "{:%-I:%M %p}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
* {
|
|
||||||
font-family: sans-serif;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
window#waybar {
|
|
||||||
background: rgba(20, 20, 20, 0.92);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
#custom-browser,
|
|
||||||
#custom-dvd,
|
|
||||||
#custom-sanctuary {
|
|
||||||
padding: 0 24px;
|
|
||||||
margin: 6px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#clock {
|
|
||||||
padding: 0 18px;
|
|
||||||
}
|
|
||||||
@@ -316,26 +316,41 @@ loop goes away. **Not exercised** — the lab has no optical drive.
|
|||||||
|
|
||||||
## Custom code this deletes
|
## Custom code this deletes
|
||||||
|
|
||||||
|
Deleted on this branch, and counted rather than estimated:
|
||||||
|
|
||||||
| | lines | replaced by |
|
| | lines | replaced by |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `classroom-dock` | 754 | the Plasma panel |
|
| `scripts/classroom-dock` | 754 | the Plasma panel |
|
||||||
| `classroom-chrome-windows` | 314 | `--start-maximized`, and a window manager |
|
| `scripts/classroom-chrome-windows` | 314 | `--start-maximized`, and a window manager |
|
||||||
| `classroom-window` | 208 | the task manager |
|
| `tests/test-classroom-chrome-windows.sh` | 280 | — |
|
||||||
| `classroom-notify` | 214 | Plasma notifications |
|
| `config/dock/style.css.in` | 102 | the colour scheme |
|
||||||
| `classroom-notifications` | 74 | — |
|
| `scripts/classroom-notifications` | 74 | Plasma notifications |
|
||||||
| `classroom-scale` | 57 | Plasma display scaling |
|
| `config/sway/config` | 62 | `kwinrc` |
|
||||||
| `classroom_sway.py` | 127 | — |
|
| `config/mako/config.in` | 48 | `plasmanotifyrc` |
|
||||||
| `classroom_icons.py` | 95 | the icon theme |
|
| `config/waybar/*` | 55 | the Plasma panel |
|
||||||
| `classroom_style.py` | 36 | the colour scheme |
|
| `config/bash_profile`, getty autologin | 6 | SDDM |
|
||||||
| `classroom_optical.py` | 125 | Solid |
|
| `config/icons/browser.svg` | — | Chrome's own icon |
|
||||||
| sway, waybar, mako configs | — | — |
|
| **total** | **1 695** | |
|
||||||
| `config/bash_profile` | 2 | SDDM |
|
|
||||||
| **total** | **≈ 2 000** | |
|
|
||||||
|
|
||||||
Kept, because they are the classroom's own subject matter: the DVD session
|
Rewritten rather than deleted: `classroom-browser` (41 → 30 lines, it no
|
||||||
|
longer decides launch-or-focus-or-minimise), `classroom-scale` (sway output
|
||||||
|
scale → `kscreen-doctor`), `classroom-notify` (found the session by looking
|
||||||
|
for sway and refused to run without mako; now looks for the compositor and
|
||||||
|
the shell).
|
||||||
|
|
||||||
|
**Still present, and still sway-shaped.** `classroom-window`,
|
||||||
|
`classroom_sway.py`, `classroom_icons.py`, `classroom_style.py` and
|
||||||
|
`classroom_optical.py` are all reachable only from the DVD player and the
|
||||||
|
MQTT notifier, and they were deliberately not rewritten — see *the DVD
|
||||||
|
player* under unresolved risks. Another ~590 lines go when that is done.
|
||||||
|
|
||||||
|
Kept because they are the classroom's own subject matter: the DVD session
|
||||||
and CSS setup, printer setup, provisioning, MQTT, the updater, shutdown.
|
and CSS setup, printer setup, provisioning, MQTT, the updater, shutdown.
|
||||||
Added: the audio policy and its test, the theme switch, and configuration.
|
Added: the audio policy and its test, the theme switch, and configuration.
|
||||||
|
|
||||||
|
The package was built and its contents checked: every `/usr/local` path any
|
||||||
|
classroom command names is in it.
|
||||||
|
|
||||||
## Alternatives, and why they lost
|
## Alternatives, and why they lost
|
||||||
|
|
||||||
**Stay on sway, add Waybar.** Cheapest migration, and it loses on the thing
|
**Stay on sway, add Waybar.** Cheapest migration, and it loses on the thing
|
||||||
|
|||||||
@@ -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())
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
@@ -11,12 +11,12 @@ set -euo pipefail
|
|||||||
# Used by classroom-notify-test to send a sample, and by classroom-mqtt
|
# Used by classroom-notify-test to send a sample, and by classroom-mqtt
|
||||||
# to put messages from the church broker on the screen.
|
# to put messages from the church broker on the screen.
|
||||||
#
|
#
|
||||||
# The classroom desktop runs as the classroom user, and mako is attached
|
# The classroom desktop runs as the classroom user, and the notification
|
||||||
# to that user's D-Bus session. An administrator over SSH sits in a
|
# service is part of that user's Plasma session. An administrator over SSH
|
||||||
# different session where nothing owns org.freedesktop.Notifications, so
|
# sits in a different session where nothing owns
|
||||||
# a plain notify-send there asks D-Bus to start a second mako, which has
|
# org.freedesktop.Notifications, so a plain notify-send there asks D-Bus to
|
||||||
# no display to open and times out with NoReply. Find the running desktop
|
# start a service that has no display to open, and times out with NoReply.
|
||||||
# session and deliver into it instead.
|
# Find the running desktop session and deliver into it instead.
|
||||||
|
|
||||||
CLASSROOM_USER=classroom
|
CLASSROOM_USER=classroom
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ environ_value() {
|
|||||||
printf '%s\n' "$1" | sed -n "s/^$2=//p" | head -n 1
|
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.
|
# back off the process instead of being guessed at.
|
||||||
display_from_lock() {
|
display_from_lock() {
|
||||||
local link target
|
local link target
|
||||||
@@ -115,17 +115,19 @@ if [[ "$current_uid" == "$classroom_uid" &&
|
|||||||
-n "${WAYLAND_DISPLAY:-}" &&
|
-n "${WAYLAND_DISPLAY:-}" &&
|
||||||
-n "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then
|
-n "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then
|
||||||
# Already inside the classroom desktop session.
|
# Already inside the classroom desktop session.
|
||||||
sway_pid="n/a"
|
session_pid="n/a"
|
||||||
runtime_dir="${XDG_RUNTIME_DIR:-}"
|
runtime_dir="${XDG_RUNTIME_DIR:-}"
|
||||||
bus="$DBUS_SESSION_BUS_ADDRESS"
|
bus="$DBUS_SESSION_BUS_ADDRESS"
|
||||||
wayland_display="$WAYLAND_DISPLAY"
|
wayland_display="$WAYLAND_DISPLAY"
|
||||||
else
|
else
|
||||||
session="the $CLASSROOM_USER desktop session"
|
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" ]] ||
|
[[ -n "$session_pid" ]] ||
|
||||||
fail "no classroom desktop session is running: there is no sway process for user $CLASSROOM_USER."
|
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
|
# Checked before anything that needs to look inside the session, so
|
||||||
# an administrator gets told what to do rather than a permission
|
# 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."
|
fail "run this as root (sudo classroom-notify) or as the $CLASSROOM_USER user."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
environment_block="$(read_environ "$sway_pid")"
|
environment_block="$(read_environ "$session_pid")"
|
||||||
|
|
||||||
if [[ -n "$environment_block" ]]; then
|
if [[ -n "$environment_block" ]]; then
|
||||||
source_note="read from sway (pid $sway_pid)"
|
source_note="read from the compositor (pid $session_pid)"
|
||||||
else
|
else
|
||||||
source_note="sway's environment was unreadable; using session defaults"
|
source_note="the compositor's environment was unreadable; using session defaults"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
runtime_dir="$(environ_value "$environment_block" XDG_RUNTIME_DIR)"
|
runtime_dir="$(environ_value "$environment_block" XDG_RUNTIME_DIR)"
|
||||||
@@ -164,7 +166,7 @@ else
|
|||||||
wayland_display="$(environ_value "$environment_block" WAYLAND_DISPLAY)"
|
wayland_display="$(environ_value "$environment_block" WAYLAND_DISPLAY)"
|
||||||
|
|
||||||
if [[ -z "$wayland_display" ]]; then
|
if [[ -z "$wayland_display" ]]; then
|
||||||
wayland_display="$(display_from_lock "$sway_pid")"
|
wayland_display="$(display_from_lock "$session_pid")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
environment=(
|
environment=(
|
||||||
@@ -186,15 +188,15 @@ else
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Deliver to the mako that is already running. Sending into a session
|
# Deliver to the notification service that is already running. Sending into
|
||||||
# without one would have D-Bus start a second mako, which is exactly the
|
# a session without one would have D-Bus start something with no display to
|
||||||
# failure this script exists to avoid.
|
# draw on, which is exactly the failure this script exists to avoid.
|
||||||
pgrep -u "$CLASSROOM_USER" -x mako >/dev/null 2>&1 ||
|
pgrep -u "$CLASSROOM_USER" -x plasmashell >/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"
|
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
|
if [[ "$dry_run" == true ]]; then
|
||||||
echo "Would notify $session:"
|
echo "Would notify $session:"
|
||||||
echo " sway pid: $sway_pid"
|
echo " compositor pid: $session_pid"
|
||||||
echo " session environment: $source_note"
|
echo " session environment: $source_note"
|
||||||
echo " XDG_RUNTIME_DIR: $runtime_dir"
|
echo " XDG_RUNTIME_DIR: $runtime_dir"
|
||||||
echo " DBUS_SESSION_BUS_ADDRESS: $bus"
|
echo " DBUS_SESSION_BUS_ADDRESS: $bus"
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
[Service]
|
|
||||||
ExecStart=
|
|
||||||
ExecStart=-/sbin/agetty --autologin classroom --noclear %I $TERM
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
#!/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_secondary(8, arriving=False)
|
|
||||||
check("restoring the role never moves the window",
|
|
||||||
not any("move position" in item for item in commands), commands)
|
|
||||||
check("restoring the role never raises it over a dialog",
|
|
||||||
not any("focus" in item for item in commands), commands)
|
|
||||||
check("it only puts the window back to floating and borderless",
|
|
||||||
commands == ["[con_id=8] floating enable", "[con_id=8] border none"],
|
|
||||||
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. the dock's Close target survives losing focus")
|
|
||||||
# The whole point: the pointer has to travel from the popup down to the
|
|
||||||
# dock, and that moves focus. If the target followed focus the button
|
|
||||||
# would vanish before anyone could reach it.
|
|
||||||
active = script.ActiveSecondary()
|
|
||||||
|
|
||||||
chrome = {5: window(5, "main", focused=True)}
|
|
||||||
check("nothing to close with only the main browser",
|
|
||||||
active.update([], chrome) is None)
|
|
||||||
|
|
||||||
chrome = {5: window(5, "main"), 8: window(8, "popup", focused=True)}
|
|
||||||
check("a focused popup becomes the target", active.update([8], chrome) == 8)
|
|
||||||
|
|
||||||
chrome = {5: window(5, "main"), 8: window(8, "popup")}
|
|
||||||
check("it stays the target when nothing is focused",
|
|
||||||
active.update([8], chrome) == 8)
|
|
||||||
|
|
||||||
chrome = {5: window(5, "main", focused=True), 8: window(8, "popup")}
|
|
||||||
check("it stays when focus moves to the main browser",
|
|
||||||
active.update([8], chrome) == 8)
|
|
||||||
check("the main browser is never the target", active.target != 5)
|
|
||||||
|
|
||||||
chrome = {5: window(5, "main"), 8: window(8, "popup"), 9: window(9, "dialog", focused=True)}
|
|
||||||
check("a newer focused popup takes over", active.update([8, 9], chrome) == 9)
|
|
||||||
|
|
||||||
chrome = {5: window(5, "main"), 8: window(8, "popup"), 9: window(9, "dialog")}
|
|
||||||
check("and keeps it after focus moves to the dock",
|
|
||||||
active.update([8, 9], chrome) == 9)
|
|
||||||
|
|
||||||
chrome = {5: window(5, "main"), 8: window(8, "popup")}
|
|
||||||
check("closing the dialog falls back to the render window",
|
|
||||||
active.update([8], chrome) == 8)
|
|
||||||
|
|
||||||
chrome = {5: window(5, "main")}
|
|
||||||
check("closing the last popup clears it", active.update([], chrome) is None)
|
|
||||||
|
|
||||||
fresh = script.ActiveSecondary()
|
|
||||||
chrome = {5: window(5, "main", focused=True), 8: window(8, "popup")}
|
|
||||||
check("an unfocused popup is still closeable",
|
|
||||||
fresh.update([8], chrome) == 8)
|
|
||||||
|
|
||||||
hidden = script.ActiveSecondary()
|
|
||||||
chrome = {8: window(8, "one", hidden=True)}
|
|
||||||
check("a minimised popup is not adopted",
|
|
||||||
hidden.update([8], chrome) is None)
|
|
||||||
|
|
||||||
print()
|
|
||||||
print("4b. the print dialog stays in front")
|
|
||||||
|
|
||||||
def win(identifier, title, focused=False, hidden=False):
|
|
||||||
w = window(identifier, title, focused=focused, hidden=hidden)
|
|
||||||
w["name"] = title
|
|
||||||
return w
|
|
||||||
|
|
||||||
check("the dialog is recognised by its exact title",
|
|
||||||
script.is_print_dialog({"name": "Print"}))
|
|
||||||
check("the render window is not",
|
|
||||||
not script.is_print_dialog({"name": "Printable Document - Google Chrome"}))
|
|
||||||
check("nothing else is", not script.is_print_dialog({"name": "Print Preview"}))
|
|
||||||
check("a missing title is not", not script.is_print_dialog({}))
|
|
||||||
|
|
||||||
commands.clear()
|
|
||||||
# The render window has raised itself over the dialog.
|
|
||||||
chrome = {5: win(5, "main"), 8: win(8, "Printable Document", focused=True),
|
|
||||||
9: win(9, "Print")}
|
|
||||||
script.keep_print_in_front([8, 9], chrome)
|
|
||||||
check("focus is taken back for the dialog",
|
|
||||||
commands == ["[con_id=9] focus"], commands)
|
|
||||||
|
|
||||||
commands.clear()
|
|
||||||
chrome = {5: win(5, "main"), 8: win(8, "Printable Document"), 9: win(9, "Print", focused=True)}
|
|
||||||
script.keep_print_in_front([8, 9], chrome)
|
|
||||||
check("nothing happens while the dialog already has focus", commands == [], commands)
|
|
||||||
|
|
||||||
commands.clear()
|
|
||||||
chrome = {5: win(5, "main", focused=True), 8: win(8, "Printable Document"),
|
|
||||||
9: win(9, "Print")}
|
|
||||||
script.keep_print_in_front([8, 9], chrome)
|
|
||||||
check("focus is never stolen from the main browser", commands == [], commands)
|
|
||||||
|
|
||||||
commands.clear()
|
|
||||||
chrome = {5: win(5, "main"), 8: win(8, "Printable Document")}
|
|
||||||
script.keep_print_in_front([8], chrome)
|
|
||||||
check("nothing happens when there is no dialog", commands == [], commands)
|
|
||||||
|
|
||||||
commands.clear()
|
|
||||||
chrome = {8: win(8, "Printable Document", focused=True), 9: win(9, "Print", hidden=True)}
|
|
||||||
script.keep_print_in_front([8, 9], chrome)
|
|
||||||
check("a minimised dialog is left alone", commands == [], commands)
|
|
||||||
|
|
||||||
# Both arriving together: whichever is configured last ends up in front,
|
|
||||||
# so the dialog has to be last.
|
|
||||||
order = sorted([8, 9], key=lambda i: script.is_print_dialog(
|
|
||||||
{8: {"name": "Printable Document"}, 9: {"name": "Print"}}[i]))
|
|
||||||
check("the dialog is configured after the render window", order == [8, 9], order)
|
|
||||||
order = sorted([9, 8], key=lambda i: script.is_print_dialog(
|
|
||||||
{8: {"name": "Printable Document"}, 9: {"name": "Print"}}[i]))
|
|
||||||
check("even when the tree lists it first", order == [8, 9], order)
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -3,7 +3,7 @@ set -uo pipefail
|
|||||||
|
|
||||||
# Tests the session resolution in classroom-notify-test.
|
# Tests the session resolution in classroom-notify-test.
|
||||||
#
|
#
|
||||||
# sway is stood in for by a real background process started with a known
|
# The compositor is stood in for by a real background process started with a known
|
||||||
# environment, so /proc/<pid>/environ is genuine rather than mocked. id,
|
# environment, so /proc/<pid>/environ is genuine rather than mocked. id,
|
||||||
# pgrep, runuser and notify-send are shimmed onto PATH so every branch
|
# pgrep, runuser and notify-send are shimmed onto PATH so every branch
|
||||||
# can be reached from an ordinary account.
|
# can be reached from an ordinary account.
|
||||||
@@ -14,7 +14,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|||||||
SCRIPT="$ROOT/scripts/classroom-notify"
|
SCRIPT="$ROOT/scripts/classroom-notify"
|
||||||
|
|
||||||
WORK="$(mktemp -d)"
|
WORK="$(mktemp -d)"
|
||||||
trap 'rm -rf "$WORK"; kill "${FAKE_SWAY_PID:-}" 2>/dev/null' EXIT
|
trap 'rm -rf "$WORK"; kill "${FAKE_SESSION_PID:-}" 2>/dev/null' EXIT
|
||||||
|
|
||||||
passed=0
|
passed=0
|
||||||
failed=0
|
failed=0
|
||||||
@@ -67,12 +67,12 @@ SHIM
|
|||||||
cat > "$WORK/bin/pgrep" <<'SHIM'
|
cat > "$WORK/bin/pgrep" <<'SHIM'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
case "${!#}" in
|
case "${!#}" in
|
||||||
sway)
|
kwin_wayland)
|
||||||
[[ -n "${FAKE_SWAY_PID:-}" ]] || exit 1
|
[[ -n "${FAKE_SESSION_PID:-}" ]] || exit 1
|
||||||
echo "$FAKE_SWAY_PID"
|
echo "$FAKE_SESSION_PID"
|
||||||
;;
|
;;
|
||||||
mako)
|
plasmashell)
|
||||||
[[ "${FAKE_MAKO:-1}" == "1" ]] || exit 1
|
[[ "${FAKE_SHELL:-1}" == "1" ]] || exit 1
|
||||||
echo 4242
|
echo 4242
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
@@ -120,27 +120,27 @@ run() {
|
|||||||
"$SCRIPT" "$@" 2>&1
|
"$SCRIPT" "$@" 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
start_fake_sway() {
|
start_fake_session() {
|
||||||
env "$@" sleep 300 &
|
env "$@" sleep 300 &
|
||||||
FAKE_SWAY_PID=$!
|
FAKE_SESSION_PID=$!
|
||||||
export FAKE_SWAY_PID
|
export FAKE_SESSION_PID
|
||||||
# Give the exec time to land so /proc/<pid>/environ is populated.
|
# Give the exec time to land so /proc/<pid>/environ is populated.
|
||||||
while [[ ! -r "/proc/$FAKE_SWAY_PID/environ" ]]; do sleep 0.05; done
|
while [[ ! -r "/proc/$FAKE_SESSION_PID/environ" ]]; do sleep 0.05; done
|
||||||
}
|
}
|
||||||
|
|
||||||
stop_fake_sway() {
|
stop_fake_session() {
|
||||||
[[ -n "${FAKE_SWAY_PID:-}" ]] && kill "$FAKE_SWAY_PID" 2>/dev/null
|
[[ -n "${FAKE_SESSION_PID:-}" ]] && kill "$FAKE_SESSION_PID" 2>/dev/null
|
||||||
wait "${FAKE_SWAY_PID:-}" 2>/dev/null
|
wait "${FAKE_SESSION_PID:-}" 2>/dev/null
|
||||||
unset FAKE_SWAY_PID
|
unset FAKE_SESSION_PID
|
||||||
}
|
}
|
||||||
|
|
||||||
unset WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS
|
unset WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS
|
||||||
|
|
||||||
# --------------------------------------------- 1. resolve from environ
|
# --------------------------------------------- 1. resolve from environ
|
||||||
|
|
||||||
echo "1. resolves the session from sway's environment"
|
echo "1. resolves the session from the compositor's environment"
|
||||||
|
|
||||||
start_fake_sway \
|
start_fake_session \
|
||||||
XDG_RUNTIME_DIR="$RUNTIME" \
|
XDG_RUNTIME_DIR="$RUNTIME" \
|
||||||
DBUS_SESSION_BUS_ADDRESS="unix:path=$RUNTIME/bus" \
|
DBUS_SESSION_BUS_ADDRESS="unix:path=$RUNTIME/bus" \
|
||||||
WAYLAND_DISPLAY=wayland-7
|
WAYLAND_DISPLAY=wayland-7
|
||||||
@@ -149,9 +149,9 @@ out="$(run)"
|
|||||||
check "reports delivery to the classroom session" "Sent normal notification to the classroom desktop session" "$out"
|
check "reports delivery to the classroom session" "Sent normal notification to the classroom desktop session" "$out"
|
||||||
|
|
||||||
log="$(cat "$NOTIFY_LOG")"
|
log="$(cat "$NOTIFY_LOG")"
|
||||||
check "runtime dir taken from sway" "XDG_RUNTIME_DIR=$RUNTIME" "$log"
|
check "runtime dir taken from the compositor" "XDG_RUNTIME_DIR=$RUNTIME" "$log"
|
||||||
check "bus address taken from sway" "DBUS_SESSION_BUS_ADDRESS=unix:path=$RUNTIME/bus" "$log"
|
check "bus address taken from the compositor" "DBUS_SESSION_BUS_ADDRESS=unix:path=$RUNTIME/bus" "$log"
|
||||||
check "wayland display from sway" "WAYLAND_DISPLAY=wayland-7" "$log"
|
check "wayland display from the compositor" "WAYLAND_DISPLAY=wayland-7" "$log"
|
||||||
check "styling preserved" "--app-name=Ekklesia Classroom" "$log"
|
check "styling preserved" "--app-name=Ekklesia Classroom" "$log"
|
||||||
check "default sample title" "DVD Ready" "$log"
|
check "default sample title" "DVD Ready" "$log"
|
||||||
check "default sample body" "A disc is ready to play." "$log"
|
check "default sample body" "A disc is ready to play." "$log"
|
||||||
@@ -161,7 +161,7 @@ check "default sample body" "A disc is ready to play."
|
|||||||
echo "2. --dry-run reports the resolved session and sends nothing"
|
echo "2. --dry-run reports the resolved session and sends nothing"
|
||||||
|
|
||||||
out="$(run --dry-run)"
|
out="$(run --dry-run)"
|
||||||
check "shows the sway pid" "sway pid: $FAKE_SWAY_PID" "$out"
|
check "shows the compositor pid" "compositor pid: $FAKE_SESSION_PID" "$out"
|
||||||
check "shows the runtime dir" "XDG_RUNTIME_DIR: $RUNTIME" "$out"
|
check "shows the runtime dir" "XDG_RUNTIME_DIR: $RUNTIME" "$out"
|
||||||
check "shows the wayland display" "WAYLAND_DISPLAY: wayland-7" "$out"
|
check "shows the wayland display" "WAYLAND_DISPLAY: wayland-7" "$out"
|
||||||
check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")"
|
check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")"
|
||||||
@@ -196,35 +196,35 @@ check "explains it needs root" "run this as root (sudo classroom-notify)" "$out"
|
|||||||
check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")"
|
check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")"
|
||||||
FAKE_CURRENT_UID=4000
|
FAKE_CURRENT_UID=4000
|
||||||
|
|
||||||
# ----------------------------------------- 6. mako must already be up
|
# ------------------------------- 6. the shell must already be up
|
||||||
|
|
||||||
echo "6. refuses to let D-Bus start a second mako"
|
echo "6. refuses to let D-Bus start a notification service"
|
||||||
|
|
||||||
export FAKE_MAKO=0
|
export FAKE_SHELL=0
|
||||||
out="$(run)"
|
out="$(run)"
|
||||||
check "explains mako is not running" "mako is not running in the classroom session" "$out"
|
check "explains the shell is not running" "the classroom shell is not running" "$out"
|
||||||
check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")"
|
check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")"
|
||||||
unset FAKE_MAKO
|
unset FAKE_SHELL
|
||||||
|
|
||||||
stop_fake_sway
|
stop_fake_session
|
||||||
|
|
||||||
# --------------------------------- 7. display derived from the lock fd
|
# --------------------------------- 7. display derived from the lock fd
|
||||||
|
|
||||||
echo "7. derives the display from sway's wayland lock when environ has none"
|
echo "7. derives the display from the compositor's wayland lock when environ has none"
|
||||||
|
|
||||||
touch "$RUNTIME/wayland-9.lock"
|
touch "$RUNTIME/wayland-9.lock"
|
||||||
env -u WAYLAND_DISPLAY \
|
env -u WAYLAND_DISPLAY \
|
||||||
XDG_RUNTIME_DIR="$RUNTIME" \
|
XDG_RUNTIME_DIR="$RUNTIME" \
|
||||||
DBUS_SESSION_BUS_ADDRESS="unix:path=$RUNTIME/bus" \
|
DBUS_SESSION_BUS_ADDRESS="unix:path=$RUNTIME/bus" \
|
||||||
bash -c "exec 9< '$RUNTIME/wayland-9.lock'; sleep 300" &
|
bash -c "exec 9< '$RUNTIME/wayland-9.lock'; sleep 300" &
|
||||||
FAKE_SWAY_PID=$!
|
FAKE_SESSION_PID=$!
|
||||||
export FAKE_SWAY_PID
|
export FAKE_SESSION_PID
|
||||||
while [[ ! -r "/proc/$FAKE_SWAY_PID/fd/9" ]]; do sleep 0.05; done
|
while [[ ! -r "/proc/$FAKE_SESSION_PID/fd/9" ]]; do sleep 0.05; done
|
||||||
|
|
||||||
out="$(run --dry-run)"
|
out="$(run --dry-run)"
|
||||||
check "display derived from the lock" "WAYLAND_DISPLAY: wayland-9" "$out"
|
check "display derived from the lock" "WAYLAND_DISPLAY: wayland-9" "$out"
|
||||||
|
|
||||||
stop_fake_sway
|
stop_fake_session
|
||||||
|
|
||||||
# ------------------------------------------- 8. no session at all
|
# ------------------------------------------- 8. no session at all
|
||||||
|
|
||||||
@@ -236,9 +236,9 @@ check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")"
|
|||||||
|
|
||||||
# ------------------------------------- 9. falls back when environ is thin
|
# ------------------------------------- 9. falls back when environ is thin
|
||||||
|
|
||||||
echo "9. falls back to session defaults when sway's environment is unreadable"
|
echo "9. falls back to session defaults when the compositor's environment is unreadable"
|
||||||
|
|
||||||
start_fake_sway XDG_RUNTIME_DIR="$RUNTIME"
|
start_fake_session XDG_RUNTIME_DIR="$RUNTIME"
|
||||||
|
|
||||||
out="$(run --dry-run)"
|
out="$(run --dry-run)"
|
||||||
check "derives the bus from the runtime dir" \
|
check "derives the bus from the runtime dir" \
|
||||||
@@ -249,7 +249,7 @@ check "still delivers" "Sent normal notification to the classroom desktop sessio
|
|||||||
check "bus reached notify-send" "DBUS_SESSION_BUS_ADDRESS=unix:path=$RUNTIME/bus" \
|
check "bus reached notify-send" "DBUS_SESSION_BUS_ADDRESS=unix:path=$RUNTIME/bus" \
|
||||||
"$(cat "$NOTIFY_LOG")"
|
"$(cat "$NOTIFY_LOG")"
|
||||||
|
|
||||||
stop_fake_sway
|
stop_fake_session
|
||||||
|
|
||||||
# ------------------------------------------ 10. missing bus is explained
|
# ------------------------------------------ 10. missing bus is explained
|
||||||
|
|
||||||
@@ -257,19 +257,19 @@ echo "10. explains a missing session bus instead of hanging"
|
|||||||
|
|
||||||
MISSING="$WORK/empty"
|
MISSING="$WORK/empty"
|
||||||
mkdir -p "$MISSING"
|
mkdir -p "$MISSING"
|
||||||
start_fake_sway XDG_RUNTIME_DIR="$MISSING"
|
start_fake_session XDG_RUNTIME_DIR="$MISSING"
|
||||||
|
|
||||||
out="$(run)"
|
out="$(run)"
|
||||||
check "names the missing socket" "bus socket $MISSING/bus does not exist" "$out"
|
check "names the missing socket" "bus socket $MISSING/bus does not exist" "$out"
|
||||||
check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")"
|
check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")"
|
||||||
|
|
||||||
stop_fake_sway
|
stop_fake_session
|
||||||
|
|
||||||
# ----------------------------- 11. already inside the classroom session
|
# ----------------------------- 11. already inside the classroom session
|
||||||
|
|
||||||
echo "11. sends directly when already inside the classroom session"
|
echo "11. sends directly when already inside the classroom session"
|
||||||
|
|
||||||
# No sway process to find: an in-session caller must not need one.
|
# No compositor process to find: an in-session caller must not need one.
|
||||||
export WAYLAND_DISPLAY=wayland-4
|
export WAYLAND_DISPLAY=wayland-4
|
||||||
export DBUS_SESSION_BUS_ADDRESS="unix:path=$RUNTIME/bus"
|
export DBUS_SESSION_BUS_ADDRESS="unix:path=$RUNTIME/bus"
|
||||||
export XDG_RUNTIME_DIR="$RUNTIME"
|
export XDG_RUNTIME_DIR="$RUNTIME"
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ provision = (root / "scripts/classroom-provision").read_text()
|
|||||||
units = "\n".join(path.read_text() for path in (root / "systemd").rglob("*.service"))
|
units = "\n".join(path.read_text() for path in (root / "systemd").rglob("*.service"))
|
||||||
canary = (root / "update_canary.sh").read_text()
|
canary = (root / "update_canary.sh").read_text()
|
||||||
validation_script = (root / "tools/validate-trixie-release.sh").read_text()
|
validation_script = (root / "tools/validate-trixie-release.sh").read_text()
|
||||||
sway = (root / "config/sway/config").read_text()
|
layout = (root / "config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js").read_text()
|
||||||
|
rules = (root / "config/plasma/xdg/kwinrulesrc").read_text()
|
||||||
|
restrictions = (root / "config/plasma/xdg/kdeglobals").read_text()
|
||||||
|
supervision = (root / "systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf").read_text()
|
||||||
build = (root / "tools/build-release.sh").read_text()
|
build = (root / "tools/build-release.sh").read_text()
|
||||||
policy = json.loads(
|
policy = json.loads(
|
||||||
(root / "config/chrome/policies/managed/ekklesia-classroom.json").read_text()
|
(root / "config/chrome/policies/managed/ekklesia-classroom.json").read_text()
|
||||||
@@ -67,14 +70,41 @@ check("printer provisioning is bounded", "--kill-after=5s 30s" in provision)
|
|||||||
check("Chrome uses the system print dialog", policy.get("DisablePrintPreview") is True)
|
check("Chrome uses the system print dialog", policy.get("DisablePrintPreview") is True)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("4. secondary Chrome windows")
|
print("4. secondary windows are the window manager's business")
|
||||||
check("stateful Chrome helper starts with Sway", "/usr/local/bin/classroom-chrome-windows" in sway)
|
# Chrome gives its main window and every window a page opens the same
|
||||||
check("Chrome helper is packaged", '"$ROOT/scripts/classroom-chrome-windows"' in build)
|
# class, so a rule keyed on that class maximises the lesson's popup too.
|
||||||
check("Chrome role module is packaged", '"$ROOT/lib/classroom_chrome.py"' in build)
|
# It was measured doing exactly that. The browser is told to start
|
||||||
check("no broad Chrome floating rule", not any(
|
# maximized instead, and no rule mentions it.
|
||||||
"google-chrome" in line and "floating enable" in line
|
check("no window rule claims the browser", "google-chrome" not in rules)
|
||||||
for line in sway.splitlines()
|
check("the browser asks for its own first window", "--start-maximized" in
|
||||||
))
|
(root / "scripts/classroom-browser").read_text())
|
||||||
|
check("Wonder Ink asks for the same", "--start-maximized" in
|
||||||
|
(root / "scripts/classroom-wonderink").read_text())
|
||||||
|
check("the Chrome window arbiter is gone", not (root / "scripts/classroom-chrome-windows").exists())
|
||||||
|
check("and is not packaged", "classroom-chrome-windows" not in build)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("4b. the panel is never allowed to stay dead")
|
||||||
|
# Plasma restarts a crashed panel three times a minute and then gives up.
|
||||||
|
# Measured: dead on the third kill. These lines are what make it
|
||||||
|
# unconditional, and losing them would lose the room its only way around.
|
||||||
|
check("restart is unconditional", "Restart=always" in supervision)
|
||||||
|
check("the burst limit is lifted", "StartLimitIntervalSec=0" in supervision)
|
||||||
|
check("the drop-in is packaged", "plasma-plasmashell.service.d" in build)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("4c. the teacher cannot reach a command line")
|
||||||
|
for restriction in ("run_command=false", "shell_access=false",
|
||||||
|
"action/lock_screen=false",
|
||||||
|
"plasma/plasmashell/unlockedDesktop=false"):
|
||||||
|
check(f"{restriction}", restriction in restrictions)
|
||||||
|
check("the restrictions are immutable", "[KDE Action Restrictions][$i]" in restrictions)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("4d. clicking a launcher never hides an application")
|
||||||
|
check("minimise on click is off", 'minimizeActiveTaskOnClick", false' in layout)
|
||||||
|
check("there is no minimise button", "ButtonsOnRight=FX" in
|
||||||
|
(root / "config/plasma/xdg/kwinrc").read_text())
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("5. bounded Trixie validation")
|
print("5. bounded Trixie validation")
|
||||||
|
|||||||
@@ -283,6 +283,48 @@ install -Dm644 \
|
|||||||
"$ROOT/lib/classroom_audio.py" \
|
"$ROOT/lib/classroom_audio.py" \
|
||||||
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_audio.py"
|
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_audio.py"
|
||||||
|
|
||||||
|
# Still here, and still sway-shaped.
|
||||||
|
#
|
||||||
|
# The DVD control surface and the MQTT notifier were deliberately not
|
||||||
|
# rewritten in this change, because the room's disc playback works and
|
||||||
|
# breaking it to tidy the tree would be a bad trade. classroom-window and
|
||||||
|
# classroom_sway speak sway's IPC, so the DVD player's focus handling is
|
||||||
|
# the one part of the classroom that does NOT yet work on this desktop.
|
||||||
|
# See docs/classroom-v2-experiment.md.
|
||||||
|
install -Dm755 \
|
||||||
|
"$ROOT/scripts/classroom-window" \
|
||||||
|
"$STAGE/usr/local/bin/classroom-window"
|
||||||
|
|
||||||
|
install -Dm755 \
|
||||||
|
"$ROOT/scripts/classroom-notify" \
|
||||||
|
"$STAGE/usr/local/bin/classroom-notify"
|
||||||
|
|
||||||
|
install -Dm755 \
|
||||||
|
"$ROOT/scripts/classroom-notify-test" \
|
||||||
|
"$STAGE/usr/local/bin/classroom-notify-test"
|
||||||
|
|
||||||
|
install -Dm644 \
|
||||||
|
"$ROOT/lib/classroom_sway.py" \
|
||||||
|
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_sway.py"
|
||||||
|
|
||||||
|
install -Dm644 \
|
||||||
|
"$ROOT/lib/classroom_optical.py" \
|
||||||
|
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_optical.py"
|
||||||
|
|
||||||
|
# The DVD overlay draws its own control bar as a layer-shell surface, so it
|
||||||
|
# still needs the icon and stylesheet helpers the dock used to share.
|
||||||
|
install -Dm644 \
|
||||||
|
"$ROOT/lib/classroom_icons.py" \
|
||||||
|
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_icons.py"
|
||||||
|
|
||||||
|
install -Dm644 \
|
||||||
|
"$ROOT/lib/classroom_style.py" \
|
||||||
|
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_style.py"
|
||||||
|
|
||||||
|
install -Dm644 \
|
||||||
|
"$ROOT/config/icons/dvd.svg" \
|
||||||
|
"$STAGE/etc/ekklesia-classroom/icons/dvd.svg"
|
||||||
|
|
||||||
# DVD mode control bar.
|
# DVD mode control bar.
|
||||||
for icon in media-play media-pause chapter-prev chapter-next \
|
for icon in media-play media-pause chapter-prev chapter-next \
|
||||||
disc-menu eject close; do
|
disc-menu eject close; do
|
||||||
|
|||||||
Reference in New Issue
Block a user