Files
Classroom_Desktop/lib/classroom_wonderink_workspace.py
T
ekklesiaandClaude Opus 5.5 b5d9535467 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]>
2026-09-26 21:12:17 -05:00

307 lines
9.9 KiB
Python

"""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