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:
Andor Uhlár 2015-05-10 18:33:36 +02:00 committed by Jimmy
parent 75a8938e83
commit 568d60753e
4 changed files with 298 additions and 2 deletions

View File

@ -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):

View 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

View File

@ -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."""

View File

@ -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