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
+247 -13
View File
@@ -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