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