#!/usr/bin/env python3 """Wonder Ink: an application with tabs, and nothing else a browser has. WHY THIS IS NOT CHROME ANY MORE. Wonder Ink used to be `google-chrome --app=`, which gave exactly the window a classroom wants — no tab strip, no address bar, reads as a program — and teachers then told us the thing app mode takes away is the thing they need. A lesson is prepared across several tabs: the lesson, its video, the activity. Chrome cannot be asked for "tabs but no omnibox". App mode has no tabs; a normal window has tabs and an address bar, and there is no policy, flag or extension that removes the address bar from it. Tabbed web apps come closest and need a manifest on the site, which is not ours, and they push off-origin links out into a normal browser window, which is the whole of what we are trying to avoid. So the tab strip is ours and the engine is Chromium either way. This is QtWebEngine, which Debian packages, updates with the rest of the system and — measured on Debian 13, because the usual objection to it is codecs — builds with H.264 and AAC: a 720p H.264 file decoded and played to the end in the lab, and MediaSource reports avc1, mp4a and vp9 all supported. app.wonderink.org itself loads and its sign-in is an ordinary email and password form, not a Google flow that would mind an embedded browser. WHAT A TEACHER GETS. [ Lesson ] [ Video ] [ Activity ] Tabs appear when there is more than one, each with a close button, and nothing else: no address bar, no search, no button that makes a new tab. A tab can only come from the lesson itself — a link the site opens in a new window, a middle click, a Ctrl+click. That is the seam this whole design turns on: Wonder Ink can take a teacher anywhere it needs to, including a video CDN it has never told us about, and a teacher cannot take themselves anywhere. There is no allowlist of domains, on purpose. A list would have to be right about every service a lesson might ever embed, and the day it is 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. """ import os import sys from pathlib import Path sys.path.insert(0, "/usr/local/lib/ekklesia-classroom") sys.path.insert( 0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"), ) from classroom_config import feature, read_device_config # noqa: E402 HOME_PAGE = "https://app.wonderink.org/" DATA_HOME = Path( os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share" ) CACHE_HOME = Path( os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache" ) # Not the old Chrome directory. Two engines' state in one directory is a # thing nobody can reason about later, and the Chrome profile is left # where it is so that a rollback to the previous release finds it intact. 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 # browser, and it brings a Widevine module with it. QtWebEngine will use # that module if it is told where it is, so a lesson whose video service # happens to want DRM is not stopped by the engine change. Absent — on a # machine with no Chrome — the flag is simply not passed and everything # that does not need Widevine is unaffected. WIDEVINE = Path( "/opt/google/chrome/WidevineCdm/_platform_specific/linux_x64/libwidevinecdm.so" ) # Has to be in the environment before QtWebEngine starts, which is why it # is here rather than further down with the rest of the setup. if WIDEVINE.exists(): flags = os.environ.get("QTWEBENGINE_CHROMIUM_FLAGS", "") os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = ( f"{flags} --widevine-path={WIDEVINE}".strip() ) 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 ( # noqa: E402 QAbstractPrintDialog, QPrintDialog, QPrinter, ) from PyQt6.QtWebEngineCore import ( # noqa: E402 QWebEngineDownloadRequest, QWebEnginePage, QWebEngineProfile, QWebEngineSettings, ) from PyQt6.QtWebEngineWidgets import QWebEngineView # noqa: E402 from PyQt6.QtWidgets import ( # noqa: E402 QApplication, QMainWindow, QMenu, QTabWidget, ) # One running Wonder Ink per session. A second QtWebEngine process on the # same profile directory would fight the first for its storage lock, and # the panel gives a teacher a button that is easy to press twice. SOCKET = "ekklesia-wonderink" # How much of a page title fits on a tab before it stops being a label # and starts being a paragraph. TAB_TITLE = 22 def say(message): print(f"classroom-wonderink: {message}", file=sys.stderr) class LessonPage(QWebEnginePage): """A page that answers "open this somewhere else" with a new tab. Every route a site has to a second window arrives here: target=_blank, window.open, a middle click, a Ctrl+click, and the context menu's open in new tab. Chrome's app mode answered all of them with a bare popup window; answering with a tab is the entire feature. """ def __init__(self, profile, window): super().__init__(profile, window) self._window = window def createWindow(self, window_type): background = ( window_type == QWebEnginePage.WebWindowType.WebBrowserBackgroundTab ) view = self._window.open_tab(focus=not background) return view.page() def javaScriptConsoleMessage(self, level, message, line, source): # A lesson's own console noise is not the classroom's business and # 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.""" def __init__(self, profile, window): super().__init__(window) self._window = window self.setPage(LessonPage(profile, window)) def contextMenuEvent(self, event): # Built rather than filtered. QtWebEngine's standard menu carries # view-source, save-page and open-in-new-*window* entries, and a # menu the classroom assembles itself cannot grow one of those # back in a future Qt release without somebody noticing here. menu = QMenu(self) for action in ( QWebEnginePage.WebAction.Back, QWebEnginePage.WebAction.Forward, QWebEnginePage.WebAction.Reload, ): menu.addAction(self.page().action(action)) request = self.lastContextMenuRequest() if request is not None and not request.linkUrl().isEmpty(): menu.addSeparator() open_tab = self.page().action( QWebEnginePage.WebAction.OpenLinkInNewTab ) open_tab.setText("Open in a new tab") menu.addAction(open_tab) if request is not None and request.isContentEditable(): menu.addSeparator() for action in ( QWebEnginePage.WebAction.Cut, QWebEnginePage.WebAction.Copy, QWebEnginePage.WebAction.Paste, ): menu.addAction(self.page().action(action)) elif request is not None and request.selectedText(): menu.addSeparator() menu.addAction(self.page().action(QWebEnginePage.WebAction.Copy)) menu.addSeparator() printing = QAction("Print…", menu) printing.triggered.connect(self._window.print_current) menu.addAction(printing) menu.popup(event.globalPos()) class WonderInk(QMainWindow): def __init__(self, profile): 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") self._tabs = QTabWidget(self) self._tabs.setDocumentMode(True) self._tabs.setTabsClosable(True) self._tabs.setMovable(True) # No corner widget. This is where a browser puts its new-tab # button, and the classroom deliberately has nowhere to press. self._tabs.setElideMode(Qt.TextElideMode.ElideRight) self._tabs.tabCloseRequested.connect(self.close_tab) self._tabs.currentChanged.connect(self._current_changed) self.setCentralWidget(self._tabs) self._shortcuts() # ---- tabs ---------------------------------------------------------- def open_tab(self, url=None, focus=True): view = LessonView(self._profile, self) view.titleChanged.connect(lambda t, v=view: self._retitle(v, t)) view.iconChanged.connect(lambda i, v=view: self._reicon(v, i)) view.page().fullScreenRequested.connect(self._fullscreen) view.page().windowCloseRequested.connect( lambda v=view: self.close_tab(self._tabs.indexOf(v)) ) # A lesson that calls window.print() itself. Measured in the lab: # without this the button on the page does nothing at all, which # is worse than no print button, because a teacher presses it and # concludes the printer is broken. Bound to the page that asked # rather than to whichever tab is in front. view.page().printRequested.connect(lambda v=view: self.print_view(v)) index = self._tabs.addTab(view, "…") if url is not None: view.load(QUrl(url)) if focus: self._tabs.setCurrentIndex(index) self._show_tab_bar() return view def close_tab(self, index): if index < 0: return view = self._tabs.widget(index) # The last tab is the application. Anything else and the lesson # carries on in the tabs that are left, which is the behaviour a # teacher expects and the reason tabs are worth having at all. if self._tabs.count() == 1: self.close() return self._tabs.removeTab(index) self._pdf_views.pop(view, None) view.page().deleteLater() view.deleteLater() self._show_tab_bar() def _show_tab_bar(self): # One tab is not a choice, so it is not drawn. A room with a # single lesson open looks exactly like the application it # replaced, and the strip appears the moment there is somewhere # to switch to. self._tabs.tabBar().setVisible(self._tabs.count() > 1) def _retitle(self, view, title): index = self._tabs.indexOf(view) if index < 0: return # 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) if index == self._tabs.currentIndex(): self.setWindowTitle(title) def _reicon(self, view, icon): index = self._tabs.indexOf(view) if index >= 0: self._tabs.setTabIcon(index, icon) def _current_changed(self, index): view = self._tabs.widget(index) if view is not None: self.setWindowTitle(view.title() or "Wonder Ink") view.setFocus() def current(self): return self._tabs.currentWidget() # ---- the keys a teacher might already know -------------------------- def _shortcuts(self): def bind(sequence, handler): action = QAction(self) action.setShortcut(QKeySequence(sequence)) action.setShortcutContext(Qt.ShortcutContext.WindowShortcut) action.triggered.connect(handler) self.addAction(action) return action # Deliberately absent: Ctrl+T, Ctrl+N, Ctrl+L. There is no new # tab, no new window and no address bar to focus. bind(QKeySequence.StandardKey.Close, self._close_current) bind(QKeySequence.StandardKey.Back, self._back) bind(QKeySequence.StandardKey.Forward, self._forward) bind(QKeySequence.StandardKey.Refresh, self._reload) bind(QKeySequence.StandardKey.Print, self.print_current) bind(QKeySequence.StandardKey.NextChild, self._next_tab) bind(QKeySequence.StandardKey.PreviousChild, self._previous_tab) bind("Ctrl+Shift+Tab", self._previous_tab) def _close_current(self): self.close_tab(self._tabs.currentIndex()) def _back(self): if self.current(): self.current().back() def _forward(self): if self.current(): self.current().forward() def _reload(self): if self.current(): self.current().reload() def _next_tab(self): count = self._tabs.count() if count > 1: self._tabs.setCurrentIndex((self._tabs.currentIndex() + 1) % count) def _previous_tab(self): count = self._tabs.count() 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): self.print_view(self.current()) def print_view(self, view): if view is None: return 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 # Qt's own dialog talks to CUPS, so a lesson prints to the room's # queue the same way everything else in the classroom does. view.print(printer) # ---- full screen ---------------------------------------------------- def _fullscreen(self, request): # A lesson's video filling a classroom television is the point of # the room, so this is always granted. The tab strip goes with it: # a video is not a window a teacher needs chrome around. request.accept() if request.toggleOn(): if self.isFullScreen(): return self._fullscreen_view = self.sender() self._tabs.tabBar().setVisible(False) self.showFullScreen() return self._fullscreen_view = None # Clearing the one state bit, rather than asking for maximised. # # showMaximized() here was measured to leave the window about # 200 pixels wide in the corner of the screen: it starts # maximised and so has no ordinary geometry to come back to, and # under KWin the fullscreen-to-maximised transition lands on # that empty geometry. Turning off the fullscreen bit and leaving # the maximised bit alone keeps the window it already had. self.setWindowState( self.windowState() & ~Qt.WindowState.WindowFullScreen ) # Belt and braces for a window that was somehow neither. if not self.isMaximized(): self.showMaximized() self._show_tab_bar() def changeEvent(self, event): # Leaving fullscreen by any route the page takes — its own button, # Escape, a script — comes through here, so the tab strip comes # back even when the request above is not what ended it. super().changeEvent(event) if event.type() == QEvent.Type.WindowStateChange: if not self.isFullScreen() and self._fullscreen_view is None: self._show_tab_bar() def notify(summary, body): import subprocess try: subprocess.Popen( [ "notify-send", "--app-name=Wonder Ink", "--icon=ekklesia-wonderink", summary, body, ] ) except OSError: say(f"{summary}: {body}") def plain_user_agent(profile): """Chrome's user agent, without the token that says this is not it. QtWebEngine advertises "QtWebEngine/6.8.2 Chrome/122...", and a site that gates on it — a sign-in page deciding whether it trusts the browser, a video service picking a stream — sees something it has never heard of. The engine really is that Chromium, so the honest string is the one without the extra token. """ return " ".join( part for part in profile.httpUserAgent().split() if not part.startswith("QtWebEngine/") ) def raise_running_instance(): """Ask an already-running Wonder Ink to come forward. True if it did.""" socket = QLocalSocket() socket.connectToServer(SOCKET) if not socket.waitForConnected(500): return False socket.write(b"raise\n") socket.waitForBytesWritten(500) socket.disconnectFromServer() return True def main(): config = read_device_config() # A device that has no Wonder Ink has no Wonder Ink. The panel does not # offer it, so nothing should reach here; saying it out loud is for the # technician who ran the command by hand and wants to know why. # feature(), not enabled(..., default=True). The default belongs in # classroom_config.FEATURES with every other application's, so that # the panel and the command can never disagree about whether a device # offers this — which is the whole reason that table exists. if not feature(config, "WONDERINK_ENABLED"): say("WONDERINK_ENABLED is false on this device; not starting") return 0 if raise_running_instance(): return 0 QGuiApplication.setDesktopFileName("ekklesia-wonderink") app = QApplication(sys.argv) app.setApplicationName("Wonder Ink") # The name the panel matches a window to its launcher by. Wayland has # no window class, so this is the app id, and it has to agree with # StartupWMClass in ekklesia-wonderink.desktop exactly. app.setDesktopFileName("ekklesia-wonderink") PROFILE.mkdir(parents=True, exist_ok=True) CACHE.mkdir(parents=True, exist_ok=True) profile = QWebEngineProfile("wonderink", app) 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. profile.setPersistentCookiesPolicy( QWebEngineProfile.PersistentCookiesPolicy.NoPersistentCookies ) profile.setHttpUserAgent(plain_user_agent(profile)) # 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) settings = profile.settings() settings.setAttribute( QWebEngineSettings.WebAttribute.FullScreenSupportEnabled, True ) # A lesson may start its own video. # # Chrome refuses to begin audible playback that no click asked for, # and Wonder Ink's Present view was measured failing on exactly that: # the same video plays a click earlier in the lesson view and stops # dead when Present starts it, because the gesture from the Present # click does not carry through the view transition. See # docs/wonder-ink.md for the investigation. # # The fix was written down there and deliberately not applied, because # in Chrome the only lever was AutoplayAllowlist on the policy that # governs the ordinary browser as well. Here there is no such # problem: this profile is Wonder Ink and nothing else, so allowing a # lesson to start its own video allows exactly that and reaches no # other page in the room. The browser is a separate application with # a separate policy and is not touched. settings.setAttribute( QWebEngineSettings.WebAttribute.PlaybackRequiresUserGesture, False ) settings.setAttribute( QWebEngineSettings.WebAttribute.ScreenCaptureEnabled, False ) # No view-source, no developer tools, nothing that turns a lesson into # a way out of the appliance. 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 # maximised. A window that has only ever been maximised has no # geometry of its own and comes back as a stub. window.resize(1280, 800) window.showMaximized() # The other half of the single instance: a second press of the panel # button connects here and the window comes forward instead of a # second Wonder Ink starting on the same profile. QLocalServer.removeServer(SOCKET) server = QLocalServer(app) server.listen(SOCKET) def come_forward(): connection = server.nextPendingConnection() connection.disconnectFromServer() if window.isMinimized(): window.showNormal() window.raise_() window.activateWindow() server.newConnection.connect(come_forward) return app.exec() if __name__ == "__main__": sys.exit(main())