Add greasemonkey compatible userscript module.
WebKit backend only for now. Loads all .js files from a directory, specified in the greasemonkey-directory key in the storage section, defaulting to data/greasemonkey, and wraps them in a minimal environment providing some GM_* functions. Makes those scripts available via the "greasemonkey" registered object in objreg and injects scripts at appropriate times in a page load base on @run-at directives.
This commit is contained in:
parent
75a8938e83
commit
568d60753e
@ -31,7 +31,7 @@ from qutebrowser.keyinput import modeman
|
||||
from qutebrowser.config import config
|
||||
from qutebrowser.utils import utils, objreg, usertypes, log, qtutils
|
||||
from qutebrowser.misc import miscwidgets, objects
|
||||
from qutebrowser.browser import mouse, hints
|
||||
from qutebrowser.browser import mouse, hints, greasemonkey
|
||||
|
||||
|
||||
tab_id_gen = itertools.count(0)
|
||||
@ -64,6 +64,10 @@ def init():
|
||||
from qutebrowser.browser.webengine import webenginetab
|
||||
webenginetab.init()
|
||||
|
||||
log.init.debug("Initializing Greasemonkey...")
|
||||
gm_manager = greasemonkey.GreasemonkeyManager()
|
||||
objreg.register('greasemonkey', gm_manager)
|
||||
|
||||
|
||||
class WebTabError(Exception):
|
||||
|
||||
|
262
qutebrowser/browser/greasemonkey.py
Normal file
262
qutebrowser/browser/greasemonkey.py
Normal file
@ -0,0 +1,262 @@
|
||||
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
|
||||
|
||||
# Copyright 2017 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
|
||||
#
|
||||
# This file is part of qutebrowser.
|
||||
#
|
||||
# qutebrowser is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# qutebrowser is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""Load, parse and make avalaible greasemonkey scripts."""
|
||||
|
||||
import re
|
||||
import os
|
||||
import json
|
||||
import fnmatch
|
||||
import functools
|
||||
import glob
|
||||
|
||||
from PyQt5.QtCore import pyqtSignal, QObject
|
||||
|
||||
from qutebrowser.utils import log, standarddir
|
||||
|
||||
# TODO: GM_ bootstrap
|
||||
|
||||
|
||||
def _scripts_dir():
|
||||
"""Get the directory of the scripts."""
|
||||
return os.path.join(standarddir.data(), 'greasemonkey')
|
||||
|
||||
|
||||
class GreasemonkeyScript:
|
||||
"""Container class for userscripts, parses metadata blocks."""
|
||||
|
||||
GM_BOOTSTRAP_TEMPLATE = r"""var _qute_script_id = "__gm_{scriptName}";
|
||||
|
||||
function GM_log(text) {{
|
||||
console.log(text);
|
||||
}}
|
||||
|
||||
GM_info = (function() {{
|
||||
return {{
|
||||
'script': {scriptInfo},
|
||||
'scriptMetaStr': {scriptMeta},
|
||||
'scriptWillUpdate': false,
|
||||
'version': '0.0.1',
|
||||
'scriptHandler': 'Tampermonkey' //so scripts don't expect exportFunction
|
||||
}};
|
||||
}}());
|
||||
|
||||
function GM_setValue(key, value) {{
|
||||
if (localStorage !== null &&
|
||||
typeof key === "string" &&
|
||||
(typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value == "boolean")) {{
|
||||
localStorage.setItem(_qute_script_id + key, value);
|
||||
}}
|
||||
}}
|
||||
|
||||
function GM_getValue(key, default_) {{
|
||||
if (localStorage !== null && typeof key === "string") {{
|
||||
return localStorage.getItem(_qute_script_id + key) || default_;
|
||||
}}
|
||||
}}
|
||||
|
||||
function GM_deleteValue(key) {{
|
||||
if (localStorage !== null && typeof key === "string") {{
|
||||
localStorage.removeItem(_qute_script_id + key);
|
||||
}}
|
||||
}}
|
||||
|
||||
function GM_listValues() {{
|
||||
var i;
|
||||
var keys = [];
|
||||
for (i = 0; i < localStorage.length; ++i) {{
|
||||
if (localStorage.key(i).startsWith(_qute_script_id)) {{
|
||||
keys.push(localStorage.key(i));
|
||||
}}
|
||||
}}
|
||||
return keys;
|
||||
}}
|
||||
|
||||
function GM_openInTab(url) {{
|
||||
window.open(url);
|
||||
}}
|
||||
|
||||
|
||||
// Almost verbatim copy from Eric
|
||||
function GM_xmlhttpRequest(/* object */ details) {{
|
||||
details.method = details.method.toUpperCase() || "GET";
|
||||
|
||||
if(!details.url) {{
|
||||
throw("GM_xmlhttpRequest requires an URL.");
|
||||
}}
|
||||
|
||||
// build XMLHttpRequest object
|
||||
var oXhr = new XMLHttpRequest;
|
||||
// run it
|
||||
if("onreadystatechange" in details)
|
||||
oXhr.onreadystatechange = function() {{
|
||||
details.onreadystatechange(oXhr)
|
||||
}};
|
||||
if("onload" in details)
|
||||
oXhr.onload = function() {{ details.onload(oXhr) }};
|
||||
if("onerror" in details)
|
||||
oXhr.onerror = function() {{ details.onerror(oXhr) }};
|
||||
|
||||
oXhr.open(details.method, details.url, true);
|
||||
|
||||
if("headers" in details)
|
||||
for(var header in details.headers)
|
||||
oXhr.setRequestHeader(header, details.headers[header]);
|
||||
|
||||
if("data" in details)
|
||||
oXhr.send(details.data);
|
||||
else
|
||||
oXhr.send();
|
||||
}}
|
||||
|
||||
function GM_addStyle(/* String */ styles) {{
|
||||
var head = document.getElementsByTagName("head")[0];
|
||||
if (head === undefined) {{
|
||||
document.onreadystatechange = function() {{
|
||||
if (document.readyState == "interactive") {{
|
||||
var oStyle = document.createElement("style");
|
||||
oStyle.setAttribute("type", "text/css");
|
||||
oStyle.appendChild(document.createTextNode(styles));
|
||||
document.getElementsByTagName("head")[0].appendChild(oStyle);
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
else {{
|
||||
var oStyle = document.createElement("style");
|
||||
oStyle.setAttribute("type", "text/css");
|
||||
oStyle.appendChild(document.createTextNode(styles));
|
||||
head.appendChild(oStyle);
|
||||
}}
|
||||
}}
|
||||
|
||||
unsafeWindow = window;
|
||||
"""
|
||||
|
||||
def __init__(self, properties, code):
|
||||
self._code = code
|
||||
self.includes = []
|
||||
self.excludes = []
|
||||
self.description = None
|
||||
self.name = None
|
||||
self.run_at = None
|
||||
for name, value in properties:
|
||||
if name == 'name':
|
||||
self.name = value
|
||||
elif name == 'description':
|
||||
self.description = value
|
||||
elif name in ['include', 'match']:
|
||||
self.includes.append(value)
|
||||
elif name in ['exclude', 'exclude_match']:
|
||||
self.excludes.append(value)
|
||||
elif name == 'run-at':
|
||||
self.run_at = value
|
||||
|
||||
HEADER_REGEX = r'// ==UserScript==.|\n+// ==/UserScript==\n'
|
||||
PROPS_REGEX = r'// @(?P<prop>[^\s]+)\s+(?P<val>.+)'
|
||||
|
||||
@classmethod
|
||||
def parse(cls, source):
|
||||
"""GreaseMonkeyScript factory.
|
||||
|
||||
Takes a userscript source and returns a GreaseMonkeyScript.
|
||||
Parses the greasemonkey metadata block, if present, to fill out
|
||||
attributes.
|
||||
"""
|
||||
matches = re.split(cls.HEADER_REGEX, source, maxsplit=1)
|
||||
try:
|
||||
props, _code = matches
|
||||
except ValueError:
|
||||
props = ""
|
||||
script = cls(re.findall(cls.PROPS_REGEX, props), code)
|
||||
script.script_meta = '"{}"'.format("\\n".join(props.split('\n')[2:]))
|
||||
return script
|
||||
|
||||
def code(self):
|
||||
"""Return the processed javascript code of this script.
|
||||
|
||||
Adorns the source code with GM_* methods for greasemonkey
|
||||
compatibility and wraps it in an IFFE to hide it within a
|
||||
lexical scope. Note that this means line numbers in your
|
||||
browser's debugger/inspector will not match up to the line
|
||||
numbers in the source script directly.
|
||||
"""
|
||||
gm_bootstrap = self.GM_BOOTSTRAP_TEMPLATE.format(
|
||||
scriptName=self.name,
|
||||
scriptInfo=self._meta_json(),
|
||||
scriptMeta=self.script_meta)
|
||||
return '\n'.join([gm_bootstrap, self._code])
|
||||
|
||||
def _meta_json(self):
|
||||
return json.dumps({
|
||||
'name': self.name,
|
||||
'description': self.description,
|
||||
'matches': self.includes,
|
||||
'includes': self.includes,
|
||||
'excludes': self.excludes,
|
||||
'run-at': self.run_at,
|
||||
})
|
||||
|
||||
|
||||
class GreasemonkeyManager:
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._run_start = []
|
||||
self._run_end = []
|
||||
|
||||
scripts_dir = os.path.abspath(_scripts_dir())
|
||||
log.greasemonkey.debug("Reading scripts from: {}".format(scripts_dir))
|
||||
for script_filename in glob.glob(os.path.join(scripts_dir, '*.js')):
|
||||
if not os.path.isfile(script_filename):
|
||||
continue
|
||||
script_path = os.path.join(scripts_dir, script_filename)
|
||||
with open(script_path, encoding='utf-8') as script_file:
|
||||
script = GreasemonkeyScript.parse(script_file.read())
|
||||
if not script.name:
|
||||
script.name = script_filename
|
||||
|
||||
if script.run_at == 'document-start':
|
||||
self._run_start.append(script)
|
||||
elif script.run_at == 'document-end':
|
||||
self._run_end.append(script)
|
||||
else:
|
||||
log.greasemonkey.warning("Script {} has invalid run-at "
|
||||
"defined, ignoring."
|
||||
.format(script_path))
|
||||
continue
|
||||
log.greasemonkey.debug("Loaded script: {}".format(script.name))
|
||||
|
||||
def scripts_for(self, url):
|
||||
"""Fetch scripts that are registered to run for url.
|
||||
|
||||
returns a tuple of lists of scripts meant to run at (document-start,
|
||||
document-end)
|
||||
"""
|
||||
match = functools.partial(fnmatch.fnmatch, url)
|
||||
tester = (lambda script:
|
||||
any(map(match, script.includes())) and not
|
||||
any(map(match, script.excludes())))
|
||||
return (list(filter(tester, self._run_start)),
|
||||
list(filter(tester, self._run_end)))
|
||||
|
||||
def all_scripts(self):
|
||||
"""Return all scripts found in the configured script directory."""
|
||||
return self._run_start + self._run_end
|
@ -86,6 +86,10 @@ class BrowserPage(QWebPage):
|
||||
self.on_save_frame_state_requested)
|
||||
self.restoreFrameStateRequested.connect(
|
||||
self.on_restore_frame_state_requested)
|
||||
self.mainFrame().javaScriptWindowObjectCleared.connect(
|
||||
functools.partial(self.inject_userjs, load='start'))
|
||||
self.mainFrame().initialLayoutCompleted.connect(
|
||||
functools.partial(self.inject_userjs, load='end'))
|
||||
|
||||
def javaScriptPrompt(self, frame, js_msg, default):
|
||||
"""Override javaScriptPrompt to use qutebrowser prompts."""
|
||||
@ -283,6 +287,30 @@ class BrowserPage(QWebPage):
|
||||
else:
|
||||
self.error_occurred = False
|
||||
|
||||
@pyqtSlot()
|
||||
def inject_userjs(self, load):
|
||||
"""Inject user javascripts into the page.
|
||||
|
||||
param: The page load stage to inject the corresponding scripts
|
||||
for. Support values are "start" and "end",
|
||||
corresponding to the allowed values of the `@run-at`
|
||||
directive in the greasemonkey metadata spec.
|
||||
"""
|
||||
greasemonkey = objreg.get('greasemonkey')
|
||||
url = self.currentFrame().url()
|
||||
start_scripts, end_scripts = greasemonkey.scripts_for(url.toDisplayString())
|
||||
log.greasemonkey.debug('scripts: {}'.format(start_scripts if start else end_scripts))
|
||||
|
||||
toload = []
|
||||
if load == "start":
|
||||
toload = start_scripts
|
||||
elif load == "end":
|
||||
toload = end_scripts
|
||||
|
||||
for script in toload:
|
||||
log.webview.debug('Running GM script: {}'.format(script.name))
|
||||
self.currentFrame().evaluateJavaScript(script.code())
|
||||
|
||||
@pyqtSlot('QWebFrame*', 'QWebPage::Feature')
|
||||
def _on_feature_permission_requested(self, frame, feature):
|
||||
"""Ask the user for approval for geolocation/notifications."""
|
||||
|
@ -95,7 +95,8 @@ LOGGER_NAMES = [
|
||||
'commands', 'signals', 'downloads',
|
||||
'js', 'qt', 'rfc6266', 'ipc', 'shlexer',
|
||||
'save', 'message', 'config', 'sessions',
|
||||
'webelem', 'prompt', 'network', 'sql'
|
||||
'webelem', 'prompt', 'network', 'sql',
|
||||
'greasemonkey'
|
||||
]
|
||||
|
||||
|
||||
@ -144,6 +145,7 @@ webelem = logging.getLogger('webelem')
|
||||
prompt = logging.getLogger('prompt')
|
||||
network = logging.getLogger('network')
|
||||
sql = logging.getLogger('sql')
|
||||
greasemonkey = logging.getLogger('greasemonkey')
|
||||
|
||||
|
||||
ram_handler = None
|
||||
|
Loading…
Reference in New Issue
Block a user