1 /*
2     Copyright (c) 2020, Lukas Holecek <hluk@email.cz>
3 
4     This file is part of CopyQ.
5 
6     CopyQ is free software: you can redistribute it and/or modify
7     it under the terms of the GNU General Public License as published by
8     the Free Software Foundation, either version 3 of the License, or
9     (at your option) any later version.
10 
11     CopyQ is distributed in the hope that it will be useful,
12     but WITHOUT ANY WARRANTY; without even the implied warranty of
13     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14     GNU General Public License for more details.
15 
16     You should have received a copy of the GNU General Public License
17     along with CopyQ.  If not, see <http://www.gnu.org/licenses/>.
18 */
19 
20 #include "common/common.h"
21 
22 #include "common/display.h"
23 #include "common/log.h"
24 #include "common/mimetypes.h"
25 #include "common/textdata.h"
26 
27 #include <QApplication>
28 #include <QBuffer>
29 #include <QDropEvent>
30 #include <QElapsedTimer>
31 #include <QFont>
32 #include <QFontMetrics>
33 #include <QImage>
34 #include <QImageWriter>
35 #include <QKeyEvent>
36 #include <QMimeData>
37 #include <QMovie>
38 #include <QObject>
39 #include <QProcess>
40 #include <QRegularExpression>
41 #include <QTextCodec>
42 #include <QThread>
43 #include <QUrl>
44 
45 #ifdef COPYQ_WS_X11
46 # include "platform/x11/x11platform.h"
47 # include <QTimer>
48 #endif
49 
50 #include <algorithm>
51 #include <memory>
52 
53 namespace {
54 
55 const int maxElidedTextLineLength = 512;
56 
57 #ifdef COPYQ_WS_X11
58 // WORKAROUND: This fixes stuck clipboard access by creating dummy X11 events
59 //             when accessing clipboard takes too long.
60 class WakeUpThread final {
61 public:
WakeUpThread()62     WakeUpThread()
63     {
64         m_timerWakeUp.setInterval(100);
65         QObject::connect( &m_timerWakeUp, &QTimer::timeout, []() {
66             sendDummyX11Event();
67         });
68 
69         m_timerWakeUp.moveToThread(&m_wakeUpThread);
70         QObject::connect( &m_wakeUpThread, &QThread::started,
71                           &m_timerWakeUp, [this]() { m_timerWakeUp.start(); } );
72         QObject::connect( &m_wakeUpThread, &QThread::finished,
73                           &m_timerWakeUp, &QTimer::stop );
74         m_wakeUpThread.start();
75     }
76 
~WakeUpThread()77     ~WakeUpThread()
78     {
79         m_wakeUpThread.quit();
80         m_wakeUpThread.wait();
81     }
82 
83 private:
84     QTimer m_timerWakeUp;
85     QThread m_wakeUpThread;
86 };
87 #endif
88 
89 class MimeData final : public QMimeData {
90 protected:
retrieveData(const QString & mimeType,QVariant::Type preferredType) const91     QVariant retrieveData(const QString &mimeType, QVariant::Type preferredType) const override {
92         COPYQ_LOG_VERBOSE( QString("Providing \"%1\"").arg(mimeType) );
93         return QMimeData::retrieveData(mimeType, preferredType);
94     }
95 };
96 
97 // Avoids accessing old clipboard/drag'n'drop data.
98 class ClipboardDataGuard final {
99 public:
100     class ElapsedGuard {
101     public:
ElapsedGuard(const QString & format)102         explicit ElapsedGuard(const QString &format)
103             : m_format(format)
104         {
105             COPYQ_LOG_VERBOSE( QString("Accessing \"%1\"").arg(format) );
106             m_elapsed.start();
107         }
108 
~ElapsedGuard()109         ~ElapsedGuard()
110         {
111             const auto t = m_elapsed.elapsed();
112             if (t > 500)
113                 log( QString("ELAPSED %1 ms acessing \"%2\"").arg(t).arg(m_format), LogWarning );
114         }
115     private:
116         QString m_format;
117         QElapsedTimer m_elapsed;
118     };
119 
ClipboardDataGuard(const QMimeData & data,bool * abortCloning=nullptr)120     explicit ClipboardDataGuard(const QMimeData &data, bool *abortCloning = nullptr)
121         : m_dataGuard(&data)
122         , m_abort(abortCloning)
123     {
124         m_timerExpire.start();
125     }
126 
hasFormat(const QString & mime)127     bool hasFormat(const QString &mime)
128     {
129         ElapsedGuard _("has:" + mime);
130         return refresh() && m_dataGuard->hasFormat(mime);
131     }
132 
data(const QString & mime)133     QByteArray data(const QString &mime)
134     {
135         ElapsedGuard _(mime);
136         return refresh() ? m_dataGuard->data(mime) : QByteArray();
137     }
138 
urls()139     QList<QUrl> urls()
140     {
141         ElapsedGuard _("urls");
142         return refresh() ? m_dataGuard->urls() : QList<QUrl>();
143     }
144 
text()145     QString text()
146     {
147         ElapsedGuard _("text");
148         return refresh() ? m_dataGuard->text() : QString();
149     }
150 
hasText()151     bool hasText()
152     {
153         ElapsedGuard _("hasText");
154         return refresh() && m_dataGuard->hasText();
155     }
156 
getImageData()157     QImage getImageData()
158     {
159         ElapsedGuard _("imageData");
160         if (!refresh())
161             return QImage();
162 
163         // NOTE: Application hangs if using mulitple sessions and
164         //       calling QMimeData::hasImage() on X11 clipboard.
165         COPYQ_LOG_VERBOSE("Fetching image data from clipboard");
166         const QImage image = m_dataGuard->imageData().value<QImage>();
167         COPYQ_LOG_VERBOSE( QString("Image is %1").arg(image.isNull() ? "invalid" : "valid") );
168         return image;
169     }
170 
getUtf8Data(const QString & format)171     QByteArray getUtf8Data(const QString &format)
172     {
173         ElapsedGuard _("UTF8:" + format);
174         if (!refresh())
175             return QByteArray();
176 
177         if (format == mimeUriList) {
178             QByteArray bytes;
179             for ( const auto &url : urls() ) {
180                 if ( !bytes.isEmpty() )
181                     bytes += '\n';
182                 bytes += url.toString().toUtf8();
183             }
184             return bytes;
185         }
186 
187         if ( format == mimeText && !hasFormat(mimeText) )
188             return text().toUtf8();
189 
190         if ( format.startsWith("text/") )
191             return dataToText( data(format), format ).toUtf8();
192 
193         return data(format);
194     }
195 
196 private:
refresh()197     bool refresh()
198     {
199         if (m_abort && *m_abort)
200             return false;
201 
202         if (m_dataGuard.isNull())
203             return false;
204 
205         const auto elapsed = m_timerExpire.elapsed();
206         if (elapsed > 5000) {
207             log("Clipboard data expired, refusing to access old data", LogWarning);
208             m_dataGuard = nullptr;
209             return false;
210         }
211 
212         if (elapsed > 100)
213             QCoreApplication::processEvents();
214 
215         return !m_dataGuard.isNull();
216     }
217 
218     QPointer<const QMimeData> m_dataGuard;
219     QElapsedTimer m_timerExpire;
220     bool *m_abort = nullptr;
221 
222 #ifdef COPYQ_WS_X11
223     WakeUpThread m_wakeUpThread;
224 #endif
225 };
226 
getImageFormatFromMime(const QString & mime)227 QString getImageFormatFromMime(const QString &mime)
228 {
229     const auto imageMimePrefix = "image/";
230     const auto prefixLength = static_cast<int>(strlen(imageMimePrefix));
231     return mime.startsWith(imageMimePrefix) ? mime.mid(prefixLength) : QString();
232 }
233 
234 /**
235  * Sometimes only Qt internal image data are available in cliboard,
236  * so this tries to convert the image data (if available) to given format.
237  */
cloneImageData(const QImage & image,const QString & format,const QString & mime,QVariantMap * dataMap)238 void cloneImageData(
239         const QImage &image, const QString &format,
240         const QString &mime, QVariantMap *dataMap)
241 {
242     if (image.isNull())
243         return;
244 
245     // Omit converting unsupported formats (takes too much time and still fails).
246     if ( !QImageWriter::supportedImageFormats().contains(format.toUtf8()) )
247         return;
248 
249     QBuffer buffer;
250     bool saved = image.save(&buffer, format.toUtf8().constData());
251 
252     COPYQ_LOG( QString("Converting image to \"%1\" format: %2")
253                .arg(format,
254                     saved ? "Done" : "Failed") );
255 
256     if (saved)
257         dataMap->insert(mime, buffer.buffer());
258 }
259 
260 /**
261  * Allow cloning images only with reasonable size.
262  */
canCloneImageData(const QImage & image)263 bool canCloneImageData(const QImage &image)
264 {
265     return !image.isNull()
266         && image.height() <= 4096
267         && image.width() <= 4096;
268 }
269 
setImageData(const QVariantMap & data,const QString & mime,QMimeData * mimeData)270 bool setImageData(const QVariantMap &data, const QString &mime, QMimeData *mimeData)
271 {
272     if ( !data.contains(mime) )
273         return false;
274 
275     const QString imageFormat = getImageFormatFromMime(mime);
276     if ( imageFormat.isEmpty() )
277         return false;
278 
279     QByteArray bytes = data.value(mime).toByteArray();
280 
281     // Omit converting animated images to static ones.
282     QBuffer buffer(&bytes);
283     QMovie animatedImage( &buffer, imageFormat.toUtf8().constData() );
284     if ( animatedImage.frameCount() > 1 )
285         return false;
286 
287     const QImage image = QImage::fromData( bytes, imageFormat.toUtf8().constData() );
288     if ( image.isNull() )
289         return false;
290 
291     mimeData->setImageData(image);
292     return true;
293 }
294 
codecForText(const QByteArray & bytes)295 QTextCodec *codecForText(const QByteArray &bytes)
296 {
297     // Guess unicode codec for text if BOM is missing.
298     if (bytes.size() >= 2 && bytes.size() % 2 == 0) {
299         if (bytes.size() >= 4 && bytes.size() % 4 == 0) {
300             if (bytes.at(0) == 0 && bytes.at(1) == 0)
301                 return QTextCodec::codecForName("utf-32be");
302             if (bytes.at(2) == 0 && bytes.at(3) == 0)
303                 return QTextCodec::codecForName("utf-32le");
304         }
305 
306         if (bytes.at(0) == 0)
307             return QTextCodec::codecForName("utf-16be");
308 
309         if (bytes.at(1) == 0)
310             return QTextCodec::codecForName("utf-16le");
311     }
312 
313     return QTextCodec::codecForName("utf-8");
314 }
315 
findFormatsWithPrefix(bool hasPrefix,const QString & prefix,const QVariantMap & data)316 bool findFormatsWithPrefix(bool hasPrefix, const QString &prefix, const QVariantMap &data)
317 {
318     for (auto it = data.constBegin(); it != data.constEnd(); ++it) {
319         if ( it.key().startsWith(prefix) == hasPrefix )
320             return hasPrefix;
321     }
322 
323     return !hasPrefix;
324 }
325 
326 } // namespace
327 
isMainThread()328 bool isMainThread()
329 {
330     return QThread::currentThread() == qApp->thread();
331 }
332 
cloneData(const QMimeData & rawData,QStringList formats,bool * abortCloning)333 QVariantMap cloneData(const QMimeData &rawData, QStringList formats, bool *abortCloning)
334 {
335     ClipboardDataGuard data(rawData, abortCloning);
336 
337     const auto internalMimeTypes = {mimeOwner, mimeWindowTitle, mimeItemNotes, mimeHidden};
338 
339     QVariantMap newdata;
340 
341     /*
342      Some apps provide images even when copying huge spreadsheet, this can
343      block those apps while generating and providing the data.
344 
345      This code removes ignores any image data if text is available.
346 
347      Images in SVG and other XML formats are expected to be relatively small
348      so these doesn't have to be ignored.
349      */
350     if ( formats.contains(mimeText) && data.hasText() ) {
351         const QString mimeImagePrefix = "image/";
352         const auto first = std::remove_if(
353                     std::begin(formats), std::end(formats),
354                     [&mimeImagePrefix](const QString &format) {
355                         return format.startsWith(mimeImagePrefix)
356                             && !format.contains("xml")
357                             && !format.contains("svg");
358                     });
359         formats.erase(first, std::end(formats));
360     }
361 
362     QStringList imageFormats;
363     for (const auto &mime : formats) {
364         const QByteArray bytes = data.getUtf8Data(mime);
365         if ( bytes.isEmpty() )
366             imageFormats.append(mime);
367         else
368             newdata.insert(mime, bytes);
369     }
370 
371     for (const auto &internalMime : internalMimeTypes) {
372         if ( data.hasFormat(internalMime) )
373             newdata.insert( internalMime, data.data(internalMime) );
374     }
375 
376     // Retrieve images last since this can take a while.
377     if ( !imageFormats.isEmpty() ) {
378         const QImage image = data.getImageData();
379         if ( canCloneImageData(image) ) {
380             for (const auto &mime : imageFormats) {
381                 const QString format = getImageFormatFromMime(mime);
382                 if ( !format.isEmpty() )
383                     cloneImageData(image, format, mime, &newdata);
384             }
385         }
386     }
387 
388     // Drop duplicate UTF-8 text format.
389     if ( newdata.contains(mimeTextUtf8) && newdata[mimeTextUtf8] == newdata.value(mimeText) )
390         newdata.remove(mimeTextUtf8);
391 
392     return newdata;
393 }
394 
cloneData(const QMimeData & data)395 QVariantMap cloneData(const QMimeData &data)
396 {
397     QStringList formats;
398 
399     for ( const auto &mime : data.formats() ) {
400         // ignore uppercase mimetypes (e.g. UTF8_STRING, TARGETS, TIMESTAMP)
401         // and internal type to check clipboard owner
402         if ( !mime.isEmpty() && mime[0].isLower() )
403             formats.append(mime);
404     }
405 
406     if ( !formats.contains(mimeText) ) {
407         const QString textPrefix(QLatin1String(mimeText) + ";");
408         bool containsText = false;
409         for (int i = formats.size() - 1; i >= 0; --i) {
410             if ( formats[i].startsWith(textPrefix) ) {
411                 formats.removeAt(i);
412                 containsText = true;
413             }
414         }
415         if (containsText)
416             formats.append(mimeText);
417     }
418 
419     return cloneData(data, formats);
420 }
421 
createMimeData(const QVariantMap & data)422 QMimeData* createMimeData(const QVariantMap &data)
423 {
424     QStringList copyFormats = data.keys();
425     copyFormats.removeOne(mimeClipboardMode);
426 
427     std::unique_ptr<QMimeData> newClipboardData(new MimeData);
428 
429     for ( const auto &format : copyFormats )
430         newClipboardData->setData( format, data[format].toByteArray() );
431 
432     if ( !copyFormats.contains(mimeOwner) ) {
433         const auto owner = makeClipboardOwnerData();
434         if ( !owner.isEmpty() )
435             newClipboardData->setData( mimeOwner, owner );
436     }
437 
438     // Set image data.
439     const QStringList formats =
440             QStringList() << "image/png" << "image/bmp" << "application/x-qt-image" << data.keys();
441     for (const auto &imageFormat : formats) {
442         if ( setImageData(data, imageFormat, newClipboardData.get()) )
443             break;
444     }
445 
446     return newClipboardData.release();
447 }
448 
anySessionOwnsClipboardData(const QVariantMap & data)449 bool anySessionOwnsClipboardData(const QVariantMap &data)
450 {
451     return data.contains(mimeOwner);
452 }
453 
elideText(const QString & text,const QFont & font,const QString & format,bool escapeAmpersands,int maxWidthPixels,int maxLines)454 QString elideText(const QString &text, const QFont &font, const QString &format,
455                   bool escapeAmpersands, int maxWidthPixels, int maxLines)
456 {
457     if ( text.isEmpty() )
458         return QString();
459 
460     if (maxWidthPixels <= 0)
461         maxWidthPixels = smallIconSize() * 20;
462 
463     QStringList lines = text.split('\n');
464 
465     int firstLine = -1;
466     int lastLine = -1;
467     int commonIndent = text.size();
468     static const QRegularExpression reNonSpace("\\S");
469     for (int i = 0; i < lines.size(); ++i) {
470         const auto &line = lines[i];
471         const int lineIndent = line.indexOf(reNonSpace);
472         if (lineIndent == -1)
473             continue;
474 
475         if (firstLine == -1)
476             firstLine = i;
477 
478         lastLine = i;
479         if (lineIndent < commonIndent)
480             commonIndent = lineIndent;
481 
482         if (firstLine - lastLine + 1 >= maxLines)
483             break;
484     }
485 
486     if (lastLine == -1)
487         return QLatin1String("...");
488 
489     // If there are too many lines, append triple dot.
490     if (lastLine + 1 != lines.size())
491         lines[lastLine].append("...");
492 
493     lines = lines.mid(firstLine, lastLine - firstLine + 1);
494 
495     QFontMetrics fm(font);
496 #if QT_VERSION >= QT_VERSION_CHECK(5,11,0)
497     const int formatWidth = format.isEmpty() ? 0 : fm.horizontalAdvance(format.arg(QString()));
498 #else
499     const int formatWidth = format.isEmpty() ? 0 : fm.width(format.arg(QString()));
500 #endif
501 
502     // Remove redundant spaces from single line text.
503     if (lines.size() == 1) {
504         lines[0] = lines[0].simplified();
505         commonIndent = 0;
506     }
507 
508     // Remove common indentation each line and elide text if too long.
509     for (auto &line : lines) {
510         line = line.mid(commonIndent);
511 
512         // Make eliding huge text faster.
513         if (line.size() > maxElidedTextLineLength)
514             line = line.left(maxElidedTextLineLength) + "...";
515 
516         line = fm.elidedText(line, Qt::ElideMiddle, maxWidthPixels - formatWidth);
517     }
518 
519     // If empty lines are at beginning, prepend triple dot.
520     if (firstLine != 0) {
521         if (lines.size() == 1)
522             lines.first().prepend("...");
523         else
524             lines.prepend("...");
525     }
526 
527     QString result = lines.join("\n");
528 
529     // Escape all ampersands.
530     if (escapeAmpersands)
531         result.replace( QLatin1Char('&'), QLatin1String("&&") );
532 
533     return format.isEmpty() ? result : format.arg(result);
534 }
535 
textLabelForData(const QVariantMap & data,const QFont & font,const QString & format,bool escapeAmpersands,int maxWidthPixels,int maxLines)536 QString textLabelForData(const QVariantMap &data, const QFont &font, const QString &format,
537                          bool escapeAmpersands, int maxWidthPixels, int maxLines)
538 {
539     QString label;
540 
541     const QString notes = data.value(mimeItemNotes).toString();
542 
543     if ( data.contains(mimeHidden) ) {
544         label = QObject::tr("<HIDDEN>", "Label for hidden/secret clipboard content");
545     } else if ( data.contains(mimeText) || data.contains(mimeUriList) ) {
546         const QString text = getTextData(data);
547         const int n = text.count(QChar('\n')) + 1;
548 
549         if (n > 1)
550             label = QObject::tr("%1 (%n lines)", "Label for multi-line text in clipboard", n);
551         else
552             label = QLatin1String("%1");
553 
554         if (!format.isEmpty())
555             label = format.arg(label);
556 
557         const QString textWithNotes = notes.isEmpty() ? text : notes + ": " + text;
558         return elideText(textWithNotes, font, label, escapeAmpersands, maxWidthPixels, maxLines);
559     } else if ( findFormatsWithPrefix(true, "image/", data) ) {
560         label = QObject::tr("<IMAGE>", "Label for image in clipboard");
561     } else if ( data.contains(mimeItems) ) {
562         label = QObject::tr("<ITEMS>", "Label for copied items in clipboard");
563     } else if ( findFormatsWithPrefix(false, COPYQ_MIME_PREFIX, data) ) {
564         label = QObject::tr("<EMPTY>", "Label for empty clipboard");
565     } else {
566         label = QObject::tr("<DATA>", "Label for data in clipboard");
567     }
568 
569     if (!notes.isEmpty()) {
570         label = elideText(notes, font, QString(), escapeAmpersands, maxWidthPixels, maxLines)
571                 + ": " + label;
572     }
573 
574     if (!format.isEmpty())
575         label = format.arg(label);
576 
577     return label;
578 }
579 
textLabelForData(const QVariantMap & data)580 QString textLabelForData(const QVariantMap &data)
581 {
582     return textLabelForData(data, QFont(), QString());
583 }
584 
renameToUnique(QString * name,const QStringList & names)585 void renameToUnique(QString *name, const QStringList &names)
586 {
587     const QString baseName = *name;
588     int i = 0;
589     while ( names.contains(*name) )
590         *name = baseName + " (" + QString::number(++i) + ')';
591 }
592 
dataToText(const QByteArray & bytes,const QString & mime)593 QString dataToText(const QByteArray &bytes, const QString &mime)
594 {
595     auto codec = (mime == mimeHtml)
596             ? QTextCodec::codecForHtml(bytes, nullptr)
597             : QTextCodec::codecForUtfText(bytes, nullptr);
598 
599     if (!codec)
600         codec = codecForText(bytes);
601 
602     return codec->toUnicode(bytes);
603 }
604 
isClipboardData(const QVariantMap & data)605 bool isClipboardData(const QVariantMap &data)
606 {
607     return data.value(mimeClipboardMode).toByteArray().isEmpty();
608 }
609 
handleViKey(QKeyEvent * event,QObject * eventReceiver)610 bool handleViKey(QKeyEvent *event, QObject *eventReceiver)
611 {
612     int key = event->key();
613     Qt::KeyboardModifiers mods = event->modifiers();
614 
615     switch ( key ) {
616     case Qt::Key_G:
617         key = mods & Qt::ShiftModifier ? Qt::Key_End : Qt::Key_Home;
618         mods = mods & ~Qt::ShiftModifier;
619         break;
620     case Qt::Key_J:
621         key = Qt::Key_Down;
622         break;
623     case Qt::Key_K:
624         key = Qt::Key_Up;
625         break;
626     case Qt::Key_L:
627         key = Qt::Key_Return;
628         break;
629     case Qt::Key_F:
630     case Qt::Key_D:
631     case Qt::Key_B:
632     case Qt::Key_U:
633         if (mods & Qt::ControlModifier) {
634             key = (key == Qt::Key_F || key == Qt::Key_D) ? Qt::Key_PageDown : Qt::Key_PageUp;
635             mods = mods & ~Qt::ControlModifier;
636         } else {
637             return false;
638         }
639         break;
640     case Qt::Key_X:
641         key = Qt::Key_Delete;
642         break;
643     case Qt::Key_BracketLeft:
644         if (mods & Qt::ControlModifier) {
645             key = Qt::Key_Escape;
646             mods = mods & ~Qt::ControlModifier;
647         } else {
648             return false;
649         }
650         break;
651     default:
652         return false;
653     }
654 
655     QKeyEvent event2(QEvent::KeyPress, key, mods, event->text());
656     QCoreApplication::sendEvent(eventReceiver, &event2);
657     event->accept();
658 
659     return true;
660 }
661 
canDropToTab(const QDropEvent & event)662 bool canDropToTab(const QDropEvent &event)
663 {
664     const auto &data = *event.mimeData();
665     return data.hasFormat(mimeItems) || data.hasText() || data.hasImage() || data.hasUrls();
666 }
667 
acceptDrag(QDropEvent * event)668 void acceptDrag(QDropEvent *event)
669 {
670     // Default drop action in item list and tab bar/tree should be "move."
671     if ( event->possibleActions().testFlag(Qt::MoveAction)
672          && event->mimeData()->hasFormat(mimeOwner)
673          // WORKAROUND: Test currently pressed modifiers instead of the ones in event (QTBUG-57168).
674          && !QApplication::queryKeyboardModifiers().testFlag(Qt::ControlModifier) )
675     {
676         event->setDropAction(Qt::MoveAction);
677         event->accept();
678     } else {
679         event->acceptProposedAction();
680     }
681 }
682 
makeClipboardOwnerData()683 QByteArray makeClipboardOwnerData()
684 {
685     static const QVariant owner = qApp->property("CopyQ_session_name");
686     if ( !owner.isValid() )
687         return QByteArray();
688 
689     static int id = 0;
690     return owner.toString().toUtf8() + " " + logLabel() + "/" + QByteArray::number(++id);
691 }
692 
cloneText(const QMimeData & data)693 QString cloneText(const QMimeData &data)
694 {
695     const auto text = dataToText( data.data(mimeText), mimeText );
696     return text.isEmpty() ? data.text() : text;
697 }
698