From 8e561a93a51d96cbf7b68d1088a79e597f0f67a3 Mon Sep 17 00:00:00 2001 From: Loose Cannon Date: Sun, 22 Feb 2026 08:41:14 -0500 Subject: [PATCH 1/5] Add per-device KeeShare sync mode Enable each KeePassXC instance to write its own container file ({DEVICE_ID}.kdbx) to a shared sync directory while importing from all other devices' files. This eliminates Syncthing file conflicts when multiple devices share a group. When a KeeShare reference path points to a directory instead of a .kdbx file, per-device mode activates automatically: - Export writes to {syncDir}/{DEVICE_ID}.kdbx - Import reads all .kdbx files in the directory except own device - Classic single-file mode is fully preserved Changes: - Add KeeShare_DeviceId config key with auto-detection fallback - Add Reference::isPerDeviceMode() detection based on path extension - Extend ShareObserver with QFileSystemWatcher for directory watching - Add importPerDeviceShares() for multi-file import from sync dirs - Update EditGroupWidgetKeeShare with directory selection dialog - Add Device Identity settings field to SettingsWidgetKeeShare - Add KeeShare/PerDeviceSync custom data support for KeePassDX interop - Add unit tests for per-device mode path detection Co-Authored-By: Claude Opus 4.6 --- src/core/Config.cpp | 1 + src/core/Config.h | 1 + src/keeshare/KeeShare.cpp | 48 +++++ src/keeshare/KeeShare.h | 6 + src/keeshare/KeeShareSettings.cpp | 7 + src/keeshare/KeeShareSettings.h | 1 + src/keeshare/SettingsWidgetKeeShare.cpp | 7 + src/keeshare/SettingsWidgetKeeShare.ui | 42 ++++ src/keeshare/ShareObserver.cpp | 189 +++++++++++++++--- src/keeshare/ShareObserver.h | 6 + .../group/EditGroupWidgetKeeShare.cpp | 50 +++-- src/keeshare/group/EditGroupWidgetKeeShare.ui | 3 + tests/TestSharing.cpp | 31 +++ tests/TestSharing.h | 2 + 14 files changed, 357 insertions(+), 37 deletions(-) diff --git a/src/core/Config.cpp b/src/core/Config.cpp index 2c2b0bc57b..f92818ad9e 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 8f54f9c013..55f6a0dd84 100644 --- a/src/core/Config.h +++ b/src/core/Config.h @@ -179,6 +179,7 @@ class Config : public QObject 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 f14b0d5aea..3ce03ebebb 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 17052a9c62..51f6f9859b 100644 --- a/src/keeshare/KeeShare.h +++ b/src/keeshare/KeeShare.h @@ -54,6 +54,9 @@ class KeeShare : public QObject 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 @@ class KeeShare : public QObject 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 61ab2bb8eb..5eea29112d 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 667cb8a74e..dd786a3150 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 7462aa5f3c..472bf0a134 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 48a79d8d36..56211af1ae 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 812dbc0f09..2b78c2c862 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"); + + // Pause directory watcher during export + auto dirWatcher = m_dirWatchers.value(resolvedPath); + if (dirWatcher) { + dirWatcher->removePath(resolvedPath); + } + + results << ShareExport::intoContainer(deviceFile, reference.config, reference.group); - if (watcher) { - watcher->start(resolvedPath, FileWatchPeriod, FileWatchSize); + // 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 0694aee50c..b888e7ff2e 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 slots: 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 bea495b0aa..c9eecef323 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 1e8e0e1a6d..626272f523 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 0b5414ea8e..7e63b3e02a 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 cfb521e02e..2947479c03 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); From da85170801f0e92aaccda2f7afbc2475dcd54b5d Mon Sep 17 00:00:00 2001 From: Loose Cannon Date: Wed, 25 Feb 2026 11:15:07 -0500 Subject: [PATCH 2/5] fix Quick Unlock QMap D-Bus marshalling error Register QMap as a D-Bus metatype so Polkit CheckAuthorization can marshal the details argument. Without this, Quick Unlock fails with "Unregistered type QMap passed in arguments". --- src/quickunlock/Polkit.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/quickunlock/Polkit.cpp b/src/quickunlock/Polkit.cpp index d73a7c71b0..aad3535876 100644 --- a/src/quickunlock/Polkit.cpp +++ b/src/quickunlock/Polkit.cpp @@ -48,6 +48,7 @@ Polkit::Polkit() { PolkitSubject::registerMetaType(); PolkitAuthorizationResults::registerMetaType(); + qDBusRegisterMetaType>(); /* Note we explicitly use our own dbus path here, as the ::systemBus() method could be overridden through an environment variable to return an alternative bus path. This bus could have an application From 15246fa2313c482bd829a8dcf03f9c9c007a04f1 Mon Sep 17 00:00:00 2001 From: Loose Cannon Date: Wed, 25 Feb 2026 13:44:49 -0500 Subject: [PATCH 3/5] address copilot review: split update flags, fix device ID handling, remove dead code - split m_inFileUpdate into separate file/dir flags to prevent dropped updates - check mkpath return value and report error on failure - add QRegularExpressionValidator and length cap (32) to device ID - clear device ID config when field emptied (allows auto-detect reset) - remove unused hasPerDeviceConfig/perDeviceSyncPath and KeeShare_PerDeviceSync - skip cycleImportExport warning in per-device mode (not a real conflict) - move tooltip from pathLabel to pathEdit input field - add testPerDeviceModeImportExport test coverage --- src/keeshare/KeeShare.cpp | 17 ++-------- src/keeshare/KeeShare.h | 3 -- src/keeshare/SettingsWidgetKeeShare.cpp | 7 ++++ src/keeshare/ShareObserver.cpp | 17 ++++++---- src/keeshare/ShareObserver.h | 1 + .../group/EditGroupWidgetKeeShare.cpp | 8 +++-- src/keeshare/group/EditGroupWidgetKeeShare.ui | 6 ++-- tests/TestSharing.cpp | 34 +++++++++++++++++++ tests/TestSharing.h | 2 ++ 9 files changed, 66 insertions(+), 29 deletions(-) diff --git a/src/keeshare/KeeShare.cpp b/src/keeshare/KeeShare.cpp index 3ce03ebebb..65016270c9 100644 --- a/src/keeshare/KeeShare.cpp +++ b/src/keeshare/KeeShare.cpp @@ -29,7 +29,6 @@ namespace { static const QString KeeShare_Reference("KeeShare/Reference"); - static const QString KeeShare_PerDeviceSync("KeeShare/PerDeviceSync"); } KeeShare* KeeShare::m_instance = nullptr; @@ -80,9 +79,10 @@ QString KeeShare::deviceId() void KeeShare::setDeviceId(const QString& id) { - // Sanitize to [A-Za-z0-9] only + // Sanitize to [A-Za-z0-9] only and enforce max length QString sanitized = id; sanitized.remove(QRegularExpression("[^A-Za-z0-9]")); + sanitized.truncate(32); config()->set(Config::KeeShare_DeviceId, sanitized); } @@ -145,19 +145,6 @@ 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 51f6f9859b..0a5e91af47 100644 --- a/src/keeshare/KeeShare.h +++ b/src/keeshare/KeeShare.h @@ -67,9 +67,6 @@ class KeeShare : public QObject 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/SettingsWidgetKeeShare.cpp b/src/keeshare/SettingsWidgetKeeShare.cpp index 472bf0a134..850864d766 100644 --- a/src/keeshare/SettingsWidgetKeeShare.cpp +++ b/src/keeshare/SettingsWidgetKeeShare.cpp @@ -23,6 +23,7 @@ #include "gui/MessageBox.h" #include "keeshare/KeeShare.h" +#include #include #include #include @@ -33,6 +34,9 @@ SettingsWidgetKeeShare::SettingsWidgetKeeShare(QWidget* parent) { m_ui->setupUi(this); + m_ui->deviceIdEdit->setValidator( + new QRegularExpressionValidator(QRegularExpression("[A-Za-z0-9]{0,32}"), this)); + connect(m_ui->ownCertificateSignerEdit, SIGNAL(textChanged(QString)), SLOT(setVerificationExporter(QString))); connect(m_ui->generateOwnCerticateButton, SIGNAL(clicked(bool)), SLOT(generateCertificate())); } @@ -73,6 +77,9 @@ void SettingsWidgetKeeShare::saveSettings() auto deviceId = m_ui->deviceIdEdit->text().trimmed(); if (!deviceId.isEmpty()) { KeeShare::setDeviceId(deviceId); + } else { + // Clear stored ID so it will be auto-detected next time + config()->set(Config::KeeShare_DeviceId, QString()); } config()->set(Config::KeeShare_QuietSuccess, m_ui->quietSuccessCheckBox->isChecked()); diff --git a/src/keeshare/ShareObserver.cpp b/src/keeshare/ShareObserver.cpp index 2b78c2c862..e391b91a48 100644 --- a/src/keeshare/ShareObserver.cpp +++ b/src/keeshare/ShareObserver.cpp @@ -270,16 +270,16 @@ void ShareObserver::handleDirectoryUpdated(const QString& dirPath) dirWatcher->addPath(dirPath); } - if (!m_inFileUpdate) { + if (!m_inDirUpdate) { QTimer::singleShot(100, this, [this, dirPath] { auto shareGroup = m_shareToGroup.value(dirPath); if (!shareGroup) { - m_inFileUpdate = false; + m_inDirUpdate = false; return; } auto shareRef = KeeShare::referenceOf(shareGroup); auto results = importPerDeviceShares(dirPath, shareRef, shareGroup); - m_inFileUpdate = false; + m_inDirUpdate = false; QStringList success; QStringList warning; @@ -300,7 +300,7 @@ void ShareObserver::handleDirectoryUpdated(const QString& dirPath) } notifyAbout(success, warning, error); }); - m_inFileUpdate = true; + m_inDirUpdate = true; } } @@ -406,7 +406,12 @@ QList ShareObserver::exportShares() // Per-device mode: export to {directory}/{DEVICE_ID}.kdbx QDir dir(resolvedPath); if (!dir.exists()) { - dir.mkpath("."); + if (!dir.mkpath(".")) { + results << Result{resolvedPath, + Result::Error, + tr("Could not create directory %1").arg(resolvedPath)}; + continue; + } } const auto deviceFile = dir.absoluteFilePath(KeeShare::deviceId() + ".kdbx"); @@ -418,7 +423,7 @@ QList ShareObserver::exportShares() results << ShareExport::intoContainer(deviceFile, reference.config, reference.group); - // Resume directory watcher + // Resume directory watcher (also add path if it was newly created) if (dirWatcher) { dirWatcher->addPath(resolvedPath); } diff --git a/src/keeshare/ShareObserver.h b/src/keeshare/ShareObserver.h index b888e7ff2e..ac1a2e6451 100644 --- a/src/keeshare/ShareObserver.h +++ b/src/keeshare/ShareObserver.h @@ -89,6 +89,7 @@ private slots: QMap> m_fileWatchers; QMap> m_dirWatchers; bool m_inFileUpdate = false; + bool m_inDirUpdate = false; bool m_enabled = true; }; diff --git a/src/keeshare/group/EditGroupWidgetKeeShare.cpp b/src/keeshare/group/EditGroupWidgetKeeShare.cpp index c9eecef323..62ab6c98f3 100644 --- a/src/keeshare/group/EditGroupWidgetKeeShare.cpp +++ b/src/keeshare/group/EditGroupWidgetKeeShare.cpp @@ -149,8 +149,12 @@ void EditGroupWidgetKeeShare::updateSharingState() } multipleImport |= other.isImporting() && reference.isImporting(); conflictExport |= other.isExporting() && reference.isExporting(); - cycleImportExport |= - (other.isImporting() && reference.isExporting()) || (other.isExporting() && reference.isImporting()); + // In per-device mode, import+export to the same directory is expected + // (export writes own device file, import reads other devices' files) + if (!reference.isPerDeviceMode()) { + cycleImportExport |= + (other.isImporting() && reference.isExporting()) || (other.isExporting() && reference.isImporting()); + } } if (conflictExport) { m_ui->messageWidget->showMessage(tr("%1 is already being exported by this database.").arg(reference.path), diff --git a/src/keeshare/group/EditGroupWidgetKeeShare.ui b/src/keeshare/group/EditGroupWidgetKeeShare.ui index 626272f523..582165efc4 100644 --- a/src/keeshare/group/EditGroupWidgetKeeShare.ui +++ b/src/keeshare/group/EditGroupWidgetKeeShare.ui @@ -100,9 +100,6 @@ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - File path for classic mode, or directory path for per-device sync - @@ -127,6 +124,9 @@ Path to share file field + + File path for classic mode, or directory path for per-device sync + diff --git a/tests/TestSharing.cpp b/tests/TestSharing.cpp index 7e63b3e02a..4034a9882a 100644 --- a/tests/TestSharing.cpp +++ b/tests/TestSharing.cpp @@ -206,6 +206,40 @@ void TestSharing::testPerDeviceMode_data() QTest::newRow("directory with dots") << "/some/path.d/sync" << true; } +void TestSharing::testPerDeviceModeImportExport() +{ + QFETCH(QString, path); + QFETCH(int, type); + QFETCH(bool, expectedImporting); + QFETCH(bool, expectedExporting); + + KeeShareSettings::Reference reference; + reference.path = path; + reference.type = static_cast(type); + + QCOMPARE(reference.isImporting(), expectedImporting); + QCOMPARE(reference.isExporting(), expectedExporting); +} + +void TestSharing::testPerDeviceModeImportExport_data() +{ + QTest::addColumn("path"); + QTest::addColumn("type"); + QTest::addColumn("expectedImporting"); + QTest::addColumn("expectedExporting"); + + QTest::newRow("per-device sync imports") + << "/some/dir" << int(KeeShareSettings::SynchronizeWith) << true << true; + QTest::newRow("per-device import only") + << "/some/dir" << int(KeeShareSettings::ImportFrom) << true << false; + QTest::newRow("per-device export only") + << "/some/dir" << int(KeeShareSettings::ExportTo) << false << true; + QTest::newRow("classic file sync") + << "/some/dir/share.kdbx" << int(KeeShareSettings::SynchronizeWith) << true << true; + QTest::newRow("inactive per-device") + << "/some/dir" << int(KeeShareSettings::Inactive) << false << false; +} + const QSharedPointer TestSharing::stubkey(int index) { static QMap> keys; diff --git a/tests/TestSharing.h b/tests/TestSharing.h index 2947479c03..1c5d5cc203 100644 --- a/tests/TestSharing.h +++ b/tests/TestSharing.h @@ -38,6 +38,8 @@ private slots: void testSettingsSerialization_data(); void testPerDeviceMode(); void testPerDeviceMode_data(); + void testPerDeviceModeImportExport(); + void testPerDeviceModeImportExport_data(); private: const QSharedPointer stubkey(int index = 0); From 870bd4af38c2b5f4e250cb93def6312af5af8a3b Mon Sep 17 00:00:00 2001 From: Loose Cannon Date: Mon, 2 Mar 2026 12:19:56 -0500 Subject: [PATCH 4/5] consolidate device ID sanitization and strengthen isPerDeviceMode check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move all sanitization (strip non-alphanumeric, truncate to 32, fallback to DEFAULT) into setDeviceId() so it's consistent regardless of call site. deviceId() now delegates to setDeviceId() and re-reads the sanitized value. isPerDeviceMode() now uses QFileInfo::isDir() as a positive filesystem check — no extension-based heuristics. Tests updated to create real temp directories instead of using synthetic paths. --- src/keeshare/KeeShare.cpp | 13 ++++----- src/keeshare/KeeShareSettings.cpp | 5 ++-- tests/TestSharing.cpp | 45 +++++++++++++++++++++++-------- tests/TestSharing.h | 3 +++ 4 files changed, 46 insertions(+), 20 deletions(-) diff --git a/src/keeshare/KeeShare.cpp b/src/keeshare/KeeShare.cpp index 65016270c9..9dab173dc6 100644 --- a/src/keeshare/KeeShare.cpp +++ b/src/keeshare/KeeShare.cpp @@ -67,22 +67,23 @@ QString KeeShare::deviceId() // 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); + // Re-read the sanitized value + id = config()->get(Config::KeeShare_DeviceId).toString(); } return id; } void KeeShare::setDeviceId(const QString& id) { - // Sanitize to [A-Za-z0-9] only and enforce max length + // All sanitization consolidated here: strip non-alphanumeric, enforce max + // length, and fall back to DEFAULT if empty QString sanitized = id; sanitized.remove(QRegularExpression("[^A-Za-z0-9]")); sanitized.truncate(32); + if (sanitized.isEmpty()) { + sanitized = QStringLiteral("DEFAULT"); + } config()->set(Config::KeeShare_DeviceId, sanitized); } diff --git a/src/keeshare/KeeShareSettings.cpp b/src/keeshare/KeeShareSettings.cpp index 5eea29112d..33425a85ff 100644 --- a/src/keeshare/KeeShareSettings.cpp +++ b/src/keeshare/KeeShareSettings.cpp @@ -25,6 +25,7 @@ #include "gui/DatabaseIcons.h" #include +#include #include #include @@ -288,9 +289,7 @@ namespace KeeShareSettings bool Reference::isPerDeviceMode() const { - return !path.isEmpty() - && !path.endsWith(".kdbx", Qt::CaseInsensitive) - && !path.endsWith(".kdbx.share", Qt::CaseInsensitive); + return !path.isEmpty() && QFileInfo(path).isDir(); } bool Reference::operator<(const Reference& other) const diff --git a/tests/TestSharing.cpp b/tests/TestSharing.cpp index 4034a9882a..c89035c77c 100644 --- a/tests/TestSharing.cpp +++ b/tests/TestSharing.cpp @@ -17,6 +17,7 @@ #include "TestSharing.h" +#include #include #include @@ -35,6 +36,14 @@ Q_DECLARE_METATYPE(KeeShareSettings::Certificate) void TestSharing::initTestCase() { QVERIFY(Crypto::init()); + m_tempDir = new QTemporaryDir(); + QVERIFY(m_tempDir->isValid()); +} + +void TestSharing::cleanupTestCase() +{ + delete m_tempDir; + m_tempDir = nullptr; } void TestSharing::testNullObjects() @@ -192,18 +201,28 @@ void TestSharing::testPerDeviceMode_data() QTest::addColumn("path"); QTest::addColumn("expectedPerDevice"); - // Classic mode paths (file-based) + // Classic mode paths (file-based — don't need to exist on disk) 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; + QTest::newRow("nonexistent path") << "/nonexistent/path/nowhere" << false; + + // Per-device mode paths (real directories on disk) + auto base = m_tempDir->path(); - // 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; + QDir(base).mkpath("syncdir"); + QTest::newRow("directory path") << base + "/syncdir" << true; + + QDir(base).mkpath("syncdir_slash"); + QTest::newRow("directory trailing slash") << base + "/syncdir_slash/" << true; + + QDir(base).mkpath("sub/shared"); + QTest::newRow("nested directory") << base + "/sub/shared" << true; + + QDir(base).mkpath("path.d/sync"); + QTest::newRow("directory with dots") << base + "/path.d/sync" << true; } void TestSharing::testPerDeviceModeImportExport() @@ -228,16 +247,20 @@ void TestSharing::testPerDeviceModeImportExport_data() QTest::addColumn("expectedImporting"); QTest::addColumn("expectedExporting"); + auto base = m_tempDir->path(); + QDir(base).mkpath("importexport"); + auto dir = base + "/importexport"; + QTest::newRow("per-device sync imports") - << "/some/dir" << int(KeeShareSettings::SynchronizeWith) << true << true; + << dir << int(KeeShareSettings::SynchronizeWith) << true << true; QTest::newRow("per-device import only") - << "/some/dir" << int(KeeShareSettings::ImportFrom) << true << false; + << dir << int(KeeShareSettings::ImportFrom) << true << false; QTest::newRow("per-device export only") - << "/some/dir" << int(KeeShareSettings::ExportTo) << false << true; + << dir << int(KeeShareSettings::ExportTo) << false << true; QTest::newRow("classic file sync") - << "/some/dir/share.kdbx" << int(KeeShareSettings::SynchronizeWith) << true << true; + << dir + "/share.kdbx" << int(KeeShareSettings::SynchronizeWith) << true << true; QTest::newRow("inactive per-device") - << "/some/dir" << int(KeeShareSettings::Inactive) << false << false; + << dir << int(KeeShareSettings::Inactive) << false << false; } const QSharedPointer TestSharing::stubkey(int index) diff --git a/tests/TestSharing.h b/tests/TestSharing.h index 1c5d5cc203..7d2c994a3e 100644 --- a/tests/TestSharing.h +++ b/tests/TestSharing.h @@ -19,6 +19,7 @@ #define KEEPASSXC_TESTSHARING_H #include +#include namespace Botan { @@ -30,6 +31,7 @@ class TestSharing : public QObject private slots: void initTestCase(); + void cleanupTestCase(); void testNullObjects(); void testKeySerialization(); void testReferenceSerialization(); @@ -43,6 +45,7 @@ private slots: private: const QSharedPointer stubkey(int index = 0); + QTemporaryDir* m_tempDir = nullptr; }; #endif // KEEPASSXC_TESTSHARING_H From 737af8798f4693ee3bd461c1b7faa212f3b74e8b Mon Sep 17 00:00:00 2001 From: Loose Cannon Date: Mon, 2 Mar 2026 15:52:59 -0500 Subject: [PATCH 5/5] isPerDeviceMode takes baseDir context, fix import result path Change isPerDeviceMode(const QString&) to isPerDeviceMode(const QDir& baseDir) so the method resolves this->path against the caller-provided base directory. This fixes silent misclassification when reference paths are relative, since QFileInfo previously resolved against process CWD instead of the database directory. Override Result.path with the actual device file path in importPerDeviceShares so status messages show which specific container failed, not just the sync directory. Add regression tests for relative directory and file paths. --- src/keeshare/KeeShareSettings.cpp | 9 +++-- src/keeshare/KeeShareSettings.h | 4 ++- src/keeshare/ShareObserver.cpp | 19 ++++++---- .../group/EditGroupWidgetKeeShare.cpp | 6 ++-- tests/TestSharing.cpp | 35 +++++++++++-------- 5 files changed, 48 insertions(+), 25 deletions(-) diff --git a/src/keeshare/KeeShareSettings.cpp b/src/keeshare/KeeShareSettings.cpp index 33425a85ff..558601ee58 100644 --- a/src/keeshare/KeeShareSettings.cpp +++ b/src/keeshare/KeeShareSettings.cpp @@ -25,6 +25,7 @@ #include "gui/DatabaseIcons.h" #include +#include #include #include #include @@ -287,9 +288,13 @@ namespace KeeShareSettings return (type & ImportFrom) != 0 && !path.isEmpty(); } - bool Reference::isPerDeviceMode() const + bool Reference::isPerDeviceMode(const QDir& baseDir) const { - return !path.isEmpty() && QFileInfo(path).isDir(); + if (path.isEmpty()) { + return false; + } + const QString resolvedPath = baseDir.absoluteFilePath(path); + return QFileInfo(resolvedPath).isDir(); } bool Reference::operator<(const Reference& other) const diff --git a/src/keeshare/KeeShareSettings.h b/src/keeshare/KeeShareSettings.h index dd786a3150..1a72118ec6 100644 --- a/src/keeshare/KeeShareSettings.h +++ b/src/keeshare/KeeShareSettings.h @@ -21,6 +21,8 @@ #include #include +class QDir; + namespace Botan { class Private_Key; @@ -133,7 +135,7 @@ namespace KeeShareSettings bool isValid() const; bool isExporting() const; bool isImporting() const; - bool isPerDeviceMode() const; + bool isPerDeviceMode(const QDir& baseDir) const; bool operator<(const Reference& other) const; bool operator==(const Reference& other) const; diff --git a/src/keeshare/ShareObserver.cpp b/src/keeshare/ShareObserver.cpp index e391b91a48..e80c3d813b 100644 --- a/src/keeshare/ShareObserver.cpp +++ b/src/keeshare/ShareObserver.cpp @@ -102,6 +102,8 @@ void ShareObserver::reinitialize() QMap imported; QMap exported; + const QDir baseDir = QFileInfo(m_db->filePath()).absoluteDir(); + for (const auto& share : shares) { auto group = share.first; auto& reference = share.second; @@ -113,7 +115,7 @@ void ShareObserver::reinitialize() if (!reference.path.isEmpty() && reference.type != KeeShareSettings::Inactive) { const auto newResolvedPath = resolvePath(reference.path, m_db); - if (reference.isPerDeviceMode()) { + if (reference.isPerDeviceMode(baseDir)) { // Per-device mode: watch the directory for changes auto dirWatcher = QSharedPointer::create(); if (QDir(newResolvedPath).exists()) { @@ -137,10 +139,10 @@ void ShareObserver::reinitialize() if (reference.isImporting()) { imported[reference.path] << group->name(); + const auto resolvedDir = resolvePath(reference.path, m_db); - if (reference.isPerDeviceMode()) { + if (reference.isPerDeviceMode(baseDir)) { // 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()) { @@ -260,7 +262,8 @@ void ShareObserver::handleDirectoryUpdated(const QString& dirPath) return; } auto reference = KeeShare::referenceOf(group); - if (!reference.isImporting() || !reference.isPerDeviceMode()) { + const QDir handleBaseDir = QFileInfo(m_db->filePath()).absoluteDir(); + if (!reference.isImporting() || !reference.isPerDeviceMode(handleBaseDir)) { return; } @@ -326,7 +329,9 @@ QList ShareObserver::importPerDeviceShares( continue; // Skip own device's file } const auto filePath = dir.absoluteFilePath(fileName); - results << ShareImport::containerInto(filePath, reference, targetGroup); + auto result = ShareImport::containerInto(filePath, reference, targetGroup); + result.path = filePath; + results << result; } return results; } @@ -398,11 +403,13 @@ QList ShareObserver::exportShares() return results; } + const QDir exportBaseDir = QFileInfo(m_db->filePath()).absoluteDir(); + for (auto it = references.cbegin(); it != references.cend(); ++it) { auto reference = it.value().first(); const QString resolvedPath = resolvePath(reference.config.path, m_db); - if (reference.config.isPerDeviceMode()) { + if (reference.config.isPerDeviceMode(exportBaseDir)) { // Per-device mode: export to {directory}/{DEVICE_ID}.kdbx QDir dir(resolvedPath); if (!dir.exists()) { diff --git a/src/keeshare/group/EditGroupWidgetKeeShare.cpp b/src/keeshare/group/EditGroupWidgetKeeShare.cpp index 62ab6c98f3..2a0dc7cda5 100644 --- a/src/keeshare/group/EditGroupWidgetKeeShare.cpp +++ b/src/keeshare/group/EditGroupWidgetKeeShare.cpp @@ -24,6 +24,7 @@ #include "keeshare/KeeShare.h" #include +#include #include EditGroupWidgetKeeShare::EditGroupWidgetKeeShare(QWidget* parent) @@ -110,8 +111,9 @@ void EditGroupWidgetKeeShare::updateSharingState() // Custom message for active KeeShare reference const auto reference = KeeShare::referenceOf(m_temporaryGroup); + const QDir uiBaseDir = QFileInfo(m_database->filePath()).absoluteDir(); if (!reference.path.isEmpty()) { - if (reference.isPerDeviceMode()) { + if (reference.isPerDeviceMode(uiBaseDir)) { // 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" @@ -151,7 +153,7 @@ void EditGroupWidgetKeeShare::updateSharingState() conflictExport |= other.isExporting() && reference.isExporting(); // In per-device mode, import+export to the same directory is expected // (export writes own device file, import reads other devices' files) - if (!reference.isPerDeviceMode()) { + if (!reference.isPerDeviceMode(uiBaseDir)) { cycleImportExport |= (other.isImporting() && reference.isExporting()) || (other.isExporting() && reference.isImporting()); } diff --git a/tests/TestSharing.cpp b/tests/TestSharing.cpp index c89035c77c..27884703fc 100644 --- a/tests/TestSharing.cpp +++ b/tests/TestSharing.cpp @@ -187,42 +187,49 @@ void TestSharing::testSettingsSerialization_data() void TestSharing::testPerDeviceMode() { QFETCH(QString, path); + QFETCH(QString, baseDir); QFETCH(bool, expectedPerDevice); KeeShareSettings::Reference reference; reference.path = path; reference.type = KeeShareSettings::SynchronizeWith; - QCOMPARE(reference.isPerDeviceMode(), expectedPerDevice); + QCOMPARE(reference.isPerDeviceMode(QDir(baseDir)), expectedPerDevice); } void TestSharing::testPerDeviceMode_data() { QTest::addColumn("path"); + QTest::addColumn("baseDir"); QTest::addColumn("expectedPerDevice"); - // Classic mode paths (file-based — don't need to exist on disk) - 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; - QTest::newRow("nonexistent path") << "/nonexistent/path/nowhere" << false; - - // Per-device mode paths (real directories on disk) auto base = m_tempDir->path(); + // Classic mode paths (file-based — don't need to exist on disk) + QTest::newRow("kdbx file") << "/some/path/share.kdbx" << base << false; + QTest::newRow("kdbx.share file") << "/some/path/share.kdbx.share" << base << false; + QTest::newRow("KDBX uppercase") << "/some/path/share.KDBX" << base << false; + QTest::newRow("KDBX.SHARE uppercase") << "/some/path/share.KDBX.SHARE" << base << false; + QTest::newRow("empty path") << "" << base << false; + QTest::newRow("nonexistent path") << "/nonexistent/path/nowhere" << base << false; + + // Per-device mode paths (real directories on disk — absolute) QDir(base).mkpath("syncdir"); - QTest::newRow("directory path") << base + "/syncdir" << true; + QTest::newRow("directory path") << base + "/syncdir" << base << true; QDir(base).mkpath("syncdir_slash"); - QTest::newRow("directory trailing slash") << base + "/syncdir_slash/" << true; + QTest::newRow("directory trailing slash") << base + "/syncdir_slash/" << base << true; QDir(base).mkpath("sub/shared"); - QTest::newRow("nested directory") << base + "/sub/shared" << true; + QTest::newRow("nested directory") << base + "/sub/shared" << base << true; QDir(base).mkpath("path.d/sync"); - QTest::newRow("directory with dots") << base + "/path.d/sync" << true; + QTest::newRow("directory with dots") << base + "/path.d/sync" << base << true; + + // Per-device mode with relative path (regression test: must resolve against baseDir) + QDir(base).mkpath("reldir"); + QTest::newRow("relative directory") << "reldir" << base << true; + QTest::newRow("relative file") << "share.kdbx" << base << false; } void TestSharing::testPerDeviceModeImportExport()