diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000000000000000000000000000000000000..968527ca148cc2c629a3cac1ec6c7cb3f2cd92c0 --- /dev/null +++ b/.clang-format @@ -0,0 +1,24 @@ +--- +BasedOnStyle: Chromium +IndentWidth: 2 +UseTab: Always +AccessModifierOffset: -2 +AlignAfterOpenBracket: Align +AllowShortFunctionsOnASingleLine: Inline +AllowShortIfStatementsOnASingleLine: false +AlwaysBreakAfterReturnType: None +BinPackArguments: true +BinPackParameters: true +BreakBeforeBraces: Attach +BreakConstructorInitializers: AfterColon +ColumnLimit: 180 +ConstructorInitializerIndentWidth: 2 +ContinuationIndentWidth: 2 +DerivePointerAlignment: false +PointerAlignment: Right +ReferenceAlignment: Right +SpaceAfterCStyleCast: false +SpaceBeforeParens: ControlStatements +Standard: c++17 +TabWidth: 2 +... diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000000000000000000000000000000000000..4ca30671b605952b64e06a44bc1cc01f8ef71006 --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,3 @@ +Checks: '-*,readability-braces-around-statements' +CheckOptions: + readability-braces-around-statements.ShortStatementLines: 0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..567609b1234a9b8806c5a05da6c866e480aa148d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/.vimrc b/.vimrc new file mode 100644 index 0000000000000000000000000000000000000000..04fc270e9c9e24edfff6f6134558e15af16e305c --- /dev/null +++ b/.vimrc @@ -0,0 +1,80 @@ +let g:_executable = 'main' +let g:_arguments = '' +let g:_envs = {} +let g:_make = 'make' + +set makeprg=make +set errorformat=%f:%l:%c:\ %m +packadd termdebug + +let g:termdebug_config = {} +let g:termdebug_config['variables_window'] = v:true +let g:ale_linters = { 'cpp': ['clangd', 'cppcheck'] } + +nnoremap x :call LocalRun() +nnoremap c :call LocalMake() +nnoremap m :call LocalDebugMain() +nnoremap n :call LocalDebugLine() +nnoremap z :call LocalBuildAndRun() + +function! LocalRun() abort + let envs = join( map(items(g:_envs), { _, kv -> kv[0] . '=' . kv[1] }), ' ') + execute printf("term env %s ./%s %s", envs, g:_executable, g:_arguments) +endfunction + +function! LocalDebugMain() abort + execute printf('Termdebug %s %s', g:_executable, g:_arguments) + + for [k, v] in items(g:_envs) + call TermDebugSendCommand(printf('set env %s %s', k, v)) + endfor + + call TermDebugSendCommand('directory ' . getcwd()) + call TermDebugSendCommand('break main') + call TermDebugSendCommand('run') +endfunction + +function! LocalDebugLine() abort + execute printf('Termdebug %s %s', g:_executable, g:_arguments) + + for [k, v] in items(g:_envs) + call TermDebugSendCommand(printf('set env %s %s', k, v)) + endfor + + call TermDebugSendCommand('directory ' . getcwd()) + call TermDebugSendCommand(printf('break %s:%d', expand('%:p'), line('.'))) + call TermDebugSendCommand('run') +endfunction + +function! LocalMake() abort + let envs = join( map(items(g:_envs), { _, kv -> kv[0] . '=' . kv[1] }), ' ') + execute printf('silent !env %s %s', g:_make, envs) + + " Filter non valid errors out of quicklist. + let qfl = getqflist() + let filtered = filter(copy(qfl), {_, entry -> entry.valid == 1}) + call setqflist(filtered, 'r') + + redraw! + + if len(filtered) > 0 + execute exists(':CtrlPQuickfix') ? 'CtrlPQuickfix' : 'copen' + else + cclose + endif +endfunction + +function! LocalBuildAndRun() abort + let envs = join(map(items(g:_envs), { _, kv -> kv[0] . '=' . kv[1] }), ' ') + let cmd = printf('env %s %s', envs, g:_make) + let result = system(cmd) + let status = v:shell_error + + execute 'call LocalMake()' + + if status == 0 + call LocalRun() + else + echohl ErrorMsg | echo "Build failed (exit code " . status . ")" | echohl None + endif +endfunction diff --git a/BrowserTab.cpp b/BrowserTab.cpp new file mode 100644 index 0000000000000000000000000000000000000000..9147f86b1f060275b6c22c79c86c5123aed7aa90 --- /dev/null +++ b/BrowserTab.cpp @@ -0,0 +1,97 @@ +#include "BrowserTab.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "PasswordHelper.h" +#include "ThemeConfig.h" + +BrowserTab::BrowserTab(QWidget *parent) : QSplitter(Qt::Vertical, parent) { + view = new BrowserView; + devtools = new QWebEngineView; + passwordHelper = new PasswordHelper(this); + + auto *channel = new QWebChannel(this); + channel->registerObject("passwordHelper", passwordHelper); + view->page()->setWebChannel(channel); + + view->page()->setDevToolsPage(devtools->page()); + devtools->hide(); + + addWidget(view); + addWidget(devtools); + + // Initial layout ratio + setStretchFactor(0, 3); + setStretchFactor(1, 1); + setOpaqueResize(false); + + view->settings()->setAttribute(QWebEngineSettings::FullScreenSupportEnabled, true); + + // Visual handle for resizing + handle(1)->setAttribute(Qt::WA_Hover); + setStyleSheet(QString("QSplitter::handle { background: %1; height: 4px; }").arg(ThemeConfig::SplitterHandleColorLight)); + + // Close devtools if requested by the page (e.g. clicking 'x') + connect(devtools->page(), &QWebEnginePage::windowCloseRequested, [this]() { setDevToolsVisible(false); }); + + setupScripts(); +} + +void BrowserTab::setupScripts() { + QWebEngineScript qwebchannel; + QFile file(":/js/qwebchannel.js"); + if (file.open(QIODevice::ReadOnly)) { + qwebchannel.setSourceCode(file.readAll()); + qwebchannel.setName("qwebchannel.js"); + qwebchannel.setWorldId(QWebEngineScript::MainWorld); + qwebchannel.setInjectionPoint(QWebEngineScript::DocumentCreation); + qwebchannel.setRunsOnSubFrames(true); + view->page()->scripts().insert(qwebchannel); + } else { + qCritical() << "Failed to load bundled qwebchannel.js from resources!"; + } + + QWebEngineScript passwordScript; + QFile scriptFile(":/js/capture.js"); + if (scriptFile.open(QIODevice::ReadOnly)) { + passwordScript.setSourceCode(scriptFile.readAll()); + passwordScript.setName("passwordScript"); + passwordScript.setWorldId(QWebEngineScript::MainWorld); + passwordScript.setInjectionPoint(QWebEngineScript::DocumentReady); + passwordScript.setRunsOnSubFrames(true); + view->page()->scripts().insert(passwordScript); + } else { + qCritical() << "Failed to load capture.js from resources!"; + } +} + +void BrowserTab::setDevToolsVisible(bool visible) { + devtools->setVisible(visible); + if (visible) { + int h = height(); + setSizes({static_cast(h * 0.7), static_cast(h * 0.3)}); + } else { + setSizes({height(), 0}); + } +} + +void BrowserTab::updateTabTheme(bool dark) { + view->settings()->setAttribute(QWebEngineSettings::ForceDarkMode, false); + devtools->settings()->setAttribute(QWebEngineSettings::ForceDarkMode, false); + + view->page()->setBackgroundColor(dark ? QColor(45, 45, 45) : Qt::white); + devtools->page()->setBackgroundColor(dark ? QColor(45, 45, 45) : Qt::white); + + // Keep the view palette default to allow it to inherit from the application + view->setPalette(QPalette()); + devtools->setPalette(QPalette()); + + QString handleColor = dark ? ThemeConfig::SplitterHandleColorDark : ThemeConfig::SplitterHandleColorLight; + setStyleSheet(QString("QSplitter::handle { background: %1; height: 4px; }").arg(handleColor)); +} diff --git a/BrowserTab.h b/BrowserTab.h new file mode 100644 index 0000000000000000000000000000000000000000..a4238feab3aca6ebd73373d9d97a6ea309887a19 --- /dev/null +++ b/BrowserTab.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +#include "BrowserView.h" + +// A single browser tab containing the web view and its devtools +class BrowserTab : public QSplitter { + Q_OBJECT +public: + explicit BrowserTab(QWidget *parent = nullptr); + + BrowserView *view; + QWebEngineView *devtools; + class PasswordHelper *passwordHelper; + + void setDevToolsVisible(bool visible); + void updateTabTheme(bool dark); + + QString lastHost() const { return lastHostName; } + void setLastHost(const QString &host) { lastHostName = host; } + +private: + void setupScripts(); + QString lastHostName; +}; diff --git a/BrowserView.cpp b/BrowserView.cpp new file mode 100644 index 0000000000000000000000000000000000000000..3f0b90620fdbd377fbdfe3913ab2a9ebee5354be --- /dev/null +++ b/BrowserView.cpp @@ -0,0 +1,19 @@ +#include "BrowserView.h" +#include "DatabaseManager.h" + +BrowserView::BrowserView(QWidget *parent) : QWebEngineView(parent) { + connect(page(), &QWebEnginePage::zoomFactorChanged, [this](qreal factor) { + QString host = url().host(); + if (!host.isEmpty()) { + DatabaseManager::instance().setDomainSetting(host, "zoom", QString::number(factor)); + } + }); +} + +QWebEngineView *BrowserView::createWindow(QWebEnginePage::WebWindowType type) { + Q_UNUSED(type); + if (createTabCallback) { + return createTabCallback(); + } + return nullptr; +} diff --git a/BrowserView.h b/BrowserView.h new file mode 100644 index 0000000000000000000000000000000000000000..05e51c770577e8d606ca356504e9e96a8b2caeba --- /dev/null +++ b/BrowserView.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +#include + +// Specialized view to handle "open in new tab" requests +class BrowserView : public QWebEngineView { + Q_OBJECT +public: + explicit BrowserView(QWidget *parent = nullptr); + + // Callback used when the engine wants to create a new window/tab + std::function createTabCallback; + +protected: + QWebEngineView *createWindow(QWebEnginePage::WebWindowType type) override; +}; diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000000000000000000000000000000000000..6ba201ab906fd7ea1bc5881040e8d90531aa4796 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,36 @@ +cmake_minimum_required(VERSION 3.21) +project(browser) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTORCC ON) + +find_package(Qt6 REQUIRED COMPONENTS Widgets WebEngineWidgets Sql WebChannel) +find_package(PkgConfig REQUIRED) +pkg_check_modules(LIBSODIUM REQUIRED libsodium) + +qt_standard_project_setup() + +qt_add_executable(browser + main.cpp + ThemeConfig.h + BrowserView.h BrowserView.cpp + BrowserTab.h BrowserTab.cpp + MainWindow.h MainWindow.cpp + DatabaseManager.h DatabaseManager.cpp + VaultManager.h VaultManager.cpp + MasterPasswordDialog.h MasterPasswordDialog.cpp + PasswordHelper.h PasswordHelper.cpp + DownloadWidget.h DownloadWidget.cpp + DownloadBar.h DownloadBar.cpp + browser.qrc +) + +target_link_libraries(browser PRIVATE + Qt6::Widgets + Qt6::WebEngineWidgets + Qt6::Sql + Qt6::WebChannel + ${LIBSODIUM_LIBRARIES} +) +target_include_directories(browser PRIVATE ${LIBSODIUM_INCLUDE_DIRS}) diff --git a/DatabaseManager.cpp b/DatabaseManager.cpp new file mode 100644 index 0000000000000000000000000000000000000000..7cb77f1862db1ae68a4e4ad555abd87b00b0dc59 --- /dev/null +++ b/DatabaseManager.cpp @@ -0,0 +1,165 @@ +#include "DatabaseManager.h" + +bool DatabaseManager::init() { + QString path = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + QDir().mkpath(path); + + db = QSqlDatabase::addDatabase("QSQLITE"); + db.setDatabaseName(path + "/browser.db"); + + if (!db.open()) { + qDebug() << "Error: connection with database failed" << db.lastError(); + return false; + } + + return createTables(); +} + +bool DatabaseManager::createTables() { + QSqlQuery query; + + // History + if (!query.exec("CREATE TABLE IF NOT EXISTS history (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "url TEXT, " + "title TEXT, " + "last_visit DATETIME)")) { + qDebug() << "Error creating history table:" << query.lastError(); + return false; + } + + // Bookmarks + if (!query.exec("CREATE TABLE IF NOT EXISTS bookmarks (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "url TEXT UNIQUE, " + "title TEXT, " + "created_at DATETIME)")) { + qDebug() << "Error creating bookmarks table:" << query.lastError(); + return false; + } + + // Vault Meta + if (!query.exec("CREATE TABLE IF NOT EXISTS vault_meta (" + "key TEXT PRIMARY KEY, " + "value BLOB NOT NULL)")) { + qDebug() << "Error creating vault_meta table:" << query.lastError(); + return false; + } + + // Passwords + if (!query.exec("CREATE TABLE IF NOT EXISTS passwords (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "origin TEXT NOT NULL, " + "username TEXT NOT NULL, " + "password_ciphertext BLOB NOT NULL, " + "password_nonce BLOB NOT NULL, " + "created_at DATETIME NOT NULL, " + "updated_at DATETIME NOT NULL)")) { + qDebug() << "Error creating passwords table:" << query.lastError(); + return false; + } + + // Domain settings (extensible key-value per domain) + if (!query.exec("CREATE TABLE IF NOT EXISTS domain_settings (" + "domain TEXT NOT NULL, " + "key TEXT NOT NULL, " + "value TEXT, " + "PRIMARY KEY (domain, key))")) { + qDebug() << "Error creating domain_settings table:" << query.lastError(); + return false; + } + + return true; +} + +void DatabaseManager::addHistoryEntry(const QString &url, const QString &title) { + if (url.isEmpty() || url == "about:blank") { + return; + } + + QSqlQuery query; + query.prepare("INSERT INTO history (url, title, last_visit) VALUES (?, ?, ?)"); + query.addBindValue(url); + query.addBindValue(title); + query.addBindValue(QDateTime::currentDateTime()); + query.exec(); +} + +void DatabaseManager::addBookmark(const QString &url, const QString &title) { + QSqlQuery query; + query.prepare( + "INSERT OR REPLACE INTO bookmarks (url, title, created_at) " + "VALUES (?, ?, ?)"); + query.addBindValue(url); + query.addBindValue(title); + query.addBindValue(QDateTime::currentDateTime()); + query.exec(); +} + +bool DatabaseManager::isBookmarked(const QString &url) { + QSqlQuery query; + query.prepare("SELECT 1 FROM bookmarks WHERE url = ?"); + query.addBindValue(url); + query.exec(); + return query.next(); +} + +void DatabaseManager::removeBookmark(const QString &url) { + QSqlQuery query; + query.prepare("DELETE FROM bookmarks WHERE url = ?"); + query.addBindValue(url); + query.exec(); +} + +QSqlQuery DatabaseManager::executeQuery(const QString &queryStr) { + QSqlQuery query; + query.exec(queryStr); + return query; +} + +bool DatabaseManager::setVaultMeta(const QString &key, const QByteArray &value) { + QSqlQuery query; + query.prepare("INSERT OR REPLACE INTO vault_meta (key, value) VALUES (?, ?)"); + query.addBindValue(key); + query.addBindValue(value); + return query.exec(); +} + +QByteArray DatabaseManager::getVaultMeta(const QString &key) { + QSqlQuery query; + query.prepare("SELECT value FROM vault_meta WHERE key = ?"); + query.addBindValue(key); + if (query.exec() && query.next()) { + return query.value(0).toByteArray(); + } + return QByteArray(); +} + +void DatabaseManager::setDomainSetting(const QString &domain, const QString &key, const QString &value) { + if (domain.isEmpty()) { + return; + } + QSqlQuery query; + query.prepare( + "INSERT OR REPLACE INTO domain_settings (domain, key, value) " + "VALUES (?, ?, ?)"); + query.addBindValue(domain); + query.addBindValue(key); + query.addBindValue(value); + query.exec(); + qDebug() << "Domain setting saved:" << domain << key << value; +} + +QString DatabaseManager::getDomainSetting(const QString &domain, const QString &key, const QString &defaultValue) { + if (domain.isEmpty()) { + return defaultValue; + } + QSqlQuery query; + query.prepare("SELECT value FROM domain_settings WHERE domain = ? AND key = ?"); + query.addBindValue(domain); + query.addBindValue(key); + if (query.exec() && query.next()) { + return query.value(0).toString(); + } + return defaultValue; +} diff --git a/DatabaseManager.h b/DatabaseManager.h new file mode 100644 index 0000000000000000000000000000000000000000..e91bdb355c09d2721dde5cc06ced7bd45384bda5 --- /dev/null +++ b/DatabaseManager.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +class DatabaseManager : public QObject { + Q_OBJECT +public: + static DatabaseManager &instance() { + static DatabaseManager inst; + return inst; + } + + bool init(); + + // History + void addHistoryEntry(const QString &url, const QString &title); + + // Bookmarks + void addBookmark(const QString &url, const QString &title); + bool isBookmarked(const QString &url); + void removeBookmark(const QString &url); + + // Domain settings (key-value per domain) + void setDomainSetting(const QString &domain, const QString &key, const QString &value); + QString getDomainSetting(const QString &domain, const QString &key, const QString &defaultValue = {}); + + // Vault (Direct SQL access for VaultManager) + QSqlQuery executeQuery(const QString &queryStr); + bool setVaultMeta(const QString &key, const QByteArray &value); + QByteArray getVaultMeta(const QString &key); + +private: + DatabaseManager() = default; + ~DatabaseManager() = default; + DatabaseManager(const DatabaseManager &) = delete; + DatabaseManager &operator=(const DatabaseManager &) = delete; + + QSqlDatabase db; + bool createTables(); +}; diff --git a/DownloadBar.cpp b/DownloadBar.cpp new file mode 100644 index 0000000000000000000000000000000000000000..229660355ce7c7446270c04e2ab928a7fd2ab491 --- /dev/null +++ b/DownloadBar.cpp @@ -0,0 +1,86 @@ +#include "DownloadBar.h" +#include +#include +#include +#include +#include +#include +#include "DownloadWidget.h" + +DownloadBar::DownloadBar(QWidget *parent) : QWidget(parent) { + auto *outer = new QVBoxLayout(this); + outer->setContentsMargins(0, 0, 0, 0); + outer->setSpacing(0); + + header = new QWidget(this); + auto *headerLayout = new QHBoxLayout(header); + headerLayout->setContentsMargins(6, 2, 6, 2); + + auto *title = new QLabel("Downloads", header); + title->setStyleSheet("font-weight: bold;"); + + clearButton = new QPushButton("Clear All", header); + clearButton->setFlat(true); + clearButton->setVisible(false); + + headerLayout->addWidget(title); + headerLayout->addStretch(); + headerLayout->addWidget(clearButton); + + outer->addWidget(header); + + downloadsLayout = new QVBoxLayout; + downloadsLayout->setContentsMargins(0, 0, 0, 0); + downloadsLayout->setSpacing(1); + outer->addLayout(downloadsLayout); + + connect(clearButton, &QPushButton::clicked, this, &DownloadBar::clearCompleted); + + setVisible(false); +} + +void DownloadBar::addDownload(QWebEngineDownloadRequest *download) { + if (downloads.isEmpty()) { + download->setDownloadDirectory(QStandardPaths::writableLocation(QStandardPaths::DownloadLocation)); + } + + auto *widget = new DownloadWidget(download); + downloads.append(widget); + downloadsLayout->addWidget(widget); + + connect(widget, &DownloadWidget::removeRequested, this, &DownloadBar::onRemoveDownload); + + updateVisibility(); +} + +void DownloadBar::onRemoveDownload(DownloadWidget *widget) { + downloadsLayout->removeWidget(widget); + downloads.removeOne(widget); + widget->deleteLater(); + updateVisibility(); +} + +void DownloadBar::clearCompleted() { + for (auto *w : QList(downloads)) { + if (w->isCompleted()) { + downloadsLayout->removeWidget(w); + downloads.removeOne(w); + w->deleteLater(); + } + } + updateVisibility(); +} + +void DownloadBar::updateVisibility() { + bool hasAny = !downloads.isEmpty(); + setVisible(hasAny); + + bool hasCompleted = false; + for (auto *w : downloads) { + if (w->isCompleted()) { + hasCompleted = true; + break; + } + } + clearButton->setVisible(hasCompleted); +} diff --git a/DownloadBar.h b/DownloadBar.h new file mode 100644 index 0000000000000000000000000000000000000000..dba82eb6e0a8af4804ce9530c28f85762562b7e1 --- /dev/null +++ b/DownloadBar.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +class QVBoxLayout; +class QPushButton; +class QWebEngineDownloadRequest; +class DownloadWidget; + +class DownloadBar : public QWidget { + Q_OBJECT +public: + explicit DownloadBar(QWidget *parent = nullptr); + void addDownload(QWebEngineDownloadRequest *download); + +private slots: + void onRemoveDownload(DownloadWidget *widget); + void clearCompleted(); + +private: + void updateVisibility(); + + QVBoxLayout *downloadsLayout; + QWidget *header; + QPushButton *clearButton; + QList downloads; +}; diff --git a/DownloadWidget.cpp b/DownloadWidget.cpp new file mode 100644 index 0000000000000000000000000000000000000000..d245cf45723e1e4ae3b320820a17ad20b5c376ea --- /dev/null +++ b/DownloadWidget.cpp @@ -0,0 +1,121 @@ +#include "DownloadWidget.h" +#include +#include +#include +#include +#include + +DownloadWidget::DownloadWidget(QWebEngineDownloadRequest *downloadReq) : QFrame(nullptr), download(downloadReq) { + setFrameStyle(QFrame::StyledPanel | QFrame::Plain); + setFrameShape(QFrame::NoFrame); + + auto *layout = new QHBoxLayout(this); + layout->setContentsMargins(4, 2, 4, 2); + layout->setSpacing(6); + + QString filename = downloadReq->downloadFileName(); + if (filename.isEmpty()) { + QString path = downloadReq->url().path(); + filename = path.mid(path.lastIndexOf('/') + 1); + if (filename.isEmpty()) { + filename = "download"; + } + } + + filenameLabel = new QLabel(filename, this); + filenameLabel->setMaximumWidth(250); + + progressBar = new QProgressBar(this); + progressBar->setMinimum(0); + progressBar->setMaximum(100); + progressBar->setValue(0); + progressBar->setTextVisible(false); + progressBar->setFixedHeight(18); + + statusLabel = new QLabel("0%", this); + statusLabel->setFixedWidth(80); + statusLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + + button = new QPushButton(QStringLiteral("\u2715"), this); + button->setFixedSize(22, 22); + button->setFlat(true); + + layout->addWidget(filenameLabel); + layout->addWidget(progressBar, 1); + layout->addWidget(statusLabel); + layout->addWidget(button); + + connect(button, &QPushButton::clicked, this, &DownloadWidget::onButtonClicked); + connect(download, &QWebEngineDownloadRequest::receivedBytesChanged, this, [this]() { onDownloadProgress(download->receivedBytes(), download->totalBytes()); }); + connect(download, &QWebEngineDownloadRequest::totalBytesChanged, this, [this]() { onDownloadProgress(download->receivedBytes(), download->totalBytes()); }); + connect(download, &QWebEngineDownloadRequest::stateChanged, this, &DownloadWidget::onStateChanged); + connect(download, &QWebEngineDownloadRequest::isFinishedChanged, this, &DownloadWidget::onStateChanged); + + if (download->state() == QWebEngineDownloadRequest::DownloadInProgress || download->state() == QWebEngineDownloadRequest::DownloadCompleted) { + onStateChanged(); + } +} + +bool DownloadWidget::isCompleted() const { + auto state = download->state(); + return state == QWebEngineDownloadRequest::DownloadCompleted || state == QWebEngineDownloadRequest::DownloadCancelled || state == QWebEngineDownloadRequest::DownloadInterrupted; +} + +void DownloadWidget::onDownloadProgress(qint64 received, qint64 total) { + if (total > 0) { + int pct = qBound(0, static_cast(received * 100 / total), 100); + progressBar->setValue(pct); + statusLabel->setText(QString("%1%").arg(pct)); + } else { + progressBar->setValue(0); + statusLabel->setText(formatSize(received) + " / ?"); + } +} + +void DownloadWidget::onStateChanged() { + switch (download->state()) { + case QWebEngineDownloadRequest::DownloadInProgress: + statusLabel->setStyleSheet("color: inherit;"); + button->setText(QStringLiteral("\u2715")); + break; + case QWebEngineDownloadRequest::DownloadCompleted: + progressBar->setValue(100); + statusLabel->setText("Complete"); + statusLabel->setStyleSheet("color: green;"); + button->setText("Dismiss"); + break; + case QWebEngineDownloadRequest::DownloadCancelled: + statusLabel->setText("Cancelled"); + statusLabel->setStyleSheet("color: orange;"); + button->setText("Dismiss"); + break; + case QWebEngineDownloadRequest::DownloadInterrupted: + statusLabel->setText("Failed"); + statusLabel->setStyleSheet("color: red;"); + button->setText("Dismiss"); + break; + default: + break; + } +} + +void DownloadWidget::onButtonClicked() { + if (download->state() == QWebEngineDownloadRequest::DownloadInProgress || download->state() == QWebEngineDownloadRequest::DownloadRequested) { + download->cancel(); + } else { + emit removeRequested(this); + } +} + +QString DownloadWidget::formatSize(qint64 bytes) { + if (bytes < 1024) { + return QString("%1 B").arg(bytes); + } + if (bytes < 1024 * 1024) { + return QString("%1 KB").arg(bytes / 1024); + } + if (bytes < 1024 * 1024 * 1024) { + return QString("%1 MB").arg(bytes / (1024 * 1024)); + } + return QString("%1 GB").arg(bytes / (1024 * 1024 * 1024)); +} diff --git a/DownloadWidget.h b/DownloadWidget.h new file mode 100644 index 0000000000000000000000000000000000000000..8a7b0fadde5b243be4660647fdf4ea447cb2323c --- /dev/null +++ b/DownloadWidget.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +class QLabel; +class QProgressBar; +class QPushButton; +class QWebEngineDownloadRequest; + +class DownloadWidget : public QFrame { + Q_OBJECT +public: + explicit DownloadWidget(QWebEngineDownloadRequest *downloadReq); + bool isCompleted() const; + +signals: + void removeRequested(DownloadWidget *self); + +private slots: + void onDownloadProgress(qint64 received, qint64 total); + void onStateChanged(); + void onButtonClicked(); + +private: + static QString formatSize(qint64 bytes); + + QWebEngineDownloadRequest *download; + QLabel *filenameLabel; + QProgressBar *progressBar; + QLabel *statusLabel; + QPushButton *button; +}; diff --git a/MainWindow.cpp b/MainWindow.cpp new file mode 100644 index 0000000000000000000000000000000000000000..97c617d7fc4242a8239712223c49495b5e6395f0 --- /dev/null +++ b/MainWindow.cpp @@ -0,0 +1,529 @@ +#include "MainWindow.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "BrowserTab.h" +#include "DatabaseManager.h" +#include "DownloadBar.h" +#include "PasswordHelper.h" +#include "ThemeConfig.h" +#include "VaultManager.h" + +MainWindow::MainWindow() : isDarkMode(true) { + setAttribute(Qt::WA_DeleteOnClose); + setupToolBar(); + setupUserInterface(); + setupKeyboardShortcuts(); + + applyApplicationTheme(true); + darkModeAction->setChecked(true); + + connect(QWebEngineProfile::defaultProfile(), &QWebEngineProfile::downloadRequested, this, [this](QWebEngineDownloadRequest *download) { + downloadBar->addDownload(download); + download->accept(); + }); + + resize(ThemeConfig::DefaultWindowWidth, ThemeConfig::DefaultWindowHeight); + + // Initial tab + createNewTab(QUrl("qrc:/debug.html")); +} + +void MainWindow::setupUserInterface() { + tabs = new QTabWidget(this); + tabs->setTabsClosable(false); // We use middle click + tabs->setMovable(true); + + auto *central = new QWidget(this); + auto *layout = new QVBoxLayout(central); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + layout->addWidget(tabs, 1); + + downloadBar = new DownloadBar(central); + layout->addWidget(downloadBar); + + setCentralWidget(central); + + tabs->tabBar()->installEventFilter(this); + + connect(tabs, &QTabWidget::tabCloseRequested, this, &MainWindow::closeTabAtIndex); + connect(tabs, &QTabWidget::currentChanged, this, &MainWindow::updateWindowStatus); +} + +void MainWindow::setupToolBar() { + // Create completer first to avoid crash in event filter + completer = new QCompleter(this); + completerModel = new QSqlQueryModel(this); + completer->setModel(completerModel); + completer->setCompletionColumn(0); + completer->setCompletionMode(QCompleter::PopupCompletion); + completer->setFilterMode(Qt::MatchContains); + completer->setCaseSensitivity(Qt::CaseInsensitive); + + toolbar = new QToolBar(this); + addToolBar(toolbar); + + address = new QLineEdit(this); + address->setCompleter(completer); + address->installEventFilter(this); + toolbar->addWidget(address); + + bookmarkAction = toolbar->addAction("Star"); + connect(bookmarkAction, &QAction::triggered, this, &MainWindow::toggleBookmark); + + passwordAction = toolbar->addAction("Autofill"); + passwordAction->setToolTip("Fill saved credentials (Ctrl+Shift+L)"); + connect(passwordAction, &QAction::triggered, this, &MainWindow::showPasswordMenu); + passwordAction->setVisible(false); + + auto *addTabAction = toolbar->addAction("+"); + connect(addTabAction, &QAction::triggered, [this]() { createNewTab(); }); + + devtoolsAction = toolbar->addAction("DevTools"); + connect(devtoolsAction, &QAction::triggered, [this]() { + if (auto *tab = currentTab()) { + tab->setDevToolsVisible(!tab->devtools->isVisible()); + } + }); + + darkModeAction = toolbar->addAction("Dark Mode"); + darkModeAction->setCheckable(true); + connect(darkModeAction, &QAction::toggled, this, &MainWindow::applyApplicationTheme); + + connect(address, &QLineEdit::returnPressed, this, &MainWindow::navigateToAddressOrSearch); + updateAddressCompleter(); +} + +void MainWindow::updateAddressCompleter() { + completerModel->setQuery("SELECT DISTINCT url FROM history ORDER BY last_visit DESC LIMIT 1000"); +} + +void MainWindow::setupKeyboardShortcuts() { + // New Tab + auto *newTab = new QAction(this); + newTab->setShortcut(QKeySequence::AddTab); + connect(newTab, &QAction::triggered, [this]() { createNewTab(); }); + addAction(newTab); + + // New Window + auto *newWin = new QAction(this); + newWin->setShortcut(QKeySequence::New); + connect(newWin, &QAction::triggered, []() { (new MainWindow())->show(); }); + addAction(newWin); + + // Close Tab + auto *closeTab = new QAction(this); + closeTab->setShortcut(QKeySequence::Close); + connect(closeTab, &QAction::triggered, [this]() { closeTabAtIndex(tabs->currentIndex()); }); + addAction(closeTab); + + // Zoom shortcuts + auto *zoomIn = new QAction(this); + zoomIn->setShortcut(QKeySequence::ZoomIn); + connect(zoomIn, &QAction::triggered, [this]() { + if (auto *tab = currentTab()) { + tab->view->setZoomFactor(tab->view->zoomFactor() + 0.1); + } + }); + addAction(zoomIn); + + auto *zoomOut = new QAction(this); + zoomOut->setShortcut(QKeySequence::ZoomOut); + connect(zoomOut, &QAction::triggered, [this]() { + if (auto *tab = currentTab()) { + tab->view->setZoomFactor(qMax(0.25, tab->view->zoomFactor() - 0.1)); + } + }); + addAction(zoomOut); + + auto *zoomReset = new QAction(this); + zoomReset->setShortcut(Qt::CTRL | Qt::Key_0); + connect(zoomReset, &QAction::triggered, [this]() { + if (auto *tab = currentTab()) { + tab->view->setZoomFactor(1.0); + } + }); + addAction(zoomReset); + + // Refresh + auto *reload = new QAction(this); + reload->setShortcuts({QKeySequence::Refresh, QKeySequence(Qt::CTRL | Qt::Key_R)}); + connect(reload, &QAction::triggered, [this]() { + if (auto *tab = currentTab()) { + tab->view->reload(); + } + }); + addAction(reload); + + // Tab cycling + auto *nextTab = new QAction(this); + nextTab->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_Tab)); + connect(nextTab, &QAction::triggered, [this]() { tabs->setCurrentIndex((tabs->currentIndex() + 1) % tabs->count()); }); + addAction(nextTab); + + auto *prevTab = new QAction(this); + prevTab->setShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Tab)); + connect(prevTab, &QAction::triggered, [this]() { tabs->setCurrentIndex((tabs->currentIndex() - 1 + tabs->count()) % tabs->count()); }); + addAction(prevTab); + + // Exit fullscreen + auto *esc = new QAction(this); + esc->setShortcut(Qt::Key_Escape); + connect(esc, &QAction::triggered, [this]() { + if (isFullScreen()) { + toolbar->show(); + tabs->tabBar()->show(); + downloadBar->show(); + showNormal(); + } + }); + addAction(esc); + + // Bookmarking + auto *bookmark = new QAction(this); + bookmark->setShortcut(Qt::CTRL | Qt::Key_D); + connect(bookmark, &QAction::triggered, this, &MainWindow::toggleBookmark); + addAction(bookmark); + + // Passwords manual trigger + auto *fillPass = new QAction(this); + fillPass->setShortcut(Qt::CTRL | Qt::SHIFT | Qt::Key_L); + connect(fillPass, &QAction::triggered, this, &MainWindow::showPasswordMenu); + addAction(fillPass); +} + +void MainWindow::createNewTab(const QUrl &url, bool focus) { + auto *tab = new BrowserTab; + tab->updateTabTheme(isDarkMode); + + tab->view->createTabCallback = [this]() { + createNewTab(QUrl(), false); + return qobject_cast(tabs->widget(tabs->count() - 1))->view; + }; + + int index = tabs->addTab(tab, url.isEmpty() ? "Blank" : "Loading..."); + + connect(tab->view, &QWebEngineView::urlChanged, [this, tab](const QUrl &u) { + if (currentTab() == tab) { + address->setText(u.toString()); + updateBookmarkIcon(); + } + DatabaseManager::instance().addHistoryEntry(u.toString(), tab->view->title()); + updateAddressCompleter(); + + QString newHost = u.host(); + if (!newHost.isEmpty()) { + bool ok; + qreal saved = DatabaseManager::instance().getDomainSetting(newHost, "zoom").toDouble(&ok); + if (ok && saved > 0.0) { + tab->view->setZoomFactor(saved); + } + } + }); + + connect(tab->view, &QWebEngineView::titleChanged, [this, tab](const QString &title) { + int idx = tabs->indexOf(tab); + if (idx != -1) { + tabs->setTabText(idx, title); + if (currentTab() == tab) { + setWindowTitle(title + " - Browser"); + } + } + DatabaseManager::instance().addHistoryEntry(tab->view->url().toString(), title); + updateAddressCompleter(); + }); + + connect(tab->view->pageAction(QWebEnginePage::InspectElement), &QAction::triggered, [tab]() { tab->setDevToolsVisible(true); }); + + connect(tab->view->page(), &QWebEnginePage::fullScreenRequested, [this](QWebEngineFullScreenRequest req) { + if (req.toggleOn()) { + toolbar->hide(); + tabs->tabBar()->hide(); + downloadBar->hide(); + showFullScreen(); + } else { + toolbar->show(); + tabs->tabBar()->show(); + downloadBar->show(); + showNormal(); + } + req.accept(); + }); + + connect(tab->view->page(), &QWebEnginePage::permissionRequested, [this](QWebEnginePermission req) { + QString type; + switch (req.permissionType()) { + case QWebEnginePermission::PermissionType::MediaVideoCapture: + type = "Camera"; + break; + case QWebEnginePermission::PermissionType::MediaAudioCapture: + type = "Microphone"; + break; + case QWebEnginePermission::PermissionType::Geolocation: + type = "Location"; + break; + case QWebEnginePermission::PermissionType::Notifications: + type = "Notifications"; + break; + default: + type = "Resources"; + break; + } + + auto btn = QMessageBox::question(this, "Permission", QString("%1 wants to use your %2. Allow?").arg(req.origin().toString(), type)); + + (btn == QMessageBox::Yes) ? req.grant() : req.deny(); + }); + + connect(tab->passwordHelper, &PasswordHelper::savePasswordRequested, [this](const QString &origin, const QString &username, const QString &password) { + if (!VaultManager::instance().isUnlocked()) { + return; + } + + auto existing = VaultManager::instance().getPasswords(origin); + bool foundUser = false; + bool samePassword = false; + + for (const auto &entry : existing) { + if (entry.username == username) { + foundUser = true; + if (entry.password == password) { + samePassword = true; + } + break; + } + } + + if (samePassword) { + return; + } + + QString msg = foundUser ? QString("Do you want to update the password for %1 (User: %2)?").arg(origin, username) + : QString("Do you want to save the password for %1 (User: %2)?").arg(origin, username); + + auto btn = QMessageBox::question(this, foundUser ? "Update Password" : "Save Password", msg); + + if (btn == QMessageBox::Yes) { + VaultManager::instance().savePassword(origin, username, password); + updatePasswordIcon(); + } + }); + + connect(tab->view, &QWebEngineView::loadFinished, [this, tab](bool ok) { + if (ok) { + QString host = tab->view->url().host(); + if (!host.isEmpty()) { + tab->setLastHost(host); + bool zoomOk; + qreal saved = DatabaseManager::instance().getDomainSetting(host, "zoom").toDouble(&zoomOk); + if (zoomOk && saved > 0.0) { + tab->view->setZoomFactor(saved); + } + } + + if (!VaultManager::instance().isUnlocked()) { + return; + } + updatePasswordIcon(); + auto creds = VaultManager::instance().getPasswords(host); + if (creds.size() == 1) { + auto entry = creds.first(); + injectAutofill(tab, entry.username, entry.password); + } + } + }); + + if (!url.isEmpty()) { + tab->view->load(url); + } + + if (focus) { + tabs->setCurrentIndex(index); + address->setFocus(); + } +} + +void MainWindow::closeTabAtIndex(int index) { + if (tabs->count() > 1) { + QWidget *w = tabs->widget(index); + tabs->removeTab(index); + w->deleteLater(); + } else { + close(); + } +} + +void MainWindow::navigateToAddressOrSearch() { + if (auto *tab = currentTab()) { + QString text = address->text().trimmed(); + if (text.isEmpty()) { + return; + } + + bool isSearch = text.contains(' ') || (!text.contains('.') && !text.contains("://") && text != "localhost"); + + if (isSearch) { + tab->view->load(QUrl("https://duckduckgo.com/?q=" + QUrl::toPercentEncoding(text))); + } else { + if (!text.contains("://")) { + text = "https://" + text; + } + tab->view->load(QUrl(text)); + } + address->clearFocus(); + } +} + +void MainWindow::applyApplicationTheme(bool dark) { + isDarkMode = dark; + + // Set color scheme first so that any QWebEnginePages created or reloaded + // afterwards pick up the correct prefers-color-scheme media query value. + QGuiApplication::styleHints()->setColorScheme(dark ? Qt::ColorScheme::Dark : Qt::ColorScheme::Light); + + QPalette p; + if (dark) { + p.setColor(QPalette::Window, ThemeConfig::Dark::Window); + p.setColor(QPalette::WindowText, ThemeConfig::Dark::WindowText); + p.setColor(QPalette::Base, ThemeConfig::Dark::Base); + p.setColor(QPalette::AlternateBase, ThemeConfig::Dark::AlternateBase); + p.setColor(QPalette::ToolTipBase, ThemeConfig::Dark::ToolTipBase); + p.setColor(QPalette::ToolTipText, ThemeConfig::Dark::ToolTipText); + p.setColor(QPalette::Text, ThemeConfig::Dark::Text); + p.setColor(QPalette::Button, ThemeConfig::Dark::Button); + p.setColor(QPalette::ButtonText, ThemeConfig::Dark::ButtonText); + p.setColor(QPalette::BrightText, ThemeConfig::Dark::BrightText); + p.setColor(QPalette::Link, ThemeConfig::Dark::Link); + p.setColor(QPalette::Highlight, ThemeConfig::Dark::Highlight); + p.setColor(QPalette::HighlightedText, ThemeConfig::Dark::HighlightedText); + } else { + p = style()->standardPalette(); + } + + qApp->setPalette(p); + this->setPalette(p); + + for (int i = 0; i < tabs->count(); ++i) { + if (auto *tab = qobject_cast(tabs->widget(i))) { + tab->updateTabTheme(dark); + // Reload so the page re-evaluates prefers-color-scheme with the new + // value. + tab->view->reload(); + } + } +} + +void MainWindow::updateWindowStatus() { + if (auto *tab = currentTab()) { + address->setText(tab->view->url().toString()); + setWindowTitle(tabs->tabText(tabs->currentIndex()) + " - Browser"); + updateBookmarkIcon(); + updatePasswordIcon(); + } +} + +void MainWindow::toggleBookmark() { + if (auto *tab = currentTab()) { + QString url = tab->view->url().toString(); + QString title = tab->view->title(); + if (DatabaseManager::instance().isBookmarked(url)) { + DatabaseManager::instance().removeBookmark(url); + } else { + DatabaseManager::instance().addBookmark(url, title); + } + updateBookmarkIcon(); + } +} + +void MainWindow::updateBookmarkIcon() { + if (auto *tab = currentTab()) { + bool bookmarked = DatabaseManager::instance().isBookmarked(tab->view->url().toString()); + bookmarkAction->setText(bookmarked ? "★" : "☆"); + } +} + +void MainWindow::updatePasswordIcon() { + if (auto *tab = currentTab()) { + QString host = tab->view->url().host(); + auto creds = VaultManager::instance().getPasswords(host); + passwordAction->setVisible(!creds.isEmpty()); + } +} + +void MainWindow::showPasswordMenu() { + if (auto *tab = currentTab()) { + QString host = tab->view->url().host(); + auto creds = VaultManager::instance().getPasswords(host); + if (creds.isEmpty()) { + return; + } + + QMenu menu(this); + for (const auto &entry : creds) { + auto *action = menu.addAction(entry.username); + connect(action, &QAction::triggered, [this, tab, entry]() { injectAutofill(tab, entry.username, entry.password); }); + } + menu.exec(QCursor::pos()); + } +} + +void MainWindow::injectAutofill(BrowserTab *tab, const QString &username, const QString &password) { + QString user = username; + user.replace("\\", "\\\\"); + user.replace("\"", "\\\""); + QString pass = password; + pass.replace("\\", "\\\\"); + pass.replace("\"", "\\\""); + + QFile scriptFile(":/js/autofill.js"); + if (scriptFile.open(QIODevice::ReadOnly)) { + QString js = QString::fromUtf8(scriptFile.readAll()); + js.replace("%1", user); + js.replace("%2", pass); + tab->view->page()->runJavaScript(js); + } else { + qCritical() << "Failed to load autofill.js from resources!"; + } +} + +BrowserTab *MainWindow::currentTab() const { + return qobject_cast(tabs->currentWidget()); +} + +bool MainWindow::eventFilter(QObject *obj, QEvent *event) { + if (address && obj == address && event->type() == QEvent::FocusIn) { + updateAddressCompleter(); + } + if (tabs && obj == tabs->tabBar() && event->type() == QEvent::MouseButtonRelease) { + auto *mouse = static_cast(event); + if (mouse->button() == Qt::MiddleButton) { + int index = tabs->tabBar()->tabAt(mouse->pos()); + if (index != -1) { + closeTabAtIndex(index); + return true; + } + } + } + return QMainWindow::eventFilter(obj, event); +} diff --git a/MainWindow.h b/MainWindow.h new file mode 100644 index 0000000000000000000000000000000000000000..35442107f3dd28642a0cd14751d386bc0cb9a619 --- /dev/null +++ b/MainWindow.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include + +class QTabWidget; +class QLineEdit; +class QToolBar; +class QAction; +class BrowserTab; +class QWebEngineView; +class QCompleter; +class QSqlQueryModel; +class DownloadBar; + +class MainWindow : public QMainWindow { + Q_OBJECT +public: + MainWindow(); + +protected: + bool eventFilter(QObject *obj, QEvent *event) override; + +private slots: + void createNewTab(const QUrl &url = QUrl(), bool focus = true); + void closeTabAtIndex(int index); + void navigateToAddressOrSearch(); + void applyApplicationTheme(bool dark); + void updateWindowStatus(); + void toggleBookmark(); + void showPasswordMenu(); + +private: + void setupUserInterface(); + void setupToolBar(); + void setupKeyboardShortcuts(); + BrowserTab *currentTab() const; + void updateBookmarkIcon(); + void updateAddressCompleter(); + void updatePasswordIcon(); + void injectAutofill(BrowserTab *tab, const QString &username, const QString &password); + + QTabWidget *tabs = nullptr; + QLineEdit *address = nullptr; + QToolBar *toolbar = nullptr; + QAction *devtoolsAction = nullptr; + QAction *darkModeAction = nullptr; + QAction *bookmarkAction = nullptr; + QAction *passwordAction = nullptr; + QCompleter *completer = nullptr; + QSqlQueryModel *completerModel = nullptr; + DownloadBar *downloadBar = nullptr; + bool isDarkMode; +}; diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..3af3608a672231e773e7aea71fe39a71aca7400d --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +.PHONY: all run configure tidy format build + +all: configure tidy format build + +run: + ./build/browser + +configure: + cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON + +tidy: + run-clang-tidy -p build -fix -j$$(nproc) 2>&1 | grep -v "warnings generated" || true + +format: + clang-format -i *.h *.cpp + +build: + cmake --build build -j$$(nproc) + +install: + cp build/browser ~/.local/bin/ diff --git a/MasterPasswordDialog.cpp b/MasterPasswordDialog.cpp new file mode 100644 index 0000000000000000000000000000000000000000..2470c14dd7d2054d23837ea8556222ee3fec5f84 --- /dev/null +++ b/MasterPasswordDialog.cpp @@ -0,0 +1,78 @@ +#include "MasterPasswordDialog.h" +#include +#include +#include +#include "VaultManager.h" + +MasterPasswordDialog::MasterPasswordDialog(bool isSetupMode, QWidget *parent) : QDialog(parent), setupMode(isSetupMode) { + setWindowTitle(setupMode ? "Set Master Password" : "Unlock Vault"); + setModal(true); + + auto *layout = new QVBoxLayout(this); + + auto *passLabel = new QLabel("Master password:", this); + layout->addWidget(passLabel); + + passwordEdit = new QLineEdit(this); + passwordEdit->setEchoMode(QLineEdit::Password); + layout->addWidget(passwordEdit); + + if (setupMode) { + auto *confirmLabel = new QLabel("Confirm master password:", this); + layout->addWidget(confirmLabel); + confirmEdit = new QLineEdit(this); + confirmEdit->setEchoMode(QLineEdit::Password); + layout->addWidget(confirmEdit); + } else { + confirmEdit = nullptr; + } + + errorLabel = new QLabel(this); + QPalette pal = errorLabel->palette(); + pal.setColor(QPalette::WindowText, Qt::red); + errorLabel->setPalette(pal); + errorLabel->hide(); + layout->addWidget(errorLabel); + + buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + buttonBox->button(QDialogButtonBox::Ok)->setText(setupMode ? "Initialize" : "Unlock"); + + connect(buttonBox, &QDialogButtonBox::accepted, this, &MasterPasswordDialog::handleSubmit); + connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + layout->addWidget(buttonBox); + + passwordEdit->setFocus(); +} + +void MasterPasswordDialog::handleSubmit() { + QString password = passwordEdit->text(); + errorLabel->hide(); + + if (setupMode) { + if (password.isEmpty()) { + errorLabel->setText("Password cannot be empty."); + errorLabel->show(); + return; + } + if (password != confirmEdit->text()) { + errorLabel->setText("Passwords do not match."); + errorLabel->show(); + return; + } + if (VaultManager::instance().setup(password)) { + accept(); + } else { + errorLabel->setText("Failed to initialize vault."); + errorLabel->show(); + } + } else { + if (VaultManager::instance().unlock(password)) { + accept(); + } else { + errorLabel->setText("Incorrect password."); + errorLabel->show(); + passwordEdit->clear(); + passwordEdit->setFocus(); + } + } +} diff --git a/MasterPasswordDialog.h b/MasterPasswordDialog.h new file mode 100644 index 0000000000000000000000000000000000000000..1a475b5a53024d0aa480d7578120095dda5c2ba3 --- /dev/null +++ b/MasterPasswordDialog.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +class MasterPasswordDialog : public QDialog { + Q_OBJECT +public: + explicit MasterPasswordDialog(bool isSetupMode, QWidget *parent = nullptr); + +private slots: + void handleSubmit(); + +private: + bool setupMode; + QLineEdit *passwordEdit; + QLineEdit *confirmEdit; + QLabel *errorLabel; + QDialogButtonBox *buttonBox; +}; diff --git a/PasswordHelper.cpp b/PasswordHelper.cpp new file mode 100644 index 0000000000000000000000000000000000000000..473b06b854d9f90a5f1c828bec3462c4e0a7f4df --- /dev/null +++ b/PasswordHelper.cpp @@ -0,0 +1,12 @@ +#include "PasswordHelper.h" +#include + +PasswordHelper::PasswordHelper(QObject *parent) : QObject(parent) {} + +void PasswordHelper::saveRequest(const QString &origin, const QString &username, const QString &password) { + emit savePasswordRequested(origin, username, password); +} + +void PasswordHelper::log(const QString &msg) { + qDebug() << "[JS Log]:" << msg; +} diff --git a/PasswordHelper.h b/PasswordHelper.h new file mode 100644 index 0000000000000000000000000000000000000000..f1604d2095b2c54ac24053585e5ce0c718e5f2d3 --- /dev/null +++ b/PasswordHelper.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include + +class PasswordHelper : public QObject { + Q_OBJECT +public: + explicit PasswordHelper(QObject *parent = nullptr); + +public slots: + void saveRequest(const QString &origin, const QString &username, const QString &password); + void log(const QString &msg); + +signals: + void savePasswordRequested(const QString &origin, const QString &username, const QString &password); +}; diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..fb48f66b516cf2302362eb29e82357f8ff1757f8 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +## Ungabunga Browser + +A minimal web browser built with Qt6 WebEngine. All persistent data (history, +bookmarks, credentials) lives in one portable SQLite file. Website sessions +(cookies, localStorage, cache) are not stored between launches — semi-incognito +by default. + +### Build + +```sh +# Dependencies (Void Linux): +sudo xbps-install -S qt6-base-devel qt6-webengine-devel sqlite-devel libsodium-devel +sudo xbps-install -S hunspell hunspell-en + +cmake -B build +cmake --build build +``` + +### Usage + +On first run, the browser prompts you to set a master password for the +encrypted credential vault. Subsequent launches require the master password to +unlock the vault. + +Binary: `build/browser` + +### Features + +- **Tabbed browsing** – open/close tabs (Ctrl+T, Ctrl+W), cycle with Ctrl+Tab / + Ctrl+Shift+Tab, close with middle-click. `target="_blank"` and + `window.open()` open new tabs. +- **Address bar** – type a URL or search query (DuckDuckGo). History-based + autocomplete (last 1000 entries). Enter navigates; searches if input contains + spaces or lacks a domain pattern. +- **Bookmarks** – toggle on/off per page with Ctrl+D. Stored in SQLite. +- **History** – every page visit (URL, title, timestamp) is saved to SQLite. +- **Dark mode** – toggle via toolbar button. Applies a dark QPalette and + reloads pages so `prefers-color-scheme` is re-evaluated. +- **DevTools** – per-tab Chromium DevTools panel, toggled via toolbar button or + "Inspect Element" context menu. +- **Downloads** – downloads go to `~/Downloads`. A collapsible download bar + shows progress per file; cancel during progress, dismiss when + complete/failed. Clears completed downloads in bulk. +- **Fullscreen** – triggered by pages (F11). Exit with Escape. Toolbar/tab bar + hidden while fullscreen. +- **Permissions** – prompts for camera, microphone, geolocation, and + notifications on a per-origin basis. +- **Zoom** – Ctrl+/Ctrl- to zoom, Ctrl+0 to reset. Zoom level is persisted per + domain in SQLite and restored on navigation. +- **Password vault** – encrypted with XChaCha20-Poly1305 AEAD. The data + encryption key (DEK) is wrapped with a key encryption key (KEK) derived from + the master password via Argon2id. The DEK exists only in memory while + unlocked. + - **Capture** – JavaScript injected via QWebChannel observes login forms. On + form submission, the origin, username, and password are forwarded to the + C++ backend. + - **Save/Update** – prompts to save new credentials or update existing ones + for the same origin+username. Skips if the password is unchanged. + - **Autofill** – on page load, if exactly one credential exists for the host, + it is filled automatically. Multiple credentials show a popup menu + (Ctrl+Shift+L or toolbar button) to pick which to fill. +- **Spell check** – enabled for en_US. Requires hunspell BDIC files at + `~/.local/share/qtwebengine_dictionaries/`: + ```sh + sudo xbps-install -S hunspell-en + mkdir -p ~/.local/share/qtwebengine_dictionaries + cp /usr/share/hunspell-bdic/en_US.bdic ~/.local/share/qtwebengine_dictionaries/ + ``` + +### Database + +Everything is in one SQLite file — easy to backup, restore, or migrate. Located +at `QStandardPaths::AppDataLocation/browser.db`: + +- `history` – URL, title, last visit time +- `bookmarks` – URL, title, created time +- `passwords` – origin, username, encrypted password (ciphertext + nonce), timestamps +- `vault_meta` – key-value store for the encrypted DEK, Argon2 salt/cost parameters +- `domain_settings` – key-value store per domain (used for zoom factor persistence) + +### Dependencies + +- Qt6 (Widgets, WebEngineWidgets, Sql, WebChannel) +- libsodium (>= 1.0.18) +- hunspell (runtime only, for spell check dictionaries) +- CMake >= 3.21, C++17 compiler diff --git a/ThemeConfig.h b/ThemeConfig.h new file mode 100644 index 0000000000000000000000000000000000000000..00e955ae76f165be474d7f70a83078144b261dce --- /dev/null +++ b/ThemeConfig.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +namespace ThemeConfig { +// Window Dimensions +inline constexpr int DefaultWindowWidth = 1280; +inline constexpr int DefaultWindowHeight = 1600; + +// Dark Mode Palette Colors +namespace Dark { +inline const QColor Window = QColor(53, 53, 53); +inline const QColor WindowText = Qt::white; +inline const QColor Base = QColor(25, 25, 25); +inline const QColor AlternateBase = QColor(53, 53, 53); +inline const QColor ToolTipBase = QColor(53, 53, 53); +inline const QColor ToolTipText = Qt::white; +inline const QColor Text = Qt::white; +inline const QColor Button = QColor(53, 53, 53); +inline const QColor ButtonText = Qt::white; +inline const QColor BrightText = Qt::red; +inline const QColor Link = QColor(42, 130, 218); +inline const QColor Highlight = QColor(42, 130, 218); +inline const QColor HighlightedText = Qt::black; +} // namespace Dark + +// UI Elements +inline const QString SplitterHandleColorLight = "#cccccc"; +inline const QString SplitterHandleColorDark = "#444444"; +} // namespace ThemeConfig diff --git a/VaultManager.cpp b/VaultManager.cpp new file mode 100644 index 0000000000000000000000000000000000000000..932cf5c91b5bd79db12ab155555a1d2fb68169f7 --- /dev/null +++ b/VaultManager.cpp @@ -0,0 +1,181 @@ +#include "VaultManager.h" +#include +#include +#include +#include "DatabaseManager.h" + +VaultManager::VaultManager() { + if (sodium_init() < 0) { + qCritical() << "Libsodium could not be initialized!"; + } +} + +VaultManager::~VaultManager() { + lock(); +} + +bool VaultManager::init() { + return true; +} + +bool VaultManager::isInitialized() { + return !DatabaseManager::instance().getVaultMeta("encrypted_database_key").isEmpty(); +} + +bool VaultManager::setup(const QString &masterPassword) { + QByteArray salt(crypto_pwhash_SALTBYTES, 0); + randombytes_buf(salt.data(), salt.size()); + + QByteArray dek(crypto_aead_xchacha20poly1305_ietf_KEYBYTES, 0); + randombytes_buf(dek.data(), dek.size()); + + unsigned long long opslimit = crypto_pwhash_OPSLIMIT_INTERACTIVE; + unsigned long long memlimit = crypto_pwhash_MEMLIMIT_INTERACTIVE; + + QByteArray kek = deriveKEK(masterPassword, salt, memlimit, opslimit); + + QByteArray nonce(crypto_aead_xchacha20poly1305_ietf_NPUBBYTES, 0); + randombytes_buf(nonce.data(), nonce.size()); + + QByteArray encryptedDek(dek.size() + crypto_aead_xchacha20poly1305_ietf_ABYTES, 0); + unsigned long long outLen; + + crypto_aead_xchacha20poly1305_ietf_encrypt((unsigned char *)encryptedDek.data(), &outLen, (unsigned char *)dek.data(), dek.size(), nullptr, 0, nullptr, + (unsigned char *)nonce.data(), (unsigned char *)kek.data()); + + DatabaseManager::instance().setVaultMeta("argon2_salt", salt); + DatabaseManager::instance().setVaultMeta("encrypted_database_key", encryptedDek); + DatabaseManager::instance().setVaultMeta("database_key_nonce", nonce); + DatabaseManager::instance().setVaultMeta("argon2_memory_cost", QByteArray::number(memlimit)); + DatabaseManager::instance().setVaultMeta("argon2_time_cost", QByteArray::number(opslimit)); + + databaseKey = dek; + return true; +} + +bool VaultManager::unlock(const QString &masterPassword) { + QByteArray salt = DatabaseManager::instance().getVaultMeta("argon2_salt"); + QByteArray encryptedDek = DatabaseManager::instance().getVaultMeta("encrypted_database_key"); + QByteArray nonce = DatabaseManager::instance().getVaultMeta("database_key_nonce"); + unsigned long long memlimit = DatabaseManager::instance().getVaultMeta("argon2_memory_cost").toULongLong(); + unsigned long long opslimit = DatabaseManager::instance().getVaultMeta("argon2_time_cost").toULongLong(); + + QByteArray kek = deriveKEK(masterPassword, salt, memlimit, opslimit); + + QByteArray dek(crypto_aead_xchacha20poly1305_ietf_KEYBYTES, 0); + unsigned long long outLen; + + if (crypto_aead_xchacha20poly1305_ietf_decrypt((unsigned char *)dek.data(), &outLen, nullptr, (unsigned char *)encryptedDek.data(), encryptedDek.size(), nullptr, 0, + (unsigned char *)nonce.data(), (unsigned char *)kek.data()) != 0) { + return false; + } + + databaseKey = dek; + return true; +} + +void VaultManager::lock() { + sodium_memzero(databaseKey.data(), databaseKey.size()); + databaseKey.clear(); +} + +QByteArray VaultManager::deriveKEK(const QString &password, const QByteArray &salt, unsigned long long memlimit, unsigned long long opslimit) { + QByteArray kek(crypto_aead_xchacha20poly1305_ietf_KEYBYTES, 0); + QByteArray passwordBytes = password.toUtf8(); + + if (crypto_pwhash((unsigned char *)kek.data(), kek.size(), passwordBytes.data(), passwordBytes.size(), (unsigned char *)salt.data(), opslimit, memlimit, + crypto_pwhash_ALG_ARGON2ID13) != 0) { + qCritical() << "Argon2id failed!"; + } + return kek; +} + +bool VaultManager::savePassword(const QString &origin, const QString &username, const QString &password) { + if (!isUnlocked()) { + return false; + } + + QByteArray nonce(crypto_aead_xchacha20poly1305_ietf_NPUBBYTES, 0); + randombytes_buf(nonce.data(), nonce.size()); + + QByteArray passwordBytes = password.toUtf8(); + QByteArray ciphertext(passwordBytes.size() + crypto_aead_xchacha20poly1305_ietf_ABYTES, 0); + unsigned long long outLen; + + crypto_aead_xchacha20poly1305_ietf_encrypt((unsigned char *)ciphertext.data(), &outLen, (unsigned char *)passwordBytes.data(), passwordBytes.size(), nullptr, 0, nullptr, + (unsigned char *)nonce.data(), (unsigned char *)databaseKey.data()); + + QSqlQuery query; + // Check if it exists + query.prepare("SELECT id FROM passwords WHERE origin = ? AND username = ?"); + query.addBindValue(origin); + query.addBindValue(username); + + if (query.exec() && query.next()) { + int id = query.value(0).toInt(); + query.prepare( + "UPDATE passwords SET password_ciphertext = ?, " + "password_nonce = ?, updated_at = ? WHERE id = ?"); + query.addBindValue(ciphertext); + query.addBindValue(nonce); + query.addBindValue(QDateTime::currentDateTime()); + query.addBindValue(id); + } else { + query.prepare( + "INSERT INTO passwords (origin, username, password_ciphertext, " + "password_nonce, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?)"); + query.addBindValue(origin); + query.addBindValue(username); + query.addBindValue(ciphertext); + query.addBindValue(nonce); + query.addBindValue(QDateTime::currentDateTime()); + query.addBindValue(QDateTime::currentDateTime()); + } + + return query.exec(); +} + +QList VaultManager::getPasswords(const QString &origin) { + QList entries; + if (!isUnlocked()) { + return entries; + } + + QSqlQuery query; + if (origin.isEmpty()) { + query.prepare( + "SELECT id, origin, username, password_ciphertext, " + "password_nonce, created_at, updated_at FROM passwords"); + } else { + query.prepare( + "SELECT id, origin, username, password_ciphertext, password_nonce, " + "created_at, updated_at FROM passwords WHERE origin = ?"); + query.addBindValue(origin); + } + + if (!query.exec()) { + return entries; + } + + while (query.next()) { + QByteArray ciphertext = query.value(3).toByteArray(); + QByteArray nonce = query.value(4).toByteArray(); + QByteArray decrypted(ciphertext.size() - crypto_aead_xchacha20poly1305_ietf_ABYTES, 0); + unsigned long long outLen; + + if (crypto_aead_xchacha20poly1305_ietf_decrypt((unsigned char *)decrypted.data(), &outLen, nullptr, (unsigned char *)ciphertext.data(), ciphertext.size(), nullptr, 0, + (unsigned char *)nonce.data(), (unsigned char *)databaseKey.data()) == 0) { + PasswordEntry entry; + entry.id = query.value(0).toInt(); + entry.origin = query.value(1).toString(); + entry.username = query.value(2).toString(); + entry.password = QString::fromUtf8(decrypted); + entry.created_at = query.value(5).toDateTime(); + entry.updated_at = query.value(6).toDateTime(); + entries.append(entry); + } + } + + return entries; +} diff --git a/VaultManager.h b/VaultManager.h new file mode 100644 index 0000000000000000000000000000000000000000..6d6bc3ebee80ba94cdc321dde0ca9a1e6755808d --- /dev/null +++ b/VaultManager.h @@ -0,0 +1,44 @@ +#pragma once + +#include + +#include +#include +#include +#include + +struct PasswordEntry { + int id; + QString origin; + QString username; + QString password; + QDateTime created_at; + QDateTime updated_at; +}; + +class VaultManager : public QObject { + Q_OBJECT +public: + static VaultManager &instance() { + static VaultManager inst; + return inst; + } + + bool init(); + bool isInitialized(); + bool setup(const QString &masterPassword); + bool unlock(const QString &masterPassword); + void lock(); + bool isUnlocked() const { return !databaseKey.isEmpty(); } + + bool savePassword(const QString &origin, const QString &username, const QString &password); + QList getPasswords(const QString &origin = QString()); + +private: + VaultManager(); + ~VaultManager(); + + QByteArray databaseKey; // The DEK + + QByteArray deriveKEK(const QString &password, const QByteArray &salt, unsigned long long memlimit, unsigned long long opslimit); +}; diff --git a/browser.desktop b/browser.desktop new file mode 100644 index 0000000000000000000000000000000000000000..5b20dbdd64d94ea5cba5cf397fcb1b7eb45aa453 --- /dev/null +++ b/browser.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Type=Application +Name=Browser +Comment=Browser +Exec=browser +Terminal=false +Categories=Utility;Application; diff --git a/browser.qrc b/browser.qrc new file mode 100644 index 0000000000000000000000000000000000000000..3d54d525c167bcda2e2c5e4a6ed1718a066b1d39 --- /dev/null +++ b/browser.qrc @@ -0,0 +1,9 @@ + + + + resources/js/capture.js + resources/js/autofill.js + resources/js/qwebchannel.js + resources/debug.html + + diff --git a/compile_commands.json b/compile_commands.json new file mode 120000 index 0000000000000000000000000000000000000000..25eb4b2b4b7b60c288d5b13540a6288f0ed0b1d3 --- /dev/null +++ b/compile_commands.json @@ -0,0 +1 @@ +build/compile_commands.json \ No newline at end of file diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000000000000000000000000000000000000..995508887b80e4d57c8ff3914762de538d0bcedb --- /dev/null +++ b/main.cpp @@ -0,0 +1,48 @@ +#include + +#include +#include +#include +#include +#include + +#include "DatabaseManager.h" +#include "MainWindow.h" +#include "MasterPasswordDialog.h" +#include "VaultManager.h" + +int main(int argc, char *argv[]) { + // Must be set before QApplication to ensure QtWebEngineProcess inherits it + // Qt WebEngine searches: 1) QTWEBENGINE_DICTIONARIES_PATH 2) + // /qtwebengine_dictionaries/ + QByteArray dictPath = (QDir::homePath() + "/.local/share/qtwebengine_dictionaries").toUtf8(); + setenv("QTWEBENGINE_DICTIONARIES_PATH", dictPath.constData(), 1); + setenv("QTWEBENGINE_CHROMIUM_FLAGS", "--enable-spell-checking", 1); + + QApplication app(argc, argv); + QGuiApplication::styleHints()->setColorScheme(Qt::ColorScheme::Dark); + + if (!DatabaseManager::instance().init()) { + return 1; + } + + VaultManager::instance().init(); + + auto *profile = QWebEngineProfile::defaultProfile(); + profile->setSpellCheckLanguages({"en_US"}); + profile->setSpellCheckEnabled(true); + + // Check if vault needs setup or unlock + if (!VaultManager::instance().isUnlocked()) { + bool setupMode = !VaultManager::instance().isInitialized(); + MasterPasswordDialog dlg(setupMode); + if (dlg.exec() != QDialog::Accepted) { + return 0; // User cancelled + } + } + + auto *browser = new MainWindow(); + browser->show(); + + return app.exec(); +} diff --git a/resources/debug.html b/resources/debug.html new file mode 100644 index 0000000000000000000000000000000000000000..a0efe5b82962e189222f82f3955629de652d97fc --- /dev/null +++ b/resources/debug.html @@ -0,0 +1,18 @@ + + + + + Debug + + +

Debug

+ + + + diff --git a/resources/js/autofill.js b/resources/js/autofill.js new file mode 100644 index 0000000000000000000000000000000000000000..10d87aacc64a6d343ae1d3e247fd2d3139cbd6f1 --- /dev/null +++ b/resources/js/autofill.js @@ -0,0 +1,33 @@ +(function() { + function fill() { + var inputs = document.querySelectorAll('input'); + var passField = null; + var userField = null; + for (var i = 0; i < inputs.length; i++) { + if (inputs[i].type === 'password') { + passField = inputs[i]; + for (var j = i - 1; j >= 0; j--) { + var type = inputs[j].type.toLowerCase(); + if (type === 'text' || type === 'email' || type === 'number' || !type) { + userField = inputs[j]; + break; + } + } + break; + } + } + if (passField) { + passField.value = "%2"; + if (userField) userField.value = "%1"; + return true; + } + return false; + } + if (!fill()) { + var attempts = 0; + var interval = setInterval(function() { + attempts++; + if (fill() || attempts > 20) clearInterval(interval); + }, 500); + } +})(); diff --git a/resources/js/capture.js b/resources/js/capture.js new file mode 100644 index 0000000000000000000000000000000000000000..925a8a47b392434a72dbd29ab591b1cdb99fd09d --- /dev/null +++ b/resources/js/capture.js @@ -0,0 +1,43 @@ +(function() { + function init() { + if (typeof QWebChannel === 'undefined' || typeof qt === 'undefined' || !qt.webChannelTransport) { + setTimeout(init, 100); + return; + } + + new QWebChannel(qt.webChannelTransport, function(channel) { + window.passwordHelper = channel.objects.passwordHelper; + + function captureLogin() { + var passwords = document.querySelectorAll('input[type="password"]'); + passwords.forEach(function(pass) { + var form = pass.form; + if (form && !form.dataset.passwordHooked) { + form.dataset.passwordHooked = "true"; + + var handler = function() { + var user = form.querySelector('input[type="text"], input[type="email"], input:not([type])'); + if (user && user.value && pass.value) { + passwordHelper.saveRequest(window.location.hostname, user.value, pass.value); + } + }; + + form.addEventListener('submit', handler); + + var buttons = form.querySelectorAll('button, input[type="submit"], input[type="button"]'); + buttons.forEach(function(btn) { + btn.addEventListener('click', handler); + }); + } + }); + } + + var observer = new MutationObserver(function(mutations) { + captureLogin(); + }); + observer.observe(document.body, { childList: true, subtree: true }); + captureLogin(); + }); + } + init(); +})(); diff --git a/resources/js/qwebchannel.js b/resources/js/qwebchannel.js new file mode 100644 index 0000000000000000000000000000000000000000..d68b1ac8a1a0f7f37ee4c7ffa055ae84193fcb40 --- /dev/null +++ b/resources/js/qwebchannel.js @@ -0,0 +1,456 @@ +// Copyright (C) 2016 The Qt Company Ltd. +// Copyright (C) 2016 Klarälvdalens Datakonsult AB, a KDAB Group company, info@kdab.com, author Milian Wolff +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only +// Qt-Security score:critical reason:data-parser + +"use strict"; + +var QWebChannelMessageTypes = { + signal: 1, + propertyUpdate: 2, + init: 3, + idle: 4, + debug: 5, + invokeMethod: 6, + connectToSignal: 7, + disconnectFromSignal: 8, + setProperty: 9, + response: 10, +}; + +var QWebChannel = function(transport, initCallback, converters) +{ + if (typeof transport !== "object" || typeof transport.send !== "function") { + console.error("The QWebChannel expects a transport object with a send function and onmessage callback property." + + " Given is: transport: " + typeof(transport) + ", transport.send: " + typeof(transport.send)); + return; + } + + var channel = this; + this.transport = transport; + + var converterRegistry = + { + Date : function(response) { + if (typeof response === "string" + && response.match( + /^-?\d+-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d*)?([-+\u2212](\d{2}):(\d{2})|Z)?$/)) { + var date = new Date(response); + if (!isNaN(date)) + return date; + } + return undefined; // Return undefined if current converter is not applicable + } + }; + + this.usedConverters = []; + + this.addConverter = function(converter) + { + if (typeof converter === "string") { + if (converterRegistry.hasOwnProperty(converter)) + this.usedConverters.push(converterRegistry[converter]); + else + console.error("Converter '" + converter + "' not found"); + } else if (typeof converter === "function") { + this.usedConverters.push(converter); + } else { + console.error("Invalid converter object type " + typeof converter); + } + } + + if (Array.isArray(converters)) { + for (const converter of converters) + this.addConverter(converter); + } else if (converters !== undefined) { + this.addConverter(converters); + } + + this.send = function(data) + { + if (typeof(data) !== "string") { + data = JSON.stringify(data); + } + channel.transport.send(data); + } + + this.transport.onmessage = function(message) + { + var data = message.data; + if (typeof data === "string") { + data = JSON.parse(data); + } + switch (data.type) { + case QWebChannelMessageTypes.signal: + channel.handleSignal(data); + break; + case QWebChannelMessageTypes.response: + channel.handleResponse(data); + break; + case QWebChannelMessageTypes.propertyUpdate: + channel.handlePropertyUpdate(data); + break; + default: + console.error("invalid message received:", message.data); + break; + } + } + + this.execCallbacks = {}; + this.execId = 0; + this.exec = function(data, callback) + { + if (!callback) { + // if no callback is given, send directly + channel.send(data); + return; + } + if (channel.execId === Number.MAX_VALUE) { + // wrap + channel.execId = Number.MIN_VALUE; + } + if (data.hasOwnProperty("id")) { + console.error("Cannot exec message with property id: " + JSON.stringify(data)); + return; + } + data.id = channel.execId++; + channel.execCallbacks[data.id] = callback; + channel.send(data); + }; + + this.objects = {}; + + this.handleSignal = function(message) + { + var object = channel.objects[message.object]; + if (object) { + object.signalEmitted(message.signal, message.args); + } else { + console.warn("Unhandled signal: " + message.object + "::" + message.signal); + } + } + + this.handleResponse = function(message) + { + if (!message.hasOwnProperty("id")) { + console.error("Invalid response message received: ", JSON.stringify(message)); + return; + } + channel.execCallbacks[message.id](message.data); + delete channel.execCallbacks[message.id]; + } + + this.handlePropertyUpdate = function(message) + { + message.data.forEach(data => { + var object = channel.objects[data.object]; + if (object) { + object.propertyUpdate(data.signals, data.properties); + } else { + console.warn("Unhandled property update: " + data.object + "::" + data.signal); + } + }); + channel.exec({type: QWebChannelMessageTypes.idle}); + } + + this.debug = function(message) + { + channel.send({type: QWebChannelMessageTypes.debug, data: message}); + }; + + channel.exec({type: QWebChannelMessageTypes.init}, function(data) { + for (const objectName of Object.keys(data)) { + new QObject(objectName, data[objectName], channel); + } + + // now unwrap properties, which might reference other registered objects + for (const objectName of Object.keys(channel.objects)) { + channel.objects[objectName].unwrapProperties(); + } + + if (initCallback) { + initCallback(channel); + } + channel.exec({type: QWebChannelMessageTypes.idle}); + }); +}; + +function QObject(name, data, webChannel) +{ + this.__id__ = name; + webChannel.objects[name] = this; + + // List of callbacks that get invoked upon signal emission + this.__objectSignals__ = {}; + + // Cache of all properties, updated when a notify signal is emitted + this.__propertyCache__ = {}; + + var object = this; + + // ---------------------------------------------------------------------- + + this.unwrapQObject = function(response) + { + for (const converter of webChannel.usedConverters) { + var result = converter(response); + if (result !== undefined) + return result; + } + + if (response instanceof Array) { + // support list of objects + return response.map(qobj => object.unwrapQObject(qobj)) + } + if (!(response instanceof Object)) + return response; + + if (!response["__QObject*__"] || response.id === undefined) { + var jObj = {}; + for (const propName of Object.keys(response)) { + jObj[propName] = object.unwrapQObject(response[propName]); + } + return jObj; + } + + var objectId = response.id; + if (webChannel.objects[objectId]) + return webChannel.objects[objectId]; + + if (!response.data) { + console.error("Cannot unwrap unknown QObject " + objectId + " without data."); + return; + } + + var qObject = new QObject( objectId, response.data, webChannel ); + qObject.destroyed.connect(function() { + if (webChannel.objects[objectId] === qObject) { + delete webChannel.objects[objectId]; + // reset the now deleted QObject to an empty {} object + // just assigning {} though would not have the desired effect, but the + // below also ensures all external references will see the empty map + // NOTE: this detour is necessary to workaround QTBUG-40021 + Object.keys(qObject).forEach(name => delete qObject[name]); + } + }); + // here we are already initialized, and thus must directly unwrap the properties + qObject.unwrapProperties(); + return qObject; + } + + this.unwrapProperties = function() + { + for (const propertyIdx of Object.keys(object.__propertyCache__)) { + object.__propertyCache__[propertyIdx] = object.unwrapQObject(object.__propertyCache__[propertyIdx]); + } + } + + function addSignal(signalData, isPropertyNotifySignal) + { + var signalName = signalData[0]; + var signalIndex = signalData[1]; + object[signalName] = { + connect: function(callback) { + if (typeof(callback) !== "function") { + console.error("Bad callback given to connect to signal " + signalName); + return; + } + + object.__objectSignals__[signalIndex] = object.__objectSignals__[signalIndex] || []; + object.__objectSignals__[signalIndex].push(callback); + + // only required for "pure" signals, handled separately for properties in propertyUpdate + if (isPropertyNotifySignal) + return; + + // also note that we always get notified about the destroyed signal + if (signalName === "destroyed" || signalName === "destroyed()" || signalName === "destroyed(QObject*)") + return; + + // and otherwise we only need to be connected only once + if (object.__objectSignals__[signalIndex].length == 1) { + webChannel.exec({ + type: QWebChannelMessageTypes.connectToSignal, + object: object.__id__, + signal: signalIndex + }); + } + }, + disconnect: function(callback) { + if (typeof(callback) !== "function") { + console.error("Bad callback given to disconnect from signal " + signalName); + return; + } + // This makes a new list. This is important because it won't interfere with + // signal processing if a disconnection happens while emittig a signal + object.__objectSignals__[signalIndex] = (object.__objectSignals__[signalIndex] || []).filter(function(c) { + return c != callback; + }); + if (!isPropertyNotifySignal && object.__objectSignals__[signalIndex].length === 0) { + // only required for "pure" signals, handled separately for properties in propertyUpdate + webChannel.exec({ + type: QWebChannelMessageTypes.disconnectFromSignal, + object: object.__id__, + signal: signalIndex + }); + } + } + }; + } + + /** + * Invokes all callbacks for the given signalname. Also works for property notify callbacks. + */ + function invokeSignalCallbacks(signalName, signalArgs) + { + var connections = object.__objectSignals__[signalName]; + if (connections) { + connections.forEach(function(callback) { + callback.apply(callback, signalArgs); + }); + } + } + + this.propertyUpdate = function(signals, propertyMap) + { + // update property cache + for (const propertyIndex of Object.keys(propertyMap)) { + var propertyValue = propertyMap[propertyIndex]; + object.__propertyCache__[propertyIndex] = this.unwrapQObject(propertyValue); + } + + for (const signalName of Object.keys(signals)) { + // Invoke all callbacks, as signalEmitted() does not. This ensures the + // property cache is updated before the callbacks are invoked. + invokeSignalCallbacks(signalName, signals[signalName]); + } + } + + this.signalEmitted = function(signalName, signalArgs) + { + invokeSignalCallbacks(signalName, this.unwrapQObject(signalArgs)); + } + + function addMethod(methodData) + { + var methodName = methodData[0]; + var methodIdx = methodData[1]; + + // Fully specified methods are invoked by id, others by name for host-side overload resolution + var invokedMethod = methodName[methodName.length - 1] === ')' ? methodIdx : methodName + + object[methodName] = function() { + var args = []; + var callback; + var errCallback; + for (var i = 0; i < arguments.length; ++i) { + var argument = arguments[i]; + if (typeof argument === "function") + callback = argument; + else + args.push(argument); + } + + var result; + // during test, webChannel.exec synchronously calls the callback + // therefore, the promise must be constucted before calling + // webChannel.exec to ensure the callback is set up + if (!callback && (typeof(Promise) === 'function')) { + result = new Promise(function(resolve, reject) { + callback = resolve; + errCallback = reject; + }); + } + + webChannel.exec({ + "type": QWebChannelMessageTypes.invokeMethod, + "object": object.__id__, + "method": invokedMethod, + "args": args + }, function(response) { + if (response !== undefined) { + var result = object.unwrapQObject(response); + if (callback) { + (callback)(result); + } + } else if (errCallback) { + (errCallback)(); + } + }); + + return result; + }; + } + + function bindGetterSetter(propertyInfo) + { + var propertyIndex = propertyInfo[0]; + var propertyName = propertyInfo[1]; + var notifySignalData = propertyInfo[2]; + // initialize property cache with current value + // NOTE: if this is an object, it is not directly unwrapped as it might + // reference other QObject that we do not know yet + object.__propertyCache__[propertyIndex] = propertyInfo[3]; + + if (notifySignalData) { + if (notifySignalData[0] === 1) { + // signal name is optimized away, reconstruct the actual name + notifySignalData[0] = propertyName + "Changed"; + } + addSignal(notifySignalData, true); + } + + Object.defineProperty(object, propertyName, { + configurable: true, + get: function () { + var propertyValue = object.__propertyCache__[propertyIndex]; + if (propertyValue === undefined) { + // This shouldn't happen + console.warn("Undefined value in property cache for property \"" + propertyName + "\" in object " + object.__id__); + } + + return propertyValue; + }, + set: function(value) { + if (value === undefined) { + console.warn("Property setter for " + propertyName + " called with undefined value!"); + return; + } + object.__propertyCache__[propertyIndex] = value; + var valueToSend = value; + webChannel.exec({ + "type": QWebChannelMessageTypes.setProperty, + "object": object.__id__, + "property": propertyIndex, + "value": valueToSend + }); + } + }); + + } + + // ---------------------------------------------------------------------- + + data.methods.forEach(addMethod); + + data.properties.forEach(bindGetterSetter); + + data.signals.forEach(function(signal) { addSignal(signal, false); }); + + Object.assign(object, data.enums); +} + +QObject.prototype.toJSON = function() { + if (this.__id__ === undefined) return {}; + return { + id: this.__id__, + "__QObject*__": true + }; +}; + +//required for use with nodejs +if (typeof module === 'object') { + module.exports = { + QWebChannel: QWebChannel + }; +} diff --git a/resources/spellcheck/en-US-10-1.bdic b/resources/spellcheck/en-US-10-1.bdic new file mode 100644 index 0000000000000000000000000000000000000000..a45335846664b9a5fd6fc65f85c548ec0d9e04cf Binary files /dev/null and b/resources/spellcheck/en-US-10-1.bdic differ