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 <noreply@anthropic.com>
This commit is contained in:
Loose Cannon 2026-02-22 08:41:14 -05:00
parent 58aee6f239
commit 8e561a93a5
14 changed files with 357 additions and 37 deletions

View file

@ -202,6 +202,7 @@ static const QHash<Config::ConfigKey, ConfigDirective> 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}},

View file

@ -179,6 +179,7 @@ public:
KeeShare_Own,
KeeShare_Foreign,
KeeShare_Active,
KeeShare_DeviceId,
PasswordGenerator_LowerCase,
PasswordGenerator_UpperCase,

View file

@ -23,9 +23,13 @@
#include "gui/DatabaseIcons.h"
#include "keeshare/ShareObserver.h"
#include <QRegularExpression>
#include <QSysInfo>
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);

View file

@ -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<Database> newDb, QSharedPointer<Database> oldDb);
bool setSharingEnabled(QSharedPointer<Database> db, bool enabled);

View file

@ -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) {

View file

@ -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;

View file

@ -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());
}

View file

@ -68,6 +68,48 @@
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="deviceIdentityGroupBox">
<property name="title">
<string>Device Identity</string>
</property>
<layout class="QGridLayout" name="gridLayout_3">
<item row="0" column="0">
<widget class="QLabel" name="deviceIdLabel">
<property name="text">
<string>Device ID:</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLineEdit" name="deviceIdEdit">
<property name="accessibleName">
<string>Device ID field</string>
</property>
<property name="toolTip">
<string>Unique identifier for this device in per-device sync mode (alphanumeric only)</string>
</property>
<property name="placeholderText">
<string>Auto-detected from system</string>
</property>
</widget>
</item>
<item row="0" column="2">
<spacer name="deviceIdSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="ownCertificateGroupBox">
<property name="title">

View file

@ -23,6 +23,7 @@
#include "keeshare/ShareImport.h"
#include <QDir>
#include <QTimer>
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<FileWatcher>::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<QFileSystemWatcher>::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<FileWatcher>::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::Result> ShareObserver::importPerDeviceShares(
const QString& resolvedDir,
const KeeShareSettings::Reference& reference,
Group* targetGroup)
{
QList<Result> 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::Result> 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;

View file

@ -18,6 +18,7 @@
#ifndef KEEPASSXC_SHAREOBSERVER_H
#define KEEPASSXC_SHAREOBSERVER_H
#include <QFileSystemWatcher>
#include <QMap>
#include <QObject>
@ -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<Result> exportShares();
QList<Result> importPerDeviceShares(const QString& resolvedDir,
const KeeShareSettings::Reference& reference,
Group* targetGroup);
void deinitialize();
void reinitialize();
@ -82,6 +87,7 @@ private:
QMap<QPointer<Group>, KeeShareSettings::Reference> m_groupToReference;
QMap<QString, QPointer<Group>> m_shareToGroup;
QMap<QString, QSharedPointer<FileWatcher>> m_fileWatchers;
QMap<QString, QSharedPointer<QFileSystemWatcher>> m_dirWatchers;
bool m_inFileUpdate = false;
bool m_enabled = true;
};

View file

@ -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);

View file

@ -100,6 +100,9 @@
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
<property name="toolTip">
<string>File path for classic mode, or directory path for per-device sync</string>
</property>
</widget>
</item>
<item row="1" column="1">

View file

@ -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<QString>("path");
QTest::addColumn<bool>("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<Botan::RSA_PrivateKey> TestSharing::stubkey(int index)
{
static QMap<int, QSharedPointer<Botan::RSA_PrivateKey>> keys;

View file

@ -36,6 +36,8 @@ private slots:
void testReferenceSerialization_data();
void testSettingsSerialization();
void testSettingsSerialization_data();
void testPerDeviceMode();
void testPerDeviceMode_data();
private:
const QSharedPointer<Botan::RSA_PrivateKey> stubkey(int index = 0);