diff --git a/lib/classroom_wonderink_workspace.py b/lib/classroom_wonderink_workspace.py new file mode 100644 index 0000000..521de12 --- /dev/null +++ b/lib/classroom_wonderink_workspace.py @@ -0,0 +1,306 @@ +"""Wonder Ink's recent tabs, kept for two hours: temporary workspace restore. + +A teacher who closes Wonder Ink by accident, or whose computer restarts, +gets their tabs back if they open it again soon. A room nobody has used +for two hours starts fresh: the next teacher never sees the last one's +tabs. + +It is not a sign-in and not a memory of tabs. Measured on the real site +(docs/wonder-ink.md, *Temporary workspace restore*): WonderInk's sign-in +is the teacher's email and password, Basic-auth encoded in a cookie, so +keeping a sign-in would mean keeping a password. Nothing of the sign-in is +kept here — no cookie, no header, no storage — and a restored tab that +needs the teacher signed in gets WonderInk's own sign-in page. + +WHAT IS KEPT, AND ONLY THIS. + + format the version of this file + last_activity wall-clock time the teacher last used Wonder Ink + active which tab was in front + tabs for each, in order: its kind ("web" or "worksheet"), + its address or the worksheet's file, and its title + +Addresses are http or https and nothing else, and one that carries a +credential — a user and password in it, or a query parameter named like +a token, key, password or signature — is not kept at all. + +WHEN IT IS USED. + +Only when Wonder Ink starts, and only within IDLE_LIMIT_SECONDS of the +last time the teacher used it. The time is the teacher's last use, not +the last start: opening Wonder Ink and having its tabs restored is not +using it, so closing and reopening cannot keep a workspace alive by +itself. Anything that cannot be read, is from another version, is in the +future by more than a minute (a clock gone backwards), or has failed to +restore twice running is discarded, and Wonder Ink starts fresh. +""" + +import json +import os +import time +from pathlib import Path +from urllib.parse import parse_qsl, urlsplit + +# Two hours after the teacher last used Wonder Ink. The one place this is +# decided. +IDLE_LIMIT_SECONDS = 2 * 60 * 60 + +# FOR TESTING ONLY. Checking expiry on a real machine should not take two +# hours, so this variable, in the environment of a Wonder Ink started by +# hand from the maintenance terminal, shortens it. Nothing in the classroom +# sets it; it lasts as long as that one process, and it can only shorten. +TEST_OVERRIDE = "CLASSROOM_WONDERINK_TEST_WORKSPACE_SECONDS" +TEST_SHORTEST = 10 + +# How far the clock may seem to have gone backwards before a workspace is +# discarded for it. +CLOCK_TOLERANCE_SECONDS = 60 + +# A workspace that has been started from this many times without Wonder +# Ink then running long enough to say it worked is abandoned: one bad tab +# must not make Wonder Ink unusable. +RESTORE_ATTEMPTS = 2 + +FORMAT = 1 +KINDS = ("web", "worksheet") + +# WonderInk's own site. Every page of it has the same address — it is one +# page that draws each screen itself — so however many of its tabs a +# teacher had, one is restored, and that one asks them to sign in. +SITE_DOMAIN = "wonderink.org" + +# Query parameters that are, or may be, a credential. A tab whose address +# has one is not kept. +CREDENTIAL_WORDS = ( + "password", "passwd", "pwd", "token", "auth", "secret", "key", + "signature", "sig", "credential", "session", "jwt", "x-amz-", +) + +TITLE_LENGTH = 120 + + +def idle_limit(environ=None): + """Seconds after the teacher's last use that a workspace is kept.""" + environ = os.environ if environ is None else environ + value = environ.get(TEST_OVERRIDE, "").strip() + + if value: + try: + seconds = int(value) + except ValueError: + return IDLE_LIMIT_SECONDS + + return max(TEST_SHORTEST, min(seconds, IDLE_LIMIT_SECONDS)) + + return IDLE_LIMIT_SECONDS + + +def is_test_limit(environ=None): + return idle_limit(environ) != IDLE_LIMIT_SECONDS + + +def is_site(host, site=SITE_DOMAIN): + host = (host or "").lstrip(".").lower() + return host == site or host.endswith("." + site) + + +def keepable_url(url): + """Is this an address that may be written down? http(s), no credential.""" + try: + parts = urlsplit(url) + except ValueError: + return False + + if parts.scheme not in ("http", "https") or not parts.hostname: + return False + + if parts.username or parts.password: + return False + + for name, _value in parse_qsl(parts.query, keep_blank_values=True): + lowered = name.lower() + if any(word in lowered for word in CREDENTIAL_WORDS): + return False + + return True + + +def is_site_start(url, site=SITE_DOMAIN): + """Is this one of WonderInk's own pages, which all share one address?""" + parts = urlsplit(url) + return is_site(parts.hostname, site) and parts.path in ("", "/") + + +def tab(kind, where, title=""): + return {"kind": kind, "where": str(where), "title": str(title or "")[:TITLE_LENGTH]} + + +def plan(tabs, active, downloads, site=SITE_DOMAIN): + """Which of the saved TABS to open, in order, and which to put in front. + + Returns (tabs, active) with every tab one that may be opened: + + * a web tab only if its address may be kept (see keepable_url); + * WonderInk's own tabs as one, where the first of them was; + * a worksheet only if it is a file directly inside DOWNLOADS that + still exists and still begins as a PDF — a state file cannot send + Wonder Ink to any other file on the computer; + * the tab in front is the one that was, or the nearest kept before it. + """ + downloads = Path(downloads).resolve() + kept, index_of, site_seen = [], {}, None + + for number, item in enumerate(tabs): + if not isinstance(item, dict) or item.get("kind") not in KINDS: + continue + + where, title = str(item.get("where", "")), str(item.get("title", ""))[:TITLE_LENGTH] + + if item["kind"] == "web": + if not keepable_url(where): + continue + + if is_site_start(where, site): + if site_seen is not None: + index_of[number] = site_seen + continue + + site_seen = len(kept) + else: + if not openable_worksheet(where, downloads): + continue + + index_of[number] = len(kept) + kept.append(tab(item["kind"], where, title)) + + if not kept: + return [], 0 + + try: + active = int(active) + except (TypeError, ValueError): + active = 0 + + for candidate in range(active, -1, -1): + if candidate in index_of: + return kept, index_of[candidate] + + return kept, 0 + + +def openable_worksheet(path, downloads): + try: + resolved = Path(path).resolve() + except (OSError, RuntimeError, ValueError): + return False + + if resolved.parent != Path(downloads).resolve() or not resolved.is_file(): + return False + + try: + with open(resolved, "rb") as handle: + return handle.read(5) == b"%PDF-" + except OSError: + return False + + +def read(path, now=None, limit=None): + """The workspace to restore, or why there is none. + + Returns (data, None) — with "tabs", "active", "last_activity" and + "attempts" — or (None, reason). Anything that is not a workspace to + restore is removed. + """ + now = time.time() if now is None else now + limit = idle_limit() if limit is None else limit + path = Path(path) + + try: + data = json.loads(path.read_text()) + except FileNotFoundError: + return None, "none kept" + except (OSError, ValueError): + return _discard(path, "unreadable") + + try: + if data["format"] != FORMAT: + return _discard(path, "from another version") + + last = float(data["last_activity"]) + tabs = list(data["tabs"]) + active = int(data.get("active", 0)) + attempts = int(data.get("attempts", 0)) + except (KeyError, TypeError, ValueError): + return _discard(path, "incomplete") + + if last > now + CLOCK_TOLERANCE_SECONDS: + return _discard(path, "the clock is behind the last use") + + if now - last > limit: + return _discard(path, "expired") + + if attempts >= RESTORE_ATTEMPTS: + return _discard(path, f"it failed to restore {attempts} times") + + if not tabs: + return _discard(path, "no tabs") + + return {"tabs": tabs, "active": active, "last_activity": last, "attempts": attempts}, None + + +def write(path, tabs, active, last_activity, attempts=0): + """Keep TABS, or remove the file if there is nothing to keep. + + Only what plan() would open again is written. Written whole to a + temporary file, flushed to disk, and renamed over the old one, readable + only by the classroom account: a crash or a power cut leaves the old + file or the new one, never half of either. + """ + path = Path(path) + keepable, front = [], 0 + + for number, item in enumerate(tabs): + if item.get("kind") == "worksheet" or ( + item.get("kind") == "web" and keepable_url(item.get("where", "")) + ): + if number <= active: + front = len(keepable) + keepable.append(tab(item["kind"], item["where"], item.get("title", ""))) + + if not keepable or last_activity is None: + forget(path) + return False + + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(path.name + ".new") + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + + with os.fdopen(descriptor, "w") as handle: + json.dump({ + "format": FORMAT, + "last_activity": float(last_activity), + "active": front, + "attempts": int(attempts), + "tabs": keepable, + }, handle) + handle.flush() + os.fsync(handle.fileno()) + + os.replace(temporary, path) + return True + + +def forget(path): + """Remove a kept workspace. Never fails: Wonder Ink must start regardless.""" + path = Path(path) + + for target in (path, path.with_name(path.name + ".new")): + try: + target.unlink(missing_ok=True) + except OSError: + pass + + +def _discard(path, reason): + forget(path) + return None, reason diff --git a/scripts/classroom-wonderink b/scripts/classroom-wonderink index 87ce221..e11b643 100755 --- a/scripts/classroom-wonderink +++ b/scripts/classroom-wonderink @@ -45,15 +45,17 @@ wrong a teacher is standing in front of a class. WHAT STAYS THE SAME. A separate profile from the ordinary browser, so a teacher's Wonder Ink -sign-in and the room's shared YouTube account never meet. Cookies live -only as long as the window, which is what the Chrome policy's -CookiesSessionOnlyForUrls did for wonderink.org and is the reason -closing Wonder Ink ends the teacher's session. Nothing here touches the -ordinary browser's profile or Chrome's policies. +sign-in and the room's shared YouTube account never meet. The engine +keeps no cookie beyond the window, as Chrome's CookiesSessionOnlyForUrls +did for wonderink.org, so closing Wonder Ink signs the teacher out. What +does survive a close, for two hours after the teacher last used it, is +the list of tabs they had open (WorkspaceKeeper) — never the sign-in. +Nothing here touches the ordinary browser's profile or Chrome's policies. """ import os import sys +import time from pathlib import Path sys.path.insert(0, "/usr/local/lib/ekklesia-classroom") @@ -121,6 +123,11 @@ ENGINE_ZOOM = (0.25, 5.0) PROFILE = DATA_HOME / "ekklesia-classroom" / "wonderink-shell" CACHE = CACHE_HOME / "ekklesia-classroom" / "wonderink-shell" +# The teacher's recent tabs, for two hours after they last used Wonder +# Ink. Beside the engine's profile, never in it, and nothing of the +# sign-in: see WorkspaceKeeper and lib/classroom_wonderink_workspace.py. +WORKSPACE_FILE = DATA_HOME / "ekklesia-classroom" / "wonderink-workspace.json" + # Where a lesson's handouts land. # # A directory of Wonder Ink's own, inside the ordinary Downloads folder @@ -163,11 +170,13 @@ if WIDEVINE.exists(): ) import classroom_printing # noqa: E402 +import classroom_wonderink_workspace as workspace # noqa: E402 from PyQt6.QtCore import ( # noqa: E402 QEvent, QKeyCombination, QMarginsF, QMimeData, + QObject, QProcess, QRectF, Qt, @@ -331,6 +340,185 @@ def nearest_level(level): return min(ZOOM_STEPS, key=lambda step: abs(step - level)) +class WorkspaceKeeper(QObject): + """The teacher's recent tabs, restored after an accidental close. + + TEMPORARY WORKSPACE RESTORE, NOT A SIGN-IN. + + Wonder Ink writes down which tabs are open — their addresses, their + order, which is in front, a worksheet's file — whenever that changes, + with the time the teacher last used it. Opened again within + workspace.IDLE_LIMIT_SECONDS of that time, after a close, a crash or a + restart, it opens those tabs again. Later than that, it starts fresh + and the file goes: the next teacher never meets the last one's tabs. + + Nothing of the sign-in is kept. WonderInk's sign-in is the teacher's + email and password in a cookie (measured), so a restored WonderInk tab + shows the site's own sign-in page, and closing Wonder Ink still signs + the teacher out, as it always has. + + WHAT COUNTS AS USING IT. + + Input that reaches Wonder Ink's own windows: a mouse button, a key, the + wheel, a touch. That covers everything a teacher does — following a + link, scrolling, switching, opening and closing tabs, a worksheet, + printing, zooming — and nothing a page does by itself: timers, + animation, background requests, analytics, video buffering, a + service worker. Restoring the tabs is not using them either; the time + kept is the teacher's last use, carried over, so reopening Wonder Ink + cannot keep a workspace alive by itself. + + WHEN IT IS WRITTEN. + + A second after the tabs change, and the time of last use at most every + KEEP_EVERY_MS, written whole and renamed into place: a crash or power + cut loses at most half a minute of use and never leaves half a file. + Nothing is written until the teacher has used Wonder Ink at all. + + A RESTORE THAT CRASHES. + + Starting from a workspace is counted in the file before any tab + loads, and the count cleared once Wonder Ink has run SETTLED_MS. A + workspace that has been started from twice without getting that far + is abandoned, and Wonder Ink starts fresh without asking anybody. + """ + + ACTIVITY = ( + QEvent.Type.MouseButtonPress, + QEvent.Type.KeyPress, + QEvent.Type.Wheel, + QEvent.Type.TouchBegin, + ) + + KEEP_EVERY_MS = 30_000 + SETTLED_MS = 20_000 + + def __init__(self, window, path=None): + super().__init__(window) + self._window = window + self._path = Path(WORKSPACE_FILE if path is None else path) + self._last_wall = None + self._kept_at = None + self._attempts = 0 + # Restored tabs, by what was kept, until they have loaded something + # of their own: a tab nobody has looked at yet is still that tab. + self._pending_tabs = {} + + self._save_timer = QTimer(self) + self._save_timer.setSingleShot(True) + self._save_timer.timeout.connect(self.save) + + QApplication.instance().installEventFilter(self) + QApplication.instance().aboutToQuit.connect(self.shut_down) + + def restore(self, kept): + """Open the tabs of a kept workspace. False if none of them can be. + + The attempt is written down before any of them loads. + """ + tabs, active = workspace.plan(kept["tabs"], kept["active"], DOWNLOADS) + + if not tabs: + return False + + self._last_wall = kept["last_activity"] + self._attempts = kept["attempts"] + 1 + workspace.write(self._path, tabs, active, self._last_wall, self._attempts) + self._kept_at = time.monotonic() + + # Each tab loads when it is first shown, the one in front at once. + # Measured: a PDF loaded in a tab nobody is looking at is fitted to + # a window of no size, and shows at 10% when it is looked at. + for item in tabs: + if item["kind"] == "worksheet": + view = self._window.open_pdf(Path(item["where"]), focus=False, load=False) + where = QUrl.fromLocalFile(str(Path(item["where"]).resolve())) + else: + view = self._window.open_tab(focus=False) + where = QUrl(item["where"]) + + self._window._retitle(view, item["title"] or item["where"]) + self._window._pending[view] = (lambda v=view, u=where: v.load(u)) + self._pending_tabs[view] = item + + self._window._tabs.setCurrentIndex(active) + # setCurrentIndex does not announce a tab that is already current. + front = self._window._tabs.widget(active) + load = self._window._pending.pop(front, None) + if load is not None: + load() + QTimer.singleShot(self.SETTLED_MS, self._settled) + return True + + def _settled(self): + self._attempts = 0 + self.changed() + + # -- what changes it -------------------------------------------------- + + def eventFilter(self, watched, event): + if event.type() in self.ACTIVITY: + self.touched() + + return False + + def touched(self): + self._last_wall = time.time() + + if self._kept_at is None or time.monotonic() - self._kept_at >= self.KEEP_EVERY_MS / 1000: + self._soon(0) + else: + self._soon(int(self.KEEP_EVERY_MS - (time.monotonic() - self._kept_at) * 1000)) + + def changed(self): + """The tabs changed: written a moment later, once they settle.""" + self._soon(1000) + + def _soon(self, ms): + if not self._save_timer.isActive() or self._save_timer.remainingTime() > ms: + self._save_timer.start(max(0, ms)) + + def snapshot(self): + tabs = [] + views = self._window._tabs + + for index in range(views.count()): + view = views.widget(index) + title = views.tabToolTip(index) or view.title() + + if view in self._window._pending: + tabs.append(self._pending_tabs[view]) + elif view in self._window._pdf_views: + tabs.append(workspace.tab("worksheet", self._window._pdf_views[view], title)) + else: + tabs.append(workspace.tab("web", view.url().toString(), title)) + + return tabs, max(0, views.currentIndex()) + + def save(self): + self._save_timer.stop() + tabs, active = self.snapshot() + + try: + workspace.write(self._path, tabs, active, self._last_wall, self._attempts) + self._kept_at = time.monotonic() + except OSError as error: + say(f"could not keep the workspace: {error}") + + def shut_down(self): + """Keep the tabs, then let go of everything Qt tears down after. + + Measured: left as an application-wide event filter while Python + and Qt shut down, a keeper crashed Wonder Ink on the way out one + run in several (a segfault in sip, from Py_Exit) — an event reached + the Python eventFilter of an object already half gone. So it stops + here, at aboutToQuit, while everything still exists: 30 runs, none. + """ + self.save() + self._save_timer.stop() + QApplication.instance().removeEventFilter(self) + + class LessonPage(QWebEnginePage): """A page that answers "open this somewhere else" with a new tab. @@ -475,6 +663,10 @@ class WonderInk(QMainWindow): def __init__(self, profile, room_zoom=1.0): super().__init__() self._profile = profile + # The recent tabs, written down as they change; see WorkspaceKeeper. + self._workspace = None + # Restored tabs not yet shown: what loads them, when they are. + self._pending = {} self._fullscreen_view = None # Every local file this window has decided to open, resolved. The # navigation guard in LessonPage consults exactly this, so a path @@ -513,6 +705,8 @@ class WonderInk(QMainWindow): self._tabs.tabBar().setDrawBase(False) self._tabs.tabCloseRequested.connect(self.close_tab) self._tabs.currentChanged.connect(self._current_changed) + self._tabs.currentChanged.connect(lambda *_: self._workspace_changed()) + self._tabs.tabBar().tabMoved.connect(lambda *_: self._workspace_changed()) self.setCentralWidget(self._tabs) # The one corner widget: how large the lesson is, and Print. Not @@ -556,6 +750,10 @@ class WonderInk(QMainWindow): view.page().loadingChanged.connect( lambda info, v=view: self._loading_changed(v, info) ) + # What the recent tabs are, as they change. Not activity: a page + # that navigates itself changes a tab, not the time of last use. + view.urlChanged.connect(lambda *_: self._workspace_changed()) + view.titleChanged.connect(lambda *_: self._workspace_changed()) # Set once, here. Measured: the engine keeps a tab's zoom across # every navigation, same site or not, so it is never re-applied. view.setZoomFactor(self.zoom_factor()) @@ -592,9 +790,15 @@ class WonderInk(QMainWindow): self._tabs.removeTab(index) self._pdf_views.pop(view, None) self._mime.pop(view, None) + self._pending.pop(view, None) view.page().deleteLater() view.deleteLater() self._show_tab_bar() + self._workspace_changed() + + def _workspace_changed(self): + if self._workspace is not None: + self._workspace.changed() def _show_tab_bar(self): # One tab is not a choice, so it is not drawn. A room with a @@ -644,6 +848,10 @@ class WonderInk(QMainWindow): if view is not None: self._name_window(self._tabs.tabToolTip(index)) view.setFocus() + # A restored tab loads when it is first shown; see restore(). + pending = self._pending.pop(view, None) + if pending is not None: + pending() def _name_window(self, title): # The panel and the window switcher show this. "Wonder Ink" last, @@ -918,7 +1126,7 @@ class WonderInk(QMainWindow): self.open_pdf(path) return True - def open_pdf(self, path): + def open_pdf(self, path, focus=True, load=True): """A worksheet, as a tab of its own.""" resolved = path.resolve() @@ -927,13 +1135,16 @@ class WonderInk(QMainWindow): # directory and not a pattern. self._openable.add(resolved) - view = self.open_tab() + view = self.open_tab(focus=focus) # The file itself, not only its name: a worksheet is printed from # the PDF on disk, because the engine cannot print its own viewer. self._pdf_views[view] = resolved self._retitle(view, path.stem) self._reicon(view, QIcon()) - view.load(QUrl.fromLocalFile(str(resolved))) + + if load: + view.load(QUrl.fromLocalFile(str(resolved))) + return view # ---- printing ------------------------------------------------------- @@ -2199,6 +2410,27 @@ def keep_going(kind, value, trace): traceback.print_exception(kind, value, trace, file=sys.stderr) +def open_workspace(window, path=None): + """Restore the teacher's recent tabs, or start fresh. The keeper, either way.""" + path = WORKSPACE_FILE if path is None else path + kept, why = workspace.read(path) + keeper = WorkspaceKeeper(window, path) + window._workspace = keeper + + if workspace.is_test_limit(): + say(f"TEST ONLY: recent tabs are kept for {workspace.idle_limit()} s after the last use " + f"({workspace.TEST_OVERRIDE} is set)") + + if kept is not None and keeper.restore(kept): + say(f"restored {window._tabs.count()} recent tabs, last used " + f"{round((time.time() - kept['last_activity']) / 60)} minutes ago") + else: + say(f"starting fresh ({why or 'nothing in it can be opened'})") + window.open_tab(HOME_PAGE) + + return keeper + + def build_profile(app, name="wonderink"): """The engine profile every Wonder Ink window uses, settings and all. @@ -2213,10 +2445,12 @@ def build_profile(app, name="wonderink"): profile.setPersistentStoragePath(str(PROFILE)) profile.setCachePath(str(CACHE)) # The behaviour the Chrome policy's CookiesSessionOnlyForUrls gave - # wonderink.org, kept: closing Wonder Ink ends the teacher's session, - # so the next person to walk into the room is not signed in as the - # last one. The whole profile rather than one domain, because this - # profile only ever goes where a lesson sends it. + # wonderink.org, kept: no cookie is written to disk, for any site, so + # closing Wonder Ink ends the teacher's session and the next person is + # not signed in as the last one. The whole profile rather than one + # domain, because this profile only ever goes where a lesson sends it. + # WonderInk's sign-in is the teacher's password in a cookie (measured, + # 0.2.6.4): this is also what keeps that off the disk. profile.setPersistentCookiesPolicy( QWebEngineProfile.PersistentCookiesPolicy.NoPersistentCookies ) @@ -2435,7 +2669,7 @@ def main(): # Connected to the window rather than to a loose function: a finished # worksheet becomes a tab, and only a window can make one. profile.downloadRequested.connect(window.on_download) - window.open_tab(HOME_PAGE) + open_workspace(window) # An ordinary size before the maximised one, so the window has # something sensible to be if it is ever asked to stop being # maximised. A window that has only ever been maximised has no diff --git a/tests/test-classroom-wonderink-workspace.sh b/tests/test-classroom-wonderink-workspace.sh new file mode 100755 index 0000000..220166c --- /dev/null +++ b/tests/test-classroom-wonderink-workspace.sh @@ -0,0 +1,258 @@ +#!/bin/bash +# Wonder Ink's temporary workspace restore: which tabs come back, and when. +# +# lib/classroom_wonderink_workspace.py decides, from a file and the clock, +# whether a teacher's recent tabs are opened again, and which of them may +# be. Every way that could go wrong is either the next teacher meeting the +# last one's tabs, a credential written to disk, or a file Wonder Ink was +# never meant to open — so each is tested here with a clock the test +# controls. Whether the engine really restores them is +# tools/lab/wonderink-workspace-check's question. +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +WORK="$WORK" PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY' +import base64 +import json +import os +import stat +import sys +from pathlib import Path + +import classroom_wonderink_workspace as ws + +root = Path(sys.argv[1]) +work = Path(os.environ["WORK"]) +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 + + +HOUR = 60 * 60 +NOW = 1_790_000_000.0 +downloads = work / "Downloads" / "Wonder Ink" +downloads.mkdir(parents=True) +sheet = downloads / "Colour the Ark.pdf" +sheet.write_bytes(b"%PDF-1.4 worksheet") +path = work / "wonderink-workspace.json" + +SITE = "https://app.wonderink.org/?" +SLIDE = "https://dcc-sbbd-user-content.s3.amazonaws.com/5ee6/69fd-SaulWelcomeQuestionSlide.png" +VIDEO = "https://player.vimeo.com/video/1144568454" +THREE = [ws.tab("web", SITE, "Welcome Question"), ws.tab("web", SLIDE, "Slide"), ws.tab("web", VIDEO, "Video")] + +print("1. how long") +check("two hours by default", ws.idle_limit({}) == 2 * HOUR) +check("the test override shortens it", ws.idle_limit({ws.TEST_OVERRIDE: "120"}) == 120) +check("but never below ten seconds", ws.idle_limit({ws.TEST_OVERRIDE: "1"}) == ws.TEST_SHORTEST) +check("and never lengthens it", ws.idle_limit({ws.TEST_OVERRIDE: "999999"}) == 2 * HOUR) +check("nonsense in it is the default", ws.idle_limit({ws.TEST_OVERRIDE: "soon"}) == 2 * HOUR) +check("and it says it is one", ws.is_test_limit({ws.TEST_OVERRIDE: "120"}) and not ws.is_test_limit({})) + +print() +print("2. A. restore within the window") + + +def keep(tabs=THREE, active=1, last=NOW, attempts=0): + ws.write(path, tabs, active, last, attempts) + + +for label, minutes in (("five minutes", 5), ("ninety minutes", 90), ("a second before two hours", 120 - 1 / 60)): + keep() + data, why = ws.read(path, now=NOW + minutes * 60) + check(f"{label} later: restored", data is not None and len(data["tabs"]) == 3, why) +check("with the time of the last use, not the time it was opened again", data["last_activity"] == NOW) + +print() +print("3. B. expiration") +for label, seconds in (("two hours and a minute", 2 * HOUR + 60), ("the next day", 24 * HOUR), + ("the next week", 7 * 24 * HOUR)): + keep() + data, why = ws.read(path, now=NOW + seconds) + check(f"{label} later: fresh ({why})", data is None and why == "expired") + check("and the file is gone", not path.exists()) + +keep(last=NOW) +data, why = ws.read(path, now=NOW + 30, limit=20) +check("the test override applies to a kept workspace", data is None and why == "expired", why) + +keep(last=NOW) +data, why = ws.read(path, now=NOW - 2 * HOUR) +check("a clock gone back two hours: fresh, not a workspace made young", data is None and "clock" in why, why) +keep(last=NOW) +data, why = ws.read(path, now=NOW - 30) +check("a clock corrected by half a minute: still restored", data is not None, why) + +print() +print("4. D. which tabs, in which order, which in front") +keep() +data, _ = ws.read(path, now=NOW + 60) +check("the order is kept", [t["where"] for t in data["tabs"]] == [SITE, SLIDE, VIDEO]) +check("and the tab in front", data["active"] == 1) +check("and readable titles", [t["title"] for t in data["tabs"]] == ["Welcome Question", "Slide", "Video"]) + +tabs, active = ws.plan(THREE, 1, downloads) +check("all three can be opened, the middle one in front", len(tabs) == 3 and active == 1) + +five_site = [ws.tab("web", SITE, f"Lesson {n}") for n in range(3)] + [ws.tab("web", SLIDE, "Slide"), + ws.tab("web", "https://app.wonderink.org/", "Home")] +tabs, active = ws.plan(five_site, 4, downloads) +check("WonderInk's own tabs, which share one address, become one sign-in page, not four", + [t["where"] for t in tabs] == [SITE, SLIDE], tabs) +check("and a WonderInk tab in front brings its one tab to the front", active == 0) +tabs, active = ws.plan(five_site, 3, downloads) +check("the slide in front stays in front", active == 1) + +with_sheet = [ws.tab("web", SITE, "Lesson"), ws.tab("worksheet", str(sheet), "Colour the Ark"), ws.tab("web", SLIDE, "Slide")] +tabs, active = ws.plan(with_sheet, 1, downloads) +check("a worksheet that still exists comes back, where it was and named", tabs[1] == ws.tab("worksheet", str(sheet), "Colour the Ark") + and active == 1, tabs) +gone = downloads / "Gone.pdf" +tabs, active = ws.plan([ws.tab("web", SITE), ws.tab("worksheet", str(gone), "Gone"), ws.tab("web", SLIDE)], 1, downloads) +check("a worksheet whose file has gone is left out, silently", [t["where"] for t in tabs] == [SITE, SLIDE], tabs) +check("and the tab in front becomes the one before it", active == 0) + +print() +print("5. only files Wonder Ink downloaded, and only PDFs") +elsewhere = work / "secret.pdf" +elsewhere.write_bytes(b"%PDF-1.4 not a worksheet") +(downloads / "sub").mkdir() +(downloads / "sub" / "deep.pdf").write_bytes(b"%PDF-1.4") +notpdf = downloads / "notes.pdf" +notpdf.write_text("an error page") +link = downloads / "link.pdf" +link.symlink_to(elsewhere) +for label, target in (("a file outside Downloads/Wonder Ink", elsewhere), ("a system file", Path("/etc/passwd")), + ("a file in a folder inside it", downloads / "sub" / "deep.pdf"), + ("a file that is not a PDF", notpdf), ("a link out of it", link), + ("a path climbing out of it", downloads / ".." / ".." / "secret.pdf")): + tabs, _ = ws.plan([ws.tab("worksheet", str(target))], 0, downloads) + check(f"{label}: never opened", tabs == [], tabs) + +print() +print("6. only http and https, and no credentials") +for label, url in (("a file: address", "file:///etc/passwd"), ("javascript:", "javascript:alert(1)"), + ("data:", "data:text/html,hi"), ("blob:", "blob:https://app.wonderink.org/abc"), + ("chrome:", "chrome://settings"), ("about:blank", "about:blank"), + ("a user and password in it", "https://teacher:secret@app.wonderink.org/"), + ("a token", "https://example.org/v?token=abc"), ("an access_token", "https://example.org/v?access_token=abc"), + ("a password", "https://example.org/?password=abc"), ("an S3 signature", "https://b.s3.amazonaws.com/x.png?X-Amz-Signature=abc"), + ("a key", "https://example.org/?api_key=abc"), ("a session", "https://example.org/?sessionid=abc")): + check(f"{label}: not kept", not ws.keepable_url(url)) +check("an ordinary lesson resource is", ws.keepable_url(SLIDE) and ws.keepable_url(VIDEO + "?h=91f1770ec7")) + +print() +print("7. F. privacy: what is on disk") +basic = "Basic " + base64.b64encode(b"teacher@example.org:hunter2hunter2").decode() +risky = THREE + [ws.tab("web", "https://teacher:hunter2hunter2@app.wonderink.org/", "creds"), + ws.tab("web", "https://example.org/?token=" + basic, "token")] +ws.write(path, risky, 0, NOW) +text = path.read_text() +data = json.loads(text) +check("only what may be opened again is written", len(data["tabs"]) == 3, data["tabs"]) +check("no password, Basic-auth credential or token in the file", + "hunter2" not in text and "Basic " not in text and "token" not in text.lower() and "@" not in text, text) +check("no cookie, header or storage of any kind", + not any(k in text.lower() for k in ("cookie", "rrf-auth", "x-rrf", "authorization", "localstorage", "sessionstorage"))) +check("exactly these fields, and no others", + set(data) == {"format", "last_activity", "active", "attempts", "tabs"} + and all(set(t) == {"kind", "where", "title"} for t in data["tabs"]), data) +check("readable by the classroom account only", stat.S_IMODE(path.stat().st_mode) == 0o600) +check("no half-written file is left beside it", not path.with_name(path.name + ".new").exists()) +check("a workspace nobody has used (no time of last use) is not written", + not ws.write(path, THREE, 0, None) and not path.exists()) +check("nor one with nothing to open", not ws.write(path, [ws.tab("web", "about:blank")], 0, NOW) and not path.exists()) + +print() +print("8. E. failure: always a fresh start, never a crash") +for label, content in (("an empty file", ""), ("not JSON", "{not json"), ("JSON of the wrong shape", "[1, 2]"), + ("no time of last use", json.dumps({"format": 1, "tabs": THREE, "active": 0})), + ("a time that is not a number", json.dumps({"format": 1, "last_activity": "soon", "tabs": THREE})), + ("no tabs", json.dumps({"format": 1, "last_activity": NOW, "active": 0, "tabs": []})), + ("a future format", json.dumps({"format": 2, "last_activity": NOW, "active": 0, "tabs": THREE})), + ("tabs that are not a list", json.dumps({"format": 1, "last_activity": NOW, "tabs": 7}))): + path.write_text(content) + data, why = ws.read(path, now=NOW + 5) + check(f"{label}: fresh ({why}), and removed", data is None and not path.exists(), why) + +tabs, _ = ws.plan([{"kind": "web"}, "not a tab", {"kind": "program", "where": "/bin/sh"}, ws.tab("web", SLIDE)], 0, downloads) +check("tabs that make no sense are skipped, the rest kept", [t["where"] for t in tabs] == [SLIDE], tabs) + +path.mkdir() +data, why = ws.read(path, now=NOW) +check("a directory where the file should be: fresh, and no crash", data is None, why) +path.rmdir() + +(path.with_name(path.name + ".new")).write_text('{"format": 1, "last_act') +keep() +data, why = ws.read(path, now=NOW + 5) +check("a write cut off half way (the .new file) leaves the last whole one", data is not None, why) + +print() +print("9. E. a restore that crashes Wonder Ink") +keep(attempts=1) +data, why = ws.read(path, now=NOW + 60) +check("started from once without settling: tried once more", data is not None and data["attempts"] == 1, why) +keep(attempts=2) +data, why = ws.read(path, now=NOW + 60) +check("twice: abandoned, and Wonder Ink starts fresh", data is None and "failed to restore" in why, why) +check("and the workspace is gone", not path.exists()) + +print() +print("10. Wonder Ink keeps it, and only as this says") +ink = (root / "scripts/classroom-wonderink").read_text() +code = "\n".join(line for line in ink.splitlines() if not line.lstrip().startswith("#")) +keeper = code[code.index("class WorkspaceKeeper"):code.index("class LessonPage")] +check("the engine still keeps no cookie", "PersistentCookiesPolicy.NoPersistentCookies" in code) +check("the keeper never touches a cookie, a header or a page's storage", + not any(w in keeper for w in ("cookieStore", "QNetworkCookie", "setHttpHeader", "localStorage", "runJavaScript"))) +check("nothing else in Wonder Ink writes a cookie down", "toRawForm" not in code and "cookieStore()" not in code) +check("activity is the teacher's input: button, key, wheel, touch — not the pointer moving", + all(f"QEvent.Type.{t}" in keeper for t in ("MouseButtonPress", "KeyPress", "Wheel", "TouchBegin")) + and "MouseMove" not in keeper) +check("a restore carries the old time of last use over, not now", + 'self._last_wall = kept["last_activity"]' in keeper) +check("a restore is counted in the file before any tab loads", + keeper.index("self._attempts = kept[\"attempts\"] + 1") < keeper.index("for item in tabs:") + and "workspace.write(self._path, tabs, active, self._last_wall, self._attempts)" in keeper) +check("a restored tab loads when it is first shown, the one in front at once (a hidden PDF opened at 10%)", + "self._window._pending[view] = (lambda v=view, u=where: v.load(u))" in keeper + and "pending = self._pending.pop(view, None)" in code) +check("and until then it is kept by its saved address, not dropped as an empty tab", + "if view in self._window._pending:\n tabs.append(self._pending_tabs[view])" in keeper) +check("and the count cleared once Wonder Ink has settled", "QTimer.singleShot(self.SETTLED_MS, self._settled)" in keeper) +check("the tabs are written when they change, not only at the end", + "view.urlChanged.connect(lambda *_: self._workspace_changed())" in code + and "tabMoved.connect(lambda *_: self._workspace_changed())" in code + and "currentChanged.connect(lambda *_: self._workspace_changed())" in code) +check("a page changing its own tab is not activity", "urlChanged.connect(lambda *_: self._workspace_changed())" in code + and "touched()" not in code[code.index("def _workspace_changed"):code.index("def _workspace_changed") + 200]) +check("at quit it keeps the tabs, then stops filtering events", + "aboutToQuit.connect(self.shut_down)" in keeper and "removeEventFilter(self)" in keeper) +check("worksheet downloads are connected before the workspace opens", + code.index("profile.downloadRequested.connect(window.on_download)\n open_workspace(window)") > 0) +setters = [str(p.relative_to(root)) for top in ("config", "packaging", "systemd", "scripts") + for p in (root / top).rglob("*") if p.is_file() and not p.is_symlink() + and not str(p).startswith(str(root / "packaging" / "root")) + and ws.TEST_OVERRIDE in p.read_text(errors="ignore")] +check("nothing the classroom installs sets the test-only override", setters == [], setters) +check("and Wonder Ink says so in the journal when it is set", "TEST ONLY" in code and "workspace.is_test_limit()" in code) +check("the module is packaged", "lib/classroom_wonderink_workspace.py" in (root / "tools/build-release.sh").read_text()) + +print() +print(f"{passed} passed, {failed} failed") +sys.exit(1 if failed else 0) +PY diff --git a/tools/build-release.sh b/tools/build-release.sh index 58bcd30..418402f 100755 --- a/tools/build-release.sh +++ b/tools/build-release.sh @@ -388,6 +388,11 @@ install -Dm644 \ "$ROOT/lib/classroom_printing.py" \ "$STAGE/usr/local/lib/ekklesia-classroom/classroom_printing.py" +# Wonder Ink's recent tabs, restored within two hours of their last use. +install -Dm644 \ + "$ROOT/lib/classroom_wonderink_workspace.py" \ + "$STAGE/usr/local/lib/ekklesia-classroom/classroom_wonderink_workspace.py" + # PRESENTATIONS. # # The library a teacher sees, and the watcher that fills it from their