Line data Source code
1 : // SPDX-FileCopyrightText: 2026 Anne Jan Brouwer
2 : // SPDX-License-Identifier: GPL-3.0-or-later
3 : /**
4 : * @file
5 : * @brief Implements ClipboardManager and the per-platform clipboard hints that
6 : * keep secrets out of clipboard histories.
7 : */
8 : #include "clipboardmanager.h"
9 : #include "qtpasssettings.h"
10 : #include "util.h"
11 : #include <QApplication>
12 : #include <QClipboard>
13 : #include <utility>
14 :
15 156 : ClipboardManager::ClipboardManager(QObject *parent) : QObject(parent) {
16 156 : setAutoclearTimer();
17 156 : m_clearTimer.setSingleShot(true);
18 156 : connect(&m_clearTimer, &QTimer::timeout, this, &ClipboardManager::clear);
19 156 : connect(QApplication::instance(), &QApplication::aboutToQuit, this,
20 156 : &ClipboardManager::clear);
21 156 : }
22 :
23 160 : void ClipboardManager::setAutoclearTimer() {
24 320 : m_clearTimer.setInterval(MS_PER_SECOND *
25 320 : QtPassSettings::getAutoclearSeconds());
26 160 : }
27 :
28 18 : void ClipboardManager::copyIfAlways(const QString &text,
29 : const QString &p_output) {
30 18 : const AppSettings s = QtPassSettings::load();
31 : // Only "always copy" mode actually places text on the clipboard here; in
32 : // on-demand mode the copy happens later from the field's copy button.
33 : // Crucially, do not touch m_clippedText otherwise — it tracks what is
34 : // currently on the clipboard so the autoclear timer knows what to clear.
35 : // Overwriting it when merely showing another entry left a previously copied
36 : // password on the clipboard past its autoclear window (#1607).
37 18 : if (s.clipBoardType == Enums::CLIPBOARD_ALWAYS && !p_output.isEmpty()) {
38 2 : copyText(text);
39 : }
40 18 : }
41 :
42 31 : void ClipboardManager::copyText(const QString &text) {
43 31 : const AppSettings s = QtPassSettings::load();
44 31 : QClipboard *clip = QApplication::clipboard();
45 :
46 : QClipboard::Mode mode = QClipboard::Clipboard;
47 31 : if (s.useSelection && clip->supportsSelection()) {
48 : mode = QClipboard::Selection;
49 : }
50 :
51 : // What was there before the first secret, to put back when autoclear
52 : // runs (as pass -c does). Text only, as pass keeps it: rich content comes
53 : // back as its plain text. Where Qt cannot read the clipboard without focus
54 : // (some Wayland compositors, a copy from the tray), "before" reads as
55 : // nothing and autoclear clears instead: the safe way round.
56 31 : if (s.useAutoclear && s.restoreClipboard) {
57 17 : std::optional<QString> &previous = mode == QClipboard::Selection
58 : ? m_previousSelection
59 : : m_previousClipboard;
60 17 : const QString now = clip->text(mode);
61 : // Taken once per window, at the first copy, whatever is there: a text
62 : // of ours, or the very text being copied, is saved as nothing (restoring
63 : // it would leave a secret on the clipboard for good), and a later copy
64 : // in the window cannot take a snapshot instead.
65 17 : if (!previous) {
66 : const bool safe =
67 11 : now != text && now != m_clippedText && !m_otherCopies.contains(now);
68 22 : previous = safe ? now : QString();
69 : }
70 : }
71 31 : forgetIfSaved(text);
72 31 : clip->setMimeData(buildClipboardMimeData(text), mode);
73 :
74 31 : m_clippedText = text;
75 31 : emit statusMessage(tr("Copied to clipboard"));
76 31 : if (s.useAutoclear) {
77 21 : m_clearTimer.start();
78 : }
79 31 : }
80 :
81 82 : void ClipboardManager::forgetIfSaved(const QString &text) {
82 : // The saved text is copied as a secret now: putting it back would leave
83 : // that secret on the clipboard. Emptied, not dropped, so no later copy
84 : // takes a new snapshot in this window either; clear() then clears.
85 183 : for (std::optional<QString> *previous :
86 246 : {&m_previousClipboard, &m_previousSelection}) {
87 164 : if (*previous && **previous == text) {
88 4 : *previous = QString();
89 : }
90 : }
91 82 : }
92 :
93 51 : void ClipboardManager::trackCopied(const QString &text) {
94 51 : if (text.isEmpty()) {
95 : return;
96 : }
97 51 : forgetIfSaved(text);
98 : // Kept beside m_clippedText, not instead of it: replacing it would leave a
99 : // password copied earlier on the clipboard past its autoclear (#1607).
100 : constexpr qsizetype kMaxOtherCopies = 16;
101 51 : if (text != m_clippedText && !m_otherCopies.contains(text)) {
102 : // A mouse selection reports itself on every change while it grows or
103 : // shrinks (the X11 primary selection); only where it ends counts, or a
104 : // passing "a" would stay tracked and autoclear could take someone
105 : // else's "a" later.
106 : // An earlier copy still on the clipboard proper (Ctrl+C) stays tracked.
107 : const QString &last =
108 51 : m_otherCopies.isEmpty() ? QString() : m_otherCopies.last();
109 138 : if (!last.isEmpty() && (text.contains(last) || last.contains(text)) &&
110 54 : QApplication::clipboard()->text(QClipboard::Clipboard) != last) {
111 2 : m_otherCopies.last() = text;
112 : } else {
113 49 : if (m_otherCopies.size() >= kMaxOtherCopies) {
114 : // Make room, but never by forgetting a text that is still on the
115 : // clipboard or in the selection: it would then outlive autoclear.
116 : // At most two are, so the oldest of the rest can always go.
117 : const QString onClipboard =
118 25 : QApplication::clipboard()->text(QClipboard::Clipboard);
119 : const QString inSelection =
120 25 : QApplication::clipboard()->text(QClipboard::Selection);
121 50 : for (qsizetype i = 0; i < m_otherCopies.size(); ++i) {
122 50 : if (m_otherCopies.at(i) != onClipboard &&
123 : m_otherCopies.at(i) != inSelection) {
124 : m_otherCopies.removeAt(i);
125 : break;
126 : }
127 : }
128 : }
129 : m_otherCopies.append(text);
130 : }
131 : }
132 51 : if (QtPassSettings::load().useAutoclear) {
133 51 : m_clearTimer.start();
134 : }
135 : }
136 :
137 34 : void ClipboardManager::clear() {
138 : // Whatever happens below, the saved text is forgotten: it is the user's,
139 : // and no later autoclear may put it back.
140 : const std::optional<QString> previousClipboard =
141 34 : std::exchange(m_previousClipboard, std::nullopt);
142 : const std::optional<QString> previousSelection =
143 34 : std::exchange(m_previousSelection, std::nullopt);
144 34 : if (m_clippedText.isEmpty() && m_otherCopies.isEmpty()) {
145 : // Nothing of ours is out there. The old code compared the empty tracker
146 : // with an empty selection, "cleared" that and said so on every deselect.
147 : return;
148 : }
149 38 : const auto ours = [this](const QString &text) {
150 38 : return !text.isEmpty() &&
151 19 : (text == m_clippedText || m_otherCopies.contains(text));
152 19 : };
153 19 : QClipboard *clipboard = QApplication::clipboard();
154 19 : const bool restore = QtPassSettings::load().restoreClipboard;
155 19 : bool cleared = false;
156 19 : bool restored = false;
157 : // Ours still there: the text from before comes back if there was one and
158 : // what is there is copyText()'s own copy, otherwise it is cleared. A copy
159 : // from a shown field (trackCopied()) is cleared on purpose, not restored
160 : // over: it can come long after copyText(), when "before" is stale, and the
161 : // user chose that copy themselves. Something else copied meanwhile is left
162 : // alone. The setting is read now: switched off during the window, the
163 : // saved text was still held until here, and is dropped unused.
164 38 : const auto putBack = [&](QClipboard::Mode mode,
165 : const std::optional<QString> &previous) {
166 38 : const QString now = clipboard->text(mode);
167 38 : if (!ours(now)) {
168 : return;
169 : }
170 16 : if (restore && previous && !previous->isEmpty() && now == m_clippedText) {
171 3 : clipboard->setText(*previous, mode);
172 3 : restored = true;
173 3 : return;
174 : }
175 13 : clipboard->clear(mode);
176 13 : if (mode == QClipboard::Selection) {
177 0 : clipboard->setText(QString(""), QClipboard::Selection);
178 : }
179 13 : cleared = true;
180 19 : };
181 19 : putBack(QClipboard::Selection, previousSelection);
182 19 : putBack(QClipboard::Clipboard, previousClipboard);
183 38 : emit statusMessage(restored ? tr("Clipboard restored")
184 16 : : cleared ? tr("Clipboard cleared")
185 : : tr("Clipboard not cleared"));
186 19 : m_clippedText.clear();
187 19 : m_otherCopies.clear();
188 : }
189 :
190 51 : auto buildClipboardMimeData(const QString &text) -> QMimeData * {
191 51 : auto *mimeData = new QMimeData();
192 51 : mimeData->setText(text);
193 : // The KDE hint (what wl-copy --sensitive sets) keeps the secret out of
194 : // Klipper, CopyQ and cliphist history, on the BSDs as well as on Linux.
195 : #if defined(Q_OS_UNIX) && !defined(Q_OS_DARWIN)
196 102 : mimeData->setData("x-kde-passwordManagerHint", QByteArray("secret"));
197 : #endif
198 : #ifdef Q_OS_MAC
199 : mimeData->setData("application/x-nspasteboard-concealed-type", QByteArray());
200 : #endif
201 : #ifdef Q_OS_WIN
202 : mimeData->setData("ExcludeClipboardContentFromMonitorProcessing",
203 : dwordBytes(1));
204 : mimeData->setData("CanIncludeInClipboardHistory", dwordBytes(0));
205 : mimeData->setData("CanUploadToCloudClipboard", dwordBytes(0));
206 : #endif
207 51 : return mimeData;
208 : }
|