Blob Blame History Raw
/*
    Copyright (c) 2017, Lukas Holecek <hluk@email.cz>

    This file is part of CopyQ.

    CopyQ 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.

    CopyQ 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 CopyQ.  If not, see <http://www.gnu.org/licenses/>.
*/

#include "action.h"

#include "common/common.h"
#include "common/log.h"
#include "common/mimetypes.h"
#include "common/textdata.h"
#include "item/serialize.h"

#include <QCoreApplication>
#include <QProcessEnvironment>

#include <cstring>

namespace {

void startProcess(QProcess *process, const QStringList &args)
{
    QString executable = args.value(0);

    // Replace "copyq" command with full application path.
    if (executable == "copyq")
        executable = QCoreApplication::applicationFilePath();

    process->start(executable, args.mid(1), QIODevice::ReadWrite);
}

template <typename Entry, typename Container>
void appendAndClearNonEmpty(Entry &entry, Container &containter)
{
    if ( !entry.isEmpty() ) {
        containter.append(entry);
        entry.clear();
    }
}

bool getScriptFromLabel(const char *label, const QStringRef &cmd, QString *script)
{
    if ( cmd.startsWith(label) ) {
        *script = cmd.string()->mid( cmd.position() + static_cast<int>(strlen(label)) );
        return true;
    }

    return false;
}

QList< QList<QStringList> > parseCommands(const QString &cmd, const QStringList &capturedTexts)
{
    QList< QList<QStringList> > lines;
    QList<QStringList> commands;
    QStringList command;
    QString script;

    QString arg;
    QChar quote;
    bool escape = false;
    bool percent = false;

    for (int i = 0; i < cmd.size(); ++i) {
        const QChar &c = cmd[i];

        if (percent) {
            if (c >= '1' && c <= '9') {
                arg.resize( arg.size() - 1 );
                arg.append( capturedTexts.value(c.digitValue() - 1) );
                continue;
            }
        }
        percent = !escape && c == '%';

        if (escape) {
            escape = false;
            if (c == 'n') {
                arg.append('\n');
            } else if (c == 't') {
                arg.append('\t');
            } else if (c == '\n') {
                // Ignore escaped new line character.
            } else {
                arg.append(c);
            }
        } else if (c == '\\') {
            escape = true;
        } else if (!quote.isNull()) {
            if (quote == c) {
                quote = QChar();
                command.append(arg);
                arg.clear();
            } else {
                arg.append(c);
            }
        } else if (c == '\'' || c == '"') {
            quote = c;
        } else if (c == '|') {
            appendAndClearNonEmpty(arg, command);
            appendAndClearNonEmpty(command, commands);
        } else if (c == '\n' || c == ';') {
            appendAndClearNonEmpty(arg, command);
            appendAndClearNonEmpty(command, commands);
            appendAndClearNonEmpty(commands, lines);
        } else if ( c.isSpace() ) {
            if (!arg.isEmpty()) {
                command.append(arg);
                arg.clear();
            }
        } else if ( c == ':' && i + 1 < cmd.size() && cmd[i+1] == '\n' ) {
            // If there is unescaped colon at the end of a line,
            // treat the rest of the command as single argument.
            appendAndClearNonEmpty(arg, command);
            arg = cmd.mid(i + 2);
            break;
        } else {
            if ( arg.isEmpty() && command.isEmpty() ) {
                // Treat command as script if known label is present.
                const QStringRef cmd1 = cmd.midRef(i);
                if ( getScriptFromLabel("copyq:", cmd1, &script) )
                    command << "copyq" << "eval" << "--" << script;
                else if ( getScriptFromLabel("sh:", cmd1, &script) )
                    command << "sh" << "-c" << "--" << script << "--";
                else if ( getScriptFromLabel("bash:", cmd1, &script) )
                    command << "bash" << "-c" << "--" << script << "--";
                else if ( getScriptFromLabel("perl:", cmd1, &script) )
                    command << "perl" << "-e" << script << "--";
                else if ( getScriptFromLabel("python:", cmd1, &script) )
                    command << "python" << "-c" << script;
                else if ( getScriptFromLabel("ruby:", cmd1, &script) )
                    command << "ruby" << "-e" << script << "--";

                if ( !script.isEmpty() ) {
                    command.append( capturedTexts.mid(1) );
                    commands.append(command);
                    lines.append(commands);
                    return lines;
                }
            }

            arg.append(c);
        }
    }

    appendAndClearNonEmpty(arg, command);
    appendAndClearNonEmpty(command, commands);
    appendAndClearNonEmpty(commands, lines);

    return lines;
}

} // namespace

Action::Action(QObject *parent)
    : QObject(parent)
    , m_failed(false)
    , m_currentLine(-1)
    , m_exitCode(0)
{
}

Action::~Action()
{
    closeSubCommands();
}

QString Action::command() const
{
    QString text;
    for ( const auto &line : m_cmds ) {
        for ( const auto &args : line ) {
            if ( !text.isEmpty() )
                text.append(QChar('|'));
            text.append(args.join(" "));
        }
        text.append('\n');
    }
    return text.trimmed();
}

void Action::setCommand(const QString &command, const QStringList &arguments)
{
    m_cmds = parseCommands(command, arguments);
}

void Action::setCommand(const QStringList &arguments)
{
    m_cmds.clear();
    m_cmds.append(QList<QStringList>() << arguments);
}

void Action::setInput(const QVariantMap &data, const QString &inputFormat)
{
    if (inputFormat == mimeItems) {
        m_input = serializeData(data);
        m_inputFormats = data.keys();
    } else {
        m_input = data.value(inputFormat).toByteArray();
        m_inputFormats = QStringList(inputFormat);
    }
}

void Action::start()
{
    closeSubCommands();

    if ( m_currentLine + 1 >= m_cmds.size() ) {
        actionFinished();
        return;
    }

    ++m_currentLine;
    const QList<QStringList> &cmds = m_cmds[m_currentLine];

    Q_ASSERT( !cmds.isEmpty() );

    QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
    if (m_id != -1)
        env.insert("COPYQ_ACTION_ID", QString::number(m_id));
    if ( !m_name.isEmpty() )
        env.insert("COPYQ_ACTION_NAME", m_name);

    for (int i = 0; i < cmds.size(); ++i) {
        auto process = new QProcess(this);
        m_processes.append(process);
        process->setProcessEnvironment(env);
        if ( !m_workingDirectoryPath.isEmpty() )
            process->setWorkingDirectory(m_workingDirectoryPath);

#if QT_VERSION < 0x050600
        connect( process, SIGNAL(error(QProcess::ProcessError)),
                 SLOT(onSubProcessError(QProcess::ProcessError)) );
#else
        connect( process, SIGNAL(errorOccurred(QProcess::ProcessError)),
                 SLOT(onSubProcessError(QProcess::ProcessError)) );
#endif
        connect( process, SIGNAL(readyReadStandardError()),
                 SLOT(onSubProcessErrorOutput()) );
    }

    for (int i = 1; i < m_processes.size(); ++i) {
        m_processes[i - 1]->setStandardOutputProcess(m_processes[i]);
        connect( m_processes[i], SIGNAL(finished(int)),
                 m_processes[i - 1], SLOT(terminate()) );
    }

    connect( m_processes.last(), SIGNAL(started()),
             this, SLOT(onSubProcessStarted()) );
    connect( m_processes.last(), SIGNAL(finished(int,QProcess::ExitStatus)),
             this, SLOT(onSubProcessFinished()) );
    connect( m_processes.last(), SIGNAL(readyReadStandardOutput()),
             this, SLOT(onSubProcessOutput()) );

    // Writing directly to stdin of a process on Windows can hang the app.
    connect( m_processes.first(), SIGNAL(started()),
             this, SLOT(writeInput()), Qt::QueuedConnection );
    connect( m_processes.first(), SIGNAL(bytesWritten(qint64)),
             this, SLOT(onBytesWritten()), Qt::QueuedConnection );

    if (m_outputFormat.isEmpty())
        m_processes.last()->closeReadChannel(QProcess::StandardOutput);

    for (int i = 0; i < m_processes.size(); ++i)
        startProcess(m_processes[i], cmds[i]);
}

bool Action::waitForStarted(int msecs)
{
    return !m_processes.isEmpty() && m_processes.last()->waitForStarted(msecs);
}

bool Action::waitForFinished(int msecs)
{
    if ( !isRunning() )
        return true;

    for ( int waitMsec = 0;
          waitMsec < msecs && !m_processes.isEmpty() && !m_processes.last()->waitForFinished(100);
          waitMsec += 100 )
    {
        QCoreApplication::processEvents();
    }

    return !isRunning();
}

bool Action::isRunning() const
{
    return !m_processes.isEmpty() && m_processes.last()->state() != QProcess::NotRunning;
}

void Action::setData(const QVariantMap &data)
{
    m_data = data;
    emit dataChanged(data);
}

const QVariantMap &Action::data() const
{
    return m_data;
}

void Action::onSubProcessError(QProcess::ProcessError error)
{
    QProcess *p = qobject_cast<QProcess*>(sender());
    Q_ASSERT(p);

    // Ignore write-to-process error, process can ignore the input.
    if (error != QProcess::WriteError) {
        if (!m_errorString.isEmpty())
            m_errorString.append("\n");
        m_errorString.append( p->errorString() );
        m_failed = true;
    }

    if ( !isRunning() )
        actionFinished();
}

void Action::onSubProcessStarted()
{
    if (m_currentLine == 0)
        emit actionStarted(this);
}

void Action::onSubProcessFinished()
{
    onSubProcessOutput();
    start();
}

void Action::onSubProcessOutput()
{
    if ( m_processes.isEmpty() )
        return;

    auto p = m_processes.last();
    const auto output = p->readAll();
    if ( output.isEmpty() )
        return;

    if ( !m_outputFormat.isEmpty() ) {
        m_outputData.append(output);

        if ( !m_sep.isEmpty() ) {
            m_lastOutput.append( getTextData(output) );
            auto items = m_lastOutput.split(m_sep);
            m_lastOutput = items.takeLast();
            if ( !items.isEmpty() )
                emit newItems(items, m_outputFormat, m_tab);
        } else if ( m_outputFormat == mimeText && m_index.isValid() ) {
            emit changeItem(m_outputData, m_outputFormat, m_index);
        }
    }
}

void Action::onSubProcessErrorOutput()
{
    QProcess *p = qobject_cast<QProcess*>(sender());
    Q_ASSERT(p);

    m_errorOutput.append( getTextData(p->readAllStandardError()) );
}

void Action::writeInput()
{
    if (m_processes.isEmpty())
        return;

    QProcess *p = m_processes.first();

    if (m_input.isEmpty())
        p->closeWriteChannel();
    else
        p->write(m_input);
}

void Action::onBytesWritten()
{
    if ( !m_processes.isEmpty() )
        m_processes.first()->closeWriteChannel();
}

void Action::terminate()
{
    if (m_processes.isEmpty())
        return;

    // try to terminate process
    for (auto p : m_processes)
        p->terminate();

    // if process still running: kill it
    if ( !waitForFinished(5000) )
        terminateProcess( m_processes.last() );
}

void Action::closeSubCommands()
{
    terminate();

    if (m_processes.isEmpty())
        return;

    m_exitCode = m_processes.last()->exitCode();
    m_failed = m_failed || m_processes.last()->exitStatus() != QProcess::NormalExit;

    for (auto p : m_processes)
        p->deleteLater();

    m_processes.clear();
}

void Action::actionFinished()
{
    closeSubCommands();

    if ( !m_outputFormat.isEmpty() ) {
        if ( !m_sep.isEmpty() ) {
            if ( !m_lastOutput.isEmpty() )
                emit newItems(QStringList() << m_lastOutput, m_outputFormat, m_tab);
        } else if ( !m_outputData.isEmpty() ) {
            if ( m_index.isValid() )
                emit changeItem(m_outputData, m_outputFormat, m_index);
            else
                emit newItem(m_outputData, m_outputFormat, m_tab);
        }
    }

    emit actionFinished(this);
}