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]>
307 lines
9.9 KiB
Python
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
|