|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2012 - 2021 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing the GreaseMonkey script. |
|
8 """ |
|
9 |
|
10 import re |
|
11 |
|
12 from PyQt5.QtCore import ( |
|
13 pyqtSignal, pyqtSlot, QObject, QUrl, QByteArray, QCryptographicHash |
|
14 ) |
|
15 from PyQt5.QtGui import QIcon, QPixmap, QImage |
|
16 from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply |
|
17 from PyQt5.QtWebEngineWidgets import QWebEngineScript |
|
18 |
|
19 from .GreaseMonkeyJavaScript import bootstrap_js, values_js |
|
20 from .GreaseMonkeyDownloader import GreaseMonkeyDownloader |
|
21 |
|
22 from ..Tools.DelayedFileWatcher import DelayedFileWatcher |
|
23 from ..WebBrowserPage import WebBrowserPage |
|
24 from ..WebBrowserWindow import WebBrowserWindow |
|
25 |
|
26 |
|
27 class GreaseMonkeyScript(QObject): |
|
28 """ |
|
29 Class implementing the GreaseMonkey script. |
|
30 |
|
31 @signal scriptChanged() emitted to indicate a script change |
|
32 @signal updatingChanged(bool) emitted to indicate a change of the |
|
33 updating state |
|
34 """ |
|
35 DocumentStart = 0 |
|
36 DocumentEnd = 1 |
|
37 DocumentIdle = 2 |
|
38 |
|
39 scriptChanged = pyqtSignal() |
|
40 updatingChanged = pyqtSignal(bool) |
|
41 |
|
42 def __init__(self, manager, path): |
|
43 """ |
|
44 Constructor |
|
45 |
|
46 @param manager reference to the manager object (GreaseMonkeyManager) |
|
47 @param path path of the Javascript file (string) |
|
48 """ |
|
49 super().__init__(manager) |
|
50 |
|
51 self.__manager = manager |
|
52 self.__fileWatcher = DelayedFileWatcher(parent=None) |
|
53 |
|
54 self.__name = "" |
|
55 self.__namespace = "GreaseMonkeyNS" |
|
56 self.__description = "" |
|
57 self.__version = "" |
|
58 |
|
59 self.__include = [] |
|
60 self.__exclude = [] |
|
61 self.__require = [] |
|
62 |
|
63 self.__icon = QIcon() |
|
64 self.__iconUrl = QUrl() |
|
65 self.__downloadUrl = QUrl() |
|
66 self.__updateUrl = QUrl() |
|
67 self.__startAt = GreaseMonkeyScript.DocumentEnd |
|
68 |
|
69 self.__script = "" |
|
70 self.__fileName = path |
|
71 self.__enabled = True |
|
72 self.__valid = False |
|
73 self.__noFrames = False |
|
74 |
|
75 self.__updating = False |
|
76 |
|
77 self.__downloaders = [] |
|
78 self.__iconReplies = [] |
|
79 |
|
80 self.__parseScript() |
|
81 |
|
82 self.__fileWatcher.delayedFileChanged.connect( |
|
83 self.__watchedFileChanged) |
|
84 |
|
85 def isValid(self): |
|
86 """ |
|
87 Public method to check the validity of the script. |
|
88 |
|
89 @return flag indicating a valid script (boolean) |
|
90 """ |
|
91 return self.__valid |
|
92 |
|
93 def name(self): |
|
94 """ |
|
95 Public method to get the name of the script. |
|
96 |
|
97 @return name of the script (string) |
|
98 """ |
|
99 return self.__name |
|
100 |
|
101 def nameSpace(self): |
|
102 """ |
|
103 Public method to get the name space of the script. |
|
104 |
|
105 @return name space of the script (string) |
|
106 """ |
|
107 return self.__namespace |
|
108 |
|
109 def fullName(self): |
|
110 """ |
|
111 Public method to get the full name of the script. |
|
112 |
|
113 @return full name of the script (string) |
|
114 """ |
|
115 return "{0}/{1}".format(self.__namespace, self.__name) |
|
116 |
|
117 def description(self): |
|
118 """ |
|
119 Public method to get the description of the script. |
|
120 |
|
121 @return description of the script (string) |
|
122 """ |
|
123 return self.__description |
|
124 |
|
125 def version(self): |
|
126 """ |
|
127 Public method to get the version of the script. |
|
128 |
|
129 @return version of the script (string) |
|
130 """ |
|
131 return self.__version |
|
132 |
|
133 def icon(self): |
|
134 """ |
|
135 Public method to get the icon of the script. |
|
136 |
|
137 @return script icon |
|
138 @rtype QIcon |
|
139 """ |
|
140 return self.__icon |
|
141 |
|
142 def iconUrl(self): |
|
143 """ |
|
144 Public method to get the icon URL of the script. |
|
145 |
|
146 @return icon URL of the script (QUrl) |
|
147 """ |
|
148 return QUrl(self.__iconUrl) |
|
149 |
|
150 def downloadUrl(self): |
|
151 """ |
|
152 Public method to get the download URL of the script. |
|
153 |
|
154 @return download URL of the script (QUrl) |
|
155 """ |
|
156 return QUrl(self.__downloadUrl) |
|
157 |
|
158 def updateUrl(self): |
|
159 """ |
|
160 Public method to get the update URL of the script. |
|
161 |
|
162 @return update URL of the script (QUrl) |
|
163 """ |
|
164 return QUrl(self.__updateUrl) |
|
165 |
|
166 def startAt(self): |
|
167 """ |
|
168 Public method to get the start point of the script. |
|
169 |
|
170 @return start point of the script (DocumentStart or DocumentEnd) |
|
171 """ |
|
172 return self.__startAt |
|
173 |
|
174 def noFrames(self): |
|
175 """ |
|
176 Public method to get the noFrames flag. |
|
177 |
|
178 @return flag indicating to not run on sub frames |
|
179 @rtype bool |
|
180 """ |
|
181 return self.__noFrames |
|
182 |
|
183 def isEnabled(self): |
|
184 """ |
|
185 Public method to check, if the script is enabled. |
|
186 |
|
187 @return flag indicating an enabled state (boolean) |
|
188 """ |
|
189 return self.__enabled and self.__valid |
|
190 |
|
191 def setEnabled(self, enable): |
|
192 """ |
|
193 Public method to enable a script. |
|
194 |
|
195 @param enable flag indicating the new enabled state (boolean) |
|
196 """ |
|
197 self.__enabled = enable |
|
198 |
|
199 def include(self): |
|
200 """ |
|
201 Public method to get the list of included URLs. |
|
202 |
|
203 @return list of included URLs (list of strings) |
|
204 """ |
|
205 return self.__include[:] |
|
206 |
|
207 def exclude(self): |
|
208 """ |
|
209 Public method to get the list of excluded URLs. |
|
210 |
|
211 @return list of excluded URLs (list of strings) |
|
212 """ |
|
213 return self.__exclude[:] |
|
214 |
|
215 def require(self): |
|
216 """ |
|
217 Public method to get the list of required scripts. |
|
218 |
|
219 @return list of required scripts (list of strings) |
|
220 """ |
|
221 return self.__require[:] |
|
222 |
|
223 def fileName(self): |
|
224 """ |
|
225 Public method to get the path of the Javascript file. |
|
226 |
|
227 @return path of the Javascript file (string) |
|
228 """ |
|
229 return self.__fileName |
|
230 |
|
231 def isUpdating(self): |
|
232 """ |
|
233 Public method to get the updating flag. |
|
234 |
|
235 @return updating flag |
|
236 @rtype bool |
|
237 """ |
|
238 return self.__updating |
|
239 |
|
240 @pyqtSlot(str) |
|
241 def __watchedFileChanged(self, fileName): |
|
242 """ |
|
243 Private slot handling changes of the script file. |
|
244 |
|
245 @param fileName path of the script file |
|
246 @type str |
|
247 """ |
|
248 if self.__fileName == fileName: |
|
249 self.__reloadScript() |
|
250 |
|
251 def __parseScript(self): |
|
252 """ |
|
253 Private method to parse the given script and populate the data |
|
254 structure. |
|
255 """ |
|
256 self.__name = "" |
|
257 self.__namespace = "GreaseMonkeyNS" |
|
258 self.__description = "" |
|
259 self.__version = "" |
|
260 |
|
261 self.__include = [] |
|
262 self.__exclude = [] |
|
263 self.__require = [] |
|
264 |
|
265 self.__icon = QIcon() |
|
266 self.__iconUrl = QUrl() |
|
267 self.__downloadUrl = QUrl() |
|
268 self.__updateUrl = QUrl() |
|
269 self.__startAt = GreaseMonkeyScript.DocumentEnd |
|
270 |
|
271 self.__script = "" |
|
272 self.__enabled = True |
|
273 self.__valid = False |
|
274 self.__noFrames = False |
|
275 |
|
276 try: |
|
277 with open(self.__fileName, "r", encoding="utf-8") as f: |
|
278 fileData = f.read() |
|
279 except OSError: |
|
280 # silently ignore because it shouldn't happen |
|
281 return |
|
282 |
|
283 if self.__fileName not in self.__fileWatcher.files(): |
|
284 self.__fileWatcher.addPath(self.__fileName) |
|
285 |
|
286 rx = re.compile("// ==UserScript==(.*)// ==/UserScript==") |
|
287 match = rx.search(fileData) |
|
288 if match is None: |
|
289 # invalid script file |
|
290 return |
|
291 |
|
292 metaDataBlock = match.group(1).strip() |
|
293 if metaDataBlock == "": |
|
294 # invalid script file |
|
295 return |
|
296 |
|
297 for line in metaDataBlock.splitlines(): |
|
298 if not line.strip(): |
|
299 continue |
|
300 |
|
301 if not line.startswith("// @"): |
|
302 continue |
|
303 |
|
304 line = line[3:].replace("\t", " ") |
|
305 index = line.find(" ") |
|
306 |
|
307 key = line[:index].strip() |
|
308 value = line[index + 1:].strip() if index > 0 else "" |
|
309 |
|
310 if not key: |
|
311 continue |
|
312 |
|
313 if key == "@name": |
|
314 self.__name = value |
|
315 |
|
316 elif key == "@namespace": |
|
317 self.__namespace = value |
|
318 |
|
319 elif key == "@description": |
|
320 self.__description = value |
|
321 |
|
322 elif key == "@version": |
|
323 self.__version = value |
|
324 |
|
325 elif key in ["@include", "@match"]: |
|
326 self.__include.append(value) |
|
327 |
|
328 elif key in ["@exclude", "@exclude_match"]: |
|
329 self.__exclude.append(value) |
|
330 |
|
331 elif key == "@require": |
|
332 self.__require.append(value) |
|
333 |
|
334 elif key == "@run-at": |
|
335 if value == "document-end": |
|
336 self.__startAt = GreaseMonkeyScript.DocumentEnd |
|
337 elif value == "document-start": |
|
338 self.__startAt = GreaseMonkeyScript.DocumentStart |
|
339 elif value == "document-idle": |
|
340 self.__startAt = GreaseMonkeyScript.DocumentIdle |
|
341 |
|
342 elif key == "@downloadURL" and self.__downloadUrl.isEmpty(): |
|
343 self.__downloadUrl = QUrl(value) |
|
344 |
|
345 elif key == "@updateURL" and self.__updateUrl.isEmpty(): |
|
346 self.__updateUrl = QUrl(value) |
|
347 |
|
348 elif key == "@icon": |
|
349 self.__iconUrl = QUrl(value) |
|
350 |
|
351 elif key == "@noframes": |
|
352 self.__noFrames = True |
|
353 |
|
354 self.__iconUrl = self.__downloadUrl.resolved(self.__iconUrl) |
|
355 |
|
356 if not self.__include: |
|
357 self.__include.append("*") |
|
358 |
|
359 nspace = bytes(QCryptographicHash.hash( |
|
360 QByteArray(self.fullName().encode("utf-8")), |
|
361 QCryptographicHash.Algorithm.Md4).toHex()).decode("ascii") |
|
362 valuesScript = values_js.format(nspace) |
|
363 self.__script = "(function(){{{0}\n{1}\n{2}\n}})();".format( |
|
364 valuesScript, self.__manager.requireScripts(self.__require), |
|
365 fileData |
|
366 ) |
|
367 self.__valid = True |
|
368 |
|
369 self.__downloadIcon() |
|
370 self.__downloadRequires() |
|
371 |
|
372 def webScript(self): |
|
373 """ |
|
374 Public method to create a script object. |
|
375 |
|
376 @return prepared script object |
|
377 @rtype QWebEngineScript |
|
378 """ |
|
379 script = QWebEngineScript() |
|
380 script.setSourceCode("{0}\n{1}".format( |
|
381 bootstrap_js, self.__script |
|
382 )) |
|
383 script.setName(self.fullName()) |
|
384 script.setWorldId(WebBrowserPage.SafeJsWorld) |
|
385 script.setRunsOnSubFrames(not self.__noFrames) |
|
386 return script |
|
387 |
|
388 def updateScript(self): |
|
389 """ |
|
390 Public method to updated the script. |
|
391 """ |
|
392 if not self.__downloadUrl.isValid() or self.__updating: |
|
393 return |
|
394 |
|
395 self.__updating = True |
|
396 self.updatingChanged.emit(self.__updating) |
|
397 |
|
398 downloader = GreaseMonkeyDownloader( |
|
399 self.__downloadUrl, |
|
400 self.__manager, |
|
401 GreaseMonkeyDownloader.DownloadMainScript) |
|
402 downloader.updateScript(self.__fileName) |
|
403 downloader.finished.connect( |
|
404 lambda: self.__downloaderFinished(downloader)) |
|
405 downloader.error.connect( |
|
406 lambda: self.__downloaderError(downloader)) |
|
407 self.__downloaders.append(downloader) |
|
408 |
|
409 self.__downloadRequires() |
|
410 |
|
411 def __downloaderFinished(self, downloader): |
|
412 """ |
|
413 Private slot to handle a finished download. |
|
414 |
|
415 @param downloader reference to the downloader object |
|
416 @type GreaseMonkeyDownloader |
|
417 """ |
|
418 if downloader in self.__downloaders: |
|
419 self.__downloaders.remove(downloader) |
|
420 self.__updating = False |
|
421 self.updatingChanged.emit(self.__updating) |
|
422 |
|
423 def __downloaderError(self, downloader): |
|
424 """ |
|
425 Private slot to handle a downloader error. |
|
426 |
|
427 @param downloader reference to the downloader object |
|
428 @type GreaseMonkeyDownloader |
|
429 """ |
|
430 if downloader in self.__downloaders: |
|
431 self.__downloaders.remove(downloader) |
|
432 self.__updating = False |
|
433 self.updatingChanged.emit(self.__updating) |
|
434 |
|
435 def __reloadScript(self): |
|
436 """ |
|
437 Private method to reload the script. |
|
438 """ |
|
439 self.__parseScript() |
|
440 |
|
441 self.__manager.removeScript(self, False) |
|
442 self.__manager.addScript(self) |
|
443 |
|
444 self.scriptChanged.emit() |
|
445 |
|
446 def __downloadRequires(self): |
|
447 """ |
|
448 Private method to download the required scripts. |
|
449 """ |
|
450 for urlStr in self.__require: |
|
451 if not self.__manager.requireScripts([urlStr]): |
|
452 downloader = GreaseMonkeyDownloader( |
|
453 QUrl(urlStr), |
|
454 self.__manager, |
|
455 GreaseMonkeyDownloader.DownloadRequireScript) |
|
456 downloader.finished.connect( |
|
457 lambda: self.__requireDownloaded(downloader)) |
|
458 downloader.error.connect( |
|
459 lambda: self.__requireDownloadError(downloader)) |
|
460 self.__downloaders.append(downloader) |
|
461 |
|
462 def __requireDownloaded(self, downloader): |
|
463 """ |
|
464 Private slot to handle a finished download of a required script. |
|
465 |
|
466 @param downloader reference to the downloader object |
|
467 @type GreaseMonkeyDownloader |
|
468 """ |
|
469 if downloader in self.__downloaders: |
|
470 self.__downloaders.remove(downloader) |
|
471 |
|
472 self.__reloadScript() |
|
473 |
|
474 def __requireDownloadError(self, downloader): |
|
475 """ |
|
476 Private slot to handle a downloader error. |
|
477 |
|
478 @param downloader reference to the downloader object |
|
479 @type GreaseMonkeyDownloader |
|
480 """ |
|
481 if downloader in self.__downloaders: |
|
482 self.__downloaders.remove(downloader) |
|
483 |
|
484 def __downloadIcon(self): |
|
485 """ |
|
486 Private slot to download the script icon. |
|
487 """ |
|
488 if self.__iconUrl.isValid(): |
|
489 request = QNetworkRequest(self.__iconUrl) |
|
490 reply = WebBrowserWindow.networkManager().get(request) |
|
491 reply.finished.connect(lambda: self.__iconDownloaded(reply)) |
|
492 self.__iconReplies.append(reply) |
|
493 |
|
494 def __iconDownloaded(self, reply): |
|
495 """ |
|
496 Private slot to handle a finished download of a script icon. |
|
497 |
|
498 @param reply reference to the network reply |
|
499 @type QNetworkReply |
|
500 """ |
|
501 if reply in self.__iconReplies: |
|
502 self.__iconReplies.remove(reply) |
|
503 |
|
504 reply.deleteLater() |
|
505 if reply.error() == QNetworkReply.NetworkError.NoError: |
|
506 self.__icon = QPixmap.fromImage(QImage.fromData(reply.readAll())) |