362 lines
13 KiB
Python
362 lines
13 KiB
Python
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
|
|
|
|
# Copyright 2014-2016 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/>.
|
|
|
|
"""Functions to execute a userscript."""
|
|
|
|
import os
|
|
import os.path
|
|
import tempfile
|
|
|
|
from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QSocketNotifier
|
|
|
|
from qutebrowser.utils import message, log, objreg, standarddir
|
|
from qutebrowser.commands import runners, cmdexc
|
|
from qutebrowser.config import config
|
|
from qutebrowser.misc import guiprocess
|
|
|
|
|
|
class _QtFIFOReader(QObject):
|
|
|
|
"""A FIFO reader based on a QSocketNotifier.
|
|
|
|
Attributes:
|
|
_filepath: The path to the opened FIFO.
|
|
_fifo: The Python file object for the FIFO.
|
|
_notifier: The QSocketNotifier used.
|
|
|
|
Signals:
|
|
got_line: Emitted when a whole line arrived.
|
|
"""
|
|
|
|
got_line = pyqtSignal(str)
|
|
|
|
def __init__(self, filepath, parent=None):
|
|
super().__init__(parent)
|
|
self._filepath = filepath
|
|
# We open as R/W so we never get EOF and have to reopen the pipe.
|
|
# See http://www.outflux.net/blog/archives/2008/03/09/using-select-on-a-fifo/
|
|
# We also use os.open and os.fdopen rather than built-in open so we
|
|
# can add O_NONBLOCK.
|
|
# pylint: disable=no-member,useless-suppression
|
|
fd = os.open(filepath, os.O_RDWR | os.O_NONBLOCK)
|
|
self._fifo = os.fdopen(fd, 'r')
|
|
self._notifier = QSocketNotifier(fd, QSocketNotifier.Read, self)
|
|
self._notifier.activated.connect(self.read_line)
|
|
|
|
@pyqtSlot()
|
|
def read_line(self):
|
|
"""(Try to) read a line from the FIFO."""
|
|
log.procs.debug("QSocketNotifier triggered!")
|
|
self._notifier.setEnabled(False)
|
|
for line in self._fifo:
|
|
self.got_line.emit(line.rstrip('\r\n'))
|
|
self._notifier.setEnabled(True)
|
|
|
|
def cleanup(self):
|
|
"""Clean up so the FIFO can be closed."""
|
|
self._notifier.setEnabled(False)
|
|
self._fifo.close()
|
|
|
|
|
|
class _BaseUserscriptRunner(QObject):
|
|
|
|
"""Common part between the Windows and the POSIX userscript runners.
|
|
|
|
Attributes:
|
|
_filepath: The path of the file/FIFO which is being read.
|
|
_proc: The GUIProcess which is being executed.
|
|
_win_id: The window ID this runner is associated with.
|
|
|
|
Signals:
|
|
got_cmd: Emitted when a new command arrived and should be executed.
|
|
finished: Emitted when the userscript finished running.
|
|
"""
|
|
|
|
got_cmd = pyqtSignal(str)
|
|
finished = pyqtSignal()
|
|
|
|
def __init__(self, win_id, parent=None):
|
|
super().__init__(parent)
|
|
self._win_id = win_id
|
|
self._filepath = None
|
|
self._proc = None
|
|
self._env = None
|
|
|
|
def _run_process(self, cmd, *args, env, verbose):
|
|
"""Start the given command.
|
|
|
|
Args:
|
|
cmd: The command to be started.
|
|
*args: The arguments to hand to the command
|
|
env: A dictionary of environment variables to add.
|
|
verbose: Show notifications when the command started/exited.
|
|
"""
|
|
self._env = {'QUTE_FIFO': self._filepath}
|
|
if env is not None:
|
|
self._env.update(env)
|
|
self._proc = guiprocess.GUIProcess(self._win_id, 'userscript',
|
|
additional_env=self._env,
|
|
verbose=verbose, parent=self)
|
|
self._proc.finished.connect(self.on_proc_finished)
|
|
self._proc.start(cmd, args)
|
|
|
|
def _cleanup(self):
|
|
"""Clean up temporary files."""
|
|
tempfiles = [self._filepath]
|
|
if 'QUTE_HTML' in self._env:
|
|
tempfiles.append(self._env['QUTE_HTML'])
|
|
if 'QUTE_TEXT' in self._env:
|
|
tempfiles.append(self._env['QUTE_TEXT'])
|
|
for fn in tempfiles:
|
|
log.procs.debug("Deleting temporary file {}.".format(fn))
|
|
try:
|
|
os.remove(fn)
|
|
except OSError as e:
|
|
# NOTE: Do not replace this with "raise CommandError" as it's
|
|
# executed async.
|
|
message.error(
|
|
self._win_id, "Failed to delete tempfile {} ({})!".format(
|
|
fn, e))
|
|
self._filepath = None
|
|
self._proc = None
|
|
self._env = None
|
|
|
|
def run(self, cmd, *args, env=None, verbose=False):
|
|
"""Run the userscript given.
|
|
|
|
Needs to be overridden by subclasses.
|
|
|
|
Args:
|
|
cmd: The command to be started.
|
|
*args: The arguments to hand to the command
|
|
env: A dictionary of environment variables to add.
|
|
verbose: Show notifications when the command started/exited.
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
def on_proc_finished(self):
|
|
"""Called when the process has finished.
|
|
|
|
Needs to be overridden by subclasses.
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
|
|
class _POSIXUserscriptRunner(_BaseUserscriptRunner):
|
|
|
|
"""Userscript runner to be used on POSIX. Uses _QtFIFOReader.
|
|
|
|
Commands are executed immediately when they arrive in the FIFO.
|
|
|
|
Attributes:
|
|
_reader: The _QtFIFOReader instance.
|
|
"""
|
|
|
|
def __init__(self, win_id, parent=None):
|
|
super().__init__(win_id, parent)
|
|
self._reader = None
|
|
|
|
def run(self, cmd, *args, env=None, verbose=False):
|
|
try:
|
|
# tempfile.mktemp is deprecated and discouraged, but we use it here
|
|
# to create a FIFO since the only other alternative would be to
|
|
# create a directory and place the FIFO there, which sucks. Since
|
|
# os.mkfifo will raise an exception anyways when the path doesn't
|
|
# exist, it shouldn't be a big issue.
|
|
self._filepath = tempfile.mktemp(prefix='qutebrowser-userscript-',
|
|
dir=standarddir.runtime())
|
|
# pylint: disable=no-member,useless-suppression
|
|
os.mkfifo(self._filepath)
|
|
except OSError as e:
|
|
message.error(self._win_id,
|
|
"Error while creating FIFO: {}".format(e))
|
|
return
|
|
|
|
self._reader = _QtFIFOReader(self._filepath)
|
|
self._reader.got_line.connect(self.got_cmd)
|
|
|
|
self._run_process(cmd, *args, env=env, verbose=verbose)
|
|
|
|
def on_proc_finished(self):
|
|
"""Interrupt the reader when the process finished."""
|
|
self.finish()
|
|
|
|
def finish(self):
|
|
"""Quit the thread and clean up when the reader finished."""
|
|
log.procs.debug("Cleaning up")
|
|
self._reader.cleanup()
|
|
self._reader.deleteLater()
|
|
self._reader = None
|
|
super()._cleanup()
|
|
self.finished.emit()
|
|
|
|
|
|
class _WindowsUserscriptRunner(_BaseUserscriptRunner):
|
|
|
|
"""Userscript runner to be used on Windows.
|
|
|
|
This is a much more dumb implementation compared to POSIXUserscriptRunner.
|
|
It uses a normal flat file for commands and executes them all at once when
|
|
the process has finished, as Windows doesn't really understand the concept
|
|
of using files as named pipes.
|
|
|
|
This also means the userscript *has* to use >> (append) rather than >
|
|
(overwrite) to write to the file!
|
|
|
|
Attributes:
|
|
_oshandle: The oshandle of the temp file.
|
|
"""
|
|
|
|
def __init__(self, win_id, parent=None):
|
|
super().__init__(win_id, parent)
|
|
self._oshandle = None
|
|
|
|
def _cleanup(self):
|
|
"""Clean up temporary files after the userscript finished."""
|
|
try:
|
|
os.close(self._oshandle)
|
|
except OSError:
|
|
log.procs.exception("Failed to close file handle!")
|
|
super()._cleanup()
|
|
self._oshandle = None
|
|
|
|
def on_proc_finished(self):
|
|
"""Read back the commands when the process finished."""
|
|
try:
|
|
with open(self._filepath, 'r', encoding='utf-8') as f:
|
|
for line in f:
|
|
self.got_cmd.emit(line.rstrip())
|
|
except OSError:
|
|
log.procs.exception("Failed to read command file!")
|
|
self._cleanup()
|
|
self.finished.emit()
|
|
|
|
def run(self, cmd, *args, env=None, verbose=False):
|
|
try:
|
|
self._oshandle, self._filepath = tempfile.mkstemp(text=True)
|
|
except OSError as e:
|
|
message.error(self._win_id, "Error while creating tempfile: "
|
|
"{}".format(e))
|
|
return
|
|
self._run_process(cmd, *args, env=env, verbose=verbose)
|
|
|
|
|
|
class _DummyUserscriptRunner(QObject):
|
|
|
|
"""Simple dummy runner which displays an error when using userscripts.
|
|
|
|
Used on unknown systems since we don't know what (or if any) approach will
|
|
work there.
|
|
|
|
Signals:
|
|
finished: Always emitted.
|
|
"""
|
|
|
|
finished = pyqtSignal()
|
|
|
|
def __init__(self, win_id, parent=None):
|
|
# pylint: disable=unused-argument
|
|
super().__init__(parent)
|
|
|
|
def run(self, cmd, *args, env=None, verbose=False):
|
|
"""Print an error as userscripts are not supported."""
|
|
# pylint: disable=unused-argument,unused-variable
|
|
self.finished.emit()
|
|
raise cmdexc.CommandError(
|
|
"Userscripts are not supported on this platform!")
|
|
|
|
|
|
# Here we basically just assign a generic UserscriptRunner class which does the
|
|
# right thing depending on the platform.
|
|
if os.name == 'posix':
|
|
UserscriptRunner = _POSIXUserscriptRunner
|
|
elif os.name == 'nt': # pragma: no cover
|
|
UserscriptRunner = _WindowsUserscriptRunner
|
|
else: # pragma: no cover
|
|
UserscriptRunner = _DummyUserscriptRunner
|
|
|
|
|
|
def store_source(frame):
|
|
"""Store HTML/plaintext in files.
|
|
|
|
This writes files containing the HTML/plaintext source of the page, and
|
|
returns a dict with the paths as QUTE_HTML/QUTE_TEXT.
|
|
|
|
Args:
|
|
frame: The QWebFrame to get the info from, or None to do nothing.
|
|
|
|
Return:
|
|
A dictionary with the needed environment variables.
|
|
|
|
Warning:
|
|
The caller is responsible to delete the files after using them!
|
|
"""
|
|
if frame is None:
|
|
return {}
|
|
env = {}
|
|
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8',
|
|
suffix='.html',
|
|
delete=False) as html_file:
|
|
html_file.write(frame.toHtml())
|
|
env['QUTE_HTML'] = html_file.name
|
|
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8',
|
|
suffix='.txt',
|
|
delete=False) as txt_file:
|
|
txt_file.write(frame.toPlainText())
|
|
env['QUTE_TEXT'] = txt_file.name
|
|
return env
|
|
|
|
|
|
def run(cmd, *args, win_id, env, verbose=False):
|
|
"""Convenience method to run a userscript.
|
|
|
|
Args:
|
|
cmd: The userscript binary to run.
|
|
*args: The arguments to pass to the userscript.
|
|
win_id: The window id the userscript is executed in.
|
|
env: A dictionary of variables to add to the process environment.
|
|
verbose: Show notifications when the command started/exited.
|
|
"""
|
|
tabbed_browser = objreg.get('tabbed-browser', scope='window',
|
|
window=win_id)
|
|
commandrunner = runners.CommandRunner(win_id, tabbed_browser)
|
|
runner = UserscriptRunner(win_id, tabbed_browser)
|
|
runner.got_cmd.connect(
|
|
lambda cmd:
|
|
log.commands.debug("Got userscript command: {}".format(cmd)))
|
|
runner.got_cmd.connect(commandrunner.run_safely)
|
|
user_agent = config.get('network', 'user-agent')
|
|
if user_agent is not None:
|
|
env['QUTE_USER_AGENT'] = user_agent
|
|
cmd_path = os.path.expanduser(cmd)
|
|
|
|
# if cmd is not given as an absolute path, look it up
|
|
# ~/.local/share/qutebrowser/userscripts (or $XDG_DATA_DIR)
|
|
if not os.path.isabs(cmd_path):
|
|
log.misc.debug("{} is no absolute path".format(cmd_path))
|
|
cmd_path = os.path.join(standarddir.data(), "userscripts", cmd)
|
|
if not os.path.exists(cmd_path):
|
|
cmd_path = os.path.join(standarddir.system_data(),
|
|
"userscripts", cmd)
|
|
log.misc.debug("Userscript to run: {}".format(cmd_path))
|
|
|
|
runner.run(cmd_path, *args, env=env, verbose=verbose)
|
|
runner.finished.connect(commandrunner.deleteLater)
|
|
runner.finished.connect(runner.deleteLater)
|