MainWindow.cpp raw
  1#include "MainWindow.h"
  2#include <QAction>
  3#include <QApplication>
  4#include <QCompleter>
  5#include <QCursor>
  6#include <QDebug>
  7#include <QFile>
  8#include <QLineEdit>
  9#include <QMenu>
 10#include <QMessageBox>
 11#include <QMouseEvent>
 12#include <QPalette>
 13#include <QSqlQueryModel>
 14#include <QStandardPaths>
 15#include <QStyle>
 16#include <QStyleHints>
 17#include <QTabBar>
 18#include <QTabWidget>
 19#include <QToolBar>
 20#include <QVBoxLayout>
 21#include <QWebEngineDownloadRequest>
 22#include <QWebEngineFullScreenRequest>
 23#include <QWebEnginePermission>
 24#include <QWebEngineProfile>
 25#include "BrowserTab.h"
 26#include "DatabaseManager.h"
 27#include "DownloadBar.h"
 28#include "PasswordHelper.h"
 29#include "ThemeConfig.h"
 30#include "VaultManager.h"
 31
 32MainWindow::MainWindow() : isDarkMode(true) {
 33	setAttribute(Qt::WA_DeleteOnClose);
 34	setupToolBar();
 35	setupUserInterface();
 36	setupKeyboardShortcuts();
 37
 38	applyApplicationTheme(true);
 39	darkModeAction->setChecked(true);
 40
 41	connect(QWebEngineProfile::defaultProfile(), &QWebEngineProfile::downloadRequested, this, [this](QWebEngineDownloadRequest *download) {
 42		downloadBar->addDownload(download);
 43		download->accept();
 44	});
 45
 46	resize(ThemeConfig::DefaultWindowWidth, ThemeConfig::DefaultWindowHeight);
 47
 48	// Initial tab
 49	createNewTab(QUrl("qrc:/debug.html"));
 50}
 51
 52void MainWindow::setupUserInterface() {
 53	tabs = new QTabWidget(this);
 54	tabs->setTabsClosable(false);	 // We use middle click
 55	tabs->setMovable(true);
 56
 57	auto *central = new QWidget(this);
 58	auto *layout = new QVBoxLayout(central);
 59	layout->setContentsMargins(0, 0, 0, 0);
 60	layout->setSpacing(0);
 61	layout->addWidget(tabs, 1);
 62
 63	downloadBar = new DownloadBar(central);
 64	layout->addWidget(downloadBar);
 65
 66	setCentralWidget(central);
 67
 68	tabs->tabBar()->installEventFilter(this);
 69
 70	connect(tabs, &QTabWidget::tabCloseRequested, this, &MainWindow::closeTabAtIndex);
 71	connect(tabs, &QTabWidget::currentChanged, this, &MainWindow::updateWindowStatus);
 72}
 73
 74void MainWindow::setupToolBar() {
 75	// Create completer first to avoid crash in event filter
 76	completer = new QCompleter(this);
 77	completerModel = new QSqlQueryModel(this);
 78	completer->setModel(completerModel);
 79	completer->setCompletionColumn(0);
 80	completer->setCompletionMode(QCompleter::PopupCompletion);
 81	completer->setFilterMode(Qt::MatchContains);
 82	completer->setCaseSensitivity(Qt::CaseInsensitive);
 83
 84	toolbar = new QToolBar(this);
 85	addToolBar(toolbar);
 86
 87	address = new QLineEdit(this);
 88	address->setCompleter(completer);
 89	address->installEventFilter(this);
 90	toolbar->addWidget(address);
 91
 92	bookmarkAction = toolbar->addAction("Star");
 93	connect(bookmarkAction, &QAction::triggered, this, &MainWindow::toggleBookmark);
 94
 95	passwordAction = toolbar->addAction("Autofill");
 96	passwordAction->setToolTip("Fill saved credentials (Ctrl+Shift+L)");
 97	connect(passwordAction, &QAction::triggered, this, &MainWindow::showPasswordMenu);
 98	passwordAction->setVisible(false);
 99
100	auto *addTabAction = toolbar->addAction("+");
101	connect(addTabAction, &QAction::triggered, [this]() { createNewTab(); });
102
103	devtoolsAction = toolbar->addAction("DevTools");
104	connect(devtoolsAction, &QAction::triggered, [this]() {
105		if (auto *tab = currentTab()) {
106			tab->setDevToolsVisible(!tab->devtools->isVisible());
107		}
108	});
109
110	darkModeAction = toolbar->addAction("Dark Mode");
111	darkModeAction->setCheckable(true);
112	connect(darkModeAction, &QAction::toggled, this, &MainWindow::applyApplicationTheme);
113
114	connect(address, &QLineEdit::returnPressed, this, &MainWindow::navigateToAddressOrSearch);
115	updateAddressCompleter();
116}
117
118void MainWindow::updateAddressCompleter() {
119	completerModel->setQuery("SELECT DISTINCT url FROM history ORDER BY last_visit DESC LIMIT 1000");
120}
121
122void MainWindow::setupKeyboardShortcuts() {
123	// New Tab
124	auto *newTab = new QAction(this);
125	newTab->setShortcut(QKeySequence::AddTab);
126	connect(newTab, &QAction::triggered, [this]() { createNewTab(); });
127	addAction(newTab);
128
129	// New Window
130	auto *newWin = new QAction(this);
131	newWin->setShortcut(QKeySequence::New);
132	connect(newWin, &QAction::triggered, []() { (new MainWindow())->show(); });
133	addAction(newWin);
134
135	// Close Tab
136	auto *closeTab = new QAction(this);
137	closeTab->setShortcut(QKeySequence::Close);
138	connect(closeTab, &QAction::triggered, [this]() { closeTabAtIndex(tabs->currentIndex()); });
139	addAction(closeTab);
140
141	// Zoom shortcuts
142	auto *zoomIn = new QAction(this);
143	zoomIn->setShortcut(QKeySequence::ZoomIn);
144	connect(zoomIn, &QAction::triggered, [this]() {
145		if (auto *tab = currentTab()) {
146			tab->view->setZoomFactor(tab->view->zoomFactor() + 0.1);
147		}
148	});
149	addAction(zoomIn);
150
151	auto *zoomOut = new QAction(this);
152	zoomOut->setShortcut(QKeySequence::ZoomOut);
153	connect(zoomOut, &QAction::triggered, [this]() {
154		if (auto *tab = currentTab()) {
155			tab->view->setZoomFactor(qMax(0.25, tab->view->zoomFactor() - 0.1));
156		}
157	});
158	addAction(zoomOut);
159
160	auto *zoomReset = new QAction(this);
161	zoomReset->setShortcut(Qt::CTRL | Qt::Key_0);
162	connect(zoomReset, &QAction::triggered, [this]() {
163		if (auto *tab = currentTab()) {
164			tab->view->setZoomFactor(1.0);
165		}
166	});
167	addAction(zoomReset);
168
169	// Refresh
170	auto *reload = new QAction(this);
171	reload->setShortcuts({QKeySequence::Refresh, QKeySequence(Qt::CTRL | Qt::Key_R)});
172	connect(reload, &QAction::triggered, [this]() {
173		if (auto *tab = currentTab()) {
174			tab->view->reload();
175		}
176	});
177	addAction(reload);
178
179	// Tab cycling
180	auto *nextTab = new QAction(this);
181	nextTab->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_Tab));
182	connect(nextTab, &QAction::triggered, [this]() { tabs->setCurrentIndex((tabs->currentIndex() + 1) % tabs->count()); });
183	addAction(nextTab);
184
185	auto *prevTab = new QAction(this);
186	prevTab->setShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Tab));
187	connect(prevTab, &QAction::triggered, [this]() { tabs->setCurrentIndex((tabs->currentIndex() - 1 + tabs->count()) % tabs->count()); });
188	addAction(prevTab);
189
190	// Exit fullscreen
191	auto *esc = new QAction(this);
192	esc->setShortcut(Qt::Key_Escape);
193	connect(esc, &QAction::triggered, [this]() {
194		if (isFullScreen()) {
195			toolbar->show();
196			tabs->tabBar()->show();
197			downloadBar->show();
198			showNormal();
199		}
200	});
201	addAction(esc);
202
203	// Bookmarking
204	auto *bookmark = new QAction(this);
205	bookmark->setShortcut(Qt::CTRL | Qt::Key_D);
206	connect(bookmark, &QAction::triggered, this, &MainWindow::toggleBookmark);
207	addAction(bookmark);
208
209	// Passwords manual trigger
210	auto *fillPass = new QAction(this);
211	fillPass->setShortcut(Qt::CTRL | Qt::SHIFT | Qt::Key_L);
212	connect(fillPass, &QAction::triggered, this, &MainWindow::showPasswordMenu);
213	addAction(fillPass);
214}
215
216void MainWindow::createNewTab(const QUrl &url, bool focus) {
217	auto *tab = new BrowserTab;
218	tab->updateTabTheme(isDarkMode);
219
220	tab->view->createTabCallback = [this]() {
221		createNewTab(QUrl(), false);
222		return qobject_cast<BrowserTab *>(tabs->widget(tabs->count() - 1))->view;
223	};
224
225	int index = tabs->addTab(tab, url.isEmpty() ? "Blank" : "Loading...");
226
227	connect(tab->view, &QWebEngineView::urlChanged, [this, tab](const QUrl &u) {
228		if (currentTab() == tab) {
229			address->setText(u.toString());
230			updateBookmarkIcon();
231		}
232		DatabaseManager::instance().addHistoryEntry(u.toString(), tab->view->title());
233		updateAddressCompleter();
234
235		QString newHost = u.host();
236		if (!newHost.isEmpty()) {
237			bool ok;
238			qreal saved = DatabaseManager::instance().getDomainSetting(newHost, "zoom").toDouble(&ok);
239			if (ok && saved > 0.0) {
240				tab->view->setZoomFactor(saved);
241			}
242		}
243	});
244
245	connect(tab->view, &QWebEngineView::titleChanged, [this, tab](const QString &title) {
246		int idx = tabs->indexOf(tab);
247		if (idx != -1) {
248			tabs->setTabText(idx, title);
249			if (currentTab() == tab) {
250				setWindowTitle(title + " - Browser");
251			}
252		}
253		DatabaseManager::instance().addHistoryEntry(tab->view->url().toString(), title);
254		updateAddressCompleter();
255	});
256
257	connect(tab->view->pageAction(QWebEnginePage::InspectElement), &QAction::triggered, [tab]() { tab->setDevToolsVisible(true); });
258
259	connect(tab->view->page(), &QWebEnginePage::fullScreenRequested, [this](QWebEngineFullScreenRequest req) {
260		if (req.toggleOn()) {
261			toolbar->hide();
262			tabs->tabBar()->hide();
263			downloadBar->hide();
264			showFullScreen();
265		} else {
266			toolbar->show();
267			tabs->tabBar()->show();
268			downloadBar->show();
269			showNormal();
270		}
271		req.accept();
272	});
273
274	connect(tab->view->page(), &QWebEnginePage::permissionRequested, [this](QWebEnginePermission req) {
275		QString type;
276		switch (req.permissionType()) {
277			case QWebEnginePermission::PermissionType::MediaVideoCapture:
278				type = "Camera";
279				break;
280			case QWebEnginePermission::PermissionType::MediaAudioCapture:
281				type = "Microphone";
282				break;
283			case QWebEnginePermission::PermissionType::Geolocation:
284				type = "Location";
285				break;
286			case QWebEnginePermission::PermissionType::Notifications:
287				type = "Notifications";
288				break;
289			default:
290				type = "Resources";
291				break;
292		}
293
294		auto btn = QMessageBox::question(this, "Permission", QString("%1 wants to use your %2. Allow?").arg(req.origin().toString(), type));
295
296		(btn == QMessageBox::Yes) ? req.grant() : req.deny();
297	});
298
299	connect(tab->passwordHelper, &PasswordHelper::savePasswordRequested, [this](const QString &origin, const QString &username, const QString &password) {
300		if (!VaultManager::instance().isUnlocked()) {
301			return;
302		}
303
304		auto existing = VaultManager::instance().getPasswords(origin);
305		bool foundUser = false;
306		bool samePassword = false;
307
308		for (const auto &entry : existing) {
309			if (entry.username == username) {
310				foundUser = true;
311				if (entry.password == password) {
312					samePassword = true;
313				}
314				break;
315			}
316		}
317
318		if (samePassword) {
319			return;
320		}
321
322		QString msg = foundUser ? QString("Do you want to update the password for %1 (User: %2)?").arg(origin, username)
323														: QString("Do you want to save the password for %1 (User: %2)?").arg(origin, username);
324
325		auto btn = QMessageBox::question(this, foundUser ? "Update Password" : "Save Password", msg);
326
327		if (btn == QMessageBox::Yes) {
328			VaultManager::instance().savePassword(origin, username, password);
329			updatePasswordIcon();
330		}
331	});
332
333	connect(tab->view, &QWebEngineView::loadFinished, [this, tab](bool ok) {
334		if (ok) {
335			QString host = tab->view->url().host();
336			if (!host.isEmpty()) {
337				tab->setLastHost(host);
338				bool zoomOk;
339				qreal saved = DatabaseManager::instance().getDomainSetting(host, "zoom").toDouble(&zoomOk);
340				if (zoomOk && saved > 0.0) {
341					tab->view->setZoomFactor(saved);
342				}
343			}
344
345			if (!VaultManager::instance().isUnlocked()) {
346				return;
347			}
348			updatePasswordIcon();
349			auto creds = VaultManager::instance().getPasswords(host);
350			if (creds.size() == 1) {
351				auto entry = creds.first();
352				injectAutofill(tab, entry.username, entry.password);
353			}
354		}
355	});
356
357	if (!url.isEmpty()) {
358		tab->view->load(url);
359	}
360
361	if (focus) {
362		tabs->setCurrentIndex(index);
363		address->setFocus();
364	}
365}
366
367void MainWindow::closeTabAtIndex(int index) {
368	if (tabs->count() > 1) {
369		QWidget *w = tabs->widget(index);
370		tabs->removeTab(index);
371		w->deleteLater();
372	} else {
373		close();
374	}
375}
376
377void MainWindow::navigateToAddressOrSearch() {
378	if (auto *tab = currentTab()) {
379		QString text = address->text().trimmed();
380		if (text.isEmpty()) {
381			return;
382		}
383
384		bool isSearch = text.contains(' ') || (!text.contains('.') && !text.contains("://") && text != "localhost");
385
386		if (isSearch) {
387			tab->view->load(QUrl("https://duckduckgo.com/?q=" + QUrl::toPercentEncoding(text)));
388		} else {
389			if (!text.contains("://")) {
390				text = "https://" + text;
391			}
392			tab->view->load(QUrl(text));
393		}
394		address->clearFocus();
395	}
396}
397
398void MainWindow::applyApplicationTheme(bool dark) {
399	isDarkMode = dark;
400
401	// Set color scheme first so that any QWebEnginePages created or reloaded
402	// afterwards pick up the correct prefers-color-scheme media query value.
403	QGuiApplication::styleHints()->setColorScheme(dark ? Qt::ColorScheme::Dark : Qt::ColorScheme::Light);
404
405	QPalette p;
406	if (dark) {
407		p.setColor(QPalette::Window, ThemeConfig::Dark::Window);
408		p.setColor(QPalette::WindowText, ThemeConfig::Dark::WindowText);
409		p.setColor(QPalette::Base, ThemeConfig::Dark::Base);
410		p.setColor(QPalette::AlternateBase, ThemeConfig::Dark::AlternateBase);
411		p.setColor(QPalette::ToolTipBase, ThemeConfig::Dark::ToolTipBase);
412		p.setColor(QPalette::ToolTipText, ThemeConfig::Dark::ToolTipText);
413		p.setColor(QPalette::Text, ThemeConfig::Dark::Text);
414		p.setColor(QPalette::Button, ThemeConfig::Dark::Button);
415		p.setColor(QPalette::ButtonText, ThemeConfig::Dark::ButtonText);
416		p.setColor(QPalette::BrightText, ThemeConfig::Dark::BrightText);
417		p.setColor(QPalette::Link, ThemeConfig::Dark::Link);
418		p.setColor(QPalette::Highlight, ThemeConfig::Dark::Highlight);
419		p.setColor(QPalette::HighlightedText, ThemeConfig::Dark::HighlightedText);
420	} else {
421		p = style()->standardPalette();
422	}
423
424	qApp->setPalette(p);
425	this->setPalette(p);
426
427	for (int i = 0; i < tabs->count(); ++i) {
428		if (auto *tab = qobject_cast<BrowserTab *>(tabs->widget(i))) {
429			tab->updateTabTheme(dark);
430			// Reload so the page re-evaluates prefers-color-scheme with the new
431			// value.
432			tab->view->reload();
433		}
434	}
435}
436
437void MainWindow::updateWindowStatus() {
438	if (auto *tab = currentTab()) {
439		address->setText(tab->view->url().toString());
440		setWindowTitle(tabs->tabText(tabs->currentIndex()) + " - Browser");
441		updateBookmarkIcon();
442		updatePasswordIcon();
443	}
444}
445
446void MainWindow::toggleBookmark() {
447	if (auto *tab = currentTab()) {
448		QString url = tab->view->url().toString();
449		QString title = tab->view->title();
450		if (DatabaseManager::instance().isBookmarked(url)) {
451			DatabaseManager::instance().removeBookmark(url);
452		} else {
453			DatabaseManager::instance().addBookmark(url, title);
454		}
455		updateBookmarkIcon();
456	}
457}
458
459void MainWindow::updateBookmarkIcon() {
460	if (auto *tab = currentTab()) {
461		bool bookmarked = DatabaseManager::instance().isBookmarked(tab->view->url().toString());
462		bookmarkAction->setText(bookmarked ? "★" : "☆");
463	}
464}
465
466void MainWindow::updatePasswordIcon() {
467	if (auto *tab = currentTab()) {
468		QString host = tab->view->url().host();
469		auto creds = VaultManager::instance().getPasswords(host);
470		passwordAction->setVisible(!creds.isEmpty());
471	}
472}
473
474void MainWindow::showPasswordMenu() {
475	if (auto *tab = currentTab()) {
476		QString host = tab->view->url().host();
477		auto creds = VaultManager::instance().getPasswords(host);
478		if (creds.isEmpty()) {
479			return;
480		}
481
482		QMenu menu(this);
483		for (const auto &entry : creds) {
484			auto *action = menu.addAction(entry.username);
485			connect(action, &QAction::triggered, [this, tab, entry]() { injectAutofill(tab, entry.username, entry.password); });
486		}
487		menu.exec(QCursor::pos());
488	}
489}
490
491void MainWindow::injectAutofill(BrowserTab *tab, const QString &username, const QString &password) {
492	QString user = username;
493	user.replace("\\", "\\\\");
494	user.replace("\"", "\\\"");
495	QString pass = password;
496	pass.replace("\\", "\\\\");
497	pass.replace("\"", "\\\"");
498
499	QFile scriptFile(":/js/autofill.js");
500	if (scriptFile.open(QIODevice::ReadOnly)) {
501		QString js = QString::fromUtf8(scriptFile.readAll());
502		js.replace("%1", user);
503		js.replace("%2", pass);
504		tab->view->page()->runJavaScript(js);
505	} else {
506		qCritical() << "Failed to load autofill.js from resources!";
507	}
508}
509
510BrowserTab *MainWindow::currentTab() const {
511	return qobject_cast<BrowserTab *>(tabs->currentWidget());
512}
513
514bool MainWindow::eventFilter(QObject *obj, QEvent *event) {
515	if (address && obj == address && event->type() == QEvent::FocusIn) {
516		updateAddressCompleter();
517	}
518	if (tabs && obj == tabs->tabBar() && event->type() == QEvent::MouseButtonRelease) {
519		auto *mouse = static_cast<QMouseEvent *>(event);
520		if (mouse->button() == Qt::MiddleButton) {
521			int index = tabs->tabBar()->tabAt(mouse->pos());
522			if (index != -1) {
523				closeTabAtIndex(index);
524				return true;
525			}
526		}
527	}
528	return QMainWindow::eventFilter(obj, event);
529}