Line data Source code
1 : // SPDX-FileCopyrightText: 2014 Anne Jan Brouwer
2 : // SPDX-License-Identifier: GPL-3.0-or-later
3 : /**
4 : * @file
5 : * @brief Implements MainWindow; the backend signal wiring lives in
6 : * connectBackends() and connectPassSignals(), the decrypted-entry
7 : * rendering in passShowHandler().
8 : */
9 : #include "mainwindow.h"
10 :
11 : #include "qtpasslogging.h"
12 :
13 : #include "configdialog.h"
14 : #include "enums.h"
15 : #include "executor.h"
16 : #include "exportpublickeydialog.h"
17 : #include "filecontent.h"
18 : #include "firstrunwizard.h"
19 : #include "menubarpeek.h"
20 : #include "nativegrep.h"
21 : #include "passworddialog.h"
22 : #include "passworddisplaypanel.h"
23 : #include "pathvalidator.h"
24 : #include "plainmessagebox.h"
25 : #include "processoutputpanel.h"
26 : #include "qpushbuttonasqrcode.h"
27 : #include "qpushbuttonshowpassword.h"
28 : #include "qpushbuttonwithclipboard.h"
29 : #include "qtpass.h"
30 : #include "qtpasssettings.h"
31 : #include "templateio.h"
32 : #include "totp.h"
33 : #include "trayicon.h"
34 : #include "ui_mainwindow.h"
35 : #include "usersdialog.h"
36 : #include "util.h"
37 : #include "windowstatestore.h"
38 : #include <QApplication>
39 : #include <QCloseEvent>
40 : #include <QDate>
41 : #include <QDesktopServices>
42 : #include <QDialog>
43 : #include <QDockWidget>
44 : #include <QFileInfo>
45 : #include <QHBoxLayout>
46 : #include <QInputDialog>
47 : #include <QLabel>
48 : #include <QLineEdit>
49 : #include <QMenu>
50 : #include <QMenuBar>
51 : #include <QMessageBox>
52 : #include <QProgressDialog>
53 : #include <QPushButton>
54 : #include <QShortcut>
55 : #include <QTextCharFormat>
56 : #include <QTextStream>
57 : #include <QTimer>
58 : #include <QTreeWidget>
59 : #include <QUrl>
60 : #include <algorithm>
61 : #include <array>
62 : #include <utility>
63 :
64 131 : MainWindow::MainWindow(const QString &searchText, QWidget *parent)
65 131 : : QMainWindow(parent), ui(new Ui::MainWindow) {
66 : #ifdef __APPLE__
67 : // extra treatment for mac os
68 : // see https://doc.qt.io/qt-6/qkeysequence.html#qt_set_sequence_auto_mnemonic
69 : qt_set_sequence_auto_mnemonic(true);
70 : #endif
71 131 : ui->setupUi(this);
72 :
73 131 : m_qtPass = new QtPass(this);
74 131 : restoreWindow();
75 131 : connectBackends();
76 :
77 131 : new QShortcut(QKeySequence(QKeySequence::StandardKey::Copy), this, this,
78 131 : &MainWindow::copyPasswordFromTreeview);
79 :
80 131 : m_tree = new StoreTree(ui->treeView, this);
81 131 : m_tree->setStore(QtPassSettings::getPassStore(Util::findPasswordStore()));
82 131 : m_tree->setPass(QtPassSettings::getPass());
83 131 : connect(ui->treeView, &QWidget::customContextMenuRequested, this,
84 131 : &MainWindow::showContextMenu);
85 131 : connect(ui->treeView, &DeselectableTreeView::emptyClicked, this,
86 131 : &MainWindow::deselect);
87 :
88 131 : initTextBrowser();
89 :
90 131 : updateProfileBox();
91 :
92 131 : initDisplayPanel();
93 :
94 131 : QtPassSettings::getPass()->updateEnv();
95 :
96 : // Install the search-box key filter once, not on every setUiElementsEnabled
97 : // call.
98 131 : ui->lineEdit->installEventFilter(this);
99 : // Both live in Breeze's "tools area" and get its header palette; see the
100 : // PaletteChange branch in eventFilter().
101 131 : ui->toolBar->installEventFilter(this);
102 131 : menuBar()->installEventFilter(this);
103 :
104 131 : initTimers();
105 :
106 131 : initToolBarButtons();
107 131 : initStatusBar();
108 131 : initProcessOutput();
109 :
110 131 : ui->lineEdit->setClearButtonEnabled(true);
111 131 : updateGrepButtonVisibility();
112 :
113 131 : setUiElementsEnabled(true);
114 :
115 131 : ui->lineEdit->setText(searchText);
116 :
117 : // No QApplication::quit() here: before exec() it is a documented no-op.
118 : // main() checks initSucceeded() and exits before show() instead.
119 131 : m_qtPass->init();
120 : // OK is not gated on Util::configIsValid(): an accepted configuration can
121 : // still lack a .gpg-id, so ask again; only a cancel ends the loop.
122 132 : while (!Util::configIsValid(QtPassSettings::load())) {
123 2 : if (!config()) {
124 : return;
125 : }
126 : }
127 130 : m_freshStart = false;
128 130 : m_initSucceeded = true;
129 :
130 : // Initial focus is set in showEvent(): a 10 ms QTimer here could fire
131 : // before the window was realised (queued ActivationChange, nested exec() in
132 : // init()) and selectAll() segfaulted inside Qt (#1187, #1188).
133 0 : }
134 :
135 131 : void MainWindow::connectBackends() {
136 131 : connect(m_qtPass, &QtPass::outputReady, this,
137 154 : [this](const QString &html) { flashText(html, false, true); });
138 131 : connect(m_qtPass, &QtPass::operationFinished, this,
139 155 : [this]() { setUiElementsEnabled(true); });
140 131 : connect(m_qtPass, &QtPass::statusMessage, this,
141 131 : &MainWindow::showStatusMessage);
142 131 : connect(m_qtPass, &QtPass::pushRequested, this, &MainWindow::onPush);
143 131 : connect(m_qtPass, &QtPass::entryInserted, this,
144 132 : [this]() { on_treeView_clicked(getCurrentTreeViewIndex()); });
145 131 : connect(&m_qtPass->clipboard(), &ClipboardManager::statusMessage, this,
146 141 : [this](const QString &message) { showStatusMessage(message); });
147 : // Both backends: the active one can change through the configuration
148 : // dialog without a restart.
149 131 : connectPassSignals(QtPassSettings::getRealPass());
150 131 : connectPassSignals(QtPassSettings::getImitatePass());
151 131 : ImitatePass *ipass = QtPassSettings::getImitatePass();
152 131 : connect(ipass, &ImitatePass::startReencryptPath, this,
153 131 : &MainWindow::startReencryptPath);
154 131 : connect(ipass, &ImitatePass::reencryptProgress, this,
155 131 : &MainWindow::reencryptProgress);
156 131 : connect(ipass, &ImitatePass::endReencryptPath, this,
157 131 : &MainWindow::endReencryptPath);
158 131 : }
159 :
160 131 : void MainWindow::initTextBrowser() {
161 : {
162 131 : const AppSettings s = QtPassSettings::load();
163 131 : if (s.useMonospace) {
164 1 : QFont monospace("Monospace");
165 1 : monospace.setStyleHint(QFont::Monospace);
166 1 : ui->textBrowser->setFont(monospace);
167 1 : }
168 131 : if (s.noLineWrapping) {
169 1 : ui->textBrowser->setLineWrapMode(QTextBrowser::NoWrap);
170 : }
171 131 : clearPanelTimer.setInterval(MS_PER_SECOND * s.autoclearPanelSeconds);
172 131 : }
173 131 : ui->textBrowser->setOpenExternalLinks(true);
174 131 : ui->textBrowser->setContextMenuPolicy(Qt::CustomContextMenu);
175 131 : connect(ui->textBrowser, &QWidget::customContextMenuRequested, this,
176 131 : &MainWindow::showBrowserContextMenu);
177 131 : }
178 :
179 131 : void MainWindow::initDisplayPanel() {
180 131 : m_displayPanel = new PasswordDisplayPanel(
181 131 : ui->gridLayout, ui->verticalLayoutPassword, this, this);
182 131 : connect(m_displayPanel, &PasswordDisplayPanel::copyRequested,
183 131 : &m_qtPass->clipboard(), &ClipboardManager::copyText);
184 131 : connect(m_displayPanel, &PasswordDisplayPanel::textCopied,
185 131 : &m_qtPass->clipboard(), &ClipboardManager::trackCopied);
186 131 : connect(ui->textBrowser, &SecretTextBrowser::copied, &m_qtPass->clipboard(),
187 131 : &ClipboardManager::trackCopied);
188 131 : connect(m_displayPanel, &PasswordDisplayPanel::qrRequested, m_qtPass,
189 131 : &QtPass::showTextAsQRCode);
190 : // Double-click on what is shown edits it, as in the tree.
191 131 : connect(m_displayPanel, &PasswordDisplayPanel::editRequested, this, [this] {
192 3 : if (ui->actionEdit->isEnabled()) {
193 2 : onEdit();
194 : }
195 3 : });
196 131 : }
197 :
198 131 : void MainWindow::initTimers() {
199 131 : clearPanelTimer.setSingleShot(true);
200 133 : connect(&clearPanelTimer, &QTimer::timeout, this, [this]() { clearPanel(); });
201 :
202 131 : searchTimer.setInterval(350);
203 131 : searchTimer.setSingleShot(true);
204 :
205 131 : connect(&searchTimer, &QTimer::timeout, this, &MainWindow::onTimeoutSearch);
206 :
207 : // Safety net: if a backend operation disables the UI but never signals
208 : // completion, re-enable after a timeout so the window can't get stuck.
209 131 : m_uiWatchdog.setSingleShot(true);
210 131 : m_uiWatchdog.setInterval(UiWatchdogMs);
211 131 : connect(&m_uiWatchdog, &QTimer::timeout, this, [this]() {
212 0 : showStatusMessage(tr("Operation timed out; re-enabling interface."));
213 : // Drop any in-flight OTP request so a late finishedShow cannot be mistaken
214 : // for the answer to it.
215 0 : cancelOtpRequest();
216 0 : setUiElementsEnabled(true);
217 0 : });
218 131 : }
219 :
220 131 : void MainWindow::initProcessOutput() {
221 131 : m_processOutput = new ProcessOutputPanel(this);
222 131 : addDockWidget(Qt::BottomDockWidgetArea, m_processOutput);
223 : // After addDockWidget so our preference wins over any cached state
224 : // QMainWindow applies; restoreWindow() already processed the saved layout.
225 131 : m_processOutput->setVisible(QtPassSettings::isShowProcessOutput());
226 : // From the setting, not isVisible(): the window is not shown yet, so no
227 : // child of it reports visible.
228 131 : ui->actionShowProcessOutput->setChecked(
229 131 : QtPassSettings::isShowProcessOutput());
230 131 : connect(ui->actionShowProcessOutput, &QAction::toggled, this,
231 131 : [this](bool show) {
232 2 : m_processOutput->setVisible(show);
233 2 : AppSettings s = QtPassSettings::load();
234 2 : if (s.showProcessOutput != show) {
235 2 : s.showProcessOutput = show;
236 2 : QtPassSettings::save(s);
237 : }
238 2 : });
239 :
240 131 : connect(QtPassSettings::getPass(), &Pass::finishedAnyWithPid, this,
241 131 : [this](const QString &out, const QString &err, Enums::PROCESS pid) {
242 : // Never route potentially-secret output through the panel:
243 : // PASS_SHOW goes to the text browser (cleared on a timer when
244 : // "Autoclear panel" is set),
245 : // PASS_GREP would return password-file lines (#252; it is no
246 : // longer started, the check stays), and PASS_INSERT
247 : // is excluded defensively in case a future path uses --echo.
248 5 : if (ProcessOutputPanel::isSensitiveProcess(pid)) {
249 : return;
250 : }
251 4 : if (!out.isEmpty()) {
252 1 : onProcessOutput(out, false, pid);
253 : }
254 4 : if (!err.isEmpty()) {
255 3 : onProcessOutput(err, true, pid);
256 : }
257 : });
258 131 : }
259 :
260 258 : MainWindow::~MainWindow() {
261 : // Quit from the tray menu or a SIGTERM never delivers closeEvent(); the
262 : // window still knows its last geometry here.
263 131 : saveWindowState();
264 131 : delete m_qtPass;
265 389 : }
266 :
267 : // The signals of one backend that the window answers itself.
268 262 : void MainWindow::connectPassSignals(Pass *pass) {
269 : // A failed decrypt never emits finishedShow; drop the pending OTP/copy
270 : // request so a later decrypt of the same entry is not taken as its answer.
271 262 : connect(pass, &Pass::processErrorExit, this, &MainWindow::onOperationError);
272 262 : connect(pass, &Pass::critical, this, &MainWindow::critical);
273 262 : connect(pass, &Pass::startingExecuteWrapper, this,
274 262 : &MainWindow::executeWrapperStarted);
275 : // The profile box comes back when the queue is done, also after the
276 : // watchdog released the rest of the interface early.
277 262 : connect(pass, &Pass::idle, this, &MainWindow::updateProfileBoxEnabled);
278 262 : connect(pass, &Pass::statusMsg, this, &MainWindow::showStatusMessage);
279 262 : connect(pass, &Pass::finishedShow, this, &MainWindow::passShowHandler);
280 : // Both check the file against their own pending request and ignore the
281 : // rest, so they can stay connected for good.
282 262 : connect(pass, &Pass::finishedShow, this, &MainWindow::otpFromFileToClipboard);
283 262 : connect(pass, &Pass::finishedShow, this,
284 262 : &MainWindow::passwordFromFileToClipboard);
285 262 : connect(pass, &Pass::finishedGrep, this, &MainWindow::onGrepFinished);
286 262 : }
287 :
288 : // Idempotent; called from closeEvent() and the destructor.
289 133 : void MainWindow::saveWindowState() {
290 133 : QtPassSettings::setGeometry(saveGeometry());
291 133 : QtPassSettings::setSavestate(saveState());
292 133 : }
293 :
294 43 : void MainWindow::focusInput() {
295 : // findChild() rather than ui->lineEdit: on a fresh-config first launch
296 : // config() calls setWindowFlags(), which rebuilds the native window, and
297 : // under Qt 6.11 the QLineEdit was destroyed while ui->lineEdit kept its old
298 : // address (SIGSEGV in QWidget::testAttribute). findChild() returns null.
299 43 : if (!isVisible()) {
300 : return;
301 : }
302 43 : auto *lineEdit = findChild<QLineEdit *>(QStringLiteral("lineEdit"));
303 43 : if (lineEdit == nullptr || !lineEdit->isVisible()) {
304 : return;
305 : }
306 43 : lineEdit->selectAll();
307 43 : lineEdit->setFocus();
308 : // Only once focus has landed: set in showEvent(), an early return above
309 : // would consume the one-shot and never retry.
310 43 : m_firstShowCompleted = true;
311 : }
312 :
313 5798 : auto MainWindow::event(QEvent *event) -> bool {
314 5798 : if (event->type() == QEvent::ApplicationPaletteChange) {
315 11 : m_themeSwitched = true;
316 : // Breeze may re-stamp the bars before this arrives (Plasma 6 does):
317 : // their PaletteChange then found no switch yet, so check them now too.
318 11 : QTimer::singleShot(0, this, &MainWindow::dropStaleToolsAreaPalettes);
319 : }
320 5798 : return QMainWindow::event(event);
321 : }
322 :
323 175 : void MainWindow::changeEvent(QEvent *event) {
324 175 : QWidget::changeEvent(event);
325 175 : if (event->type() == QEvent::PaletteChange && m_displayPanel != nullptr) {
326 : // Desktop switched light/dark. Top-level widgets get PaletteChange
327 : // (ApplicationPaletteChange goes to QApplication); stylesheets with
328 : // baked-in palette colours must be re-derived by hand.
329 10 : m_displayPanel->refreshPalette();
330 : }
331 175 : if (event->type() == QEvent::ActivationChange && isActiveWindow() &&
332 : isVisible()) {
333 : // Defer a tick so the activation dispatch chain unwinds first: calling
334 : // focusInput() inline has segfaulted on half-rebuilt ui state.
335 25 : QMetaObject::invokeMethod(this, &MainWindow::focusInput,
336 : Qt::QueuedConnection);
337 : }
338 175 : }
339 :
340 27 : void MainWindow::showEvent(QShowEvent *event) {
341 27 : QMainWindow::showEvent(event);
342 27 : if (m_firstShowCompleted) {
343 : return;
344 : }
345 : // Queued so the platform map and pending rebuilds (setWindowFlags on the
346 : // wizard path) settle first. focusInput() sets the latch only on success,
347 : // so a failed lookup retries on the next show.
348 25 : QMetaObject::invokeMethod(this, &MainWindow::focusInput,
349 : Qt::QueuedConnection);
350 : }
351 :
352 131 : void MainWindow::initToolBarButtons() {
353 131 : connect(ui->actionAddPassword, &QAction::triggered, this,
354 131 : &MainWindow::addPassword);
355 131 : connect(ui->actionAddFolder, &QAction::triggered, this,
356 131 : &MainWindow::addFolder);
357 131 : connect(ui->actionEdit, &QAction::triggered, this, &MainWindow::onEdit);
358 131 : connect(ui->actionDelete, &QAction::triggered, this, &MainWindow::onDelete);
359 131 : connect(ui->actionPush, &QAction::triggered, this, &MainWindow::onPush);
360 131 : connect(ui->actionUpdate, &QAction::triggered, this, &MainWindow::onUpdate);
361 131 : connect(ui->actionUsers, &QAction::triggered, this, &MainWindow::onUsers);
362 131 : connect(ui->actionConfig, &QAction::triggered, this, &MainWindow::onConfig);
363 131 : connect(ui->actionOtp, &QAction::triggered, this, &MainWindow::onOtp);
364 :
365 : // Menu-only. Quit ends the application, tray icon or not, as in KeePassXC,
366 : // Telegram and the KDE, GNOME and macOS guidelines (#1788). Close is
367 : // close(), a hide when "hide on close" is set, like the X button and Alt+F4.
368 131 : connect(ui->actionQuit, &QAction::triggered, qApp, &QApplication::quit);
369 131 : connect(ui->actionClose, &QAction::triggered, this, &MainWindow::close);
370 : #ifdef Q_OS_MACOS
371 : // The menu bar is the system's up there; nothing to hide.
372 : ui->actionShowMenuBar->setVisible(false);
373 : #else
374 : // The action lives in the menu it hides, so it has to be on the window as
375 : // well for Ctrl+M to keep working while the bar is gone.
376 131 : addAction(ui->actionShowMenuBar);
377 131 : ui->actionShowMenuBar->setChecked(QtPassSettings::load().showMenuBar);
378 131 : menuBar()->setVisible(ui->actionShowMenuBar->isChecked());
379 131 : connect(ui->actionShowMenuBar, &QAction::toggled, this, [this](bool show) {
380 5 : menuBar()->setVisible(show);
381 5 : AppSettings s = QtPassSettings::load();
382 5 : s.showMenuBar = show;
383 5 : QtPassSettings::save(s);
384 5 : });
385 131 : new MenuBarPeek(this, ui->actionShowMenuBar);
386 : #endif
387 132 : connect(ui->actionFaq, &QAction::triggered, this, [] {
388 2 : QDesktopServices::openUrl(QUrl(QStringLiteral("https://qtpass.org/faq")));
389 1 : });
390 131 : connect(ui->actionAbout, &QAction::triggered, this, &MainWindow::showAbout);
391 131 : connect(ui->actionAboutQt, &QAction::triggered, qApp, &QApplication::aboutQt);
392 :
393 131 : ui->actionAddPassword->setIcon(
394 524 : QIcon::fromTheme("document-new", QIcon(":/icons/document-new.svg")));
395 131 : ui->actionAddFolder->setIcon(
396 524 : QIcon::fromTheme("folder-new", QIcon(":/icons/folder-new.svg")));
397 131 : ui->actionEdit->setIcon(QIcon::fromTheme(
398 393 : "document-properties", QIcon(":/icons/document-properties.svg")));
399 131 : ui->actionDelete->setIcon(
400 524 : QIcon::fromTheme("edit-delete", QIcon(":/icons/edit-delete.svg")));
401 131 : ui->actionPush->setIcon(
402 524 : QIcon::fromTheme("go-up", QIcon(":/icons/go-top.svg")));
403 131 : ui->actionUpdate->setIcon(
404 524 : QIcon::fromTheme("go-down", QIcon(":/icons/go-bottom.svg")));
405 131 : ui->actionUsers->setIcon(QIcon::fromTheme(
406 393 : "x-office-address-book", QIcon(":/icons/x-office-address-book.svg")));
407 131 : ui->actionConfig->setIcon(QIcon::fromTheme(
408 262 : "applications-system", QIcon(":/icons/applications-system.svg")));
409 131 : }
410 :
411 131 : void MainWindow::initStatusBar() {
412 262 : ui->statusBar->showMessage(tr("Welcome to QtPass %1").arg(VERSION), 2000);
413 :
414 262 : QPixmap logo = QPixmap::fromImage(QImage(":/artwork/icon.svg"))
415 131 : .scaledToHeight(statusBar()->height());
416 131 : auto *logoApp = new QLabel(statusBar());
417 131 : logoApp->setPixmap(logo);
418 131 : statusBar()->addPermanentWidget(logoApp);
419 131 : }
420 :
421 1 : auto MainWindow::getCurrentTreeViewIndex() -> QModelIndex {
422 1 : return ui->treeView->currentIndex();
423 : }
424 :
425 49 : void MainWindow::flashText(const QString &text, const bool isError,
426 : const bool isHtml) {
427 49 : if (isError) {
428 7 : ui->textBrowser->setTextColor(Qt::red);
429 : } else {
430 : // setText() re-applies the current char format, red after an error, to
431 : // the whole document. Clear the foreground rather than pin a palette
432 : // colour, so text follows runtime light/dark switches (#946).
433 42 : QTextCharFormat format = ui->textBrowser->currentCharFormat();
434 : format.clearForeground();
435 42 : ui->textBrowser->setCurrentCharFormat(format);
436 : }
437 :
438 49 : if (isHtml) {
439 : QString _text = text;
440 80 : if (!ui->textBrowser->toPlainText().isEmpty()) {
441 26 : _text = ui->textBrowser->toHtml() + _text;
442 : }
443 40 : ui->textBrowser->setHtml(_text);
444 : } else {
445 9 : ui->textBrowser->setText(text);
446 : }
447 49 : }
448 :
449 2 : void MainWindow::applyTextBrowserSettings() {
450 2 : const AppSettings s = QtPassSettings::load();
451 2 : if (s.useMonospace) {
452 1 : QFont monospace("Monospace");
453 1 : monospace.setStyleHint(QFont::Monospace);
454 1 : ui->textBrowser->setFont(monospace);
455 1 : } else {
456 1 : ui->textBrowser->setFont(QFont());
457 : }
458 :
459 2 : if (s.noLineWrapping) {
460 1 : ui->textBrowser->setLineWrapMode(QTextBrowser::NoWrap);
461 : } else {
462 1 : ui->textBrowser->setLineWrapMode(QTextBrowser::WidgetWidth);
463 : }
464 2 : }
465 :
466 142 : void MainWindow::applyWindowFlagsSettings() {
467 142 : const bool wantOnTop = QtPassSettings::isAlwaysOnTop();
468 : const Qt::WindowFlags flags = windowFlags();
469 142 : if (flags.testFlag(Qt::WindowStaysOnTopHint) == wantOnTop) {
470 : // setWindowFlags() rebuilds the native window, the teardown that left
471 : // ui->lineEdit dangling (see focusInput()); skip it when nothing changes.
472 : return;
473 : }
474 : // Toggle just this hint; resetting to Qt::Window discarded every other flag.
475 : const bool wasVisible = isVisible();
476 4 : setWindowFlags(wantOnTop ? (flags | Qt::WindowStaysOnTopHint)
477 : : (flags & ~Qt::WindowStaysOnTopHint));
478 : // setWindowFlags() hides a shown window; a window that was not shown yet
479 : // (first-run wizard from the constructor) is left to its normal show.
480 2 : if (wasVisible) {
481 2 : show();
482 : }
483 : }
484 :
485 4 : auto MainWindow::config() -> bool {
486 4 : if (m_freshStart) {
487 : // No usable configuration yet: the wizard finds the programs, the key
488 : // and the store, and creates the store when it has to.
489 2 : FirstRunWizard wizard(this);
490 2 : if (wizard.exec() != QDialog::Accepted) {
491 : return false;
492 : }
493 2 : } else {
494 2 : ConfigDialog d(this);
495 2 : d.setModal(true);
496 2 : if (d.exec() != QDialog::Accepted) {
497 : return false;
498 : }
499 2 : }
500 :
501 2 : applyTextBrowserSettings();
502 2 : applyWindowFlagsSettings();
503 :
504 2 : updateProfileBox();
505 2 : const AppSettings s = QtPassSettings::load();
506 2 : m_tree->setStore(s.passStore);
507 2 : deselect();
508 2 : ui->treeView->setCurrentIndex(QModelIndex());
509 :
510 2 : Pass *activePass = QtPassSettings::getPass();
511 2 : activePass->updateEnv();
512 2 : m_tree->setPass(activePass);
513 2 : clearPanelTimer.setInterval(MS_PER_SECOND * s.autoclearPanelSeconds);
514 2 : m_qtPass->clipboard().setAutoclearTimer();
515 :
516 2 : updateGitButtonVisibility();
517 2 : updateOtpButtonVisibility();
518 2 : updateGrepButtonVisibility();
519 2 : updateProcessOutputVisibility();
520 : #ifndef Q_OS_MACOS
521 : // The action's toggled handler shows or hides the bar (and saves again).
522 2 : ui->actionShowMenuBar->setChecked(s.showMenuBar);
523 : #endif
524 2 : if (s.useTrayIcon && m_tray == nullptr) {
525 1 : initTrayIcon();
526 1 : } else if (!s.useTrayIcon && m_tray != nullptr) {
527 0 : destroyTrayIcon();
528 : }
529 :
530 : // Stay fresh-start while the configuration is unusable, so the
531 : // constructor's loop re-runs the wizard. The re-prompt lives there:
532 : // recursing here never reported a cancel back to the caller.
533 2 : if (Util::configIsValid(s)) {
534 2 : m_freshStart = false;
535 : }
536 : return true;
537 2 : }
538 :
539 2 : void MainWindow::onUpdate(bool block) {
540 2 : ui->statusBar->showMessage(tr("Updating password-store"), 2000);
541 2 : if (block) {
542 1 : QtPassSettings::getPass()->GitPull_b();
543 : } else {
544 1 : QtPassSettings::getPass()->GitPull();
545 : }
546 2 : }
547 :
548 1 : void MainWindow::onPush() {
549 1 : if (QtPassSettings::isUseGit()) {
550 1 : ui->statusBar->showMessage(tr("Updating password-store"), 2000);
551 1 : QtPassSettings::getPass()->GitPush();
552 : }
553 1 : }
554 :
555 37 : auto MainWindow::getFile(const QModelIndex &index, bool forPass) -> QString {
556 37 : return m_tree->fileFor(index, forPass);
557 : }
558 :
559 13 : void MainWindow::on_treeView_clicked(const QModelIndex &index) {
560 13 : bool cleared = ui->treeView->currentIndex().flags() == Qt::NoItemFlags;
561 13 : QString file = getFile(index, true);
562 13 : ui->passwordName->setText(file);
563 13 : if (!file.isEmpty() && !cleared) {
564 : // Remember what the panel is about to show, so onOtp() can tell whether the
565 : // code it can see belongs to the entry that is currently selected.
566 12 : m_shownFile = file;
567 24 : QtPassSettings::getPass()->Show(file);
568 : } else {
569 1 : m_shownFile.clear();
570 1 : clearPanel(false);
571 1 : ui->actionEdit->setEnabled(false);
572 1 : ui->actionDelete->setEnabled(true);
573 : }
574 13 : }
575 :
576 4 : void MainWindow::on_treeView_doubleClicked(const QModelIndex &index) {
577 4 : QFileInfo fileOrFolder = m_tree->fileInfo(ui->treeView->currentIndex());
578 :
579 4 : if (fileOrFolder.isFile()) {
580 6 : editPassword(getFile(index, true));
581 : }
582 4 : }
583 :
584 13 : void MainWindow::deselect() {
585 13 : m_shownFile.clear();
586 13 : cancelOtpRequest();
587 13 : m_qtPass->clipboard().clear();
588 13 : ui->treeView->clearSelection();
589 13 : ui->actionEdit->setEnabled(false);
590 13 : ui->actionDelete->setEnabled(false);
591 13 : ui->passwordName->setText("");
592 13 : clearPanel(false);
593 13 : }
594 :
595 34 : void MainWindow::executeWrapperStarted() {
596 34 : m_displayPanel->clear();
597 34 : ui->textBrowser->clear();
598 34 : setUiElementsEnabled(false);
599 34 : clearPanelTimer.stop();
600 34 : if (QtPassSettings::isShowProcessOutput()) {
601 29 : m_processOutput->setVisible(true);
602 : }
603 34 : }
604 :
605 24 : void MainWindow::passShowHandler(const QString &p_output, const QString &file) {
606 : // Only the entry we most recently asked to show may repaint the panel; a
607 : // slower decrypt of an entry the user has since left is dropped, and so is
608 : // one that arrives after the selection was cleared (deselect, a profile
609 : // switch): nothing is asked to be shown then.
610 24 : if (!file.isEmpty() && file != m_shownFile) {
611 : // A newer show is pending and releases the interface when it lands;
612 : // with none (the selection was cleared) nothing else would until the
613 : // watchdog. setUiElementsEnabled() keeps a re-encryption's lock.
614 8 : if (m_shownFile.isEmpty()) {
615 7 : setUiElementsEnabled(true);
616 : }
617 8 : return;
618 : }
619 16 : const AppSettings s = QtPassSettings::load();
620 : // What a template marks secret, in the settings or the store's
621 : // .templates; read per entry shown, so an edit there counts at once.
622 16 : if (s.hidePassword) {
623 2 : m_displayPanel->setMarkedSecrets(
624 4 : TemplateIO::secretFieldsIn(s.passTemplate) +
625 6 : TemplateIO::readSecretFields(s.passStore));
626 : }
627 : QStringList templ =
628 16 : s.useTemplate ? TemplateIO::fieldsIn(s.passTemplate) : QStringList();
629 16 : bool allFields = s.useTemplate && s.templateAllFields;
630 : // A secret the panel hides is a field even when the template doesn't list
631 : // it: left in the text below the fields, it would show in plain view.
632 : FileContent fileContent = FileContent::parse(
633 16 : p_output, templ, allFields, [this, &s](const QString &name) {
634 7 : return m_displayPanel->isSecret(name, s);
635 16 : });
636 : QString output = p_output;
637 : // Display variant: empty when the password line is itself an otpauth URI, so
638 : // the shared secret is neither rendered nor copied to the clipboard.
639 16 : QString password = fileContent.getPasswordForDisplay();
640 :
641 : // Skipped for an OTP request: the user asked for a code, and two
642 : // OleSetClipboard calls in one turn can leave the Windows clipboard empty.
643 16 : if (m_otpRequestFile.isEmpty() || m_otpRequestFile != file) {
644 14 : m_qtPass->clipboard().copyIfAlways(password, p_output);
645 : }
646 :
647 16 : m_displayPanel->clear();
648 :
649 16 : if (s.hideContent) {
650 4 : output = "***" + tr("Content hidden") + "***";
651 14 : } else if (s.displayAsIs) {
652 : // As it is, but not a block per line of millions.
653 0 : output = FileContent::boundedForDisplay(p_output);
654 : } else {
655 28 : m_displayPanel->displayFields(password, fileContent.getNamedValues(), s,
656 14 : s.useOtp ? fileContent.getOtpUri()
657 : : QString());
658 28 : output = fileContent.getRemainingDataForDisplay();
659 : }
660 :
661 16 : if (s.useAutoclearPanel) {
662 2 : clearPanelTimer.start();
663 : }
664 :
665 32 : flashText(QtPass::formatOutput(output), false, true);
666 16 : setUiElementsEnabled(true);
667 32 : }
668 :
669 20 : void MainWindow::otpFromFileToClipboard(const QString &p_output,
670 : const QString &file) {
671 : // Permanently connected: only the decrypt of the entry onOtp() asked for
672 : // is ours, everything else belongs to a tree click or a Ctrl+C.
673 20 : if (m_otpRequestFile.isEmpty() || file != m_otpRequestFile) {
674 17 : return;
675 : }
676 5 : m_otpRequestFile.clear();
677 :
678 5 : if (p_output.isEmpty()) {
679 : // Distinguish "could not read the entry" from "entry has no OTP".
680 1 : flashText(tr("Could not decrypt this password entry"), true);
681 1 : setUiElementsEnabled(true);
682 1 : return;
683 : }
684 :
685 : // passShowHandler ran first for this finishedShow; if it rendered the OTP
686 : // row, reuse its code (as onOtp()'s fast path does). Otherwise parse:
687 : // hideContent, displayAsIs or no OTP field.
688 4 : const QString shown = m_displayPanel->currentOtpCode();
689 4 : if (!shown.isEmpty()) {
690 1 : m_qtPass->clipboard().copyText(shown);
691 1 : showStatusMessage(tr("OTP code copied to clipboard"));
692 1 : setUiElementsEnabled(true);
693 : return;
694 : }
695 :
696 3 : const AppSettings s = QtPassSettings::load();
697 : // Parse with the same template settings passShowHandler uses, so an OTP
698 : // field is recognised identically in both paths.
699 : const QStringList templ =
700 3 : s.useTemplate ? TemplateIO::fieldsIn(s.passTemplate) : QStringList();
701 3 : const bool allFields = s.useTemplate && s.templateAllFields;
702 : const FileContent fileContent =
703 3 : FileContent::parse(p_output, templ, allFields);
704 :
705 : const std::optional<Totp::Settings> settings =
706 6 : Totp::parse(fileContent.getOtpUri());
707 3 : if (settings.has_value()) {
708 4 : m_qtPass->clipboard().copyText(Totp::generateNow(*settings));
709 4 : showStatusMessage(tr("OTP code copied to clipboard"));
710 : } else {
711 2 : flashText(tr("No OTP code found in this password entry"), true);
712 : }
713 3 : setUiElementsEnabled(true);
714 6 : }
715 :
716 35 : void MainWindow::clearPanel(bool notify) {
717 35 : m_displayPanel->clear();
718 35 : const bool grepWasVisible = ui->grepResultsList->isVisible();
719 35 : ui->grepResultsList->clear();
720 35 : if (grepWasVisible) {
721 2 : ui->grepResultsList->setVisible(false);
722 2 : ui->treeView->setVisible(true);
723 2 : if (m_grep.inGrepMode()) {
724 : m_grep.clearGrepMode();
725 2 : ui->grepButton->blockSignals(true);
726 2 : ui->grepButton->setChecked(false);
727 2 : ui->grepButton->blockSignals(false);
728 2 : ui->lineEdit->blockSignals(true);
729 2 : ui->lineEdit->clear();
730 2 : ui->lineEdit->blockSignals(false);
731 4 : ui->lineEdit->setPlaceholderText(tr("Search password"));
732 : }
733 : }
734 35 : if (notify) {
735 4 : QString output = "***" + tr("Password and content hidden") + "***";
736 2 : ui->textBrowser->setHtml(output);
737 : } else {
738 66 : ui->textBrowser->setHtml("");
739 : }
740 35 : }
741 :
742 257 : void MainWindow::setUiElementsEnabled(bool state) {
743 : // A running re-encryption owns the UI state: the progress dialog's Cancel is
744 : // the user's escape hatch and endReencryptPath() releases the interface, so
745 : // neither an unrelated completion nor the watchdog may re-enable it early.
746 257 : if (state && m_reencryptRunning) {
747 1 : m_uiWatchdog.stop();
748 1 : return;
749 : }
750 : // Arm the watchdog while the UI is disabled; disarm once re-enabled.
751 : if (state) {
752 202 : m_uiWatchdog.stop();
753 : } else {
754 54 : m_uiWatchdog.start();
755 : }
756 256 : ui->treeView->setEnabled(state);
757 256 : ui->lineEdit->setEnabled(state);
758 256 : ui->actionAddPassword->setEnabled(state);
759 256 : ui->actionAddFolder->setEnabled(state);
760 256 : ui->actionUsers->setEnabled(state);
761 256 : ui->actionConfig->setEnabled(state);
762 256 : m_uiEnabled = state;
763 256 : updateProfileBoxEnabled();
764 : const bool uiEnabled = state;
765 : // is a file selected?
766 512 : state &= ui->treeView->currentIndex().isValid();
767 256 : ui->actionDelete->setEnabled(state);
768 256 : ui->actionEdit->setEnabled(state);
769 256 : updateGitButtonVisibility(uiEnabled);
770 : // `state` is now "UI enabled AND a file is selected", which is exactly when
771 : // generating an OTP makes sense.
772 256 : updateOtpButtonVisibility(state);
773 : }
774 :
775 140 : void MainWindow::restoreWindow() {
776 : // saveGeometry() covers position, size, maximized state and screen;
777 : // anything applied on top discards it. Centre only when nothing was saved.
778 280 : const QByteArray geometry = QtPassSettings::getGeometry();
779 140 : if (geometry.isEmpty() || !restoreGeometry(geometry)) {
780 4 : WindowStateStore::centreOnCursorScreen(*this);
781 : }
782 140 : restoreState(QtPassSettings::getSavestate(saveState()));
783 140 : const AppSettings s = QtPassSettings::load();
784 :
785 140 : applyWindowFlagsSettings();
786 :
787 140 : if (s.useTrayIcon && m_tray == nullptr) {
788 1 : initTrayIcon();
789 1 : if (s.startMinimized) {
790 : // since we are still in constructor, can't directly hide
791 2 : QTimer::singleShot(10, this, &MainWindow::hide);
792 : }
793 139 : } else if (!s.useTrayIcon && m_tray != nullptr) {
794 0 : destroyTrayIcon();
795 : }
796 280 : }
797 :
798 1 : void MainWindow::onConfig() { config(); }
799 :
800 1 : void MainWindow::showAbout() {
801 1 : QMessageBox::about(
802 1 : this, tr("About QtPass"),
803 1 : tr("<h3>QtPass %1</h3>"
804 : "<p>A multi-platform GUI for <a "
805 : "href=\"https://www.passwordstore.org/\">"
806 : "pass</a>, the standard Unix password manager.</p>"
807 : "<p><a href=\"https://qtpass.org/\">qtpass.org</a> · "
808 : "<a href=\"https://github.com/IJHack/QtPass\">Source and "
809 : "issues</a></p>"
810 : "<p>Copyright © 2014–%2 IJhack. Licensed under the "
811 : "<a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">GNU GPL "
812 : "version 3</a> or later.</p>")
813 2 : .arg(QStringLiteral(VERSION),
814 2 : QString::number(QDate::currentDate().year())));
815 1 : }
816 :
817 24 : void MainWindow::on_lineEdit_textChanged(const QString &arg1) {
818 24 : if (m_grep.inGrepMode())
819 : return;
820 38 : ui->statusBar->showMessage(tr("Looking for: %1").arg(arg1), 1000);
821 19 : ui->treeView->expandAll();
822 : // Drop any decrypt still pending for the entry that was shown: otherwise it
823 : // keeps its right to the panel and, with "Always copy", the clipboard, and
824 : // when it lands late it shows that entry's password under the new heading.
825 19 : m_shownFile.clear();
826 19 : cancelOtpRequest();
827 19 : clearPanel(false);
828 38 : ui->passwordName->setText("");
829 19 : ui->actionEdit->setEnabled(false);
830 19 : ui->actionDelete->setEnabled(false);
831 19 : searchTimer.start();
832 : }
833 :
834 17 : void MainWindow::onTimeoutSearch() {
835 17 : QString query = ui->lineEdit->text();
836 :
837 17 : if (query.isEmpty()) {
838 1 : ui->treeView->collapseAll();
839 1 : deselect();
840 : }
841 :
842 : // The words are matched literally, in order, with anything in between:
843 : // "work vpn" finds work/acme/vpn. Regular expressions belong to the
844 : // content search; here a typed "[" or "(" must not switch filtering off.
845 17 : QStringList words;
846 : for (const QString &word :
847 51 : query.split(QLatin1Char(' '), Qt::SkipEmptyParts)) {
848 17 : words << QRegularExpression::escape(word);
849 : }
850 34 : QRegularExpression regExp(words.join(QStringLiteral(".*")),
851 17 : QRegularExpression::CaseInsensitiveOption);
852 17 : m_tree->setFilter(regExp);
853 :
854 17 : if (m_tree->proxy().rowCount() > 0 && !query.isEmpty()) {
855 14 : selectFirstFile();
856 : } else {
857 3 : ui->actionEdit->setEnabled(false);
858 3 : ui->actionDelete->setEnabled(false);
859 : }
860 34 : }
861 :
862 6 : void MainWindow::on_lineEdit_returnPressed() {
863 6 : qCDebug(lcQtPass) << "on_lineEdit_returnPressed"
864 0 : << m_tree->proxy().rowCount();
865 :
866 6 : if (m_grep.inGrepMode()) {
867 4 : const QString query = ui->lineEdit->text();
868 4 : if (!query.isEmpty()) {
869 3 : ui->grepResultsList->clear();
870 6 : ui->statusBar->showMessage(tr("Searching…"));
871 : if (m_grep.beginSearch()) {
872 3 : QApplication::setOverrideCursor(Qt::WaitCursor);
873 : }
874 6 : QtPassSettings::getPass()->Grep(query, ui->grepCaseButton->isChecked());
875 : } else {
876 : if (m_grep.cancelSearch()) {
877 1 : QApplication::restoreOverrideCursor();
878 : }
879 1 : ui->grepResultsList->clear();
880 1 : ui->grepResultsList->setVisible(false);
881 1 : ui->treeView->setVisible(true);
882 : }
883 : return;
884 : }
885 :
886 2 : if (m_tree->proxy().rowCount() > 0) {
887 2 : selectFirstFile();
888 2 : on_treeView_clicked(ui->treeView->currentIndex());
889 : }
890 : }
891 :
892 12 : void MainWindow::on_grepButton_toggled(bool checked) {
893 12 : const AppSettings s = QtPassSettings::load();
894 12 : if (checked) {
895 : m_grep.enterGrepMode();
896 14 : ui->lineEdit->setPlaceholderText(tr("Search content (regex)"));
897 : // Both backends search through NativeGrep (QRegularExpression), so one
898 : // dialect: PCRE.
899 7 : ui->lineEdit->setToolTip(
900 7 : tr("Content search uses Perl-compatible regular expressions (PCRE)."));
901 7 : ui->lineEdit->clear();
902 7 : searchTimer.stop();
903 7 : m_tree->setFilter(QRegularExpression());
904 7 : ui->grepResultsList->setVisible(false);
905 : // Keep treeView visible until results arrive
906 : } else {
907 : if (m_grep.leaveGrepMode()) {
908 1 : QApplication::restoreOverrideCursor();
909 : }
910 5 : searchTimer.stop();
911 5 : ui->lineEdit->blockSignals(true);
912 5 : ui->lineEdit->clear();
913 5 : ui->lineEdit->blockSignals(false);
914 10 : ui->lineEdit->setPlaceholderText(tr("Search password"));
915 10 : ui->lineEdit->setToolTip(QString());
916 5 : ui->grepResultsList->clear();
917 5 : ui->grepResultsList->setVisible(false);
918 5 : ui->treeView->setVisible(true);
919 5 : m_tree->setFilter(QRegularExpression());
920 : }
921 12 : }
922 :
923 6 : void MainWindow::onGrepFinished(
924 : const QList<QPair<QString, QStringList>> &results) {
925 : const GrepSearchController::FinishOutcome outcome = m_grep.finishSearch();
926 6 : if (outcome.restoreCursor) {
927 1 : QApplication::restoreOverrideCursor();
928 : }
929 : // Re-enable the UI before the discard check so a cancelled search can never
930 : // leave controls disabled.
931 6 : setUiElementsEnabled(true);
932 6 : if (outcome.discard) {
933 3 : return;
934 : }
935 4 : if (!m_grep.inGrepMode())
936 : return;
937 4 : ui->grepResultsList->clear();
938 4 : if (results.isEmpty()) {
939 2 : ui->statusBar->showMessage(tr("No matches found."), 3000);
940 1 : ui->grepResultsList->setVisible(false);
941 1 : ui->treeView->setVisible(true);
942 1 : return;
943 : }
944 3 : const AppSettings s = QtPassSettings::load();
945 3 : const bool hideContent = s.hideContent;
946 : int totalLines = 0;
947 : int entries = 0;
948 : bool stopped = false;
949 24 : for (const auto &pair : results) {
950 21 : if (NativeGrep::isStoppedMarker(pair)) {
951 : stopped = true;
952 0 : continue;
953 : }
954 21 : ++entries;
955 21 : auto *entryItem = new QTreeWidgetItem(ui->grepResultsList);
956 21 : entryItem->setText(0, pair.first);
957 21 : entryItem->setData(0, Qt::UserRole, pair.first);
958 42 : for (const QString &line : pair.second) {
959 21 : auto *lineItem = new QTreeWidgetItem(entryItem);
960 : // The marker is no content: shown as it is with hidden content too,
961 : // and not counted as a match.
962 21 : if (NativeGrep::isMoreMarker(line)) {
963 0 : lineItem->setText(0, tr("… more matches in this entry"));
964 : } else {
965 63 : lineItem->setText(0, hideContent ? "***" + tr("Content hidden") + "***"
966 : : line);
967 21 : ++totalLines;
968 : }
969 21 : lineItem->setData(0, Qt::UserRole, pair.first);
970 : }
971 : }
972 3 : ui->grepResultsList->expandAll();
973 3 : ui->treeView->setVisible(false);
974 3 : ui->grepResultsList->setVisible(true);
975 3 : if (stopped) {
976 : // The search stopped there; more entries may match.
977 0 : ui->statusBar->showMessage(
978 0 : tr("Showing the first %n match(es); search for something narrower "
979 : "to see the rest.",
980 : nullptr, totalLines),
981 : 10000);
982 : } else {
983 9 : ui->statusBar->showMessage(tr("Found %n match(es)", nullptr, totalLines) +
984 3 : " " +
985 6 : tr("in %n entr(ies).", nullptr, entries),
986 : 3000);
987 : }
988 3 : if (s.useAutoclearPanel)
989 1 : clearPanelTimer.start();
990 3 : }
991 :
992 1 : void MainWindow::on_grepResultsList_itemClicked(QTreeWidgetItem *item,
993 : int /*column*/) {
994 1 : const AppSettings s = QtPassSettings::load();
995 1 : const QString entry = item->data(0, Qt::UserRole).toString();
996 1 : if (entry.isEmpty())
997 : return;
998 : const QString fullPath =
999 2 : QDir::cleanPath(QDir(s.passStore).filePath(entry + ".gpg"));
1000 1 : const QModelIndex proxyIndex = m_tree->indexFor(fullPath);
1001 : if (!proxyIndex.isValid())
1002 : return;
1003 1 : ui->treeView->setCurrentIndex(proxyIndex);
1004 1 : on_treeView_clicked(proxyIndex);
1005 1 : if (s.hideContent || s.useAutoclearPanel)
1006 1 : ui->grepResultsList->clear();
1007 1 : ui->grepResultsList->setVisible(false);
1008 1 : ui->treeView->setVisible(true);
1009 1 : ui->treeView->scrollTo(proxyIndex);
1010 1 : ui->treeView->setFocus();
1011 1 : }
1012 :
1013 16 : void MainWindow::selectFirstFile() {
1014 16 : QModelIndex index = firstFile(m_tree->rootIndex());
1015 16 : ui->treeView->setCurrentIndex(index);
1016 16 : }
1017 :
1018 24 : auto MainWindow::firstFile(QModelIndex parentIndex) -> QModelIndex {
1019 24 : StoreModel &proxy = m_tree->proxy();
1020 24 : int numRows = proxy.rowCount(parentIndex);
1021 34 : for (int row = 0; row < numRows; ++row) {
1022 24 : QModelIndex index = proxy.index(row, 0, parentIndex);
1023 24 : if (m_tree->fileInfo(index).isFile()) {
1024 14 : return index;
1025 : }
1026 15 : if (proxy.hasChildren(index)) {
1027 8 : QModelIndex childFile = firstFile(index);
1028 : if (childFile.isValid())
1029 5 : return childFile;
1030 : }
1031 : }
1032 10 : return QModelIndex();
1033 : }
1034 :
1035 : // Guards the input dialogs (create, move, rename) against names like
1036 : // "../../etc/passwd" or absolute paths escaping the store root.
1037 9 : auto MainWindow::confirmPathInStore(const QString &candidate) -> bool {
1038 18 : if (PathValidator::isPathInStore(QtPassSettings::getPassStore(), candidate)) {
1039 : return true;
1040 : }
1041 9 : QMessageBox::warning(this, tr("Invalid name"),
1042 3 : tr("That name would resolve outside the password "
1043 : "store. Please choose a different name."));
1044 3 : return false;
1045 : }
1046 :
1047 6 : void MainWindow::setPassword(const QString &file, bool isNew) {
1048 6 : const AppSettings s = QtPassSettings::load();
1049 6 : PasswordDialog d(QtPassSettings::getPass(), s, file, isNew, this);
1050 : // A copy out of the editor is autocleared like one from the display.
1051 6 : connect(&d, &PasswordDialog::copied, &m_qtPass->clipboard(),
1052 6 : &ClipboardManager::trackCopied);
1053 :
1054 6 : if (isNew) {
1055 : const QString storePath = s.passStore;
1056 1 : QString folder = m_tree->currentDir(false);
1057 1 : if (folder.isEmpty()) {
1058 0 : folder = storePath;
1059 : }
1060 : // Folders to pick from, the tree's current one preselected. Real folders
1061 : // only: a linked or junctioned one would write the entry outside the store.
1062 2 : QStringList folders{QString()};
1063 1 : const QStringList found = Util::directoriesUnder(storePath);
1064 15 : for (const QString &path : found) {
1065 14 : const QString rel = QDir(storePath).relativeFilePath(path);
1066 42 : if (!rel.startsWith(u'.') && !rel.contains(QStringLiteral("/."))) {
1067 : folders << rel;
1068 : }
1069 : }
1070 1 : std::sort(folders.begin() + 1, folders.end());
1071 1 : d.setNewEntryLocation(storePath, folders,
1072 2 : QDir(storePath).relativeFilePath(folder));
1073 : QHash<QString, QStringList> templates =
1074 1 : TemplateIO::readTemplates(storePath);
1075 : if (!templates.isEmpty()) {
1076 : QString defaultTemplate =
1077 1 : TemplateIO::getFolderTemplate(folder, storePath);
1078 1 : d.setAvailableTemplates(templates, defaultTemplate);
1079 : }
1080 1 : }
1081 :
1082 6 : if (!d.exec()) {
1083 5 : ui->treeView->setFocus();
1084 : }
1085 6 : }
1086 :
1087 2 : void MainWindow::addPassword() { setPassword(QString()); }
1088 :
1089 4 : auto MainWindow::confirmDeletion(const QString &file, bool isDir) -> bool {
1090 4 : const QString path = QDir::separator() + file;
1091 4 : if (!isDir) {
1092 2 : return PlainMessageBox::askYes(
1093 2 : this, tr("Delete password?"),
1094 4 : tr("Are you sure you want to delete %1?").arg(path));
1095 : }
1096 : // Whole sentences, not a question stitched from pieces: a verb-final
1097 : // language cannot put the rest of the question after the path.
1098 : // Plain text, as every box quoting the store: the path is a name other
1099 : // writers control, and as rich text "a b" would also show as "a b".
1100 : QString question =
1101 2 : tr("Are you sure you want to delete %1 and the whole content?").arg(path);
1102 : // A link, junction or special file inside is as unexpected as a stray
1103 : // plain file: the walker leaves them out of content and reports them.
1104 2 : QStringList skipped;
1105 6 : const QStringList content = Util::regularFilesUnder(
1106 6 : m_tree->fileSystem().rootPath() + path, {QStringLiteral("*")}, &skipped);
1107 : const bool unexpected =
1108 2 : !skipped.isEmpty() ||
1109 2 : std::any_of(content.cbegin(), content.cend(), [](const QString &entry) {
1110 4 : return QFileInfo(entry).suffix() != QLatin1String("gpg");
1111 : });
1112 : if (unexpected) {
1113 1 : question += QStringLiteral("\n\n") +
1114 1 : tr("Attention: there are unexpected files in the given "
1115 1 : "folder, check them before continuing.");
1116 : }
1117 4 : return PlainMessageBox::askYes(this, tr("Delete folder?"), question);
1118 : }
1119 :
1120 1 : auto MainWindow::confirmLinkRemoval(const QString &file) -> bool {
1121 : // Only the link goes (ImitatePass::Remove unlinks it); nothing behind it
1122 : // is looked at or mentioned.
1123 1 : return PlainMessageBox::askYes(
1124 1 : this, tr("Delete link?"),
1125 1 : tr("%1 is a symbolic link or junction. Remove the link? What it "
1126 : "points to is left alone.")
1127 2 : .arg(QDir::separator() + file));
1128 : }
1129 :
1130 8 : void MainWindow::onDelete() {
1131 8 : const QModelIndex currentIndex = ui->treeView->currentIndex();
1132 : if (!currentIndex.isValid()) {
1133 : // Otherwise the whole password directory would be deleted (#556).
1134 5 : return;
1135 : }
1136 :
1137 6 : const bool isDir = !m_tree->fileInfo(currentIndex).isFile();
1138 : const QString file =
1139 6 : isDir ? m_tree->currentDir(true) : getFile(currentIndex, true);
1140 : const QString folder =
1141 6 : m_tree->fileSystem().rootPath() + QDir::separator() + file;
1142 6 : const bool linkedFolder = isDir && Util::isLinkedFolder(folder);
1143 :
1144 5 : if (!linkedFolder && refuseLinkedFolder(folder)) {
1145 : // Behind a link: deleting would reach outside the store.
1146 : return;
1147 : }
1148 : const bool confirmed =
1149 5 : linkedFolder ? confirmLinkRemoval(file) : confirmDeletion(file, isDir);
1150 5 : if (!confirmed) {
1151 : return;
1152 : }
1153 6 : QtPassSettings::getPass()->Remove(file, isDir);
1154 : }
1155 :
1156 51 : void MainWindow::cancelOtpRequest() {
1157 51 : m_otpRequestFile.clear();
1158 51 : m_copyRequestFile.clear();
1159 51 : }
1160 :
1161 18 : void MainWindow::onOperationError() {
1162 18 : cancelOtpRequest();
1163 : // executeWrapperStarted() emptied the panel and the browser when the
1164 : // operation started (a refusal says so too); what is left to forget is
1165 : // which entry the panel was about to show.
1166 18 : m_shownFile.clear();
1167 18 : }
1168 :
1169 : // Derives the code in-process from one decrypt, so it works with either
1170 : // backend on every platform; otpFromFileToClipboard() finishes the job.
1171 10 : void MainWindow::onOtp() {
1172 10 : QString file = getFile(ui->treeView->currentIndex(), true);
1173 10 : if (file.isEmpty()) {
1174 1 : flashText(tr("No password selected for OTP generation"), true);
1175 1 : return;
1176 : }
1177 9 : if (!QtPassSettings::isUseOtp()) {
1178 : // Normally unreachable (the action is hidden), but never fail silently.
1179 1 : flashText(tr("No OTP code found in this password entry"), true);
1180 1 : return;
1181 : }
1182 :
1183 : // Fast path: the panel shows a live code, so no decrypt, and one clipboard
1184 : // write (two setMimeData calls in one turn can empty the Windows
1185 : // clipboard). Only for *this* entry: arrow keys and right-click move
1186 : // currentIndex without QTreeView::clicked, so the code may be another's.
1187 : const QString shown =
1188 8 : (m_shownFile == file) ? m_displayPanel->currentOtpCode() : QString();
1189 8 : if (!shown.isEmpty()) {
1190 1 : m_qtPass->clipboard().copyText(shown);
1191 2 : showStatusMessage(tr("OTP code copied to clipboard"));
1192 : return;
1193 : }
1194 :
1195 : // Fallback: decrypt once. The request file keeps passShowHandler from
1196 : // copying the password and tells otpFromFileToClipboard its answer.
1197 7 : m_otpRequestFile = file;
1198 7 : setUiElementsEnabled(false);
1199 : // Keep the marker in step so a second request takes the fast path; safe as
1200 : // executeWrapperStarted() clears the panel, so a failed decrypt shows none.
1201 7 : m_shownFile = file;
1202 14 : QtPassSettings::getPass()->Show(file);
1203 : }
1204 :
1205 2 : void MainWindow::onEdit() {
1206 2 : QString file = getFile(ui->treeView->currentIndex(), true);
1207 2 : editPassword(file);
1208 2 : }
1209 :
1210 2 : void MainWindow::onUsers() {
1211 2 : const QString dir = m_tree->currentDir(false);
1212 2 : if (refuseLinkedFolder(dir)) {
1213 : return;
1214 : }
1215 :
1216 1 : UsersDialog d(QtPassSettings::getPass(), QtPassSettings::load(), dir, this);
1217 1 : if (!d.exec()) {
1218 1 : ui->treeView->setFocus();
1219 : }
1220 1 : }
1221 :
1222 2 : void MainWindow::messageAvailable(const QString &message) {
1223 2 : show();
1224 2 : raise();
1225 2 : if (message.isEmpty()) {
1226 1 : focusInput();
1227 : } else {
1228 1 : ui->treeView->expandAll();
1229 1 : ui->lineEdit->setText(message);
1230 1 : on_lineEdit_returnPressed();
1231 : }
1232 2 : }
1233 :
1234 137 : void MainWindow::updateProfileBox() {
1235 137 : const Profiles profiles = QtPassSettings::getProfiles();
1236 : bool activeGone = false;
1237 :
1238 7 : if (profiles.isEmpty()) {
1239 130 : ui->profileWidget->hide();
1240 : } else {
1241 7 : ui->profileWidget->show();
1242 : // A refill is no choice of the user's: clearing and adding items moves
1243 : // the current text, which would otherwise switch profiles.
1244 7 : const QSignalBlocker quiet(ui->profileBox);
1245 7 : ui->profileBox->clear();
1246 19 : for (auto i = profiles.cbegin(); i != profiles.cend(); ++i) {
1247 12 : if (!i.key().isEmpty()) {
1248 12 : ui->profileBox->addItem(i.key());
1249 : }
1250 : }
1251 7 : ui->profileBox->model()->sort(0);
1252 14 : int index = ui->profileBox->findText(QtPassSettings::getProfile());
1253 7 : if (index != -1) { // -1 for not found
1254 6 : ui->profileBox->setCurrentIndex(index);
1255 1 : } else if (ui->profileBox->count() > 0) {
1256 : // The active profile is gone (removed in Settings): the box shows the
1257 : // first one, and the settings and backend must follow it, or the
1258 : // removed profile's store stays in use behind it.
1259 1 : ui->profileBox->setCurrentIndex(0);
1260 : activeGone = true;
1261 : }
1262 7 : }
1263 7 : if (activeGone) {
1264 2 : on_profileBox_currentTextChanged(ui->profileBox->currentText());
1265 : }
1266 137 : updateProfileBoxEnabled();
1267 137 : }
1268 :
1269 417 : void MainWindow::updateProfileBoxEnabled() {
1270 : // No profile switch while anything of the store runs or waits: the
1271 : // backend's store and environment would change under it, and what is
1272 : // still queued (a push after a save, say) would finish for the other
1273 : // profile (#2071). Asked of the backend, not only of the interface: the
1274 : // watchdog releases the interface while a slow gpg or git still runs.
1275 : // Every backend: one kept from before a switch of backend may still
1276 : // run what was queued on it.
1277 835 : ui->profileBox->setEnabled(m_uiEnabled && ui->profileBox->count() > 1 &&
1278 11 : QtPassSettings::backendsIdle());
1279 417 : }
1280 :
1281 6 : void MainWindow::on_profileBox_currentTextChanged(const QString &name) {
1282 18 : if (m_freshStart || name == QtPassSettings::getProfile()) {
1283 1 : return;
1284 : }
1285 : // The box is disabled while anything runs; should a change come through
1286 : // anyway, the active profile stays and the box says so again.
1287 5 : if (!m_uiEnabled || !QtPassSettings::backendsIdle()) {
1288 0 : const QSignalBlocker quiet(ui->profileBox);
1289 0 : ui->profileBox->setCurrentText(QtPassSettings::getProfile());
1290 : return;
1291 0 : }
1292 : // A content search runs outside the queue: leaving grep mode cancels it,
1293 : // so its results for the old store are dropped, not shown for the new.
1294 5 : if (m_grep.inGrepMode()) {
1295 1 : ui->grepButton->setChecked(false);
1296 : }
1297 :
1298 5 : ui->lineEdit->clear();
1299 :
1300 10 : const Profile prof = QtPassSettings::getProfiles().value(name);
1301 5 : AppSettings s = QtPassSettings::load();
1302 5 : s.activeProfile = name;
1303 5 : s.passStore = prof.path;
1304 5 : s.passSigningKey = prof.signingKey;
1305 : // A profile without its own Git flags keeps the global values.
1306 5 : s.useGit = prof.useGit.value_or(s.useGit);
1307 5 : s.autoPush = prof.autoPush.value_or(s.autoPush);
1308 5 : s.autoPull = prof.autoPull.value_or(s.autoPull);
1309 5 : QtPassSettings::save(s);
1310 10 : ui->statusBar->showMessage(tr("Profile changed to %1").arg(name), 2000);
1311 :
1312 5 : QtPassSettings::getPass()->updateEnv();
1313 :
1314 10 : m_tree->setStore(QtPassSettings::getPassStore());
1315 5 : deselect();
1316 5 : ui->treeView->setCurrentIndex(QModelIndex());
1317 5 : }
1318 :
1319 2 : void MainWindow::initTrayIcon() {
1320 2 : m_tray = new TrayIcon(this);
1321 2 : if (!m_tray->getIsAllocated()) {
1322 2 : destroyTrayIcon();
1323 2 : return;
1324 : }
1325 : #ifndef Q_OS_MACOS
1326 0 : m_tray->addToggle(ui->actionShowMenuBar);
1327 : #endif
1328 : }
1329 :
1330 2 : void MainWindow::destroyTrayIcon() {
1331 2 : delete m_tray;
1332 2 : m_tray = nullptr;
1333 2 : }
1334 :
1335 2 : void MainWindow::closeEvent(QCloseEvent *event) {
1336 : // Save in both branches: a tray user only ever hides the window, and
1337 : // used to quit through the tray menu with nothing ever written.
1338 2 : saveWindowState();
1339 2 : if (QtPassSettings::isHideOnClose()) {
1340 2 : this->hide();
1341 : event->ignore();
1342 : } else {
1343 0 : m_qtPass->clipboard().clear();
1344 : event->accept();
1345 : // A visible QSystemTrayIcon keeps the application alive, so
1346 : // quitOnLastWindowClosed never fires; quit explicitly.
1347 0 : QApplication::quit();
1348 : }
1349 2 : }
1350 :
1351 5225 : auto MainWindow::eventFilter(QObject *obj, QEvent *event) -> bool {
1352 5225 : if ((obj == ui->toolBar || obj == menuBar()) &&
1353 : event->type() == QEvent::PaletteChange) {
1354 : // Breeze gives these bars a "header" palette and, after a runtime
1355 : // light/dark switch, stamps the previous theme's colours back from a
1356 : // cached kdeglobals, hiding the toolbar icons. Deferred so we never
1357 : // re-enter the style while it is still applying its palette.
1358 36 : QTimer::singleShot(0, this, &MainWindow::dropStaleToolsAreaPalettes);
1359 : }
1360 5225 : if (obj == ui->lineEdit && event->type() == QEvent::KeyPress) {
1361 1 : auto *key = dynamic_cast<QKeyEvent *>(event);
1362 1 : if (key != nullptr && key->key() == Qt::Key_Down) {
1363 1 : ui->treeView->setFocus();
1364 : }
1365 : }
1366 5225 : return QObject::eventFilter(obj, event);
1367 : }
1368 :
1369 : // Only once the application palette changed (see m_themeSwitched): a bar
1370 : // whose active Window lightness is far from the application's shows the
1371 : // other theme.
1372 31 : void MainWindow::dropStaleToolsAreaPalettes() {
1373 31 : if (!m_themeSwitched) {
1374 : return;
1375 : }
1376 : constexpr int kStaleToolsAreaLightness = 64;
1377 : // The active group on both sides: a bar's palette() reads the inactive
1378 : // group while another window has focus (the settings app, during a theme
1379 : // switch), and Breeze gives the header set different inactive colours.
1380 54 : const int appLightness = QApplication::palette()
1381 : .color(QPalette::Active, QPalette::Window)
1382 27 : .lightness();
1383 108 : for (QWidget *bar : {static_cast<QWidget *>(ui->toolBar),
1384 81 : static_cast<QWidget *>(menuBar())}) {
1385 54 : if (!bar->testAttribute(Qt::WA_SetPalette)) {
1386 48 : continue;
1387 : }
1388 : const int barLightness =
1389 12 : bar->palette().color(QPalette::Active, QPalette::Window).lightness();
1390 6 : if (qAbs(barLightness - appLightness) <= kStaleToolsAreaLightness) {
1391 2 : continue;
1392 : }
1393 4 : bar->setPalette(QPalette());
1394 : // Both bars are transparent and the style paints the tools area from the
1395 : // stale palette, so fill our own. Window, not their Button role, which is
1396 : // not the window colour in either theme.
1397 4 : bar->setBackgroundRole(QPalette::Window);
1398 4 : bar->setAutoFillBackground(true);
1399 : }
1400 : }
1401 :
1402 18 : void MainWindow::keyPressEvent(QKeyEvent *event) {
1403 18 : switch (event->key()) {
1404 1 : case Qt::Key_Delete:
1405 1 : onDelete();
1406 1 : break;
1407 1 : case Qt::Key_Return:
1408 : case Qt::Key_Enter:
1409 1 : if (m_tree->proxy().rowCount() > 0) {
1410 1 : on_treeView_clicked(ui->treeView->currentIndex());
1411 : }
1412 : break;
1413 3 : case Qt::Key_Escape:
1414 3 : ui->lineEdit->clear();
1415 3 : break;
1416 : default:
1417 : break;
1418 : }
1419 18 : }
1420 :
1421 9 : void MainWindow::addShareMenu(QMenu &contextMenu, const AppSettings &s) {
1422 9 : const QString dirPath = QDir::cleanPath(m_tree->currentDir(false));
1423 9 : auto *shareMenu = new QMenu(tr("Share"), &contextMenu);
1424 9 : contextMenu.addMenu(shareMenu);
1425 :
1426 9 : const QString gpgIdPath = Pass::getGpgIdPath(dirPath, s.passStore);
1427 9 : const bool gpgIdExists = !gpgIdPath.isEmpty() && QFile(gpgIdPath).exists();
1428 9 : const QString exePath = s.usePass ? s.passExecutable : s.gpgExecutable;
1429 : const bool gpgAvailable =
1430 18 : !exePath.isEmpty() &&
1431 18 : (Executor::parseWslCommand(exePath) || QFile(exePath).exists());
1432 :
1433 9 : QAction *reencrypt = shareMenu->addAction(tr("Re-encrypt all passwords"));
1434 9 : reencrypt->setEnabled(gpgIdExists && gpgAvailable);
1435 9 : connect(reencrypt, &QAction::triggered, this,
1436 30 : [this, dirPath]() { reencryptPath(dirPath); });
1437 :
1438 9 : QAction *exportKey = shareMenu->addAction(tr("Export my public key..."));
1439 9 : exportKey->setEnabled(gpgAvailable);
1440 9 : connect(exportKey, &QAction::triggered, this, &MainWindow::exportPublicKey);
1441 :
1442 9 : QAction *addRecipientAction = shareMenu->addAction(tr("Add recipient..."));
1443 9 : addRecipientAction->setEnabled(gpgIdExists && gpgAvailable);
1444 9 : connect(addRecipientAction, &QAction::triggered, this,
1445 29 : [this, dirPath]() { addRecipient(dirPath); });
1446 :
1447 9 : QAction *shareHelp = shareMenu->addAction(tr("What is this?"));
1448 9 : connect(shareHelp, &QAction::triggered, this, &MainWindow::showShareHelp);
1449 9 : }
1450 :
1451 11 : void MainWindow::showContextMenu(const QPoint &pos) {
1452 11 : const AppSettings s = QtPassSettings::load();
1453 11 : const QModelIndex index = ui->treeView->indexAt(pos);
1454 : const bool selected = index.isValid();
1455 : if (!selected) {
1456 1 : ui->treeView->clearSelection();
1457 1 : ui->actionDelete->setEnabled(false);
1458 1 : ui->actionEdit->setEnabled(false);
1459 : }
1460 11 : ui->treeView->setCurrentIndex(index);
1461 11 : const QPoint globalPos = ui->treeView->viewport()->mapToGlobal(pos);
1462 11 : const QFileInfo fileOrFolder = m_tree->fileInfo(ui->treeView->currentIndex());
1463 11 : const bool isDir = fileOrFolder.isDir();
1464 :
1465 11 : QMenu contextMenu;
1466 11 : if (!selected || isDir) {
1467 : const std::array<std::pair<QString, void (MainWindow::*)()>, 4> onFolder{{
1468 10 : {tr("Open folder with file manager"), &MainWindow::openFolder},
1469 10 : {tr("Add folder"), &MainWindow::addFolder},
1470 10 : {tr("Add password"), &MainWindow::addPassword},
1471 10 : {tr("Users"), &MainWindow::onUsers},
1472 0 : }};
1473 50 : for (const auto &[text, slot] : onFolder) {
1474 40 : connect(contextMenu.addAction(text), &QAction::triggered, this, slot);
1475 : }
1476 1 : } else if (fileOrFolder.isFile()) {
1477 1 : connect(contextMenu.addAction(tr("Edit")), &QAction::triggered, this,
1478 2 : &MainWindow::onEdit);
1479 : }
1480 11 : if (selected) {
1481 10 : contextMenu.addSeparator();
1482 10 : if (isDir) {
1483 9 : connect(contextMenu.addAction(tr("Rename folder")), &QAction::triggered,
1484 18 : this, &MainWindow::renameFolder);
1485 1 : } else if (fileOrFolder.isFile()) {
1486 1 : connect(contextMenu.addAction(tr("Rename password")), &QAction::triggered,
1487 2 : this, &MainWindow::renamePassword);
1488 : }
1489 10 : connect(contextMenu.addAction(tr("Delete")), &QAction::triggered, this,
1490 10 : &MainWindow::onDelete);
1491 10 : if (isDir) {
1492 9 : addShareMenu(contextMenu, s);
1493 : }
1494 : }
1495 11 : contextMenu.exec(globalPos);
1496 11 : }
1497 :
1498 1 : void MainWindow::showBrowserContextMenu(const QPoint &pos) {
1499 1 : QMenu *contextMenu = ui->textBrowser->createStandardContextMenu(pos);
1500 : // Reparent from textBrowser: a browser stylesheet cascades to the child
1501 : // QMenu and breaks its opaque native background.
1502 1 : contextMenu->setParent(this, contextMenu->windowFlags());
1503 1 : QPoint globalPos = ui->textBrowser->viewport()->mapToGlobal(pos);
1504 :
1505 1 : contextMenu->exec(globalPos);
1506 1 : delete contextMenu;
1507 1 : }
1508 :
1509 1 : void MainWindow::openFolder() {
1510 1 : QString dir = m_tree->currentDir(false);
1511 :
1512 1 : QString path = QDir::toNativeSeparators(dir);
1513 1 : QDesktopServices::openUrl(QUrl::fromLocalFile(path));
1514 1 : }
1515 :
1516 6 : void MainWindow::addFolder() {
1517 6 : const AppSettings s = QtPassSettings::load();
1518 : bool ok;
1519 6 : QString dir = m_tree->currentDir(false);
1520 : QString newdir =
1521 6 : QInputDialog::getText(this, tr("New folder"),
1522 6 : tr("New folder: \n(Will be placed in %1 )")
1523 12 : .arg(s.passStore + m_tree->currentDir(true)),
1524 12 : QLineEdit::Normal, "", &ok);
1525 6 : if (!ok || newdir.isEmpty()) {
1526 : return;
1527 : }
1528 : // Checked and created as one and the same path: the check resolves ".."
1529 : // lexically, the system after following links, so "link/../x" would pass
1530 : // as the store's x and be made beside the link's target.
1531 5 : newdir = QDir::cleanPath(newdir.prepend(dir));
1532 5 : if (!confirmPathInStore(newdir)) {
1533 : return;
1534 : }
1535 : // Exists already: an error, as QDir::mkdir() made it before.
1536 4 : if (QFileInfo::exists(newdir) || !Util::mkpathForStore(newdir)) {
1537 2 : PlainMessageBox::warning(this, tr("Error"),
1538 2 : tr("Failed to create folder: %1").arg(newdir));
1539 1 : return;
1540 : }
1541 : // With a signing key, ImitatePass::loadVerifiedRecipients and pass
1542 : // (PASSWORD_STORE_SIGNING_KEY) refuse a .gpg-id without .gpg-id.sig, and
1543 : // signing needs a passphrase just to make a folder. So it inherits the
1544 : // parent's signed list; UsersDialog (Init) gives it its own.
1545 3 : const bool signingConfigured = !s.passSigningKey.trimmed().isEmpty();
1546 3 : if (s.addGPGId && !signingConfigured) {
1547 : // Seed from the parent's recipients in effect, not from `enabled` keys:
1548 : // that flag is only set in UsersDialog, so it left a zero-byte .gpg-id
1549 : // shadowing the parent's recipients (#1682).
1550 3 : if (!Pass::seedGpgIdFile(newdir, s.passStore)) {
1551 1 : PlainMessageBox::warning(
1552 1 : this, tr("Error"),
1553 2 : tr("Failed to create .gpg-id file in: %1").arg(newdir));
1554 1 : return;
1555 : }
1556 : }
1557 6 : }
1558 :
1559 3 : void MainWindow::renameFolder() {
1560 : bool ok;
1561 3 : QString srcDir = QDir::cleanPath(m_tree->currentDir(false));
1562 3 : QString srcDirName = QDir(srcDir).dirName();
1563 : QString newName =
1564 3 : QInputDialog::getText(this, tr("Rename folder"), tr("Rename folder to: "),
1565 6 : QLineEdit::Normal, srcDirName, &ok);
1566 3 : if (!ok || newName.isEmpty()) {
1567 : return;
1568 : }
1569 : QString destDir = srcDir;
1570 2 : destDir.replace(srcDir.lastIndexOf(srcDirName), srcDirName.length(), newName);
1571 2 : if (!confirmPathInStore(destDir)) {
1572 : return;
1573 : }
1574 3 : QtPassSettings::getPass()->Move(srcDir, destDir, false);
1575 : }
1576 :
1577 5 : void MainWindow::editPassword(const QString &file) {
1578 5 : if (!file.isEmpty()) {
1579 5 : const AppSettings s = QtPassSettings::load();
1580 5 : if (s.useGit && s.autoPull) {
1581 1 : onUpdate(true);
1582 : }
1583 : // The dialog decrypts the entry itself to fill its fields. That decrypt
1584 : // lands on the same finishedShow the panel uses, so drop the panel's claim
1585 : // to it: otherwise passShowHandler() repaints and, with "Always copy",
1586 : // puts the entry's old password on the clipboard - replacing whatever the
1587 : // user copied to paste into the dialog.
1588 5 : m_shownFile.clear();
1589 5 : setPassword(file, false);
1590 5 : }
1591 5 : }
1592 :
1593 2 : void MainWindow::renamePassword() {
1594 : bool ok;
1595 2 : QString file = getFile(ui->treeView->currentIndex(), false);
1596 2 : QString filePath = QFileInfo(file).path();
1597 2 : QString fileName = QFileInfo(file).fileName();
1598 4 : if (fileName.endsWith(".gpg", Qt::CaseInsensitive)) {
1599 2 : fileName.chop(4);
1600 : }
1601 :
1602 : QString newName =
1603 2 : QInputDialog::getText(this, tr("Rename file"), tr("Rename file to: "),
1604 4 : QLineEdit::Normal, fileName, &ok);
1605 2 : if (!ok || newName.isEmpty()) {
1606 : return;
1607 : }
1608 2 : QString newFile = QDir(filePath).filePath(newName);
1609 2 : if (!confirmPathInStore(newFile)) {
1610 : return;
1611 : }
1612 3 : QtPassSettings::getPass()->Move(file, newFile, false);
1613 : }
1614 :
1615 4 : void MainWindow::copyPasswordFromTreeview() {
1616 4 : QFileInfo fileOrFolder = m_tree->fileInfo(ui->treeView->currentIndex());
1617 :
1618 4 : if (fileOrFolder.isFile()) {
1619 4 : QString file = getFile(ui->treeView->currentIndex(), true);
1620 : // The newest Ctrl+C wins: finishedShow names its file, so the decrypt of
1621 : // an earlier request is simply not this one's.
1622 4 : m_copyRequestFile = file;
1623 : // This Show repaints the panel as well; see onOtp().
1624 4 : m_shownFile = file;
1625 8 : QtPassSettings::getPass()->Show(file);
1626 : }
1627 4 : }
1628 :
1629 17 : void MainWindow::passwordFromFileToClipboard(const QString &text,
1630 : const QString &file) {
1631 17 : if (m_copyRequestFile.isEmpty() || file != m_copyRequestFile) {
1632 15 : return;
1633 : }
1634 3 : m_copyRequestFile.clear();
1635 : // The first line, without a list of every line (an entry can expand into
1636 : // millions of them); left(-1) is the whole text when there is no newline.
1637 3 : const QString first = text.left(text.indexOf(QLatin1Char('\n')));
1638 : // A `pass otp insert` entry's first line is the otpauth:// URI carrying the
1639 : // TOTP seed; copying it would leak 2FA material. Matches the display path
1640 : // (FileContent::getPasswordForDisplay / PasswordDisplayPanel).
1641 3 : if (FileContent::isOtpUriValue(first)) {
1642 2 : flashText(tr("This entry holds an OTP secret, not a password"), true);
1643 : return;
1644 : }
1645 2 : m_qtPass->clipboard().copyText(first);
1646 : }
1647 :
1648 21 : void MainWindow::showStatusMessage(const QString &msg, int timeout) {
1649 21 : ui->statusBar->showMessage(msg, timeout);
1650 21 : }
1651 :
1652 3 : void MainWindow::reencryptPath(const QString &dir) {
1653 3 : QDir checkDir(dir);
1654 3 : if (!checkDir.exists()) {
1655 2 : PlainMessageBox::critical(this, tr("Error"),
1656 2 : tr("Directory does not exist: %1").arg(dir));
1657 1 : return;
1658 : }
1659 2 : if (refuseLinkedFolder(dir)) {
1660 : return;
1661 : }
1662 :
1663 2 : const bool yes = PlainMessageBox::askYes(
1664 2 : this, tr("Re-encrypt passwords"),
1665 2 : tr("Re-encrypt all passwords in %1?\n\n"
1666 : "This will re-encrypt ALL password files in this folder "
1667 : "using the current recipients defined in .gpg-id.\n\n"
1668 : "This may rewrite many files and cannot be undone easily.\n\n"
1669 : "Continue?")
1670 4 : .arg(QDir(dir).dirName()));
1671 :
1672 2 : if (!yes)
1673 : return;
1674 :
1675 : // Disable now; the asynchronous startReencryptPath signal re-runs this
1676 : // harmlessly.
1677 1 : startReencryptPath();
1678 :
1679 : // Under the current profile's settings: with the pass backend active, the
1680 : // native one is not re-initialised on a profile switch.
1681 2 : QtPassSettings::getImitatePass()->reencryptPath(
1682 2 : QDir::cleanPath(QDir(dir).absolutePath()), QtPassSettings::load());
1683 3 : }
1684 :
1685 : // Idempotent: reencryptPath() calls it before ImitatePass emits the signal.
1686 6 : void MainWindow::startReencryptPath() {
1687 6 : m_reencryptRunning = true;
1688 6 : setUiElementsEnabled(false);
1689 : // The worker always ends with endReencryptPath(), and Cancel is available
1690 : // throughout, so the generic watchdog is not needed for this operation.
1691 6 : m_uiWatchdog.stop();
1692 6 : ui->treeView->setDisabled(true);
1693 6 : if (m_reencryptProgress)
1694 : return;
1695 4 : auto *progress = new QProgressDialog(tr("Re-encrypting passwords..."),
1696 8 : tr("Cancel"), 0, 0, this);
1697 4 : progress->setWindowTitle(tr("Re-encrypt passwords"));
1698 4 : progress->setWindowModality(Qt::WindowModal);
1699 4 : progress->setAutoClose(false);
1700 4 : progress->setAutoReset(false);
1701 4 : progress->setMinimumDuration(0);
1702 4 : connect(progress, &QProgressDialog::canceled, this, [this]() {
1703 1 : QtPassSettings::getImitatePass()->cancelReencryptPath();
1704 1 : showStatusMessage(tr("Cancelling re-encryption"), 5000);
1705 1 : });
1706 : m_reencryptProgress = progress;
1707 4 : progress->show();
1708 : }
1709 :
1710 5 : void MainWindow::reencryptProgress(int current, int total) {
1711 5 : if (!m_reencryptProgress)
1712 : return;
1713 4 : m_reencryptProgress->setMaximum(total);
1714 4 : m_reencryptProgress->setLabelText(
1715 8 : tr("Re-encrypting passwords: %1 of %2").arg(current).arg(total));
1716 : // On a modal dialog setValue() runs processEvents(), which can deliver
1717 : // endReencryptPath() and reset m_reencryptProgress: keep this last.
1718 4 : m_reencryptProgress->setValue(current);
1719 : }
1720 :
1721 4 : void MainWindow::endReencryptPath() {
1722 4 : m_reencryptRunning = false;
1723 4 : if (m_reencryptProgress) {
1724 4 : m_reencryptProgress->hide();
1725 4 : m_reencryptProgress->deleteLater();
1726 : m_reencryptProgress = nullptr;
1727 : }
1728 4 : setUiElementsEnabled(true);
1729 4 : }
1730 :
1731 3 : void MainWindow::exportPublicKey() {
1732 3 : const AppSettings s = QtPassSettings::load();
1733 : const QString identity = s.passSigningKey;
1734 3 : if (identity.isEmpty()) {
1735 2 : QMessageBox::information(
1736 1 : this, tr("Export public key"),
1737 0 : tr("<h3>Export your public key</h3>"
1738 : "<p>No signing key is configured. Set one in QtPass Settings "
1739 : "> GPG keys, or run this in a terminal:</p>"
1740 : "<pre>gpg --armor --export --output my_key.asc <your-key-id"
1741 : "></pre>"
1742 : "<p>Then send the file to your teammates.</p>"));
1743 1 : return;
1744 : }
1745 : QString gpgExe = s.gpgExecutable;
1746 2 : if (gpgExe.isEmpty()) {
1747 0 : gpgExe = QStringLiteral("gpg");
1748 : }
1749 10 : QStringList args = {"--armor", "--export"};
1750 2 : args.append(identity.split(' ', Qt::SkipEmptyParts));
1751 2 : QString stdOut;
1752 2 : QString stdErr;
1753 2 : int exitCode = Executor::executeBlocking(gpgExe, args, &stdOut, &stdErr);
1754 2 : if (exitCode != 0 || stdOut.isEmpty()) {
1755 2 : PlainMessageBox::warning(this, tr("Export public key"),
1756 1 : tr("Could not export public key for %1.\n\n%2")
1757 2 : .arg(identity, stdErr.isEmpty()
1758 2 : ? tr("No output from gpg.")
1759 : : stdErr));
1760 : return;
1761 : }
1762 1 : ExportPublicKeyDialog dialog(identity, stdOut, this);
1763 1 : dialog.exec();
1764 4 : }
1765 :
1766 : // UsersDialog only ticks keys already in the keyring; importing a foreign
1767 : // key still has to happen via gpg (or QtPass settings) first.
1768 2 : void MainWindow::addRecipient(const QString &dir) {
1769 2 : if (refuseLinkedFolder(dir)) {
1770 1 : return;
1771 : }
1772 1 : UsersDialog d(QtPassSettings::getPass(), QtPassSettings::load(), dir, this);
1773 1 : d.exec();
1774 1 : }
1775 :
1776 : // A tree-picked folder that is, or lies behind, a symlink or junction is not
1777 : // a folder of the store. The store root itself may be a link (a synced
1778 : // folder); that is configuration, not a tree pick.
1779 11 : auto MainWindow::refuseLinkedFolder(const QString &dir) -> bool {
1780 11 : if (!Util::isUnderLink(dir, QtPassSettings::load().passStore)) {
1781 : return false;
1782 : }
1783 3 : PlainMessageBox::critical(
1784 3 : this, tr("Not a folder of the store"),
1785 3 : tr("%1 is, or lies behind, a symbolic link or junction. What that "
1786 : "points to is not part of the password store and is left alone.")
1787 6 : .arg(QDir::toNativeSeparators(QDir::cleanPath(dir))));
1788 3 : return true;
1789 : }
1790 :
1791 1 : void MainWindow::showShareHelp() {
1792 2 : QMessageBox::information(
1793 1 : this, tr("Sharing passwords with GPG"),
1794 1 : tr("<h3>Sharing passwords with GPG</h3>"
1795 : "<p>To share passwords with other users:</p>"
1796 : "<ol>"
1797 : "<li><b>Export your public key</b> and send it to teammates</li>"
1798 : "<li><b>Import teammates' public keys</b> into your GPG keyring</li>"
1799 : "<li><b>Re-encrypt passwords</b> so all recipients can decrypt "
1800 : "them</li>"
1801 : "</ol>"
1802 : "<p>Only people who have a matching secret key can decrypt the "
1803 : "passwords.</p>"
1804 : "<p><b>Tip:</b> Use the same GPG key for all shared folders.</p>"
1805 : "<p>See the FAQ for more details.</p>"));
1806 1 : }
1807 :
1808 258 : void MainWindow::updateGitButtonVisibility(bool uiEnabled) {
1809 258 : const AppSettings s = QtPassSettings::load();
1810 : // Without Git there is nothing to push or pull, ever: the buttons go, not
1811 : // grey out. While an operation runs they stay and are disabled.
1812 : const bool usable =
1813 258 : s.useGit && !(s.gitExecutable.isEmpty() && s.passExecutable.isEmpty());
1814 258 : ui->actionPush->setVisible(usable);
1815 258 : ui->actionUpdate->setVisible(usable);
1816 : // The separator in front of them would otherwise sit next to the next one.
1817 258 : const QList<QAction *> actions = ui->toolBar->actions();
1818 258 : const qsizetype at = actions.indexOf(ui->actionPush);
1819 258 : if (at > 0 && actions.at(at - 1)->isSeparator()) {
1820 258 : actions.at(at - 1)->setVisible(usable);
1821 : }
1822 258 : enableGitButtons(usable && uiEnabled);
1823 258 : }
1824 :
1825 258 : void MainWindow::updateOtpButtonVisibility(bool uiEnabled) {
1826 : // No platform gating: codes are generated in-process. Visible but disabled
1827 : // when OTP is off: the panel never shows an OTP field (a secret), so hiding
1828 : // this too would leave no trace of the feature. Honour uiEnabled, or the
1829 : // user can stack Show calls while a decrypt is in flight.
1830 258 : ui->actionOtp->setVisible(true);
1831 271 : ui->actionOtp->setEnabled(QtPassSettings::isUseOtp() && uiEnabled);
1832 258 : }
1833 :
1834 133 : void MainWindow::updateGrepButtonVisibility() {
1835 133 : const bool enabled = QtPassSettings::isUseGrepSearch();
1836 133 : ui->grepButton->setVisible(enabled);
1837 133 : ui->grepCaseButton->setVisible(enabled);
1838 133 : if (!enabled && m_grep.inGrepMode()) {
1839 1 : ui->grepButton->setChecked(false);
1840 : }
1841 133 : }
1842 :
1843 258 : void MainWindow::enableGitButtons(const bool &state) {
1844 258 : ui->actionPush->setEnabled(state);
1845 258 : ui->actionUpdate->setEnabled(state);
1846 258 : }
1847 :
1848 3 : void MainWindow::critical(const QString &title, const QString &msg) {
1849 : // Pass's messages quote store paths; see PlainMessageBox.
1850 3 : PlainMessageBox::critical(this, title, msg);
1851 3 : }
1852 :
1853 6 : void MainWindow::onProcessOutput(const QString &output, bool isError,
1854 : Enums::PROCESS pid) {
1855 6 : if (!QtPassSettings::isShowProcessOutput()) {
1856 : return;
1857 : }
1858 5 : m_processOutput->append(output, isError,
1859 10 : ProcessOutputPanel::processName(pid));
1860 : }
1861 :
1862 2 : void MainWindow::updateProcessOutputVisibility() {
1863 : // The action's toggled handler shows or hides the dock.
1864 2 : ui->actionShowProcessOutput->setChecked(
1865 2 : QtPassSettings::isShowProcessOutput());
1866 2 : m_processOutput->setVisible(QtPassSettings::isShowProcessOutput());
1867 2 : }
|