Support pasting clipboard images as PNG attachments

This commit is contained in:
evgeny committed 2026-10-31 22:13:11 +01:00
1 parent c8012d35f1
commit 4d30fa6bcc
6 files changed
+546 -25

No files matched your search

+223 -11
View File
@@ -32,7 +32,14 @@
#include <QImageIOHandler>
#include <QUrl>
#include <QToolTip>
#include <QBuffer>
#include <QFile>
#include <QImageWriter>
#include <QPixmap>
#include <QRunnable>
#include <QTemporaryFile>
#include <cmath>
#include <functional>
extern "C" {
#include "video/video.h"
@@ -42,6 +49,105 @@ extern "C" {
quint64 InputBar::s_submissionSequence = 0;
namespace {
constexpr qint64 clipboardMaxBytes = 128 * 1024 * 1024;
constexpr qint64 clipboardMaxPixels = 32 * 1024 * 1024;
const QList<QByteArray> clipboardImageFormats = {"image/png", "image/jpeg", "image/bmp", "image/webp"};
bool hasClipboardImage(const QMimeData* source) {
if (!source) return false;
if (source->hasImage()) return true;
for (const auto& format : clipboardImageFormats) if (source->hasFormat(QString::fromLatin1(format))) return true;
return false;
}
bool validClipboardSize(const QSize& size) {
return size.width() > 0 && size.height() > 0 && size.width() <= 16384 && size.height() <= 16384 &&
qint64(size.width()) * size.height() <= clipboardMaxPixels;
}
/* Общая точка вставки для клавиатуры и контекстного меню; обычный текст обрабатывает QTextEdit. */
class ComposerTextEdit final : public QTextEdit {
public:
ComposerTextEdit(QWidget* parent, std::function<void(const QMimeData*)> pasteImage)
: QTextEdit(parent), m_pasteImage(std::move(pasteImage)) {}
protected:
bool canInsertFromMimeData(const QMimeData* source) const override {
return hasClipboardImage(source) || QTextEdit::canInsertFromMimeData(source);
}
void insertFromMimeData(const QMimeData* source) override {
if (hasClipboardImage(source)) {
if (isReadOnly()) { GUI_WARN("clipboard image: insertion rejected in read-only composer"); return; }
m_pasteImage(source);
} else QTextEdit::insertFromMimeData(source);
}
private:
std::function<void(const QMimeData*)> m_pasteImage;
};
}
/* Не QObject: результат worker и GUI совместно держат путь, последний владелец удаляет только свой PNG. */
struct ClipboardPng {
QString path, name;
QImage thumbnail;
QSize dimensions;
qint64 bytes = 0;
~ClipboardPng() {
if (path.isEmpty()) return;
QFile file(path);
if (!file.remove()) GUI_WARN("clipboard image: cleanup failed path=%s error=%s", qPrintable(path), qPrintable(file.errorString()));
else GUI_DEBUG("clipboard image: temporary PNG removed path=%s", qPrintable(path));
}
};
/* Возврат происходит после destructor QTemporaryFile: его native handle уже закрыт, в том числе в Windows. */
static std::shared_ptr<ClipboardPng> encodeClipboardPng(QImage image, const QByteArray& encoded,
const QString& name, QString& error) {
if (image.isNull()) {
QBuffer buffer; buffer.setData(encoded); buffer.open(QIODevice::ReadOnly);
QImageReader reader(&buffer); reader.setAutoTransform(true);
if (!reader.canRead()) error = reader.errorString();
else if (!validClipboardSize(reader.size())) error = QStringLiteral("invalid dimensions or more than 32 megapixels");
else { image = reader.read(); if (image.isNull()) error = reader.errorString(); }
}
if (error.isEmpty() && (!validClipboardSize(image.size()) || image.sizeInBytes() > clipboardMaxBytes))
error = QStringLiteral("image exceeds 16384 pixels per side, 32 megapixels or 128 MiB");
if (!error.isEmpty()) return {};
image.setDevicePixelRatio(1);
auto result = std::make_shared<ClipboardPng>();
QTemporaryFile file(QDir::tempPath() + "/vibechat-clipboard-XXXXXX.png");
if (!file.open()) { error = file.errorString(); return {}; }
result->path = file.fileName(); file.setAutoRemove(false);
QImageWriter writer(&file, "png");
if (!writer.write(image)) { error = writer.errorString(); return {}; }
if (!file.flush()) { error = file.errorString(); return {}; }
result->name = name; result->dimensions = image.size(); result->bytes = file.size();
result->thumbnail = image.scaled(80, 60, Qt::KeepAspectRatio, Qt::SmoothTransformation);
return result;
}
/* Независимый source для worker: удаление/замена черновика не влияет на запрос, а отказ не мешает повтору. */
static bool copyClipboardPng(const QString& source, struct attachment_send_req* request, QString& error) {
ClipboardPng temporary;
QTemporaryFile output(QDir::tempPath() + "/vibechat-clipboard-send-XXXXXX.png");
if (!output.open()) { error = output.errorString(); return false; }
temporary.path = output.fileName(); output.setAutoRemove(false);
const QByteArray path = temporary.path.toUtf8();
if (path.size() >= int(sizeof(request->source))) { error = QStringLiteral("temporary source path is too long"); return false; }
QFile input(source);
if (!input.open(QIODevice::ReadOnly)) { error = input.errorString(); return false; }
while (!input.atEnd()) {
const QByteArray bytes = input.read(65536);
if (input.error() != QFileDevice::NoError) { error = input.errorString(); return false; }
if (output.write(bytes) != bytes.size()) { error = output.errorString(); return false; }
}
if (!output.flush()) { error = output.errorString(); return false; }
snprintf(request->source, sizeof(request->source), "%s", path.constData());
request->temporary_source = 1;
temporary.path.clear();
return true;
}
static bool isImageFile(const QString& suffix) {
QString s = suffix.toLower();
return s == "jpg" || s == "jpeg" || s == "png" || s == "gif" ||
@@ -88,12 +194,13 @@ QString InputBar::formatFileSize(qint64 bytes) {
InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *parent)
: QWidget(parent)
, m_input(new QTextEdit(this))
, m_input(new ComposerTextEdit(this, [this](const QMimeData* source) { pasteImage(source); }))
, m_emojiBtn(nullptr)
, m_pttBtn(nullptr)
, m_emojiPanel(emojiPanel)
, m_recorder(recorder)
{
m_clipboardPool.setMaxThreadCount(1);
auto *mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(8, 4, 8, 0);
mainLayout->setSpacing(2);
@@ -114,7 +221,12 @@ InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *par
auto *prevLay = new QHBoxLayout(m_attachPreview);
prevLay->setContentsMargins(0, 2, 0, 2);
prevLay->setSpacing(4);
m_attachThumbnail = new QLabel(m_attachPreview);
m_attachThumbnail->setObjectName("clipboardImageThumbnail");
m_attachThumbnail->hide();
prevLay->addWidget(m_attachThumbnail);
m_attachPreviewLabel = new QLabel(m_attachPreview);
m_attachPreviewLabel->setTextFormat(Qt::PlainText);
m_attachPreviewLabel->setStyleSheet("background: palette(light); border-radius: 6px; padding: 2px 8px;");
prevLay->addWidget(m_attachPreviewLabel, 1);
m_encryptCheck = new QCheckBox(QCoreApplication::translate("ChatGui", "Encrypt attachment"), m_attachPreview);
@@ -137,6 +249,7 @@ InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *par
mainLayout->addWidget(m_attachPreview);
m_preparationLabel = new QLabel(this);
m_preparationLabel->setObjectName("mediaPreparationStatus");
m_preparationLabel->hide();
mainLayout->addWidget(m_preparationLabel);
@@ -244,6 +357,9 @@ InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *par
/* QWidget уничтожает дочерние viewport после полей InputBar: снимаем callbacks до их уничтожения. */
InputBar::~InputBar() {
cancelClipboardPreparation();
m_clipboardPool.waitForDone();
m_clipboardDraft.reset();
GUI_DEBUG("InputBar: removing file drop targets count=%d", int(m_dropTargets.size()));
for (auto* target : m_dropTargets) {
target->removeEventFilter(this);
@@ -281,7 +397,7 @@ bool InputBar::submissionPending() const {
/* Канальный текст и каждая часть ответа имеют собственное подтверждение и снимок черновика. */
quint64 InputBar::beginSubmission(const QByteArray& reply, quint64 generation, const QString& target,
const QString& text, const QString& source) {
if (reply.isEmpty() && text.isEmpty() && (source.isEmpty() || m_dmMode)) return 0;
if (reply.isEmpty() && text.isEmpty() && (source.isEmpty() || (m_dmMode && !m_clipboardDraft))) return 0;
const quint64 request = ++s_submissionSequence;
m_pendingSubmissions.insert(request, {generation, target, text, source, m_input->document()->revision(), m_attachmentRevision});
GUI_DEBUG("send: pending ch=%s reply=%s request=%llu", qPrintable(target), reply.toHex().constData(), (unsigned long long)request);
@@ -319,7 +435,8 @@ void InputBar::submissionResult(quint64 request, bool success) {
}
if (!pending.text.isEmpty() && pending.textRevision == m_input->document()->revision()) m_input->clear();
if (!pending.source.isEmpty() && pending.attachmentRevision == m_attachmentRevision) clearPendingAttachment();
if (!submissionPending() && !m_submissionFailed && m_input->toPlainText().isEmpty() && m_pendingAttachSrc.isEmpty()) clearReply();
if (!submissionPending() && !m_submissionFailed && !m_clipboardPreparing &&
m_input->toPlainText().isEmpty() && m_pendingAttachSrc.isEmpty()) clearReply();
}
/* Черновик и выбранный ответ восстанавливаются после сохранения/отмены. Вложения остаются в composer. */
@@ -327,6 +444,7 @@ void InputBar::editMessage(const QByteArray& id, quint64 version, const QString&
if (m_dmMode || id.size() != 32 || !version || m_editRequest || submissionPending() || m_pttActive) {
GUI_WARN("edit: composer busy/invalid target=%s", qPrintable(m_channelIdForRecord)); return;
}
cancelClipboardPreparation();
if (!m_editId.isEmpty()) cancelEdit();
m_savedText = m_input->toPlainText(); m_savedReply = m_replyTo; m_savedReplyLabel = m_replyLabel->text();
clearReply(); m_editId = id; m_editVersion = version;
@@ -369,6 +487,78 @@ void InputBar::rejectAttachment(const QString& reason, const QString& source) {
emit attachmentRejected(reason);
}
void InputBar::cancelClipboardPreparation() {
++m_clipboardGeneration;
m_clipboardPool.clear();
m_clipboardPreparing = false;
showPreparation();
}
void InputBar::pasteImage(const QMimeData* source) {
if (!canAttachFile()) {
rejectAttachment(QCoreApplication::translate("ChatGui", "Images cannot be pasted in the current composer state.")); return;
}
const quint64 context = m_attachmentContext, generation = ++m_clipboardGeneration;
const QString target = m_channelIdForRecord;
m_clipboardPool.clear(); m_clipboardPreparing = true; showPreparation();
GUI_DEBUG("clipboard image: snapshot target=%s generation=%llu formats=%s", qPrintable(target),
(unsigned long long)generation, qPrintable(source->formats().join(",")));
QImage image;
QByteArray representation = "Qt image";
if (source->hasImage()) {
const QVariant data = source->imageData();
image = qvariant_cast<QImage>(data);
if (image.isNull() && data.canConvert<QPixmap>()) image = qvariant_cast<QPixmap>(data).toImage();
}
QByteArray encoded;
if (image.isNull()) for (const auto& format : clipboardImageFormats) {
if (!source->hasFormat(QString::fromLatin1(format))) continue;
encoded = source->data(QString::fromLatin1(format));
if (!encoded.isEmpty()) { representation = format; break; }
}
if (context != m_attachmentContext || generation != m_clipboardGeneration) {
GUI_DEBUG("clipboard image: snapshot superseded target=%s generation=%llu", qPrintable(target),
(unsigned long long)generation); return;
}
if ((!image.isNull() && (!validClipboardSize(image.size()) || image.sizeInBytes() > clipboardMaxBytes)) ||
encoded.size() > clipboardMaxBytes || (image.isNull() && encoded.isEmpty())) {
m_clipboardPreparing = false; showPreparation();
GUI_WARN("clipboard image: invalid snapshot target=%s dimensions=%dx%d image_bytes=%lld encoded_bytes=%lld",
qPrintable(target), image.width(), image.height(), (long long)image.sizeInBytes(), (long long)encoded.size());
rejectAttachment(QCoreApplication::translate("ChatGui", "Cannot paste image: invalid data or more than 32 megapixels / 128 MiB."));
return;
}
const QString name = "clipboard-" + QDateTime::currentDateTime().toString("yyyyMMdd-HHmmss-zzz") + ".png";
++m_attachmentRevision;
GUI_INFO("clipboard image: preparing PNG target=%s generation=%llu representation=%s dimensions=%dx%d encoded_bytes=%lld",
qPrintable(target), (unsigned long long)generation, representation.constData(), image.width(), image.height(),
(long long)encoded.size());
m_clipboardPool.start(QRunnable::create([this, image, encoded, name, target, context, generation] {
QString error;
const auto result = encodeClipboardPng(image, encoded, name, error);
if (!result) GUI_ERROR("clipboard image: PNG preparation failed target=%s reason=%s", qPrintable(target), qPrintable(error));
if (!QMetaObject::invokeMethod(this, [this, result, error, target, context, generation] {
if (context != m_attachmentContext || generation != m_clipboardGeneration) {
GUI_DEBUG("clipboard image: stale PNG discarded target=%s generation=%llu", qPrintable(target),
(unsigned long long)generation); return;
}
m_clipboardPreparing = false; showPreparation();
if (!result) {
rejectAttachment(QCoreApplication::translate("ChatGui", "Could not prepare clipboard image: %1").arg(error)); return;
}
if (!prepareAttachment(result->path, context)) return;
m_clipboardDraft = result;
m_pendingAttachName = result->name;
m_attachPreviewLabel->setText(QString("%1 (%2, %3×%4)").arg(result->name, formatFileSize(result->bytes))
.arg(result->dimensions.width()).arg(result->dimensions.height()));
m_attachThumbnail->setPixmap(QPixmap::fromImage(result->thumbnail)); m_attachThumbnail->show();
GUI_INFO("clipboard image: preview ready target=%s source=%s dimensions=%dx%d bytes=%lld",
qPrintable(target), qPrintable(result->path), result->dimensions.width(), result->dimensions.height(),
(long long)result->bytes);
}, Qt::QueuedConnection)) GUI_ERROR("clipboard image: cannot queue PNG result target=%s", qPrintable(target));
}));
}
bool InputBar::eventFilter(QObject *obj, QEvent *event) {
if (m_dropTargets.contains(obj)) {
if (event->type() == QEvent::DragLeave) {
@@ -464,6 +654,9 @@ void InputBar::onSendClicked() {
m_editRequest = ++s_submissionSequence; m_input->setReadOnly(true);
emit saveEdit(m_channelIdForRecord, m_editId, m_editVersion, text, m_editRequest); return;
}
if (m_clipboardPreparing) {
GUI_WARN("send: clipboard PNG still preparing target=%s", qPrintable(m_channelIdForRecord)); return;
}
if (submissionPending()) {
GUI_WARN("send: submission still pending ch=%s", qPrintable(m_channelIdForRecord)); return;
}
@@ -486,15 +679,17 @@ void InputBar::onSendClicked() {
const bool typedImage = m_pendingAttachAsImage &&
QStringList{"jpg", "jpeg", "png"}.contains(QFileInfo(attachmentSource).suffix().toLower());
if (typedImage) {
QImageReader reader(attachmentSource); reader.setAutoTransform(true);
const QByteArray format = reader.format().toLower();
QSize size = reader.size();
if (reader.transformation() & QImageIOHandler::TransformationRotate90) size.transpose();
QByteArray format; QSize size; QString readError; bool readable;
{ // Reader должен закрыть исходник до синхронного submissionResult, который может удалить PNG черновика.
QImageReader reader(attachmentSource); reader.setAutoTransform(true);
format = reader.format().toLower(); size = reader.size(); readable = reader.canRead(); readError = reader.errorString();
if (reader.transformation() & QImageIOHandler::TransformationRotate90) size.transpose();
}
const QByteArray source = attachmentSource.toUtf8(), name = attachmentName.toUtf8();
if (!reader.canRead() || (format != "jpeg" && format != "png") || size.width() <= 0 || size.height() <= 0 ||
if (!readable || (format != "jpeg" && format != "png") || size.width() <= 0 || size.height() <= 0 ||
size.width() > 16384 || size.height() > 16384 || source.size() >= 1024 || name.size() >= 256) {
GUI_ERROR("image rejected: source=%s format=%s dimensions=%dx%d error=%s", qPrintable(attachmentSource),
format.constData(), size.width(), size.height(), qPrintable(reader.errorString()));
format.constData(), size.width(), size.height(), qPrintable(readError));
submissionResult(requestId, false);
rejectAttachment(QCoreApplication::translate("ChatGui", "Cannot read the image or its dimensions are too large."), attachmentSource);
return;
@@ -508,6 +703,15 @@ void InputBar::onSendClicked() {
snprintf(request->info.name, sizeof(request->info.name), "%s", name.constData());
if (reply.size() == 32) memcpy(request->reply_to, reply.constData(), 32);
request->request_id = requestId;
if (m_clipboardDraft) {
QString error;
if (!copyClipboardPng(attachmentSource, request, error)) {
GUI_ERROR("clipboard image: send snapshot failed source=%s reason=%s", qPrintable(attachmentSource), qPrintable(error));
attachment_send_discard(request); submissionResult(requestId, false); return;
}
GUI_DEBUG("clipboard image: send owns snapshot target=%s draft=%s source=%s request=%llu", qPrintable(target),
qPrintable(attachmentSource), request->source, (unsigned long long)requestId);
}
GUI_INFO("image queued: target=%s format=%s dimensions=%dx%d encrypted=%d", qPrintable(target),
format.constData(), size.width(), size.height(), encrypted);
emit submitAttachment(target, request);
@@ -615,13 +819,14 @@ void InputBar::setMediaEnabled(bool enabled) {
if (m_encryptCheck) m_encryptCheck->setEnabled(enabled);
if (m_pttBtn) m_pttBtn->setEnabled(enabled && m_editId.isEmpty());
if (m_attachBtn) m_attachBtn->setEnabled(enabled && m_editId.isEmpty());
if (!enabled) cancelClipboardPreparation();
if (!enabled && m_dropHint) m_dropHint->hide();
}
void InputBar::onPttPressed() {
if (!m_editId.isEmpty()) { GUI_WARN("voice: blocked while editing"); return; }
if (!isEnabled() || m_channelIdForRecord.isEmpty()) { GUI_WARN("voice: composer unavailable"); return; }
if (submissionPending()) { GUI_WARN("send: recording blocked while submission pending"); return; }
if (submissionPending() || m_clipboardPreparing) { GUI_WARN("send: recording blocked while submission/PNG pending"); return; }
if (!m_recorder || !m_recorder->isInitialized()) return;
m_recorder->startRecording();
m_pttActive = m_recorder->isRecording();
@@ -724,6 +929,9 @@ bool InputBar::prepareAttachment(const QString& srcPath, quint64 context) {
}
m_encryptCheck->setChecked(false);
cancelClipboardPreparation();
m_clipboardDraft.reset();
m_attachThumbnail->clear(); m_attachThumbnail->hide();
m_pendingAttachSrc = srcPath;
++m_attachmentRevision;
m_pendingAttachDest = destPath;
@@ -744,6 +952,9 @@ bool InputBar::prepareAttachment(const QString& srcPath, quint64 context) {
}
void InputBar::clearPendingAttachment() {
cancelClipboardPreparation();
m_clipboardDraft.reset();
m_attachThumbnail->clear(); m_attachThumbnail->hide();
m_encryptCheck->setChecked(false);
++m_attachmentRevision;
m_pendingAttachSrc.clear();
@@ -772,7 +983,8 @@ void InputBar::updatePreparation(const QString& target, const QByteArray& job, i
void InputBar::showPreparation() {
QString status;
if (!m_preparations.value(m_channelIdForRecord).isEmpty()) status = QCoreApplication::translate("ChatGui", "Preparing media…");
if (m_clipboardPreparing || !m_preparations.value(m_channelIdForRecord).isEmpty())
status = QCoreApplication::translate("ChatGui", "Preparing media…");
else if (m_preparationErrors.contains(m_channelIdForRecord)) status = QCoreApplication::translate("ChatGui", "Failed to prepare attachment");
m_preparationLabel->setText(status);
m_preparationLabel->setVisible(!status.isEmpty());