diff --git a/docs/browser.md b/docs/browser.md index d8377cb..79d75ff 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -57,50 +57,31 @@ is meant to avoid. Worth a look at the endpoint: open that menu once and confirm it offers nothing actionable. -## Wonder Ink between teachers — NOT YET VERIFIED +## Wonder Ink between teachers — CONFIRMED WORKING -The intent: teacher A signs in, uses the room, Chrome closes, teacher B -opens Chrome and Wonder Ink is signed out — while YouTube stays signed -into the church account. +Teacher A signs in, uses the room, Chrome closes, teacher B opens Chrome +and Wonder Ink is signed out, while YouTube stays signed into the church +account. -`CookiesSessionOnlyForUrls` with `[*.]wonderink.org` is the official -policy for that, and that pattern covers `app.wonderink.org` and every -other subdomain. **Whether it is sufficient has not been established.** +`CookiesSessionOnlyForUrls` with `[*.]wonderink.org` does it. That +pattern covers `app.wonderink.org` and every other subdomain. -It could not be tested here. Confirming it needs a real Wonder Ink -login, which this machine does not have, and three attempts to prove the -mechanism against a local test site were inconclusive — the positive -control never worked, so the negative results mean nothing. Rather than -report a guess as a result: unverified. +**Confirmed on the physical endpoint**, which is the only place it could +be: it needs a real Wonder Ink login. It could not be established here, +and three attempts to prove the mechanism against a local test site were +inconclusive because the positive control never worked — so this was +shipped as unverified and the endpoint settled it. -Two things must be checked at the endpoint before anyone relies on it. +Two consequences worth keeping in mind. -### 1. Is the session in cookies at all? +Wonder Ink's session is therefore **in cookies**, not in `localStorage`. +That matters because there is no per-site Chrome policy that clears +localStorage on exit: `ClearBrowsingDataOnExitList` and +`BrowsingDataLifetime` both exist in Chrome 152 but are global, and +blanket clearing would take YouTube with it. Had it gone the other way, +this would have needed a classroom-owned cleanup instead. -`CookiesSessionOnlyForUrls` clears **cookies**. A single-page app like -Wonder Ink may keep its token in `localStorage` instead, which that -policy does not promise to touch. - -Sign in, then in DevTools (F12) → Application: - -- **Cookies** under `https://app.wonderink.org` — is there a session or - auth cookie? -- **Local Storage** and **IndexedDB** for the same origin — is there a - token there? - -Then the real test: close every Chrome window, confirm the process is -gone (`pgrep -x chrome` returns nothing), reopen, and see whether Wonder -Ink is still signed in. - -If it is still signed in, the session is not in cookies, and **there is -no per-site Chrome policy that clears localStorage on exit**. -`ClearBrowsingDataOnExitList` and `BrowsingDataLifetime` both exist in -Chrome 152 but are global, not per-site — and blanket clearing is -exactly what must not happen here, because it would take YouTube with -it. That case needs a small classroom-owned cleanup instead, which has -deliberately not been built on speculation. - -### 2. If Wonder Ink login is "Sign in with Google" +### If Wonder Ink login is "Sign in with Google" `app.wonderink.org` loads Google Identity Services (`accounts.google.com/gsi/client`), so Google sign-in is at least diff --git a/docs/testing.md b/docs/testing.md index 4a6e7dd..f01eec8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -32,6 +32,10 @@ Change it, then `sudo systemctl restart getty@tty1`. - [ ] Chrome's buttons are still easy to hit with the mouse - [ ] Dock, clock, notifications and the DVD bar grow by the same amount — once, not twice +- [ ] Dock icons stay crisp at 1.25 and 1.5 on a normal-density monitor, + not just from across the room: compare an icon against the clock + text beside it +- [ ] DVD control bar icons are crisp at the same scales - [ ] `UI_SCALE=0.8` shrinks everything together - [ ] A nonsense value (`UI_SCALE=banana`) still gives a working shell - [ ] An extreme value (`UI_SCALE=9`) is clamped, not obeyed diff --git a/docs/ui-scale.md b/docs/ui-scale.md index a5ca43a..3400a8c 100644 --- a/docs/ui-scale.md +++ b/docs/ui-scale.md @@ -81,6 +81,16 @@ integer scale of 2 to GTK, which renders at 2x, and sway scales the result down to 1.5x. The dock is the right size but very slightly softer than at 1.0. On a TV at classroom distance this is not visible. +Icons have to be drawn for that themselves. `new_from_file_at_scale` +rasterises an SVG at its *logical* size, and the compositor then +enlarges that finished raster — which made icons visibly soft on a 24" +monitor while the clock text beside them stayed sharp. `classroom_icons` +renders each SVG at `size x scale_factor` physical pixels and hands GTK +a cairo surface carrying that device scale, so the artwork lands at the +display's real density and the layout is unchanged. Measured on the +Chrome icon, peak edge contrast at UI_SCALE 1.5 went from 49 to 83 out +of 255, and at 2.0 from 37 to 82; at 1.0 it is identical to before. + **XWayland is softer at fractional scale.** X11 clients have no concept of a fractional output scale, so sway renders them at the logical size and scales the buffer. Measured on terminal text, scale 1.5 produced diff --git a/lib/classroom_icons.py b/lib/classroom_icons.py new file mode 100644 index 0000000..9fee007 --- /dev/null +++ b/lib/classroom_icons.py @@ -0,0 +1,95 @@ +"""Icons that stay sharp when the compositor scales the session. + +new_from_file_at_scale rasterises an SVG at its *logical* size. Under +sway's output scaling the compositor then enlarges that finished raster, +which is why icons went soft while the clock text and the dock surface +stayed crisp: those are drawn by GTK at the display's real pixel +density, and the icon was not. + +GTK 3 has no fractional scaling, so sway advertises the ceiling integer +scale and scales the finished buffer down. Measured under sway: scale +factor 1 at output scale 1.0, and 2 at 1.25, 1.5 and 2.0. + +So the fix is to rasterise at size * scale_factor physical pixels and +hand GTK a cairo surface carrying that device scale. The icon lands at +the real density, while its logical size — and therefore the whole dock +layout — is exactly what it was before. +""" + +from pathlib import Path + +import gi + +gi.require_version("Gtk", "3.0") +gi.require_version("Gdk", "3.0") +gi.require_version("GdkPixbuf", "2.0") + +from gi.repository import Gdk, GdkPixbuf, GLib, Gtk # noqa: E402 + +ICON_DIR = Path("/etc/ekklesia-classroom/icons") + + +def render_surface(path, size, scale, window=None): + """An SVG at `size` logical pixels, drawn at `scale` times the density. + + The box semantics match the old custom_icon(): the artwork is fitted + inside a square of the requested size with its aspect preserved, so + a wide logo like the DVD wordmark keeps the proportions it has now. + """ + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + str(path), + size * scale, + size * scale, + True, + ) + + return Gdk.cairo_surface_create_from_pixbuf(pixbuf, scale, window) + + +class ScalableIcon(Gtk.Image): + """A custom SVG icon, redrawn whenever the display density changes.""" + + def __init__(self, filename, size, directory=None): + super().__init__() + + self.directory = directory or ICON_DIR + self.size = size + self.drawn_at = None + self.usable = False + + self.set_icon(filename) + + # The scale factor is not known until the widget is on a display, + # and it changes if UI_SCALE is changed under a running session. + self.connect("realize", lambda *_: self.redraw()) + self.connect("notify::scale-factor", lambda *_: self.redraw()) + + def set_icon(self, filename): + self.path = self.directory / filename + self.usable = self.path.exists() + self.drawn_at = None + + self.redraw() + + def redraw(self): + if not self.usable: + return + + scale = self.get_scale_factor() or 1 + + if scale == self.drawn_at: + return + + try: + surface = render_surface( + self.path, + self.size, + scale, + self.get_window(), + ) + except (GLib.Error, MemoryError): + self.usable = False + return + + self.set_from_surface(surface) + self.drawn_at = scale diff --git a/scripts/classroom-dock b/scripts/classroom-dock index b64ad02..f27028e 100755 --- a/scripts/classroom-dock +++ b/scripts/classroom-dock @@ -22,6 +22,7 @@ from classroom_config import ( # noqa: E402 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 @@ -112,27 +113,18 @@ def themed_icon(*names): def custom_icon(filename, size): - path = ICON_DIR / filename + """A classroom SVG icon, drawn at the display's real pixel density.""" + icon = ScalableIcon(filename, size, ICON_DIR) - if not path.exists(): - return None - - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - str(path), - size, - size, - True, - ) - - return Gtk.Image.new_from_pixbuf(pixbuf) + return icon if icon.usable else None -def throbber_frames(size): +def throbber_frames(size, scale): """A white arc, drawn once per frame of one revolution. - Pre-rendered into pixbufs and swapped into a Gtk.Image rather than - drawn live, so the busy state stays an ordinary image and the button - underneath keeps behaving exactly as it does with an icon. + 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 @@ -142,7 +134,13 @@ def throbber_frames(size): frames = [] for index in range(THROBBER_FRAMES): - surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, size, size) + 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) @@ -153,9 +151,7 @@ def throbber_frames(size): context.arc(size / 2, size / 2, radius, start, start + math.pi * 1.4) context.stroke() - frames.append( - Gdk.pixbuf_get_from_surface(surface, 0, 0, size, size) - ) + frames.append(surface) return frames @@ -166,12 +162,32 @@ class Throbber(Gtk.Image): def __init__(self, size): super().__init__() - self.frames = throbber_frames(size) + 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_pixbuf(self.frames[0]) + 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) @@ -189,7 +205,7 @@ class Throbber(Gtk.Image): def advance(self): self.frame = (self.frame + 1) % len(self.frames) - self.set_from_pixbuf(self.frames[self.frame]) + self.set_from_surface(self.frames[self.frame]) return True diff --git a/scripts/classroom-dvd-session b/scripts/classroom-dvd-session index 8a01d44..ae849f0 100755 --- a/scripts/classroom-dvd-session +++ b/scripts/classroom-dvd-session @@ -33,10 +33,9 @@ import gi gi.require_version("Gtk", "3.0") gi.require_version("Gdk", "3.0") -gi.require_version("GdkPixbuf", "2.0") gi.require_version("GtkLayerShell", "0.1") -from gi.repository import Gdk, GdkPixbuf, GLib, Gtk, GtkLayerShell +from gi.repository import Gdk, GLib, Gtk, GtkLayerShell sys.path.insert(0, "/usr/local/lib/ekklesia-classroom") sys.path.insert( @@ -49,6 +48,7 @@ import classroom_style as style # noqa: E402 import classroom_sway as sway # noqa: E402 import classroom_vlc as vlc # noqa: E402 from classroom_config import SHELL_SCALE, read_device_config, scaled # noqa: E402 +from classroom_icons import ScalableIcon # noqa: E402 # Input shaping needs pycairo. Without it the bar still works, it just # takes pointer events across its whole strip instead of only where it @@ -141,12 +141,8 @@ window#classroom-dvd-bar {{ def icon(filename, size): - path = ICON_DIR / filename - - if not path.exists(): - return None - - return GdkPixbuf.Pixbuf.new_from_file_at_scale(str(path), size, size, True) + """A control bar icon, drawn at the display's real pixel density.""" + return ScalableIcon(filename, size, ICON_DIR) def free_port(): @@ -324,8 +320,7 @@ class ControlBar(Gtk.Window): button.set_relief(Gtk.ReliefStyle.NONE) button.get_style_context().add_class("dock-button") - pixbuf = icon(filename, scaled(ICON_SIZE, scale)) - image = Gtk.Image.new_from_pixbuf(pixbuf) if pixbuf else Gtk.Image() + image = icon(filename, scaled(ICON_SIZE, scale)) self.images[name] = image button.add(image) @@ -446,13 +441,7 @@ class ControlBar(Gtk.Window): if image is None: return - pixbuf = icon( - "media-pause.svg" if playing else "media-play.svg", - scaled(ICON_SIZE, self.scale), - ) - - if pixbuf is not None: - image.set_from_pixbuf(pixbuf) + image.set_icon("media-pause.svg" if playing else "media-play.svg") class DvdSession: diff --git a/tools/build-release.sh b/tools/build-release.sh index ad5ed3b..14968c0 100755 --- a/tools/build-release.sh +++ b/tools/build-release.sh @@ -142,6 +142,10 @@ install -Dm644 \ "$ROOT/lib/classroom_config.py" \ "$STAGE/usr/local/lib/ekklesia-classroom/classroom_config.py" +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"