File indexing completed on 2024-04-28 16:54:26

0001 /*
0002     SPDX-FileCopyrightText: 2000, 2001, 2002 Carsten Pfeiffer <pfeiffer@kde.org>
0003 
0004     SPDX-License-Identifier: GPL-2.0-or-later
0005 */
0006 #include "urlgrabber.h"
0007 
0008 #include <netwm.h>
0009 
0010 #include "klipper_debug.h"
0011 #include <QFile>
0012 #include <QIcon>
0013 #include <QMenu>
0014 #include <QMimeDatabase>
0015 #include <QRegularExpression>
0016 #include <QTimer>
0017 #include <QUuid>
0018 
0019 #include <KApplicationTrader>
0020 #include <KIO/ApplicationLauncherJob>
0021 #include <KLocalizedString>
0022 #include <KNotificationJobUiDelegate>
0023 #include <KService>
0024 #include <KStringHandler>
0025 #include <KWindowInfo>
0026 #include <KX11Extras>
0027 
0028 #include "clipcommandprocess.h"
0029 #include "klippersettings.h"
0030 
0031 // TODO: script-interface?
0032 #include "history.h"
0033 #include "historystringitem.h"
0034 
0035 URLGrabber::URLGrabber(History *history)
0036     : m_myCurrentAction(nullptr)
0037     , m_myMenu(nullptr)
0038     , m_myPopupKillTimer(new QTimer(this))
0039     , m_myPopupKillTimeout(8)
0040     , m_stripWhiteSpace(true)
0041     , m_history(history)
0042 {
0043     m_myPopupKillTimer->setSingleShot(true);
0044     connect(m_myPopupKillTimer, &QTimer::timeout, this, &URLGrabber::slotKillPopupMenu);
0045 }
0046 
0047 URLGrabber::~URLGrabber()
0048 {
0049     qDeleteAll(m_myActions);
0050     m_myActions.clear();
0051     delete m_myMenu;
0052 }
0053 
0054 //
0055 // Called from Klipper::slotRepeatAction, i.e. by pressing Ctrl-Alt-R
0056 // shortcut. I.e. never from clipboard monitoring
0057 //
0058 void URLGrabber::invokeAction(HistoryItemConstPtr item)
0059 {
0060     m_myClipItem = item;
0061     actionMenu(item, false);
0062 }
0063 
0064 void URLGrabber::setActionList(const ActionList &list)
0065 {
0066     qDeleteAll(m_myActions);
0067     m_myActions.clear();
0068     m_myActions = list;
0069 }
0070 
0071 void URLGrabber::matchingMimeActions(const QString &clipData)
0072 {
0073     QUrl url(clipData);
0074     if (!KlipperSettings::enableMagicMimeActions()) {
0075         return;
0076     }
0077     if (!url.isValid()) {
0078         return;
0079     }
0080     if (url.isRelative()) { // openinng a relative path will just not work. what path should be used?
0081         return;
0082     }
0083     if (url.isLocalFile()) {
0084         if (clipData == QLatin1String("//")) {
0085             return;
0086         }
0087         if (!QFile::exists(url.toLocalFile())) {
0088             return;
0089         }
0090     }
0091 
0092     // try to figure out if clipData contains a filename
0093     QMimeDatabase db;
0094     QMimeType mimetype = db.mimeTypeForUrl(url);
0095 
0096     // let's see if we found some reasonable mimetype.
0097     // If we do we'll populate menu with actions for apps
0098     // that can handle that mimetype
0099 
0100     // first: if clipboard contents starts with http, let's assume it's "text/html".
0101     // That is even if we've url like "http://www.kde.org/somescript.pl", we'll
0102     // still treat that as html page, because determining a mimetype using kio
0103     // might take a long time, and i want this function to be quick!
0104     if ((clipData.startsWith(QLatin1String("http://")) || clipData.startsWith(QLatin1String("https://"))) && mimetype.name() != QLatin1String("text/html")) {
0105         mimetype = db.mimeTypeForName(QStringLiteral("text/html"));
0106     }
0107 
0108     if (!mimetype.isDefault()) {
0109         KService::List lst = KApplicationTrader::queryByMimeType(mimetype.name());
0110         if (!lst.isEmpty()) {
0111             ClipAction *action = new ClipAction(QString(), mimetype.comment());
0112             foreach (const KService::Ptr &service, lst) {
0113                 action->addCommand(ClipCommand(QString(), service->name(), true, service->icon(), ClipCommand::IGNORE, service->storageId()));
0114             }
0115             m_myMatches.append(action);
0116         }
0117     }
0118 }
0119 
0120 const ActionList &URLGrabber::matchingActions(const QString &clipData, bool automatically_invoked)
0121 {
0122     m_myMatches.clear();
0123 
0124     matchingMimeActions(clipData);
0125 
0126     // now look for matches in custom user actions
0127     QRegularExpression re;
0128     foreach (ClipAction *action, m_myActions) {
0129         re.setPattern(action->actionRegexPattern());
0130         const QRegularExpressionMatch match = re.match(clipData);
0131         if (match.hasMatch() && (action->automatic() || !automatically_invoked)) {
0132             action->setActionCapturedTexts(match.capturedTexts());
0133             m_myMatches.append(action);
0134         }
0135     }
0136 
0137     return m_myMatches;
0138 }
0139 
0140 void URLGrabber::checkNewData(HistoryItemConstPtr item)
0141 {
0142     actionMenu(item, true); // also creates m_myMatches
0143 }
0144 
0145 void URLGrabber::actionMenu(HistoryItemConstPtr item, bool automatically_invoked)
0146 {
0147     if (!item) {
0148         qCWarning(KLIPPER_LOG, "Attempt to invoke URLGrabber without an item");
0149         return;
0150     }
0151     QString text(item->text());
0152     if (m_stripWhiteSpace) {
0153         text = text.trimmed();
0154     }
0155     ActionList matchingActionsList = matchingActions(text, automatically_invoked);
0156 
0157     if (!matchingActionsList.isEmpty()) {
0158         // don't react on blacklisted (e.g. konqi's/netscape's urls) unless the user explicitly asked for it
0159         if (automatically_invoked && isAvoidedWindow()) {
0160             return;
0161         }
0162 
0163         m_myCommandMapper.clear();
0164 
0165         m_myPopupKillTimer->stop();
0166 
0167         m_myMenu = new QMenu;
0168         m_myMenu->setWindowFlags(m_myMenu->windowFlags() | Qt::FramelessWindowHint);
0169 
0170         connect(m_myMenu, &QMenu::triggered, this, &URLGrabber::slotItemSelected);
0171 
0172         foreach (ClipAction *clipAct, matchingActionsList) {
0173             m_myMenu->addSection(QIcon::fromTheme(QStringLiteral("klipper")), clipAct->description());
0174             QList<ClipCommand> cmdList = clipAct->commands();
0175             int listSize = cmdList.count();
0176             for (int i = 0; i < listSize; ++i) {
0177                 ClipCommand command = cmdList.at(i);
0178 
0179                 QString item = command.description;
0180                 if (item.isEmpty())
0181                     item = command.command;
0182 
0183                 QString id = QUuid::createUuid().toString();
0184                 QAction *action = new QAction(this);
0185                 action->setData(id);
0186                 action->setText(item);
0187 
0188                 if (!command.icon.isEmpty())
0189                     action->setIcon(QIcon::fromTheme(command.icon));
0190 
0191                 m_myCommandMapper.insert(id, qMakePair(clipAct, i));
0192                 m_myMenu->addAction(action);
0193             }
0194         }
0195 
0196         // only insert this when invoked via clipboard monitoring, not from an
0197         // explicit Ctrl-Alt-R
0198         if (automatically_invoked) {
0199             m_myMenu->addSeparator();
0200             QAction *disableAction = new QAction(i18n("Disable This Popup"), this);
0201             connect(disableAction, &QAction::triggered, this, &URLGrabber::sigDisablePopup);
0202             m_myMenu->addAction(disableAction);
0203         }
0204         m_myMenu->addSeparator();
0205 
0206         QAction *cancelAction = new QAction(QIcon::fromTheme(QStringLiteral("dialog-cancel")), i18n("&Cancel"), this);
0207         connect(cancelAction, &QAction::triggered, m_myMenu, &QMenu::hide);
0208         m_myMenu->addAction(cancelAction);
0209         m_myClipItem = item;
0210 
0211         if (m_myPopupKillTimeout > 0)
0212             m_myPopupKillTimer->start(1000 * m_myPopupKillTimeout);
0213 
0214         Q_EMIT sigPopup(m_myMenu);
0215     }
0216 }
0217 
0218 void URLGrabber::slotItemSelected(QAction *action)
0219 {
0220     if (m_myMenu)
0221         m_myMenu->hide(); // deleted by the timer or the next action
0222 
0223     QString id = action->data().toString();
0224 
0225     if (id.isEmpty()) {
0226         qCDebug(KLIPPER_LOG) << "Klipper: no command associated";
0227         return;
0228     }
0229 
0230     // first is action ptr, second is command index
0231     QPair<ClipAction *, int> actionCommand = m_myCommandMapper.value(id);
0232 
0233     if (actionCommand.first)
0234         execute(actionCommand.first, actionCommand.second);
0235     else
0236         qCDebug(KLIPPER_LOG) << "Klipper: cannot find associated action";
0237 }
0238 
0239 void URLGrabber::execute(const ClipAction *action, int cmdIdx) const
0240 {
0241     if (!action) {
0242         qCDebug(KLIPPER_LOG) << "Action object is null";
0243         return;
0244     }
0245 
0246     ClipCommand command = action->command(cmdIdx);
0247 
0248     if (command.isEnabled) {
0249         QString text(m_myClipItem->text());
0250         if (m_stripWhiteSpace) {
0251             text = text.trimmed();
0252         }
0253         if (!command.serviceStorageId.isEmpty()) {
0254             KService::Ptr service = KService::serviceByStorageId(command.serviceStorageId);
0255             auto *job = new KIO::ApplicationLauncherJob(service);
0256             job->setUrls({QUrl(text)});
0257             job->setUiDelegate(new KNotificationJobUiDelegate(KJobUiDelegate::AutoHandlingEnabled));
0258             job->start();
0259         } else {
0260             ClipCommandProcess *proc = new ClipCommandProcess(*action, command, text, m_history, m_myClipItem);
0261             if (proc->program().isEmpty()) {
0262                 delete proc;
0263                 proc = nullptr;
0264             } else {
0265                 proc->start();
0266             }
0267         }
0268     }
0269 }
0270 
0271 void URLGrabber::loadSettings()
0272 {
0273     m_stripWhiteSpace = KlipperSettings::stripWhiteSpace();
0274     m_myAvoidWindows = KlipperSettings::noActionsForWM_CLASS();
0275     m_myPopupKillTimeout = KlipperSettings::timeoutForActionPopups();
0276 
0277     qDeleteAll(m_myActions);
0278     m_myActions.clear();
0279 
0280     KConfigGroup cg(KSharedConfig::openConfig(), "General");
0281     int num = cg.readEntry("Number of Actions", 0);
0282     QString group;
0283     for (int i = 0; i < num; i++) {
0284         group = QStringLiteral("Action_%1").arg(i);
0285         m_myActions.append(new ClipAction(KSharedConfig::openConfig(), group));
0286     }
0287 }
0288 
0289 void URLGrabber::saveSettings() const
0290 {
0291     KConfigGroup cg(KSharedConfig::openConfig(), "General");
0292     cg.writeEntry("Number of Actions", m_myActions.count());
0293 
0294     int i = 0;
0295     QString group;
0296     foreach (ClipAction *action, m_myActions) {
0297         group = QStringLiteral("Action_%1").arg(i);
0298         action->save(KSharedConfig::openConfig(), group);
0299         ++i;
0300     }
0301 
0302     KlipperSettings::setNoActionsForWM_CLASS(m_myAvoidWindows);
0303 }
0304 
0305 // find out whether the active window's WM_CLASS is in our avoid-list
0306 bool URLGrabber::isAvoidedWindow() const
0307 {
0308     const WId active = KX11Extras::activeWindow();
0309     if (!active) {
0310         return false;
0311     }
0312     KWindowInfo info(active, NET::Properties(), NET::WM2WindowClass);
0313     return m_myAvoidWindows.contains(QString::fromLatin1(info.windowClassName()));
0314 }
0315 
0316 void URLGrabber::slotKillPopupMenu()
0317 {
0318     if (m_myMenu && m_myMenu->isVisible()) {
0319         if (m_myMenu->geometry().contains(QCursor::pos()) && m_myPopupKillTimeout > 0) {
0320             m_myPopupKillTimer->start(1000 * m_myPopupKillTimeout);
0321             return;
0322         }
0323     }
0324 
0325     if (m_myMenu) {
0326         m_myMenu->deleteLater();
0327         m_myMenu = nullptr;
0328     }
0329 }
0330 
0331 ///////////////////////////////////////////////////////////////////////////
0332 ////////
0333 
0334 ClipCommand::ClipCommand(const QString &_command,
0335                          const QString &_description,
0336                          bool _isEnabled,
0337                          const QString &_icon,
0338                          Output _output,
0339                          const QString &_serviceStorageId)
0340     : command(_command)
0341     , description(_description)
0342     , isEnabled(_isEnabled)
0343     , output(_output)
0344     , serviceStorageId(_serviceStorageId)
0345 {
0346     if (!_icon.isEmpty())
0347         icon = _icon;
0348     else {
0349         // try to find suitable icon
0350         QString appName = command.section(QLatin1Char(' '), 0, 0);
0351         if (!appName.isEmpty()) {
0352             if (QIcon::hasThemeIcon(appName))
0353                 icon = appName;
0354             else
0355                 icon.clear();
0356         }
0357     }
0358 }
0359 
0360 ClipAction::ClipAction(const QString &regExp, const QString &description, bool automatic)
0361     : m_regexPattern(regExp)
0362     , m_myDescription(description)
0363     , m_automatic(automatic)
0364 {
0365 }
0366 
0367 ClipAction::ClipAction(KSharedConfigPtr kc, const QString &group)
0368     : m_regexPattern(kc->group(group).readEntry("Regexp"))
0369     , m_myDescription(kc->group(group).readEntry("Description"))
0370     , m_automatic(kc->group(group).readEntry("Automatic", QVariant(true)).toBool())
0371 {
0372     KConfigGroup cg(kc, group);
0373 
0374     int num = cg.readEntry("Number of commands", 0);
0375 
0376     // read the commands
0377     for (int i = 0; i < num; i++) {
0378         QString _group = group + QStringLiteral("/Command_%1");
0379         KConfigGroup _cg(kc, _group.arg(i));
0380 
0381         addCommand(ClipCommand(_cg.readPathEntry("Commandline", QString()),
0382                                _cg.readEntry("Description"), // i18n'ed
0383                                _cg.readEntry("Enabled", false),
0384                                _cg.readEntry("Icon"),
0385                                static_cast<ClipCommand::Output>(_cg.readEntry("Output", QVariant(ClipCommand::IGNORE)).toInt())));
0386     }
0387 }
0388 
0389 ClipAction::~ClipAction()
0390 {
0391     m_myCommands.clear();
0392 }
0393 
0394 void ClipAction::addCommand(const ClipCommand &cmd)
0395 {
0396     if (cmd.command.isEmpty() && cmd.serviceStorageId.isEmpty())
0397         return;
0398 
0399     m_myCommands.append(cmd);
0400 }
0401 
0402 void ClipAction::replaceCommand(int idx, const ClipCommand &cmd)
0403 {
0404     if (idx < 0 || idx >= m_myCommands.count()) {
0405         qCDebug(KLIPPER_LOG) << "wrong command index given";
0406         return;
0407     }
0408 
0409     m_myCommands.replace(idx, cmd);
0410 }
0411 
0412 // precondition: we're in the correct action's group of the KConfig object
0413 void ClipAction::save(KSharedConfigPtr kc, const QString &group) const
0414 {
0415     KConfigGroup cg(kc, group);
0416     cg.writeEntry("Description", description());
0417     cg.writeEntry("Regexp", actionRegexPattern());
0418     cg.writeEntry("Number of commands", m_myCommands.count());
0419     cg.writeEntry("Automatic", automatic());
0420 
0421     int i = 0;
0422     // now iterate over all commands of this action
0423     foreach (const ClipCommand &cmd, m_myCommands) {
0424         QString _group = group + QStringLiteral("/Command_%1");
0425         KConfigGroup cg(kc, _group.arg(i));
0426 
0427         cg.writePathEntry("Commandline", cmd.command);
0428         cg.writeEntry("Description", cmd.description);
0429         cg.writeEntry("Enabled", cmd.isEnabled);
0430         cg.writeEntry("Icon", cmd.icon);
0431         cg.writeEntry("Output", static_cast<int>(cmd.output));
0432 
0433         ++i;
0434     }
0435 }