Sat, 23 Dec 2023 15:48:12 +0100
Updated copyright for 2024.
# -*- coding: utf-8 -*- # Copyright (c) 2012 - 2024 Detlev Offenbach <detlev@die-offenbachs.de> # """ Module implementing the GreaseMonkey script. """ import re from PyQt6.QtCore import ( QByteArray, QCryptographicHash, QObject, QUrl, pyqtSignal, pyqtSlot, ) from PyQt6.QtGui import QIcon, QImage, QPixmap from PyQt6.QtNetwork import QNetworkReply, QNetworkRequest from PyQt6.QtWebEngineCore import QWebEngineScript from ..Tools.DelayedFileWatcher import DelayedFileWatcher from ..WebBrowserPage import WebBrowserPage from ..WebBrowserWindow import WebBrowserWindow from .GreaseMonkeyDownloader import GreaseMonkeyDownloader from .GreaseMonkeyJavaScript import bootstrap_js, values_js class GreaseMonkeyScript(QObject): """ Class implementing the GreaseMonkey script. @signal scriptChanged() emitted to indicate a script change @signal updatingChanged(bool) emitted to indicate a change of the updating state """ DocumentStart = 0 DocumentEnd = 1 DocumentIdle = 2 scriptChanged = pyqtSignal() updatingChanged = pyqtSignal(bool) def __init__(self, manager, path): """ Constructor @param manager reference to the manager object @type GreaseMonkeyManager @param path path of the Javascript file @type str """ super().__init__(manager) self.__manager = manager self.__fileWatcher = DelayedFileWatcher(parent=None) self.__name = "" self.__namespace = "GreaseMonkeyNS" self.__description = "" self.__version = "" self.__include = [] self.__exclude = [] self.__require = [] self.__icon = QIcon() self.__iconUrl = QUrl() self.__downloadUrl = QUrl() self.__updateUrl = QUrl() self.__startAt = GreaseMonkeyScript.DocumentEnd self.__script = "" self.__fileName = path self.__enabled = True self.__valid = False self.__noFrames = False self.__updating = False self.__downloaders = [] self.__iconReplies = [] self.__parseScript() self.__fileWatcher.delayedFileChanged.connect(self.__watchedFileChanged) def isValid(self): """ Public method to check the validity of the script. @return flag indicating a valid script @rtype bool """ return self.__valid def name(self): """ Public method to get the name of the script. @return name of the script @rtype str """ return self.__name def nameSpace(self): """ Public method to get the name space of the script. @return name space of the script @rtype str """ return self.__namespace def fullName(self): """ Public method to get the full name of the script. @return full name of the script @rtype str """ return "{0}/{1}".format(self.__namespace, self.__name) def description(self): """ Public method to get the description of the script. @return description of the script @rtype str """ return self.__description def version(self): """ Public method to get the version of the script. @return version of the script @rtype str """ return self.__version def icon(self): """ Public method to get the icon of the script. @return script icon @rtype QIcon """ return self.__icon def iconUrl(self): """ Public method to get the icon URL of the script. @return icon URL of the script @rtype QUrl """ return QUrl(self.__iconUrl) def downloadUrl(self): """ Public method to get the download URL of the script. @return download URL of the script @rtype QUrl """ return QUrl(self.__downloadUrl) def updateUrl(self): """ Public method to get the update URL of the script. @return update URL of the script @rtype QUrl """ return QUrl(self.__updateUrl) def startAt(self): """ Public method to get the start point of the script. @return start point of the script @rtype DocumentStart or DocumentEnd """ return self.__startAt def noFrames(self): """ Public method to get the noFrames flag. @return flag indicating to not run on sub frames @rtype bool """ return self.__noFrames def isEnabled(self): """ Public method to check, if the script is enabled. @return flag indicating an enabled state @rtype bool """ return self.__enabled and self.__valid def setEnabled(self, enable): """ Public method to enable a script. @param enable flag indicating the new enabled state @type bool """ self.__enabled = enable def include(self): """ Public method to get the list of included URLs. @return list of included URLs @rtype list of str """ return self.__include[:] def exclude(self): """ Public method to get the list of excluded URLs. @return list of excluded URLs @rtype list of str """ return self.__exclude[:] def require(self): """ Public method to get the list of required scripts. @return list of required scripts @rtype list of str """ return self.__require[:] def fileName(self): """ Public method to get the path of the Javascript file. @return path of the Javascript file @rtype str """ return self.__fileName def isUpdating(self): """ Public method to get the updating flag. @return updating flag @rtype bool """ return self.__updating @pyqtSlot(str) def __watchedFileChanged(self, fileName): """ Private slot handling changes of the script file. @param fileName path of the script file @type str """ if self.__fileName == fileName: self.__reloadScript() def __parseScript(self): """ Private method to parse the given script and populate the data structure. """ self.__name = "" self.__namespace = "GreaseMonkeyNS" self.__description = "" self.__version = "" self.__include = [] self.__exclude = [] self.__require = [] self.__icon = QIcon() self.__iconUrl = QUrl() self.__downloadUrl = QUrl() self.__updateUrl = QUrl() self.__startAt = GreaseMonkeyScript.DocumentEnd self.__script = "" self.__enabled = True self.__valid = False self.__noFrames = False try: with open(self.__fileName, "r", encoding="utf-8") as f: fileData = f.read() except OSError: # silently ignore because it shouldn't happen return if self.__fileName not in self.__fileWatcher.files(): self.__fileWatcher.addPath(self.__fileName) rx = re.compile(r"""// ==UserScript==(.*)// ==/UserScript==""", re.DOTALL) match = rx.search(fileData) if match is None: # invalid script file return metaDataBlock = match.group(1).strip() if metaDataBlock == "": # invalid script file return for line in metaDataBlock.splitlines(): if not line.strip(): continue if not line.startswith("// @"): continue line = line[3:].replace("\t", " ") index = line.find(" ") key = line[:index].strip() value = line[index + 1 :].strip() if index > 0 else "" if not key: continue if key == "@name": self.__name = value elif key == "@namespace": self.__namespace = value elif key == "@description": self.__description = value elif key == "@version": self.__version = value elif key in ["@include", "@match"]: self.__include.append(value) elif key in ["@exclude", "@exclude_match"]: self.__exclude.append(value) elif key == "@require": self.__require.append(value) elif key == "@run-at": if value == "document-end": self.__startAt = GreaseMonkeyScript.DocumentEnd elif value == "document-start": self.__startAt = GreaseMonkeyScript.DocumentStart elif value == "document-idle": self.__startAt = GreaseMonkeyScript.DocumentIdle elif key == "@downloadURL" and self.__downloadUrl.isEmpty(): self.__downloadUrl = QUrl(value) elif key == "@updateURL" and self.__updateUrl.isEmpty(): self.__updateUrl = QUrl(value) elif key == "@icon": self.__iconUrl = QUrl(value) elif key == "@noframes": self.__noFrames = True self.__iconUrl = self.__downloadUrl.resolved(self.__iconUrl) if not self.__include: self.__include.append("*") nspace = bytes( QCryptographicHash.hash( QByteArray(self.fullName().encode("utf-8")), QCryptographicHash.Algorithm.Md4, ).toHex() ).decode("ascii") valuesScript = values_js.format(nspace) self.__script = "(function(){{{0}\n{1}\n{2}\n}})();".format( valuesScript, self.__manager.requireScripts(self.__require), fileData ) self.__valid = True self.__downloadIcon() self.__downloadRequires() def webScript(self): """ Public method to create a script object. @return prepared script object @rtype QWebEngineScript """ script = QWebEngineScript() script.setSourceCode("{0}\n{1}".format(bootstrap_js, self.__script)) script.setName(self.fullName()) script.setWorldId(WebBrowserPage.SafeJsWorld) script.setRunsOnSubFrames(not self.__noFrames) return script def updateScript(self): """ Public method to updated the script. """ if not self.__downloadUrl.isValid() or self.__updating: return self.__updating = True self.updatingChanged.emit(self.__updating) downloader = GreaseMonkeyDownloader( self.__downloadUrl, self.__manager, GreaseMonkeyDownloader.DownloadMainScript, ) downloader.updateScript(self.__fileName) downloader.finished.connect(lambda: self.__downloaderFinished(downloader)) downloader.error.connect(lambda: self.__downloaderError(downloader)) self.__downloaders.append(downloader) self.__downloadRequires() def __downloaderFinished(self, downloader): """ Private slot to handle a finished download. @param downloader reference to the downloader object @type GreaseMonkeyDownloader """ if downloader in self.__downloaders: self.__downloaders.remove(downloader) self.__updating = False self.updatingChanged.emit(self.__updating) def __downloaderError(self, downloader): """ Private slot to handle a downloader error. @param downloader reference to the downloader object @type GreaseMonkeyDownloader """ if downloader in self.__downloaders: self.__downloaders.remove(downloader) self.__updating = False self.updatingChanged.emit(self.__updating) def __reloadScript(self): """ Private method to reload the script. """ self.__parseScript() self.__manager.removeScript(self, False) self.__manager.addScript(self) self.scriptChanged.emit() def __downloadRequires(self): """ Private method to download the required scripts. """ for urlStr in self.__require: if not self.__manager.requireScripts([urlStr]): downloader = GreaseMonkeyDownloader( QUrl(urlStr), self.__manager, GreaseMonkeyDownloader.DownloadRequireScript, ) downloader.finished.connect( lambda: self.__requireDownloaded(downloader) ) downloader.error.connect( lambda: self.__requireDownloadError(downloader) ) self.__downloaders.append(downloader) def __requireDownloaded(self, downloader): """ Private slot to handle a finished download of a required script. @param downloader reference to the downloader object @type GreaseMonkeyDownloader """ if downloader in self.__downloaders: self.__downloaders.remove(downloader) self.__reloadScript() def __requireDownloadError(self, downloader): """ Private slot to handle a downloader error. @param downloader reference to the downloader object @type GreaseMonkeyDownloader """ if downloader in self.__downloaders: self.__downloaders.remove(downloader) def __downloadIcon(self): """ Private slot to download the script icon. """ if self.__iconUrl.isValid(): request = QNetworkRequest(self.__iconUrl) reply = WebBrowserWindow.networkManager().get(request) reply.finished.connect(lambda: self.__iconDownloaded(reply)) self.__iconReplies.append(reply) def __iconDownloaded(self, reply): """ Private slot to handle a finished download of a script icon. @param reply reference to the network reply @type QNetworkReply """ if reply in self.__iconReplies: self.__iconReplies.remove(reply) reply.deleteLater() if reply.error() == QNetworkReply.NetworkError.NoError: self.__icon = QPixmap.fromImage(QImage.fromData(reply.readAll()))