fix: a worksheet a lesson downloads comes back as a tab
Some Wonder Ink lesson links are a download rather than a page: the server sends the PDF as an attachment, so there is nothing for a tab to show and the file lands on disk. In this room that is the same as it not arriving. There is no file manager, no application launcher and no downloads bar, on purpose, and a teacher is not going to open a maintenance terminal to print a Christmas tree. 0.2.1 downloaded the worksheet, said so in a notification, and left it somewhere unreachable. Found on the canary machine with a real lesson. So a download that completes, and really is a PDF, opens as another tab. Rendering is the engine's own viewer — the same PDFium Chrome uses — so there is no second rendering library and no separate application. That was verified against a completed local file rather than assumed from the remote case: PdfViewerEnabled was already on, and a local file: URL is a different question. WHAT IS REFUSED. A completed download is not a usable worksheet. HTML that a server called .pdf, an empty file, an unreadable one: all refused on the first five bytes rather than on the name, because the alternative is a blank tab that says nothing. An interrupted download says it did not finish and to try again — and that needed stateChanged rather than isFinishedChanged, which is not emitted for an interrupted download at all. Connected to the obvious signal, this said nothing whatsoever about a worksheet that failed to arrive, which is the one case a teacher most needs told about. Measured. WHICH LOCAL FILES. Exactly the ones the window opened itself. Opening a worksheet adds one resolved path to a list, and the page's acceptNavigationRequest refuses every file: URL that is not on it. Chromium already refuses to let a remote page reach file: — verified with a link, a location.href, and a link to a worksheet that had been downloaded but not opened by the shell, all refused — so this is the second lock rather than the only one. It is here because the first one is somebody else's. Two Qt defaults are turned off beside it: LocalContentCanAccessFileUrls, which is on by default and would let a local document read other local documents, and its remote counterpart. Print to File is removed from the print dialog, because choosing it enables a path with a "..." button, which is a file chooser, in a room whose whole design is that there is no file chooser. The PDF viewer's own menus were audited: two-page view, annotations, present, properties, and nothing that opens or saves anywhere a teacher chooses. AND THE BROWSER IS NOT INVOLVED. The handler is connected to Wonder Ink's own profile, so it only ever sees downloads that started in a Wonder Ink tab. Nothing inspects a filename, nothing watches a directory, and Chrome — a separate application with a separate profile — is untouched: classroom-browser and the Chrome policies are byte-identical to stable 0.2.0. Worksheets go to ~/Downloads/Wonder Ink, which is tidiness and mostly the security argument: it is the only place one can come from. Only PDFs. Anything else downloads, says so, and is still unreachable; that limitation and two small follow-ups are written down rather than built, and nothing downloaded is ever executed. Measured from the package on a rebooted machine, and again on one upgraded from the published 0.2.1: the click-to-tab workflow end to end, a name with spaces and brackets, multi-page navigation, printing to Elmer, closing the worksheet without closing the lesson, and the four refusals. 120 convergence checks and 24 worksheet checks pass on both. Cookies are still session-only, the Present-view autoplay fix still works, fullscreen still comes back maximised, and there is still no address bar and no new-tab button. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
55a5aa3c99
commit
5f2a9098c1
+228
-43
@@ -79,6 +79,27 @@ CACHE_HOME = Path(
|
||||
PROFILE = DATA_HOME / "ekklesia-classroom" / "wonderink-shell"
|
||||
CACHE = CACHE_HOME / "ekklesia-classroom" / "wonderink-shell"
|
||||
|
||||
# Where a lesson's handouts land.
|
||||
#
|
||||
# A directory of Wonder Ink's own, inside the ordinary Downloads folder
|
||||
# rather than beside it, and the ordinary browser is not told about it and
|
||||
# does not use it. Two reasons, and the second is the one that matters:
|
||||
#
|
||||
# a teacher's worksheets are not mixed in with whatever the room's
|
||||
# shared browser has been asked to save; and
|
||||
#
|
||||
# it is the whole of the answer to "which local files may Wonder Ink
|
||||
# open". Nothing outside this directory is ever loaded, and the
|
||||
# navigation guard in LessonPage enforces that rather than trusting it.
|
||||
DOWNLOADS = Path(
|
||||
os.environ.get("XDG_DOWNLOAD_DIR") or Path.home() / "Downloads"
|
||||
) / "Wonder Ink"
|
||||
|
||||
# What a PDF starts with. A server that answers a worksheet link with an
|
||||
# HTML error page called .pdf is a real thing, and opening it would give a
|
||||
# teacher a blank tab with no way to tell why.
|
||||
PDF_MAGIC = b"%PDF-"
|
||||
|
||||
# Widevine, if this machine has it.
|
||||
#
|
||||
# Chrome is installed on every classroom machine for the ordinary
|
||||
@@ -102,7 +123,11 @@ if WIDEVINE.exists():
|
||||
from PyQt6.QtCore import QEvent, Qt, QUrl # noqa: E402
|
||||
from PyQt6.QtGui import QAction, QGuiApplication, QKeySequence # noqa: E402
|
||||
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
|
||||
from PyQt6.QtPrintSupport import QPrintDialog, QPrinter # noqa: E402
|
||||
from PyQt6.QtPrintSupport import ( # noqa: E402
|
||||
QAbstractPrintDialog,
|
||||
QPrintDialog,
|
||||
QPrinter,
|
||||
)
|
||||
from PyQt6.QtWebEngineCore import ( # noqa: E402
|
||||
QWebEngineDownloadRequest,
|
||||
QWebEnginePage,
|
||||
@@ -157,6 +182,38 @@ class LessonPage(QWebEnginePage):
|
||||
# would otherwise fill the journal on every page load.
|
||||
pass
|
||||
|
||||
def acceptNavigationRequest(self, url, navigation_type, is_main_frame):
|
||||
"""Which URLs this window is willing to go to at all.
|
||||
|
||||
Wonder Ink opens a teacher's downloaded worksheet by handing the
|
||||
tab a file: URL, so from this release the shell loads local files
|
||||
— and the moment it does, "which local files" stops being
|
||||
rhetorical. This is where it is answered.
|
||||
|
||||
A file: URL is accepted only if the window put that exact path on
|
||||
its own list, which it does for one thing: a PDF that its own
|
||||
download handler has just finished writing into DOWNLOADS. A
|
||||
lesson cannot navigate to one, a link cannot point at one, and a
|
||||
redirect cannot reach one, because none of those go through the
|
||||
list.
|
||||
|
||||
Chromium already refuses to let a remote page navigate to file:,
|
||||
so this is the second lock rather than the only one. It is here
|
||||
because the first one is somebody else's and this one is ours.
|
||||
"""
|
||||
if url.isLocalFile():
|
||||
allowed = self._window.may_open(Path(url.toLocalFile()))
|
||||
|
||||
if not allowed:
|
||||
say(f"refusing a local file this window did not open: {url.toLocalFile()}")
|
||||
|
||||
return allowed
|
||||
|
||||
# Everything a lesson legitimately does. Anything else — a mail
|
||||
# client, a chooser, some handler a desktop happens to register —
|
||||
# is not a thing a classroom appliance follows.
|
||||
return url.scheme() in ("http", "https", "about", "data", "blob", "chrome-extension")
|
||||
|
||||
|
||||
class LessonView(QWebEngineView):
|
||||
"""A page, and a right-click menu with nothing dangerous on it."""
|
||||
@@ -215,6 +272,14 @@ class WonderInk(QMainWindow):
|
||||
super().__init__()
|
||||
self._profile = profile
|
||||
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
|
||||
# that is not here cannot be loaded however it is asked for.
|
||||
self._openable = set()
|
||||
# Tabs showing a worksheet rather than a page. Their names come
|
||||
# from the file, because a PDF has no document title to take one
|
||||
# from, and their downloads are not re-opened as further tabs.
|
||||
self._pdf_views = {}
|
||||
|
||||
self.setWindowTitle("Wonder Ink")
|
||||
|
||||
@@ -274,6 +339,7 @@ class WonderInk(QMainWindow):
|
||||
return
|
||||
|
||||
self._tabs.removeTab(index)
|
||||
self._pdf_views.pop(view, None)
|
||||
view.page().deleteLater()
|
||||
view.deleteLater()
|
||||
self._show_tab_bar()
|
||||
@@ -291,7 +357,12 @@ class WonderInk(QMainWindow):
|
||||
if index < 0:
|
||||
return
|
||||
|
||||
title = (title or "Wonder Ink").strip()
|
||||
# A worksheet keeps the name it was downloaded as. Chromium's PDF
|
||||
# viewer leaves document.title empty, so left alone every PDF tab
|
||||
# would be called "Wonder Ink" and two of them would be
|
||||
# indistinguishable.
|
||||
title = self._pdf_views.get(view) or (title or "Wonder Ink")
|
||||
title = title.strip()
|
||||
short = title if len(title) <= TAB_TITLE else title[: TAB_TITLE - 1] + "…"
|
||||
self._tabs.setTabText(index, short)
|
||||
self._tabs.setTabToolTip(index, title)
|
||||
@@ -364,6 +435,125 @@ class WonderInk(QMainWindow):
|
||||
if count > 1:
|
||||
self._tabs.setCurrentIndex((self._tabs.currentIndex() - 1) % count)
|
||||
|
||||
# ---- a lesson's handouts --------------------------------------------
|
||||
|
||||
def may_open(self, path):
|
||||
"""Is this a local file the window itself decided to open?"""
|
||||
try:
|
||||
return path.resolve() in self._openable
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def on_download(self, download):
|
||||
"""A lesson handing the teacher a file.
|
||||
|
||||
Wonder Ink gives out leader guides and colouring pages, and some
|
||||
of its worksheet links are a download rather than a page — the
|
||||
server sends the PDF as an attachment, so there is nothing for a
|
||||
tab to show and the file simply lands on disk.
|
||||
|
||||
Which, in this room, is the same as it not arriving at all. There
|
||||
is no file manager, no application launcher and no downloads bar,
|
||||
on purpose, and a teacher is not going to open a maintenance
|
||||
terminal to print a Christmas tree. So a worksheet that finishes
|
||||
downloading is opened in a tab, which is where the teacher was
|
||||
looking anyway.
|
||||
"""
|
||||
download.setDownloadDirectory(str(DOWNLOADS))
|
||||
download.accept()
|
||||
|
||||
# Read now rather than in the callback: the request object is
|
||||
# deleted once it finishes, and reading it then is a crash.
|
||||
name = download.downloadFileName()
|
||||
mime = download.mimeType()
|
||||
# A save from the PDF viewer's own toolbar is a download whose
|
||||
# page is already a worksheet. Opening the copy would give a
|
||||
# teacher a second identical tab every time they pressed it.
|
||||
from_worksheet = download.page() in [
|
||||
view.page() for view in self._pdf_views
|
||||
]
|
||||
|
||||
# stateChanged, not isFinishedChanged.
|
||||
#
|
||||
# Measured: a download interrupted mid-transfer — the church wi-fi
|
||||
# dropping, a server hanging up — reaches
|
||||
# DownloadState.DownloadInterrupted and isFinishedChanged is never
|
||||
# emitted at all. Connected to that signal, Wonder Ink said
|
||||
# nothing whatsoever about a worksheet that did not arrive, which
|
||||
# is the one case a teacher most needs told about. stateChanged
|
||||
# fires for every terminal state, so it is the one to listen to.
|
||||
states = QWebEngineDownloadRequest.DownloadState
|
||||
settled = []
|
||||
|
||||
def state_changed(state):
|
||||
if settled or state == states.DownloadInProgress:
|
||||
return
|
||||
|
||||
settled.append(state)
|
||||
|
||||
if state == states.DownloadCancelled:
|
||||
return
|
||||
|
||||
if state == states.DownloadInterrupted:
|
||||
# interruptReasonString names which of the dozen ways it
|
||||
# went wrong, which is the difference between a teacher
|
||||
# retrying and a teacher giving up.
|
||||
notify(
|
||||
"The worksheet did not finish downloading",
|
||||
f"{name} — {download.interruptReasonString()}. Try again.",
|
||||
)
|
||||
return
|
||||
|
||||
path = DOWNLOADS / name
|
||||
|
||||
if not from_worksheet and self.open_worksheet(path, mime):
|
||||
return
|
||||
|
||||
notify("Saved", f"{name} is in {DOWNLOADS}")
|
||||
|
||||
download.stateChanged.connect(state_changed)
|
||||
|
||||
def open_worksheet(self, path, mime=""):
|
||||
"""Open a finished PDF in a tab. True if it was."""
|
||||
if mime != "application/pdf" and path.suffix.lower() != ".pdf":
|
||||
return False
|
||||
|
||||
# A completed download can still be a file worth refusing: an
|
||||
# error page the server called .pdf, or a nought-byte truncation.
|
||||
# The tab would come up blank and say nothing about why.
|
||||
try:
|
||||
with open(path, "rb") as handle:
|
||||
head = handle.read(len(PDF_MAGIC))
|
||||
except OSError as error:
|
||||
say(f"cannot read {path}: {error}")
|
||||
notify("Could not open the worksheet", f"{path.name} could not be read")
|
||||
return True
|
||||
|
||||
if head != PDF_MAGIC:
|
||||
say(f"not a PDF: {path}")
|
||||
notify(
|
||||
"Could not open the worksheet",
|
||||
f"{path.name} did not arrive as a PDF. It is saved in {DOWNLOADS}.",
|
||||
)
|
||||
return True
|
||||
|
||||
self.open_pdf(path)
|
||||
return True
|
||||
|
||||
def open_pdf(self, path):
|
||||
"""A worksheet, as a tab of its own."""
|
||||
resolved = path.resolve()
|
||||
|
||||
# The one place anything is added to the list the navigation
|
||||
# guard reads. Note what it is: a single resolved path, not a
|
||||
# directory and not a pattern.
|
||||
self._openable.add(resolved)
|
||||
|
||||
view = self.open_tab()
|
||||
self._pdf_views[view] = path.stem
|
||||
self._retitle(view, path.stem)
|
||||
view.load(QUrl.fromLocalFile(str(resolved)))
|
||||
|
||||
# ---- printing -------------------------------------------------------
|
||||
|
||||
def print_current(self):
|
||||
@@ -376,6 +566,18 @@ class WonderInk(QMainWindow):
|
||||
printer = QPrinter(QPrinter.PrinterMode.HighResolution)
|
||||
dialog = QPrintDialog(printer, self)
|
||||
|
||||
# No "Print to File".
|
||||
#
|
||||
# Qt offers it in every print dialog, and choosing it enables an
|
||||
# output path with a "..." button beside it — which is a file
|
||||
# chooser, in a room whose whole design is that there is no file
|
||||
# chooser. Nothing in a classroom wants a worksheet written to a
|
||||
# PDF nobody can then open; the room has a printer, and that is
|
||||
# what this dialog is for.
|
||||
dialog.setOption(
|
||||
QAbstractPrintDialog.PrintDialogOption.PrintToFile, False
|
||||
)
|
||||
|
||||
if dialog.exec() != QPrintDialog.DialogCode.Accepted:
|
||||
return
|
||||
|
||||
@@ -431,46 +633,6 @@ class WonderInk(QMainWindow):
|
||||
self._show_tab_bar()
|
||||
|
||||
|
||||
def downloads_to():
|
||||
"""Somewhere a download can land without asking a teacher anything."""
|
||||
target = Path(
|
||||
os.environ.get("XDG_DOWNLOAD_DIR") or Path.home() / "Downloads"
|
||||
)
|
||||
|
||||
try:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
target = Path.home()
|
||||
|
||||
return target
|
||||
|
||||
|
||||
def on_download(download):
|
||||
"""Accept it, put it somewhere sensible, and say so on screen.
|
||||
|
||||
Wonder Ink hands out leader guides and colouring pages, so a download
|
||||
that silently does nothing is a lesson a teacher cannot print. There
|
||||
is no downloads bar to click, so the notification is the feedback.
|
||||
"""
|
||||
target = downloads_to()
|
||||
download.setDownloadDirectory(str(target))
|
||||
download.accept()
|
||||
|
||||
name = download.downloadFileName()
|
||||
|
||||
def finished():
|
||||
state = download.state()
|
||||
|
||||
if state == QWebEngineDownloadRequest.DownloadState.DownloadCompleted:
|
||||
notify("Saved", f"{name} is in {target}")
|
||||
elif state == QWebEngineDownloadRequest.DownloadState.DownloadCancelled:
|
||||
pass
|
||||
else:
|
||||
notify("Download failed", name)
|
||||
|
||||
download.isFinishedChanged.connect(finished)
|
||||
|
||||
|
||||
def notify(summary, body):
|
||||
import subprocess
|
||||
|
||||
@@ -550,7 +712,6 @@ def main():
|
||||
QWebEngineProfile.PersistentCookiesPolicy.NoPersistentCookies
|
||||
)
|
||||
profile.setHttpUserAgent(plain_user_agent(profile))
|
||||
profile.downloadRequested.connect(on_download)
|
||||
# A lesson is not allowed to put a desktop notification on a classroom
|
||||
# television. The ordinary browser is told the same thing by policy.
|
||||
profile.setNotificationPresenter(lambda notification: None)
|
||||
@@ -586,11 +747,35 @@ def main():
|
||||
settings.setAttribute(
|
||||
QWebEngineSettings.WebAttribute.JavascriptCanOpenWindows, True
|
||||
)
|
||||
# A worksheet is rendered by the engine's own PDF viewer, which is
|
||||
# why there is no second rendering library in this room.
|
||||
settings.setAttribute(
|
||||
QWebEngineSettings.WebAttribute.PdfViewerEnabled, True
|
||||
)
|
||||
settings.setAttribute(
|
||||
QWebEngineSettings.WebAttribute.PluginsEnabled, True
|
||||
)
|
||||
# THE TWO DOORS A LOCAL FILE COULD OPEN.
|
||||
#
|
||||
# Qt leaves LocalContentCanAccessFileUrls on by default, which means a
|
||||
# local document may read other local documents. Nothing in this room
|
||||
# needs that — a PDF cannot use it anyway — and now that the shell
|
||||
# loads file: URLs at all, a default is not a good enough answer.
|
||||
# Both are named here so that the answer is in the file rather than in
|
||||
# a Qt release note.
|
||||
settings.setAttribute(
|
||||
QWebEngineSettings.WebAttribute.LocalContentCanAccessFileUrls, False
|
||||
)
|
||||
settings.setAttribute(
|
||||
QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls, False
|
||||
)
|
||||
|
||||
DOWNLOADS.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
window = WonderInk(profile)
|
||||
# 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)
|
||||
# An ordinary size before the maximised one, so the window has
|
||||
# something sensible to be if it is ever asked to stop being
|
||||
|
||||
Reference in New Issue
Block a user