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:
co-authored by
Claude Opus 5.5
parent
5fef4e02a9
commit
b5d9535467
@@ -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
@@ -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
|
||||
|
||||
Executable
+258
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user