diff --git a/src/core/Config.cpp b/src/core/Config.cpp index 2c2b0bc57..f92818ad9 100644 --- a/src/core/Config.cpp +++ b/src/core/Config.cpp @@ -202,6 +202,7 @@ static const QHash configStrings = { {Config::KeeShare_Own, {QS("KeeShare/Own"), Roaming, {}}}, {Config::KeeShare_Foreign, {QS("KeeShare/Foreign"), Roaming, {}}}, {Config::KeeShare_Active, {QS("KeeShare/Active"), Roaming, {}}}, + {Config::KeeShare_DeviceId, {QS("KeeShare/DeviceId"), Local, {}}}, // PasswordGenerator {Config::PasswordGenerator_LowerCase, {QS("PasswordGenerator/LowerCase"), Roaming, true}}, diff --git a/src/core/Config.h b/src/core/Config.h index 8f54f9c01..55f6a0dd8 100644 --- a/src/core/Config.h +++ b/src/core/Config.h @@ -179,6 +179,7 @@ public: KeeShare_Own, KeeShare_Foreign, KeeShare_Active, + KeeShare_DeviceId, PasswordGenerator_LowerCase, PasswordGenerator_UpperCase, diff --git a/src/keeshare/KeeShare.cpp b/src/keeshare/KeeShare.cpp index f14b0d5ae..3ce03ebeb 100644 --- a/src/keeshare/KeeShare.cpp +++ b/src/keeshare/KeeShare.cpp @@ -23,9 +23,13 @@ #include "gui/DatabaseIcons.h" #include "keeshare/ShareObserver.h" +#include +#include + namespace { static const QString KeeShare_Reference("KeeShare/Reference"); + static const QString KeeShare_PerDeviceSync("KeeShare/PerDeviceSync"); } KeeShare* KeeShare::m_instance = nullptr; @@ -51,6 +55,37 @@ void KeeShare::init(QObject* parent) m_instance = new KeeShare(parent); } +QString KeeShare::deviceId() +{ + auto id = config()->get(Config::KeeShare_DeviceId).toString(); + if (id.isEmpty()) { + // Generate fallback from machine unique ID, truncated to 7 chars + auto machineId = QSysInfo::machineUniqueId(); + if (!machineId.isEmpty()) { + // machineUniqueId() on Linux returns a hex string directly from /etc/machine-id + id = QString::fromLatin1(machineId).left(7).toUpper(); + } else { + // Last resort: use hostname + id = QSysInfo::machineHostName(); + } + // Sanitize to [A-Za-z0-9] only + id.remove(QRegularExpression("[^A-Za-z0-9]")); + if (id.isEmpty()) { + id = "DEFAULT"; + } + setDeviceId(id); + } + return id; +} + +void KeeShare::setDeviceId(const QString& id) +{ + // Sanitize to [A-Za-z0-9] only + QString sanitized = id; + sanitized.remove(QRegularExpression("[^A-Za-z0-9]")); + config()->set(Config::KeeShare_DeviceId, sanitized); +} + KeeShareSettings::Own KeeShare::own() { // Read existing own certificate or generate a new one if none available @@ -110,6 +145,19 @@ void KeeShare::setReferenceTo(Group* group, const KeeShareSettings::Reference& r customData->set(KeeShare_Reference, serialized.toUtf8().toBase64()); } +bool KeeShare::hasPerDeviceConfig(const Group* group) +{ + return group && group->customData()->contains(KeeShare_PerDeviceSync); +} + +QString KeeShare::perDeviceSyncPath(const Group* group) +{ + if (!group || !group->customData()->contains(KeeShare_PerDeviceSync)) { + return {}; + } + return group->customData()->value(KeeShare_PerDeviceSync); +} + bool KeeShare::isEnabled(const Group* group) { const auto reference = KeeShare::referenceOf(group); diff --git a/src/keeshare/KeeShare.h b/src/keeshare/KeeShare.h index 17052a9c6..51f6f9859 100644 --- a/src/keeshare/KeeShare.h +++ b/src/keeshare/KeeShare.h @@ -54,6 +54,9 @@ public: static const Group* resolveSharedGroup(const Group* group); static QString sharingLabel(const Group* group); + static QString deviceId(); + static void setDeviceId(const QString& id); + static KeeShareSettings::Own own(); static void setOwn(const KeeShareSettings::Own& own); @@ -64,6 +67,9 @@ public: static void setReferenceTo(Group* group, const KeeShareSettings::Reference& reference); static QString referenceTypeLabel(const KeeShareSettings::Reference& reference); + static bool hasPerDeviceConfig(const Group* group); + static QString perDeviceSyncPath(const Group* group); + void connectDatabase(QSharedPointer newDb, QSharedPointer oldDb); bool setSharingEnabled(QSharedPointer db, bool enabled); diff --git a/src/keeshare/KeeShareSettings.cpp b/src/keeshare/KeeShareSettings.cpp index 61ab2bb8e..5eea29112 100644 --- a/src/keeshare/KeeShareSettings.cpp +++ b/src/keeshare/KeeShareSettings.cpp @@ -286,6 +286,13 @@ namespace KeeShareSettings return (type & ImportFrom) != 0 && !path.isEmpty(); } + bool Reference::isPerDeviceMode() const + { + return !path.isEmpty() + && !path.endsWith(".kdbx", Qt::CaseInsensitive) + && !path.endsWith(".kdbx.share", Qt::CaseInsensitive); + } + bool Reference::operator<(const Reference& other) const { if (type != other.type) { diff --git a/src/keeshare/KeeShareSettings.h b/src/keeshare/KeeShareSettings.h index 667cb8a74..dd786a315 100644 --- a/src/keeshare/KeeShareSettings.h +++ b/src/keeshare/KeeShareSettings.h @@ -133,6 +133,7 @@ namespace KeeShareSettings bool isValid() const; bool isExporting() const; bool isImporting() const; + bool isPerDeviceMode() const; bool operator<(const Reference& other) const; bool operator==(const Reference& other) const; diff --git a/src/keeshare/SettingsWidgetKeeShare.cpp b/src/keeshare/SettingsWidgetKeeShare.cpp index 7462aa5f3..472bf0a13 100644 --- a/src/keeshare/SettingsWidgetKeeShare.cpp +++ b/src/keeshare/SettingsWidgetKeeShare.cpp @@ -47,6 +47,8 @@ void SettingsWidgetKeeShare::loadSettings() m_ui->enableExportCheckBox->setChecked(active.out); m_ui->enableImportCheckBox->setChecked(active.in); + m_ui->deviceIdEdit->setText(KeeShare::deviceId()); + m_own = KeeShare::own(); updateOwnCertificate(); } @@ -68,6 +70,11 @@ void SettingsWidgetKeeShare::saveSettings() KeeShare::setOwn(m_own); KeeShare::setActive(active); + auto deviceId = m_ui->deviceIdEdit->text().trimmed(); + if (!deviceId.isEmpty()) { + KeeShare::setDeviceId(deviceId); + } + config()->set(Config::KeeShare_QuietSuccess, m_ui->quietSuccessCheckBox->isChecked()); } diff --git a/src/keeshare/SettingsWidgetKeeShare.ui b/src/keeshare/SettingsWidgetKeeShare.ui index 48a79d8d3..56211af1a 100644 --- a/src/keeshare/SettingsWidgetKeeShare.ui +++ b/src/keeshare/SettingsWidgetKeeShare.ui @@ -68,6 +68,48 @@ + + + + Device Identity + + + + + + Device ID: + + + + + + + Device ID field + + + Unique identifier for this device in per-device sync mode (alphanumeric only) + + + Auto-detected from system + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + diff --git a/src/keeshare/ShareObserver.cpp b/src/keeshare/ShareObserver.cpp index 812dbc0f0..2b78c2c86 100644 --- a/src/keeshare/ShareObserver.cpp +++ b/src/keeshare/ShareObserver.cpp @@ -23,6 +23,7 @@ #include "keeshare/ShareImport.h" #include +#include namespace { @@ -67,6 +68,7 @@ void ShareObserver::deinitialize() m_groupToReference.clear(); m_shareToGroup.clear(); m_fileWatchers.clear(); + m_dirWatchers.clear(); } void ShareObserver::reinitialize() @@ -83,6 +85,7 @@ void ShareObserver::reinitialize() m_groupToReference.remove(group); m_shareToGroup.remove(oldResolvedPath); m_fileWatchers.remove(oldResolvedPath); + m_dirWatchers.remove(oldResolvedPath); if (newReference.isValid()) { m_groupToReference[group] = newReference; @@ -109,10 +112,23 @@ void ShareObserver::reinitialize() if (!reference.path.isEmpty() && reference.type != KeeShareSettings::Inactive) { const auto newResolvedPath = resolvePath(reference.path, m_db); - auto fileWatcher = QSharedPointer::create(this); - connect(fileWatcher.data(), &FileWatcher::fileChanged, this, &ShareObserver::handleFileUpdated); - fileWatcher->start(newResolvedPath, FileWatchPeriod, FileWatchSize); - m_fileWatchers.insert(newResolvedPath, fileWatcher); + + if (reference.isPerDeviceMode()) { + // Per-device mode: watch the directory for changes + auto dirWatcher = QSharedPointer::create(); + if (QDir(newResolvedPath).exists()) { + dirWatcher->addPath(newResolvedPath); + } + connect(dirWatcher.data(), &QFileSystemWatcher::directoryChanged, + this, &ShareObserver::handleDirectoryUpdated); + m_dirWatchers.insert(newResolvedPath, dirWatcher); + } else { + // Classic mode: watch the individual file + auto fileWatcher = QSharedPointer::create(this); + connect(fileWatcher.data(), &FileWatcher::fileChanged, this, &ShareObserver::handleFileUpdated); + fileWatcher->start(newResolvedPath, FileWatchPeriod, FileWatchSize); + m_fileWatchers.insert(newResolvedPath, fileWatcher); + } } if (reference.isExporting()) { exported[reference.path] << group->name(); @@ -121,21 +137,42 @@ void ShareObserver::reinitialize() if (reference.isImporting()) { imported[reference.path] << group->name(); - // import has to occur immediately - const auto result = this->importShare(reference.path); - if (!result.isValid()) { - // tolerable result - blocked import or missing source - continue; - } - if (result.isError()) { - error << tr("Import from %1 failed (%2)").arg(result.path).arg(result.message); - } else if (result.isWarning()) { - warning << tr("Import from %1 failed (%2)").arg(result.path).arg(result.message); - } else if (result.isInfo()) { - success << tr("Import from %1 successful (%2)").arg(result.path).arg(result.message); + if (reference.isPerDeviceMode()) { + // Per-device mode: import from all device files in the directory + const auto resolvedDir = resolvePath(reference.path, m_db); + const auto results = importPerDeviceShares(resolvedDir, reference, group); + for (const auto& result : results) { + if (!result.isValid()) { + continue; + } + if (result.isError()) { + error << tr("Import from %1 failed (%2)").arg(result.path, result.message); + } else if (result.isWarning()) { + warning << tr("Import from %1 failed (%2)").arg(result.path, result.message); + } else if (result.isInfo()) { + success << tr("Import from %1 successful (%2)").arg(result.path, result.message); + } else { + success << tr("Imported from %1").arg(result.path); + } + } } else { - success << tr("Imported from %1").arg(result.path); + // Classic mode: import single file + const auto result = this->importShare(reference.path); + if (!result.isValid()) { + // tolerable result - blocked import or missing source + continue; + } + + if (result.isError()) { + error << tr("Import from %1 failed (%2)").arg(result.path).arg(result.message); + } else if (result.isWarning()) { + warning << tr("Import from %1 failed (%2)").arg(result.path).arg(result.message); + } else if (result.isInfo()) { + success << tr("Import from %1 successful (%2)").arg(result.path).arg(result.message); + } else { + success << tr("Imported from %1").arg(result.path); + } } } } @@ -216,6 +253,84 @@ void ShareObserver::handleFileUpdated(const QString& path) } } +void ShareObserver::handleDirectoryUpdated(const QString& dirPath) +{ + auto group = m_shareToGroup.value(dirPath); + if (!group) { + return; + } + auto reference = KeeShare::referenceOf(group); + if (!reference.isImporting() || !reference.isPerDeviceMode()) { + return; + } + + // Re-add the directory to the watcher (Qt removes it after notification) + auto dirWatcher = m_dirWatchers.value(dirPath); + if (dirWatcher && dirWatcher->directories().isEmpty()) { + dirWatcher->addPath(dirPath); + } + + if (!m_inFileUpdate) { + QTimer::singleShot(100, this, [this, dirPath] { + auto shareGroup = m_shareToGroup.value(dirPath); + if (!shareGroup) { + m_inFileUpdate = false; + return; + } + auto shareRef = KeeShare::referenceOf(shareGroup); + auto results = importPerDeviceShares(dirPath, shareRef, shareGroup); + m_inFileUpdate = false; + + QStringList success; + QStringList warning; + QStringList error; + for (const auto& result : results) { + if (!result.isValid()) { + continue; + } + if (result.isError()) { + error << tr("Import from %1 failed (%2)").arg(result.path, result.message); + } else if (result.isWarning()) { + warning << tr("Import from %1 failed (%2)").arg(result.path, result.message); + } else if (result.isInfo()) { + success << tr("Import from %1 successful (%2)").arg(result.path, result.message); + } else { + success << tr("Imported from %1").arg(result.path); + } + } + notifyAbout(success, warning, error); + }); + m_inFileUpdate = true; + } +} + +QList ShareObserver::importPerDeviceShares( + const QString& resolvedDir, + const KeeShareSettings::Reference& reference, + Group* targetGroup) +{ + QList results; + if (!KeeShare::active().in) { + return results; + } + + const QString ownFile = KeeShare::deviceId() + ".kdbx"; + QDir dir(resolvedDir); + if (!dir.exists()) { + return results; + } + + const auto files = dir.entryList({"*.kdbx"}, QDir::Files, QDir::Name); + for (const auto& fileName : files) { + if (fileName.compare(ownFile, Qt::CaseInsensitive) == 0) { + continue; // Skip own device's file + } + const auto filePath = dir.absoluteFilePath(fileName); + results << ShareImport::containerInto(filePath, reference, targetGroup); + } + return results; +} + ShareObserver::Result ShareObserver::importShare(const QString& path) { if (!KeeShare::active().in) { @@ -286,16 +401,40 @@ QList ShareObserver::exportShares() for (auto it = references.cbegin(); it != references.cend(); ++it) { auto reference = it.value().first(); const QString resolvedPath = resolvePath(reference.config.path, m_db); - auto watcher = m_fileWatchers.value(resolvedPath); - if (watcher) { - watcher->stop(); - } - // TODO: save new path into group settings if not saving to signed container anymore - results << ShareExport::intoContainer(resolvedPath, reference.config, reference.group); + if (reference.config.isPerDeviceMode()) { + // Per-device mode: export to {directory}/{DEVICE_ID}.kdbx + QDir dir(resolvedPath); + if (!dir.exists()) { + dir.mkpath("."); + } + const auto deviceFile = dir.absoluteFilePath(KeeShare::deviceId() + ".kdbx"); - if (watcher) { - watcher->start(resolvedPath, FileWatchPeriod, FileWatchSize); + // Pause directory watcher during export + auto dirWatcher = m_dirWatchers.value(resolvedPath); + if (dirWatcher) { + dirWatcher->removePath(resolvedPath); + } + + results << ShareExport::intoContainer(deviceFile, reference.config, reference.group); + + // Resume directory watcher + if (dirWatcher) { + dirWatcher->addPath(resolvedPath); + } + } else { + // Classic mode: export to the file directly + auto watcher = m_fileWatchers.value(resolvedPath); + if (watcher) { + watcher->stop(); + } + + // TODO: save new path into group settings if not saving to signed container anymore + results << ShareExport::intoContainer(resolvedPath, reference.config, reference.group); + + if (watcher) { + watcher->start(resolvedPath, FileWatchPeriod, FileWatchSize); + } } } return results; diff --git a/src/keeshare/ShareObserver.h b/src/keeshare/ShareObserver.h index 0694aee50..b888e7ff2 100644 --- a/src/keeshare/ShareObserver.h +++ b/src/keeshare/ShareObserver.h @@ -18,6 +18,7 @@ #ifndef KEEPASSXC_SHAREOBSERVER_H #define KEEPASSXC_SHAREOBSERVER_H +#include #include #include @@ -68,10 +69,14 @@ private slots: void handleDatabaseChanged(); void handleDatabaseSaved(); void handleFileUpdated(const QString& path); + void handleDirectoryUpdated(const QString& dirPath); private: Result importShare(const QString& path); QList exportShares(); + QList importPerDeviceShares(const QString& resolvedDir, + const KeeShareSettings::Reference& reference, + Group* targetGroup); void deinitialize(); void reinitialize(); @@ -82,6 +87,7 @@ private: QMap, KeeShareSettings::Reference> m_groupToReference; QMap> m_shareToGroup; QMap> m_fileWatchers; + QMap> m_dirWatchers; bool m_inFileUpdate = false; bool m_enabled = true; }; diff --git a/src/keeshare/group/EditGroupWidgetKeeShare.cpp b/src/keeshare/group/EditGroupWidgetKeeShare.cpp index bea495b0a..c9eecef32 100644 --- a/src/keeshare/group/EditGroupWidgetKeeShare.cpp +++ b/src/keeshare/group/EditGroupWidgetKeeShare.cpp @@ -111,19 +111,28 @@ void EditGroupWidgetKeeShare::updateSharingState() // Custom message for active KeeShare reference const auto reference = KeeShare::referenceOf(m_temporaryGroup); if (!reference.path.isEmpty()) { - bool supported = false; - for (const auto& extension : supportedExtensions) { - if (reference.path.endsWith(extension, Qt::CaseInsensitive)) { - supported = true; - break; + if (reference.isPerDeviceMode()) { + // Per-device mode: path is a directory, show info message + m_ui->messageWidget->showMessage( + tr("Per-device sync mode: each device writes its own container in this directory.\n" + "Device ID: %1").arg(KeeShare::deviceId()), + MessageWidget::Information); + } else { + // Classic mode: validate file extension + bool supported = false; + for (const auto& extension : supportedExtensions) { + if (reference.path.endsWith(extension, Qt::CaseInsensitive)) { + supported = true; + break; + } + } + if (!supported) { + m_ui->messageWidget->showMessage(tr("Your KeePassXC version does not support sharing this container type.\n" + "Supported extensions are: %1.") + .arg(supportedExtensions.join(", ")), + MessageWidget::Warning); + return; } - } - if (!supported) { - m_ui->messageWidget->showMessage(tr("Your KeePassXC version does not support sharing this container type.\n" - "Supported extensions are: %1.") - .arg(supportedExtensions.join(", ")), - MessageWidget::Warning); - return; } const auto groups = m_database->rootGroup()->groupsRecursive(true); @@ -239,6 +248,23 @@ void EditGroupWidgetKeeShare::launchPathSelectionDialog() if (filename.isEmpty()) { filename = m_temporaryGroup->name(); } + + // For SynchronizeWith, offer both file and directory selection + if (reference.type == KeeShareSettings::SynchronizeWith) { + // Try directory selection first for per-device sync + auto dirPath = fileDialog()->getExistingDirectory( + this, tr("Select per-device sync directory"), defaultDirPath); + if (!dirPath.isEmpty()) { + // Directory selected: per-device mode + m_ui->pathEdit->setText(dirPath); + selectPath(); + FileDialog::saveLastDir("keeshare", dirPath); + updateSharingState(); + return; + } + // User cancelled directory dialog; fall through to file dialog + } + switch (reference.type) { case KeeShareSettings::ImportFrom: filename = fileDialog()->getOpenFileName(this, tr("Select import source"), defaultDirPath, filters); diff --git a/src/keeshare/group/EditGroupWidgetKeeShare.ui b/src/keeshare/group/EditGroupWidgetKeeShare.ui index 1e8e0e1a6..626272f52 100644 --- a/src/keeshare/group/EditGroupWidgetKeeShare.ui +++ b/src/keeshare/group/EditGroupWidgetKeeShare.ui @@ -100,6 +100,9 @@ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + File path for classic mode, or directory path for per-device sync + diff --git a/tests/TestSharing.cpp b/tests/TestSharing.cpp index 0b5414ea8..7e63b3e02 100644 --- a/tests/TestSharing.cpp +++ b/tests/TestSharing.cpp @@ -175,6 +175,37 @@ void TestSharing::testSettingsSerialization_data() QTest::newRow("5") << false << false << certificate0 << key0; } +void TestSharing::testPerDeviceMode() +{ + QFETCH(QString, path); + QFETCH(bool, expectedPerDevice); + + KeeShareSettings::Reference reference; + reference.path = path; + reference.type = KeeShareSettings::SynchronizeWith; + + QCOMPARE(reference.isPerDeviceMode(), expectedPerDevice); +} + +void TestSharing::testPerDeviceMode_data() +{ + QTest::addColumn("path"); + QTest::addColumn("expectedPerDevice"); + + // Classic mode paths (file-based) + QTest::newRow("kdbx file") << "/some/path/share.kdbx" << false; + QTest::newRow("kdbx.share file") << "/some/path/share.kdbx.share" << false; + QTest::newRow("KDBX uppercase") << "/some/path/share.KDBX" << false; + QTest::newRow("KDBX.SHARE uppercase") << "/some/path/share.KDBX.SHARE" << false; + QTest::newRow("empty path") << "" << false; + + // Per-device mode paths (directory-based) + QTest::newRow("directory path") << "/some/sync/dir" << true; + QTest::newRow("directory trailing slash") << "/some/sync/dir/" << true; + QTest::newRow("relative directory") << "sync/shared" << true; + QTest::newRow("directory with dots") << "/some/path.d/sync" << true; +} + const QSharedPointer TestSharing::stubkey(int index) { static QMap> keys; diff --git a/tests/TestSharing.h b/tests/TestSharing.h index cfb521e02..2947479c0 100644 --- a/tests/TestSharing.h +++ b/tests/TestSharing.h @@ -36,6 +36,8 @@ private slots: void testReferenceSerialization_data(); void testSettingsSerialization(); void testSettingsSerialization_data(); + void testPerDeviceMode(); + void testPerDeviceMode_data(); private: const QSharedPointer stubkey(int index = 0);