feat: Wonder Ink brings back recent tabs for two hours, never the sign-in

A teacher who closes Wonder Ink by accident, or whose computer crashes or
restarts, gets their recent tabs back if they open it again within two
hours of last using it. Later than that — overnight, next week — Wonder
Ink starts fresh and the file is removed.

It is a temporary workspace restore, not a sign-in. Measured on the real
site: WonderInk's rrf-auth cookie is "Basic " and the base64 of the
teacher's email and password, and its start-up authenticates again on
every load; keeping a sign-in would mean keeping a password. Nothing of
it is kept — no cookie, header or storage — and closing Wonder Ink still
signs the teacher out. What is kept is each tab's kind, address or
worksheet file, and title; the order; the tab in front; the time of last
use. An address with a credential in it is not kept at all.

Every WonderInk page has one address, so its tabs come back as one, at
the sign-in page. Its resource files are public and come back as they
were. A worksheet comes back only from Downloads/Wonder Ink, and only if
it is still there and still a PDF. The tab in front loads at once, the
rest when first shown: measured, a PDF loaded in a hidden tab showed at
10%.

Using it is the teacher's input — click, key, wheel, touch — never a
page's own activity, a print preview left open, or the restore itself,
so reopening cannot keep a workspace alive. A restore that crashes twice
is abandoned. Written atomically when tabs change and at most every 30 s
for the time of use; unreadable, future-format and clock-gone-back files
start fresh. The keeper stops filtering events at aboutToQuit: left in
place it crashed Wonder Ink on exit one run in several; 30 runs, none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-26 21:12:17 -05:00
co-authored by Claude Opus 5.5
parent 5fef4e02a9
commit b5d9535467
4 changed files with 816 additions and 13 deletions
+306
View File
@@ -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
+247 -13
View File
@@ -45,15 +45,17 @@ wrong a teacher is standing in front of a class.
WHAT STAYS THE SAME. WHAT STAYS THE SAME.
A separate profile from the ordinary browser, so a teacher's Wonder Ink 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 sign-in and the room's shared YouTube account never meet. The engine
only as long as the window, which is what the Chrome policy's keeps no cookie beyond the window, as Chrome's CookiesSessionOnlyForUrls
CookiesSessionOnlyForUrls did for wonderink.org and is the reason did for wonderink.org, so closing Wonder Ink signs the teacher out. What
closing Wonder Ink ends the teacher's session. Nothing here touches the does survive a close, for two hours after the teacher last used it, is
ordinary browser's profile or Chrome's policies. 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 os
import sys import sys
import time
from pathlib import Path from pathlib import Path
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom") 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" PROFILE = DATA_HOME / "ekklesia-classroom" / "wonderink-shell"
CACHE = CACHE_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. # Where a lesson's handouts land.
# #
# A directory of Wonder Ink's own, inside the ordinary Downloads folder # 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_printing # noqa: E402
import classroom_wonderink_workspace as workspace # noqa: E402
from PyQt6.QtCore import ( # noqa: E402 from PyQt6.QtCore import ( # noqa: E402
QEvent, QEvent,
QKeyCombination, QKeyCombination,
QMarginsF, QMarginsF,
QMimeData, QMimeData,
QObject,
QProcess, QProcess,
QRectF, QRectF,
Qt, Qt,
@@ -331,6 +340,185 @@ def nearest_level(level):
return min(ZOOM_STEPS, key=lambda step: abs(step - 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): class LessonPage(QWebEnginePage):
"""A page that answers "open this somewhere else" with a new tab. """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): def __init__(self, profile, room_zoom=1.0):
super().__init__() super().__init__()
self._profile = profile 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 self._fullscreen_view = None
# Every local file this window has decided to open, resolved. The # Every local file this window has decided to open, resolved. The
# navigation guard in LessonPage consults exactly this, so a path # navigation guard in LessonPage consults exactly this, so a path
@@ -513,6 +705,8 @@ class WonderInk(QMainWindow):
self._tabs.tabBar().setDrawBase(False) self._tabs.tabBar().setDrawBase(False)
self._tabs.tabCloseRequested.connect(self.close_tab) self._tabs.tabCloseRequested.connect(self.close_tab)
self._tabs.currentChanged.connect(self._current_changed) 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) self.setCentralWidget(self._tabs)
# The one corner widget: how large the lesson is, and Print. Not # The one corner widget: how large the lesson is, and Print. Not
@@ -556,6 +750,10 @@ class WonderInk(QMainWindow):
view.page().loadingChanged.connect( view.page().loadingChanged.connect(
lambda info, v=view: self._loading_changed(v, info) 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 # Set once, here. Measured: the engine keeps a tab's zoom across
# every navigation, same site or not, so it is never re-applied. # every navigation, same site or not, so it is never re-applied.
view.setZoomFactor(self.zoom_factor()) view.setZoomFactor(self.zoom_factor())
@@ -592,9 +790,15 @@ class WonderInk(QMainWindow):
self._tabs.removeTab(index) self._tabs.removeTab(index)
self._pdf_views.pop(view, None) self._pdf_views.pop(view, None)
self._mime.pop(view, None) self._mime.pop(view, None)
self._pending.pop(view, None)
view.page().deleteLater() view.page().deleteLater()
view.deleteLater() view.deleteLater()
self._show_tab_bar() 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): def _show_tab_bar(self):
# One tab is not a choice, so it is not drawn. A room with a # 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: if view is not None:
self._name_window(self._tabs.tabToolTip(index)) self._name_window(self._tabs.tabToolTip(index))
view.setFocus() 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): def _name_window(self, title):
# The panel and the window switcher show this. "Wonder Ink" last, # The panel and the window switcher show this. "Wonder Ink" last,
@@ -918,7 +1126,7 @@ class WonderInk(QMainWindow):
self.open_pdf(path) self.open_pdf(path)
return True return True
def open_pdf(self, path): def open_pdf(self, path, focus=True, load=True):
"""A worksheet, as a tab of its own.""" """A worksheet, as a tab of its own."""
resolved = path.resolve() resolved = path.resolve()
@@ -927,13 +1135,16 @@ class WonderInk(QMainWindow):
# directory and not a pattern. # directory and not a pattern.
self._openable.add(resolved) 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 file itself, not only its name: a worksheet is printed from
# the PDF on disk, because the engine cannot print its own viewer. # the PDF on disk, because the engine cannot print its own viewer.
self._pdf_views[view] = resolved self._pdf_views[view] = resolved
self._retitle(view, path.stem) self._retitle(view, path.stem)
self._reicon(view, QIcon()) self._reicon(view, QIcon())
view.load(QUrl.fromLocalFile(str(resolved)))
if load:
view.load(QUrl.fromLocalFile(str(resolved)))
return view return view
# ---- printing ------------------------------------------------------- # ---- printing -------------------------------------------------------
@@ -2199,6 +2410,27 @@ def keep_going(kind, value, trace):
traceback.print_exception(kind, value, trace, file=sys.stderr) 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"): def build_profile(app, name="wonderink"):
"""The engine profile every Wonder Ink window uses, settings and all. """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.setPersistentStoragePath(str(PROFILE))
profile.setCachePath(str(CACHE)) profile.setCachePath(str(CACHE))
# The behaviour the Chrome policy's CookiesSessionOnlyForUrls gave # The behaviour the Chrome policy's CookiesSessionOnlyForUrls gave
# wonderink.org, kept: closing Wonder Ink ends the teacher's session, # wonderink.org, kept: no cookie is written to disk, for any site, so
# so the next person to walk into the room is not signed in as the # closing Wonder Ink ends the teacher's session and the next person is
# last one. The whole profile rather than one domain, because this # not signed in as the last one. The whole profile rather than one
# profile only ever goes where a lesson sends it. # 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( profile.setPersistentCookiesPolicy(
QWebEngineProfile.PersistentCookiesPolicy.NoPersistentCookies QWebEngineProfile.PersistentCookiesPolicy.NoPersistentCookies
) )
@@ -2435,7 +2669,7 @@ def main():
# Connected to the window rather than to a loose function: a finished # Connected to the window rather than to a loose function: a finished
# worksheet becomes a tab, and only a window can make one. # worksheet becomes a tab, and only a window can make one.
profile.downloadRequested.connect(window.on_download) 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 # An ordinary size before the maximised one, so the window has
# something sensible to be if it is ever asked to stop being # something sensible to be if it is ever asked to stop being
# maximised. A window that has only ever been maximised has no # maximised. A window that has only ever been maximised has no
+258
View File
@@ -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("<html>an error page</html>")
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:[email protected]/"),
("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"[email protected]:hunter2hunter2").decode()
risky = THREE + [ws.tab("web", "https://teacher:[email protected]/", "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
+5
View File
@@ -388,6 +388,11 @@ install -Dm644 \
"$ROOT/lib/classroom_printing.py" \ "$ROOT/lib/classroom_printing.py" \
"$STAGE/usr/local/lib/ekklesia-classroom/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. # PRESENTATIONS.
# #
# The library a teacher sees, and the watcher that fills it from their # The library a teacher sees, and the watcher that fills it from their