diff --git a/CMakeLists.txt b/CMakeLists.txt
index e7183f1691..640108cbff 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -446,6 +446,7 @@ elseif(APPLE AND WITH_APP_BUNDLE)
set(PROXY_INSTALL_DIR "${BUNDLE_INSTALL_DIR}/MacOS")
set(BIN_INSTALL_DIR "${BUNDLE_INSTALL_DIR}/MacOS")
set(PLUGIN_INSTALL_DIR "${BUNDLE_INSTALL_DIR}/PlugIns")
+ set(XPC_INSTALL_DIR "${BUNDLE_INSTALL_DIR}/XPCServices")
set(DATA_INSTALL_DIR "${BUNDLE_INSTALL_DIR}/Resources")
else()
include(GNUInstallDirs)
diff --git a/share/macosx/keepassxc.entitlements b/share/macosx/keepassxc.entitlements
index 7126b7ac5b..37efc7a585 100644
--- a/share/macosx/keepassxc.entitlements
+++ b/share/macosx/keepassxc.entitlements
@@ -8,5 +8,7 @@
G2S7P7J672.org.keepassxc.keepassxc
+ com.apple.developer.authentication-services.autofill-credential-provider
+
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 3aa82dc305..0e36ad8675 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -227,10 +227,15 @@ if(APPLE)
gui/osutils/macutils/ScreenLockListenerMac.cpp
gui/osutils/macutils/AppKitImpl.mm
gui/osutils/macutils/AppKit.h
- quickunlock/TouchID.mm)
+ quickunlock/TouchID.mm
+ autofill/AutoFill.h
+ autofill/AutoFill.mm
+ autofill/AutoFillProviderProtocol.h
+ autofill/AutoFillXPCProtocol.h)
# TODO: Remove -Wno-error once deprecation warnings have been resolved.
set_source_files_properties(quickunlock/TouchID.mm PROPERTY COMPILE_FLAGS "-Wno-old-style-cast")
+ set_source_files_properties(autofill/AutoFill.mm PROPERTY COMPILE_FLAGS "-fobjc-arc")
endif()
if(UNIX AND NOT APPLE)
@@ -398,7 +403,7 @@ target_link_libraries(keepassxc_gui
${sshagent_LIB})
if(APPLE)
- target_link_libraries(keepassxc_gui "-framework Foundation -framework AppKit -framework Carbon -framework Security -framework LocalAuthentication -framework ScreenCaptureKit")
+ target_link_libraries(keepassxc_gui "-framework Foundation -framework AppKit -framework Carbon -framework Security -framework LocalAuthentication -framework ScreenCaptureKit -framework AuthenticationServices")
if(Qt5MacExtras_FOUND)
target_link_libraries(keepassxc_gui Qt5::MacExtras)
endif()
@@ -591,3 +596,132 @@ endif()
# The install commands in this subdirectory will be executed after all the install commands in the
# current scope are ran. It is required for correct functioning of macdeployqt.
add_subdirectory(post_install)
+
+if(APPLE AND WITH_APP_BUNDLE)
+ set(AUTOFILL_TARGET "keepassxc-autofill")
+ set(AUTOFILL_XPC_TARGET "keepassxc-autofill-xpc")
+ set(MACOSX_EXTENSION_BUNDLE_ID "org.keepassxc.keepassxc.autofill")
+ set(MACOSX_XPC_BUNDLE_ID "org.keepassxc.KeePassXC.AutoFill-XPC-Service")
+
+ # Configure extension Info.plist
+ configure_file(
+ "${CMAKE_CURRENT_SOURCE_DIR}/autofill/mac/Info.plist.in"
+ "${CMAKE_CURRENT_BINARY_DIR}/autofill/mac/Info.plist"
+ @ONLY
+ )
+
+ # Configure extension entitlements
+ configure_file(
+ "${CMAKE_CURRENT_SOURCE_DIR}/autofill/mac/MacAutoFill.entitlements"
+ "${CMAKE_CURRENT_BINARY_DIR}/autofill/mac/MacAutoFill.entitlements"
+ @ONLY
+ )
+
+ # Configure XPC service Info.plist
+ configure_file(
+ "${CMAKE_CURRENT_SOURCE_DIR}/autofill/AutoFillXPCService-Info.plist"
+ "${CMAKE_CURRENT_BINARY_DIR}/autofill/AutoFillXPCService-Info.plist"
+ @ONLY
+ )
+
+ # ===================
+ # AutoFill Extension
+ # ===================
+ add_library(${AUTOFILL_TARGET} MODULE
+ autofill/AutoFillProviderProtocol.h
+ autofill/AutoFillXPCProtocol.h
+ autofill/CredentialProviderViewController.mm
+ )
+
+ # Disable Qt AUTOMOC for pure Objective-C++ extension
+ set_target_properties(${AUTOFILL_TARGET} PROPERTIES
+ AUTOMOC OFF
+ AUTOUIC OFF
+ AUTORCC OFF
+ )
+
+ set_target_properties(${AUTOFILL_TARGET} PROPERTIES
+ BUNDLE TRUE
+ BUNDLE_EXTENSION "appex"
+ MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_BINARY_DIR}/autofill/mac/Info.plist"
+ MACOSX_BUNDLE_GUI_IDENTIFIER "${MACOSX_EXTENSION_BUNDLE_ID}"
+ MACOSX_BUNDLE_BUNDLE_NAME "KeePassXC AutoFill"
+ XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS "${CMAKE_CURRENT_BINARY_DIR}/autofill/mac/MacAutoFill.entitlements"
+ XCODE_ATTRIBUTE_SKIP_INSTALL "YES"
+ XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "${MACOSX_EXTENSION_BUNDLE_ID}"
+ XCODE_ATTRIBUTE_CLANG_ENABLE_OBJC_ARC "YES"
+ XCODE_ATTRIBUTE_WRAPPER_EXTENSION "appex"
+ )
+
+ target_compile_options(${AUTOFILL_TARGET} PRIVATE "-fobjc-arc")
+
+ target_link_libraries(${AUTOFILL_TARGET}
+ "-framework AuthenticationServices"
+ "-framework AppKit"
+ "-framework Foundation"
+ )
+
+ # ===================
+ # XPC Service
+ # ===================
+ add_executable(${AUTOFILL_XPC_TARGET}
+ autofill/AutoFillXPCService.m
+ autofill/AutoFillProviderProtocol.h
+ autofill/AutoFillXPCProtocol.h
+ )
+
+ # Disable Qt AUTOMOC for pure Objective-C XPC service
+ set_target_properties(${AUTOFILL_XPC_TARGET} PROPERTIES
+ AUTOMOC OFF
+ AUTOUIC OFF
+ AUTORCC OFF
+ )
+
+ set_target_properties(${AUTOFILL_XPC_TARGET} PROPERTIES
+ MACOSX_BUNDLE TRUE
+ BUNDLE_EXTENSION "xpc"
+ MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_BINARY_DIR}/autofill/AutoFillXPCService-Info.plist"
+ MACOSX_BUNDLE_GUI_IDENTIFIER "${MACOSX_XPC_BUNDLE_ID}"
+ MACOSX_BUNDLE_BUNDLE_NAME "KeePassXC AutoFill XPC Service"
+ XCODE_ATTRIBUTE_SKIP_INSTALL "YES"
+ XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "${MACOSX_XPC_BUNDLE_ID}"
+ XCODE_ATTRIBUTE_CLANG_ENABLE_OBJC_ARC "YES"
+ )
+
+ target_compile_options(${AUTOFILL_XPC_TARGET} PRIVATE "-fobjc-arc")
+
+ target_link_libraries(${AUTOFILL_XPC_TARGET}
+ "-framework Foundation"
+ )
+
+ # ===================
+ # Embed into main app
+ # ===================
+ add_dependencies(${PROGNAME} ${AUTOFILL_TARGET} ${AUTOFILL_XPC_TARGET})
+
+ # Embed extension via Xcode's native embedding (CMake 3.21+)
+ if(${CMAKE_VERSION} VERSION_GREATER_EQUAL "3.21.0")
+ set_target_properties(${PROGNAME} PROPERTIES
+ XCODE_EMBED_APP_EXTENSIONS ${AUTOFILL_TARGET}
+ )
+ else()
+ # Fallback: copy extension into PlugIns directory manually
+ add_custom_command(TARGET ${PROGNAME} POST_BUILD
+ COMMAND ${CMAKE_COMMAND} -E make_directory "$/PlugIns"
+ COMMAND ${CMAKE_COMMAND} -E copy_directory
+ "$"
+ "$/PlugIns/${AUTOFILL_TARGET}.appex"
+ COMMENT "Embedding AutoFill extension into app bundle"
+ )
+ endif()
+
+ # Copy XPC service into XPCServices directory
+ add_custom_command(TARGET ${PROGNAME} POST_BUILD
+ COMMAND ${CMAKE_COMMAND} -E make_directory "$/XPCServices"
+ COMMAND ${CMAKE_COMMAND} -E copy_directory
+ "$"
+ "$/XPCServices/${AUTOFILL_XPC_TARGET}.xpc"
+ COMMENT "Embedding AutoFill XPC service into app bundle"
+ )
+
+endif()
diff --git a/src/autofill/AutoFill.h b/src/autofill/AutoFill.h
new file mode 100644
index 0000000000..ccf665211e
--- /dev/null
+++ b/src/autofill/AutoFill.h
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2024 KeePassXC Team
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 or (at your option)
+ * version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#ifndef KEEPASSX_AUTOFILL_H
+#define KEEPASSX_AUTOFILL_H
+
+#include
+
+class AutoFillPrivate;
+
+class AutoFill : public QObject
+{
+ Q_OBJECT
+
+public:
+ explicit AutoFill(QObject* parent = nullptr);
+ ~AutoFill();
+
+ bool isAvailable() const;
+
+public Q_SLOTS:
+ void start();
+ void stop();
+
+private:
+ Q_DISABLE_COPY(AutoFill)
+
+ AutoFillPrivate* d_ptr;
+};
+
+#endif // KEEPASSX_AUTOFILL_H
diff --git a/src/autofill/AutoFill.mm b/src/autofill/AutoFill.mm
new file mode 100644
index 0000000000..7d89bcb550
--- /dev/null
+++ b/src/autofill/AutoFill.mm
@@ -0,0 +1,759 @@
+/*
+ * Copyright (C) 2024 KeePassXC Team
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 or (at your option)
+ * version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include "AutoFill.h"
+#include "AutoFillUtils.h"
+
+#include "core/Database.h"
+#include "core/Entry.h"
+#include "core/Group.h"
+#include "core/Metadata.h"
+#include "core/Tools.h"
+#include "gui/DatabaseWidget.h"
+#include "gui/MainWindow.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#ifdef Q_OS_MACOS
+
+#import
+#import
+#import
+
+#import "AutoFillProviderProtocol.h"
+#import "AutoFillXPCProtocol.h"
+
+Q_LOGGING_CATEGORY(lcAutoFill, "keepassxc.autofill")
+
+@class AutoFillHostAdapter;
+
+class AutoFillPrivate : public QObject
+{
+public:
+ struct CredentialRecord
+ {
+ QString recordIdentifier;
+ QString domain;
+ QString username;
+ QString password;
+ QString title;
+ QString url;
+ QString otp;
+
+ bool isValid() const
+ {
+ return !recordIdentifier.isEmpty() && !domain.isEmpty() && !password.isEmpty();
+ }
+ };
+
+ explicit AutoFillPrivate(AutoFill* parent);
+ ~AutoFillPrivate() override;
+
+ bool isAvailable() const
+ {
+ return m_available;
+ }
+
+ void start();
+ void stop();
+
+ void fetchCredentialsMatchingDomain(const QString& domain, void (^reply)(NSArray*>*));
+ void fetchCredentialWithRecordIdentifier(const QString& recordId, void (^reply)(NSDictionary*));
+
+private:
+ void connectSignals();
+ void watchExistingDatabases();
+ void watchDatabase(DatabaseWidget* widget);
+ void scheduleIdentityRefresh();
+ void refreshIdentityStore();
+ void clearIdentityStore();
+ void ensureListener();
+ void connectToServiceIfNeeded();
+ void handleServiceInvalidation();
+
+ QVector collectCredentialsForDomain(const QString& domain) const;
+ QVector collectAllCredentialRecords() const;
+ CredentialRecord buildRecord(const QSharedPointer& database,
+ Entry* entry,
+ const QString& domain,
+ const QString& sourceUrl) const;
+ QStringList entryDomains(Entry* entry) const;
+ QString recordIdentifierFor(const QSharedPointer& database, Entry* entry) const;
+ Entry* entryForRecordIdentifier(const QString& recordId, QSharedPointer& database) const;
+ DatabaseWidget* databaseWidgetForUuid(const QUuid& uuid) const;
+ NSArray*>* serializeCredentialList(const QVector& records) const;
+ NSDictionary* serializeCredential(const CredentialRecord& record) const;
+
+ bool m_available{false};
+ bool m_running{false};
+ bool m_signalsConnected{false};
+ bool m_serviceRegistered{false};
+ QSet m_watchedDatabases;
+ QTimer* m_identityRefreshTimer{nullptr};
+ NSXPCConnection* m_serviceConnection{nil};
+ NSXPCListener* m_listener{nil};
+ AutoFillHostAdapter* m_hostAdapter{nil};
+};
+
+@interface AutoFillHostAdapter : NSObject
+@property(nonatomic, assign) AutoFillPrivate* owner;
+@end
+
+@implementation AutoFillHostAdapter
+
+- (BOOL)listener:(NSXPCListener*)listener shouldAcceptNewConnection:(NSXPCConnection*)connection
+{
+ Q_UNUSED(listener);
+ connection.exportedInterface = [NSXPCInterface interfaceWithProtocol:@protocol(AutoFillProviderProtocol)];
+ connection.exportedObject = self;
+ [connection resume];
+ return YES;
+}
+
+- (void)fetchCredentialsMatchingDomain:(NSString*)domain
+ withReply:(void (^)(NSArray*>*))reply
+{
+ if (!reply) {
+ return;
+ }
+ void (^replyCopy)(NSArray*>*) = [reply copy];
+ QString host = QString::fromNSString(domain);
+ AutoFillPrivate* owner = self.owner;
+ if (!owner) {
+ replyCopy(@[]);
+ return;
+ }
+ owner->fetchCredentialsMatchingDomain(host, replyCopy);
+}
+
+- (void)fetchCredentialWithRecordIdentifier:(NSString*)recordIdentifier
+ withReply:(void (^)(NSDictionary*))reply
+{
+ if (!reply) {
+ return;
+ }
+ void (^replyCopy)(NSDictionary*) = [reply copy];
+ QString identifier = QString::fromNSString(recordIdentifier);
+ AutoFillPrivate* owner = self.owner;
+ if (!owner) {
+ replyCopy(@{});
+ return;
+ }
+ owner->fetchCredentialWithRecordIdentifier(identifier, replyCopy);
+}
+
+@end
+
+AutoFillPrivate::AutoFillPrivate(AutoFill* parent)
+ : QObject(parent)
+{
+ if (@available(macOS 12.0, *)) {
+ m_available = true;
+ }
+
+ m_identityRefreshTimer = new QTimer(this);
+ m_identityRefreshTimer->setSingleShot(true);
+ m_identityRefreshTimer->setInterval(300);
+ connect(m_identityRefreshTimer, &QTimer::timeout, this, [this]() { refreshIdentityStore(); });
+}
+
+AutoFillPrivate::~AutoFillPrivate()
+{
+ stop();
+}
+
+void AutoFillPrivate::start()
+{
+ qCDebug(lcAutoFill) << "Starting AutoFill service.";
+ if (!m_available || m_running) {
+ return;
+ }
+
+ m_running = true;
+ connectSignals();
+ watchExistingDatabases();
+ ensureListener();
+ connectToServiceIfNeeded();
+ scheduleIdentityRefresh();
+}
+
+void AutoFillPrivate::stop()
+{
+ qCDebug(lcAutoFill) << "Stopping AutoFill service.";
+ if (!m_running) {
+ return;
+ }
+
+ m_running = false;
+ m_watchedDatabases.clear();
+ m_serviceRegistered = false;
+
+ if (m_identityRefreshTimer) {
+ m_identityRefreshTimer->stop();
+ }
+
+ if (m_serviceConnection) {
+ [m_serviceConnection invalidate];
+ m_serviceConnection.invalidationHandler = nil;
+ m_serviceConnection = nil;
+ }
+
+ if (m_listener) {
+ [m_listener invalidate];
+ m_listener = nil;
+ }
+
+ if (m_hostAdapter) {
+ m_hostAdapter.owner = nullptr;
+ }
+ m_hostAdapter = nil;
+ clearIdentityStore();
+}
+
+void AutoFillPrivate::fetchCredentialsMatchingDomain(const QString& domain,
+ void (^reply)(NSArray*>*))
+{
+ if (!reply) {
+ return;
+ }
+
+ void (^replyCopy)(NSArray*>*) = [reply copy];
+ QPointer guard(this);
+ QMetaObject::invokeMethod(this,
+ [guard, replyCopy, domain]() {
+ if (!guard) {
+ replyCopy(@[]);
+ return;
+ }
+ auto matches = guard->collectCredentialsForDomain(domain);
+ replyCopy(guard->serializeCredentialList(matches));
+ },
+ Qt::QueuedConnection);
+}
+
+void AutoFillPrivate::fetchCredentialWithRecordIdentifier(const QString& recordId,
+ void (^reply)(NSDictionary*))
+{
+ if (!reply) {
+ return;
+ }
+
+ void (^replyCopy)(NSDictionary*) = [reply copy];
+ QPointer guard(this);
+ QMetaObject::invokeMethod(this,
+ [guard, replyCopy, recordId]() {
+ if (!guard) {
+ replyCopy(@{});
+ return;
+ }
+ QSharedPointer database;
+ auto* entry = guard->entryForRecordIdentifier(recordId, database);
+ if (!entry || database.isNull()) {
+ replyCopy(@{});
+ return;
+ }
+ auto domains = guard->entryDomains(entry);
+ if (domains.isEmpty()) {
+ replyCopy(@{});
+ return;
+ }
+ auto record = guard->buildRecord(database, entry, domains.first(), entry->displayUrl());
+ replyCopy(guard->serializeCredential(record));
+ },
+ Qt::QueuedConnection);
+}
+
+void AutoFillPrivate::connectSignals()
+{
+ if (m_signalsConnected) {
+ return;
+ }
+
+ if (auto* window = getMainWindow()) {
+ connect(window, &MainWindow::databaseUnlocked, this, [this](DatabaseWidget* widget) {
+ watchDatabase(widget);
+ scheduleIdentityRefresh();
+ });
+ connect(window, &MainWindow::databaseLocked, this, [this](DatabaseWidget* widget) {
+ m_watchedDatabases.remove(widget);
+ scheduleIdentityRefresh();
+ });
+ connect(window, &MainWindow::activeDatabaseChanged, this, [this](DatabaseWidget*) {
+ scheduleIdentityRefresh();
+ });
+ }
+
+ m_signalsConnected = true;
+}
+
+void AutoFillPrivate::watchExistingDatabases()
+{
+ if (auto* window = getMainWindow()) {
+ for (auto* widget : window->getOpenDatabases()) {
+ watchDatabase(widget);
+ }
+ }
+}
+
+void AutoFillPrivate::watchDatabase(DatabaseWidget* widget)
+{
+ if (!widget || m_watchedDatabases.contains(widget)) {
+ return;
+ }
+
+ m_watchedDatabases.insert(widget);
+
+ connect(widget, &DatabaseWidget::databaseModified, this, [this]() { scheduleIdentityRefresh(); });
+ connect(widget, &DatabaseWidget::databaseSaved, this, [this]() { scheduleIdentityRefresh(); });
+ connect(widget, &DatabaseWidget::databaseReplaced, this, [this](const QSharedPointer&, const QSharedPointer&) {
+ scheduleIdentityRefresh();
+ });
+ connect(widget, &DatabaseWidget::databaseLocked, this, [this, widget]() {
+ m_watchedDatabases.remove(widget);
+ scheduleIdentityRefresh();
+ });
+ connect(widget, &QObject::destroyed, this, [this, widget]() {
+ m_watchedDatabases.remove(widget);
+ scheduleIdentityRefresh();
+ });
+}
+
+void AutoFillPrivate::scheduleIdentityRefresh()
+{
+ if (!m_available || !m_running || !m_identityRefreshTimer) {
+ return;
+ }
+
+ if (!m_identityRefreshTimer->isActive()) {
+ m_identityRefreshTimer->start();
+ }
+}
+
+void AutoFillPrivate::refreshIdentityStore()
+{
+ if (!m_available) {
+ return;
+ }
+
+ qCDebug(lcAutoFill) << "Refreshing identity store.";
+ auto records = collectAllCredentialRecords();
+ qCDebug(lcAutoFill) << "Found" << records.size() << "records to refresh.";
+
+ if (@available(macOS 12.0, *)) {
+ auto identities = [NSMutableArray arrayWithCapacity:records.size()];
+ for (const auto& record : records) {
+ auto* serviceIdentifier = [[ASCredentialServiceIdentifier alloc]
+ initWithIdentifier:record.domain.toNSString()
+ type:ASCredentialServiceIdentifierTypeDomain];
+ auto* identity = [[ASPasswordCredentialIdentity alloc]
+ initWithServiceIdentifier:serviceIdentifier
+ user:record.username.toNSString()
+ recordIdentifier:record.recordIdentifier.toNSString()];
+ [identities addObject:identity];
+ }
+
+ dispatch_async(dispatch_get_main_queue(), ^{
+ qCDebug(lcAutoFill) << "Calling replaceCredentialIdentitiesWithIdentities...";
+ ASCredentialIdentityStore* store = [ASCredentialIdentityStore sharedStore];
+ if (records.isEmpty()) {
+ [store removeAllCredentialIdentitiesWithCompletion:nil];
+ qCDebug(lcAutoFill) << "Cleared all identities.";
+ return;
+ }
+
+ [store replaceCredentialIdentitiesWithIdentities:identities
+ completion:^(BOOL success, NSError* error) {
+ if (success) {
+ qCDebug(lcAutoFill) << "Successfully refreshed identities.";
+ } else if (error) {
+ qCWarning(lcAutoFill)
+ << "Unable to refresh AutoFill identities:"
+ << QString::fromNSString(error.localizedDescription);
+ }
+ }];
+ });
+ }
+}
+
+void AutoFillPrivate::clearIdentityStore()
+{
+ if (!m_available) {
+ return;
+ }
+
+ if (@available(macOS 12.0, *)) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [[ASCredentialIdentityStore sharedStore] removeAllCredentialIdentitiesWithCompletion:nil];
+ });
+ }
+}
+
+void AutoFillPrivate::ensureListener()
+{
+ if (m_listener) {
+ return;
+ }
+
+ m_hostAdapter = [[AutoFillHostAdapter alloc] init];
+ m_hostAdapter.owner = this;
+ m_listener = [NSXPCListener anonymousListener];
+ m_listener.delegate = m_hostAdapter;
+ [m_listener resume];
+}
+
+void AutoFillPrivate::connectToServiceIfNeeded()
+{
+ if (m_serviceRegistered || !m_listener || !m_running) {
+ return;
+ }
+
+ qCDebug(lcAutoFill) << "Connecting to AutoFill XPC service.";
+ m_serviceConnection = [[NSXPCConnection alloc] initWithServiceName:@"org.keepassxc.KeePassXC.AutoFill-XPC-Service"];
+ m_serviceConnection.remoteObjectInterface = [NSXPCInterface interfaceWithProtocol:@protocol(AutoFillXPCProtocol)];
+
+ // Use QPointer to safely prevent use-after-free if AutoFillPrivate
+ // is destroyed while an invalidation callback is in-flight
+ QPointer guard(this);
+ m_serviceConnection.invalidationHandler = ^{
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (guard) {
+ guard->handleServiceInvalidation();
+ }
+ });
+ };
+
+ [m_serviceConnection resume];
+
+ id remote =
+ [m_serviceConnection remoteObjectProxyWithErrorHandler:^(NSError* error) {
+ qCWarning(lcAutoFill) << "AutoFill XPC service unavailable:" << QString::fromNSString(error.localizedDescription);
+ }];
+
+ [remote registerProvider:m_listener.endpoint
+ withReply:^(NSError* error) {
+ if (error) {
+ qCWarning(lcAutoFill) << "Failed to register AutoFill provider:"
+ << QString::fromNSString(error.localizedDescription);
+ return;
+ }
+ qCDebug(lcAutoFill) << "Successfully registered AutoFill provider.";
+ m_serviceRegistered = true;
+ }];
+}
+
+void AutoFillPrivate::handleServiceInvalidation()
+{
+ qCDebug(lcAutoFill) << "AutoFill service connection invalidated.";
+ if (!m_running) {
+ if (m_serviceConnection) {
+ m_serviceConnection.invalidationHandler = nil;
+ }
+ m_serviceConnection = nil;
+ m_serviceRegistered = false;
+ return;
+ }
+
+ m_serviceRegistered = false;
+ if (m_serviceConnection) {
+ m_serviceConnection.invalidationHandler = nil;
+ }
+ m_serviceConnection = nil;
+ connectToServiceIfNeeded();
+}
+
+QVector AutoFillPrivate::collectCredentialsForDomain(const QString& domain) const
+{
+ QVector matches;
+ if (domain.isEmpty()) {
+ return collectAllCredentialRecords();
+ }
+
+ const auto normalizedDomain = AutoFillUtils::normalizeHost(domain);
+ if (normalizedDomain.isEmpty()) {
+ return matches;
+ }
+
+ if (auto* window = getMainWindow()) {
+ for (auto* widget : window->getOpenDatabases()) {
+ if (!widget || widget->isLocked()) {
+ continue;
+ }
+
+ auto database = widget->database();
+ if (database.isNull()) {
+ continue;
+ }
+
+ auto* root = database->rootGroup();
+ if (!root) {
+ continue;
+ }
+
+ for (auto* entry : root->entriesRecursive(false)) {
+ if (!entry || entry->isRecycled()) {
+ continue;
+ }
+
+ const auto domains = entryDomains(entry);
+ auto matchIt = std::find_if(domains.begin(), domains.end(), [&](const QString& candidate) {
+ return AutoFillUtils::hostsMatch(normalizedDomain, candidate);
+ });
+ if (matchIt == domains.end()) {
+ continue;
+ }
+
+ auto record = buildRecord(database, entry, *matchIt, entry->displayUrl());
+ if (record.isValid()) {
+ matches.append(record);
+ }
+ }
+ }
+ }
+ return matches;
+}
+
+QVector AutoFillPrivate::collectAllCredentialRecords() const
+{
+ QVector records;
+ QSet dedup;
+
+ if (auto* window = getMainWindow()) {
+ for (auto* widget : window->getOpenDatabases()) {
+ if (!widget || widget->isLocked()) {
+ continue;
+ }
+ auto database = widget->database();
+ if (database.isNull()) {
+ continue;
+ }
+ auto* root = database->rootGroup();
+ if (!root) {
+ continue;
+ }
+
+ for (auto* entry : root->entriesRecursive(false)) {
+ if (!entry || entry->isRecycled()) {
+ continue;
+ }
+
+ for (const auto& domain : entryDomains(entry)) {
+ auto record = buildRecord(database, entry, domain, entry->displayUrl());
+ if (!record.isValid()) {
+ continue;
+ }
+
+ const auto key = record.recordIdentifier + QLatin1Char('|') + record.domain;
+ if (dedup.contains(key)) {
+ continue;
+ }
+ dedup.insert(key);
+ records.append(record);
+ }
+ }
+ }
+ }
+
+ return records;
+}
+
+AutoFillPrivate::CredentialRecord AutoFillPrivate::buildRecord(const QSharedPointer& database,
+ Entry* entry,
+ const QString& domain,
+ const QString& sourceUrl) const
+{
+ CredentialRecord record;
+ if (database.isNull() || !entry) {
+ return record;
+ }
+
+ record.recordIdentifier = recordIdentifierFor(database, entry);
+ record.domain = domain;
+ record.username = entry->resolveMultiplePlaceholders(entry->username());
+ record.password = entry->resolveMultiplePlaceholders(entry->password());
+ record.title = entry->resolveMultiplePlaceholders(entry->title());
+ record.url = sourceUrl;
+ if (entry->hasTotp()) {
+ bool validTotp = false;
+ const auto totpValue = entry->totp(&validTotp);
+ if (validTotp) {
+ record.otp = totpValue;
+ }
+ }
+ return record;
+}
+
+QStringList AutoFillPrivate::entryDomains(Entry* entry) const
+{
+ QStringList domains;
+ if (!entry) {
+ return domains;
+ }
+
+ QSet uniqueHosts;
+ for (const auto& url : entry->getAllUrls()) {
+ const auto host = AutoFillUtils::hostFromUrl(url);
+ if (host.isEmpty() || uniqueHosts.contains(host)) {
+ continue;
+ }
+ uniqueHosts.insert(host);
+ domains.append(host);
+ }
+
+ return domains;
+}
+
+QString AutoFillPrivate::recordIdentifierFor(const QSharedPointer& database, Entry* entry) const
+{
+ if (database.isNull() || !entry) {
+ return {};
+ }
+
+ return database->uuid().toString(QUuid::WithoutBraces) + QLatin1Char(':') + entry->uuidToHex();
+}
+
+Entry* AutoFillPrivate::entryForRecordIdentifier(const QString& recordId, QSharedPointer& database) const
+{
+ database.clear();
+
+ const auto parts = recordId.split(QLatin1Char(':'), Qt::SkipEmptyParts);
+ if (parts.size() != 2) {
+ return nullptr;
+ }
+
+ QUuid databaseUuid(QStringLiteral("{%1}").arg(parts.at(0)));
+ auto entryUuid = Tools::hexToUuid(parts.at(1));
+
+ if (databaseUuid.isNull() || entryUuid.isNull()) {
+ return nullptr;
+ }
+
+ if (auto* widget = databaseWidgetForUuid(databaseUuid)) {
+ auto db = widget->database();
+ if (!db.isNull() && db->rootGroup()) {
+ database = db;
+ return db->rootGroup()->findEntryByUuid(entryUuid);
+ }
+ }
+
+ return nullptr;
+}
+
+DatabaseWidget* AutoFillPrivate::databaseWidgetForUuid(const QUuid& uuid) const
+{
+ if (uuid.isNull()) {
+ return nullptr;
+ }
+
+ if (auto* window = getMainWindow()) {
+ for (auto* widget : window->getOpenDatabases()) {
+ if (!widget || widget->isLocked()) {
+ continue;
+ }
+ auto database = widget->database();
+ if (!database.isNull() && database->uuid() == uuid) {
+ return widget;
+ }
+ }
+ }
+
+ return nullptr;
+}
+
+NSArray*>* AutoFillPrivate::serializeCredentialList(
+ const QVector& records) const
+{
+ auto* list = [NSMutableArray arrayWithCapacity:records.size()];
+ for (const auto& record : records) {
+ auto* dictionary = serializeCredential(record);
+ if (dictionary) {
+ [list addObject:dictionary];
+ }
+ }
+ return list;
+}
+
+NSDictionary* AutoFillPrivate::serializeCredential(const CredentialRecord& record) const
+{
+ if (!record.isValid()) {
+ return nil;
+ }
+
+ NSMutableDictionary* dict = [NSMutableDictionary dictionary];
+ dict[AutoFillCredentialRecordIdentifierKey] = record.recordIdentifier.toNSString();
+ dict[AutoFillCredentialDomainKey] = record.domain.toNSString();
+ dict[AutoFillCredentialUsernameKey] = record.username.toNSString();
+ dict[AutoFillCredentialPasswordKey] = record.password.toNSString();
+ dict[AutoFillCredentialTitleKey] = record.title.toNSString();
+ dict[AutoFillCredentialUrlKey] = record.url.toNSString();
+ if (!record.otp.isEmpty()) {
+ dict[AutoFillCredentialOtpKey] = record.otp.toNSString();
+ }
+ return dict;
+}
+
+#else
+
+class AutoFillPrivate
+{
+public:
+ explicit AutoFillPrivate(AutoFill*) {}
+ bool isAvailable() const
+ {
+ return false;
+ }
+ void start() {}
+ void stop() {}
+};
+
+#endif // Q_OS_MACOS
+
+AutoFill::AutoFill(QObject* parent)
+ : QObject(parent)
+ , d_ptr(new AutoFillPrivate(this))
+{
+}
+
+AutoFill::~AutoFill()
+{
+ delete d_ptr;
+}
+
+bool AutoFill::isAvailable() const
+{
+ return d_ptr->isAvailable();
+}
+
+void AutoFill::start()
+{
+ d_ptr->start();
+}
+
+void AutoFill::stop()
+{
+ d_ptr->stop();
+}
diff --git a/src/autofill/AutoFillProviderProtocol.h b/src/autofill/AutoFillProviderProtocol.h
new file mode 100644
index 0000000000..aa677b8ca7
--- /dev/null
+++ b/src/autofill/AutoFillProviderProtocol.h
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2024 KeePassXC Team
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 or (at your option)
+ * version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#ifndef KEEPASSX_AUTOFILL_PROVIDER_PROTOCOL_H
+#define KEEPASSX_AUTOFILL_PROVIDER_PROTOCOL_H
+
+#import
+
+static NSString* const AutoFillCredentialRecordIdentifierKey = @"recordIdentifier";
+static NSString* const AutoFillCredentialTitleKey = @"title";
+static NSString* const AutoFillCredentialUsernameKey = @"username";
+static NSString* const AutoFillCredentialPasswordKey = @"password";
+static NSString* const AutoFillCredentialUrlKey = @"url";
+static NSString* const AutoFillCredentialDomainKey = @"domain";
+static NSString* const AutoFillCredentialOtpKey = @"otp";
+
+@protocol AutoFillProviderProtocol
+
+- (void)fetchCredentialsMatchingDomain:(NSString*)domain
+ withReply:(void (^)(NSArray*>* credentials))reply;
+
+- (void)fetchCredentialWithRecordIdentifier:(NSString*)recordIdentifier
+ withReply:(void (^)(NSDictionary* credential))reply;
+
+@end
+
+#endif // KEEPASSX_AUTOFILL_PROVIDER_PROTOCOL_H
diff --git a/src/autofill/AutoFillUtils.h b/src/autofill/AutoFillUtils.h
new file mode 100644
index 0000000000..140c1a9c01
--- /dev/null
+++ b/src/autofill/AutoFillUtils.h
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2024 KeePassXC Team
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 or (at your option)
+ * version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#ifndef KEEPASSX_AUTOFILL_UTILS_H
+#define KEEPASSX_AUTOFILL_UTILS_H
+
+#include "gui/UrlTools.h"
+
+#include
+#include
+#include
+
+namespace AutoFillUtils
+{
+
+inline QString normalizeHost(const QString& host)
+{
+ auto normalized = host.trimmed().toLower();
+ while (normalized.endsWith('.')) {
+ normalized.chop(1);
+ }
+ return normalized;
+}
+
+inline QString hostFromUrl(const QString& value)
+{
+ if (value.trimmed().isEmpty()) {
+ return {};
+ }
+
+ QUrl url = QUrl::fromUserInput(value.trimmed());
+ QString host = url.host().trimmed();
+ if (host.isEmpty() && !value.contains('/')) {
+ host = value;
+ }
+
+ host = normalizeHost(host);
+ if (host.isEmpty()) {
+ return {};
+ }
+
+ static const QRegularExpression kDomainRegex(QStringLiteral("^[a-z0-9.-]+$"));
+ if (!kDomainRegex.match(host).hasMatch()) {
+ return {};
+ }
+
+ return host;
+}
+
+inline bool hostsMatch(const QString& requested, const QString& candidate)
+{
+ if (requested.isEmpty() || candidate.isEmpty()) {
+ return false;
+ }
+
+ // Reject hosts that start with a dot (potential security issue)
+ if (requested.startsWith('.') || candidate.startsWith('.')) {
+ return false;
+ }
+
+ if (requested == candidate) {
+ return true;
+ }
+
+ // IP addresses require exact match only (already handled above)
+ if (urlTools()->isIpAddress(requested) || urlTools()->isIpAddress(candidate)) {
+ return false;
+ }
+
+ // Base domains must match (follows BrowserService::handleURL pattern)
+ if (urlTools()->getBaseDomainFromUrl(requested) != urlTools()->getBaseDomainFromUrl(candidate)) {
+ return false;
+ }
+
+ // Allow subdomain matching when one host ends with the other
+ if (requested.endsWith('.' + candidate) || candidate.endsWith('.' + requested)) {
+ return true;
+ }
+
+ return false;
+}
+
+} // namespace AutoFillUtils
+
+#endif // KEEPASSX_AUTOFILL_UTILS_H
diff --git a/src/autofill/AutoFillXPCProtocol.h b/src/autofill/AutoFillXPCProtocol.h
new file mode 100644
index 0000000000..1a6684da94
--- /dev/null
+++ b/src/autofill/AutoFillXPCProtocol.h
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2024 KeePassXC Team
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 or (at your option)
+ * version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#ifndef AUTOFILLXPCPROTOCOL_H
+#define AUTOFILLXPCPROTOCOL_H
+
+#import
+
+#import "AutoFillProviderProtocol.h"
+
+@protocol AutoFillXPCProtocol
+
+- (void)registerProvider:(NSXPCListenerEndpoint*)endpoint withReply:(void (^)(NSError* error))reply;
+- (void)getLoginsForURL:(NSString*)url withReply:(void (^)(NSArray*>* logins))reply;
+- (void)getCredentialWithRecordIdentifier:(NSString*)recordIdentifier
+ withReply:(void (^)(NSDictionary* credential))reply;
+
+@end
+
+#endif // AUTOFILLXPCPROTOCOL_H
diff --git a/src/autofill/AutoFillXPCService-Info.plist b/src/autofill/AutoFillXPCService-Info.plist
new file mode 100644
index 0000000000..79170de7a9
--- /dev/null
+++ b/src/autofill/AutoFillXPCService-Info.plist
@@ -0,0 +1,29 @@
+
+
+
+
+ CFBundleExecutable
+ keepassxc-autofill-xpc
+ CFBundleIdentifier
+ org.keepassxc.KeePassXC.AutoFill-XPC-Service
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ keepassxc-autofill-xpc
+ CFBundlePackageType
+ XPC!
+ CFBundleShortVersionString
+ @KEEPASSXC_VERSION@
+ CFBundleVersion
+ @KEEPASSXC_VERSION@
+ LSMinimumSystemVersion
+ 11.0
+ XPCService
+
+ ServiceType
+ Application
+ JoinExistingSession
+
+
+
+
diff --git a/src/autofill/AutoFillXPCService.m b/src/autofill/AutoFillXPCService.m
new file mode 100644
index 0000000000..28a58cd4c0
--- /dev/null
+++ b/src/autofill/AutoFillXPCService.m
@@ -0,0 +1,153 @@
+/*
+ * Copyright (C) 2024 KeePassXC Team
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 or (at your option)
+ * version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#import "AutoFillXPCProtocol.h"
+#import
+#import
+#import
+
+@interface AutoFillXPCService : NSObject {
+ NSXPCConnection* _providerConnection;
+}
+
+@property(nonatomic, strong) NSXPCListenerEndpoint* providerEndpoint;
+@property(nonatomic, strong) dispatch_queue_t dispatchQueue;
+
+- (NSXPCConnection*)connection;
+
+@end
+
+@implementation AutoFillXPCService
+
+- (instancetype)init
+{
+ self = [super init];
+ if (self) {
+ _dispatchQueue = dispatch_queue_create("org.keepassxc.autofill.service.queue", DISPATCH_QUEUE_SERIAL);
+ }
+ return self;
+}
+
+- (BOOL)listener:(NSXPCListener *)listener shouldAcceptNewConnection:(NSXPCConnection *)newConnection {
+ newConnection.exportedInterface = [NSXPCInterface interfaceWithProtocol:@protocol(AutoFillXPCProtocol)];
+ newConnection.exportedObject = self;
+ [newConnection resume];
+ return YES;
+}
+
+- (void)registerProvider:(NSXPCListenerEndpoint*)endpoint withReply:(void (^)(NSError* error))reply
+{
+ void (^replyCopy)(NSError* error) = [reply copy];
+ dispatch_async(self.dispatchQueue, ^{
+ self.providerEndpoint = endpoint;
+ if (replyCopy) {
+ replyCopy(nil);
+ }
+ });
+}
+
+- (void)getLoginsForURL:(NSString*)url withReply:(void (^)(NSArray*>* logins))reply
+{
+ void (^replyCopy)(NSArray*>* logins) = [reply copy];
+ dispatch_async(self.dispatchQueue, ^{
+ NSXPCConnection* conn = self.connection;
+ if (!conn) {
+ os_log_error(OS_LOG_DEFAULT, "AutoFill XPC service: no provider connection available");
+ if (replyCopy) {
+ replyCopy(@[]);
+ }
+ return;
+ }
+ id provider = conn.remoteObjectProxy;
+ [provider fetchCredentialsMatchingDomain:url
+ withReply:^(NSArray*>* credentials) {
+ if (replyCopy) {
+ replyCopy(credentials ?: @[]);
+ }
+ }];
+ });
+}
+
+- (void)getCredentialWithRecordIdentifier:(NSString*)recordIdentifier
+ withReply:(void (^)(NSDictionary* credential))reply
+{
+ void (^replyCopy)(NSDictionary* credential) = [reply copy];
+ dispatch_async(self.dispatchQueue, ^{
+ NSXPCConnection* conn = self.connection;
+ if (!conn) {
+ os_log_error(OS_LOG_DEFAULT, "AutoFill XPC service: no provider connection available");
+ if (replyCopy) {
+ replyCopy(@{});
+ }
+ return;
+ }
+ id provider = conn.remoteObjectProxy;
+ [provider fetchCredentialWithRecordIdentifier:recordIdentifier
+ withReply:^(NSDictionary* credential) {
+ if (replyCopy) {
+ replyCopy(credential ?: @{});
+ }
+ }];
+ });
+}
+
+- (NSXPCConnection*)connection
+{
+ if (_providerConnection) {
+ return _providerConnection;
+ }
+
+ if (!self.providerEndpoint) {
+ os_log_error(OS_LOG_DEFAULT, "AutoFill service does not have provider endpoint");
+ return nil;
+ }
+
+ _providerConnection = [[NSXPCConnection alloc] initWithListenerEndpoint:self.providerEndpoint];
+ _providerConnection.remoteObjectInterface = [NSXPCInterface interfaceWithProtocol:@protocol(AutoFillProviderProtocol)];
+
+ __weak AutoFillXPCService* weakSelf = self;
+ _providerConnection.interruptionHandler = ^{
+ os_log_info(OS_LOG_DEFAULT, "AutoFill service provider connection interrupted");
+ };
+ _providerConnection.invalidationHandler = ^{
+ os_log_info(OS_LOG_DEFAULT, "AutoFill service provider connection invalidated");
+ AutoFillXPCService* strongSelf = weakSelf;
+ if (strongSelf) {
+ dispatch_async(strongSelf.dispatchQueue, ^{
+ strongSelf->_providerConnection = nil;
+ });
+ }
+ };
+
+ [_providerConnection resume];
+
+ return _providerConnection;
+}
+
+@end
+
+int main(void) {
+ @autoreleasepool {
+ os_log(OS_LOG_DEFAULT, "KeePassXC AutoFill XPC Service starting.");
+ NSXPCListener *listener = [NSXPCListener serviceListener];
+ AutoFillXPCService *service = [[AutoFillXPCService alloc] init];
+ listener.delegate = service;
+ [listener resume];
+ [[NSRunLoop currentRunLoop] run];
+ }
+ return 0;
+}
diff --git a/src/autofill/CredentialProviderViewController.mm b/src/autofill/CredentialProviderViewController.mm
new file mode 100644
index 0000000000..e52c1406df
--- /dev/null
+++ b/src/autofill/CredentialProviderViewController.mm
@@ -0,0 +1,528 @@
+/*
+ * Copyright (C) 2024 KeePassXC Team
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 or (at your option)
+ * version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#import
+#import
+#import
+
+#import "AutoFillProviderProtocol.h"
+#import "AutoFillXPCProtocol.h"
+
+typedef NS_ENUM(NSInteger, KPAFCredentialListState)
+{
+ KPAFCredentialListStateIdle = 0,
+ KPAFCredentialListStateLoading,
+ KPAFCredentialListStateEmpty,
+ KPAFCredentialListStateError,
+ KPAFCredentialListStatePopulated,
+};
+
+static NSString* const KPAFEmptyMessage = @"Unlock KeePassXC to expose matching entries.";
+static NSString* const KPAFErrorMessage =
+ @"Unable to contact KeePassXC. Make sure the app is running and the database is unlocked.";
+
+@interface KPAFCredential : NSObject
+
+@property(nonatomic, copy, readonly) NSString* recordIdentifier;
+@property(nonatomic, copy, readonly) NSString* username;
+@property(nonatomic, copy, readonly) NSString* password;
+@property(nonatomic, copy, readonly) NSString* title;
+@property(nonatomic, copy, readonly) NSString* domain;
+@property(nonatomic, copy, readonly) NSString* url;
+
+- (nullable instancetype)initWithDictionary:(NSDictionary*)dictionary;
+
+@end
+
+@implementation KPAFCredential
+
+- (instancetype)initWithDictionary:(NSDictionary*)dictionary
+{
+ self = [super init];
+ if (!self) {
+ return nil;
+ }
+
+ NSString* recordIdentifier = dictionary[AutoFillCredentialRecordIdentifierKey];
+ NSString* username = dictionary[AutoFillCredentialUsernameKey];
+ NSString* password = dictionary[AutoFillCredentialPasswordKey];
+ NSString* title = dictionary[AutoFillCredentialTitleKey];
+ NSString* domain = dictionary[AutoFillCredentialDomainKey];
+ NSString* url = dictionary[AutoFillCredentialUrlKey];
+ if (!recordIdentifier || !username || !password || !title || !domain || !url) {
+ return nil;
+ }
+
+ _recordIdentifier = [recordIdentifier copy];
+ _username = [username copy];
+ _password = [password copy];
+ _title = [title copy];
+ _domain = [domain copy];
+ _url = [url copy];
+ return self;
+}
+
+@end
+
+typedef void (^KPAFCredentialsCompletion)(NSArray* _Nullable credentials, NSError* _Nullable error);
+typedef void (^KPAFCredentialCompletion)(KPAFCredential* _Nullable credential, NSError* _Nullable error);
+
+@interface AutoFillServiceClient : NSObject
+
++ (instancetype)sharedClient;
+- (void)fetchCredentialsForDomain:(NSString* _Nullable)domain completion:(KPAFCredentialsCompletion)completion;
+- (void)fetchCredentialWithRecordIdentifier:(NSString*)recordIdentifier completion:(KPAFCredentialCompletion)completion;
+
+@end
+
+@implementation AutoFillServiceClient
+{
+ NSXPCConnection* m_connection;
+ os_log_t m_log;
+}
+
++ (instancetype)sharedClient
+{
+ static AutoFillServiceClient* sharedInstance = nil;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ sharedInstance = [[self alloc] init];
+ });
+ return sharedInstance;
+}
+
+- (instancetype)init
+{
+ self = [super init];
+ if (self) {
+ m_log = os_log_create("org.keepassxc.keepassxc", "AutoFillClient");
+ }
+ return self;
+}
+
+- (NSXPCConnection*)connection
+{
+ if (m_connection) {
+ return m_connection;
+ }
+
+ NSXPCConnection* connection = [[NSXPCConnection alloc] initWithServiceName:@"org.keepassxc.KeePassXC.AutoFill-XPC-Service"];
+ connection.remoteObjectInterface = [NSXPCInterface interfaceWithProtocol:@protocol(AutoFillXPCProtocol)];
+ __weak AutoFillServiceClient* weakSelf = self;
+ __weak NSXPCConnection* weakConnection = connection;
+ connection.invalidationHandler = ^{
+ AutoFillServiceClient* strongSelf = weakSelf;
+ if (strongSelf) {
+ os_log_error(strongSelf->m_log, "Lost AutoFill XPC connection.");
+ if (weakConnection == strongSelf->m_connection) {
+ strongSelf->m_connection = nil;
+ }
+ }
+ };
+ [connection resume];
+ m_connection = connection;
+ return m_connection;
+}
+
+- (id)proxyWithError:(void (^)(NSError*))errorHandler
+{
+ NSXPCConnection* connection = [self connection];
+ id remoteObject = [connection remoteObjectProxyWithErrorHandler:^(NSError* error) {
+ os_log_error(m_log, "AutoFill XPC proxy failed: %{public}@", error.localizedDescription);
+ if (errorHandler) {
+ errorHandler(error);
+ }
+ }];
+ return [remoteObject conformsToProtocol:@protocol(AutoFillXPCProtocol)] ? remoteObject : nil;
+}
+
+- (void)fetchCredentialsForDomain:(NSString*)domain completion:(KPAFCredentialsCompletion)completion
+{
+ id proxy = [self proxyWithError:^(NSError* error) {
+ if (completion) {
+ completion(nil, error);
+ }
+ }];
+ if (!proxy) {
+ if (completion) {
+ NSError* err = [NSError errorWithDomain:NSPOSIXErrorDomain code:ENOTCONN userInfo:nil];
+ completion(nil, err);
+ }
+ return;
+ }
+
+ [proxy getLoginsForURL:domain ?: @""
+ withReply:^(NSArray*>* payload) {
+ NSMutableArray* credentials = [NSMutableArray array];
+ for (NSDictionary* entry in payload) {
+ KPAFCredential* credential = [[KPAFCredential alloc] initWithDictionary:entry];
+ if (credential) {
+ [credentials addObject:credential];
+ }
+ }
+ if (completion) {
+ completion(credentials, nil);
+ }
+ }];
+}
+
+- (void)fetchCredentialWithRecordIdentifier:(NSString*)recordIdentifier completion:(KPAFCredentialCompletion)completion
+{
+ id proxy = [self proxyWithError:^(NSError* error) {
+ if (completion) {
+ completion(nil, error);
+ }
+ }];
+ if (!proxy) {
+ if (completion) {
+ NSError* err = [NSError errorWithDomain:NSPOSIXErrorDomain code:ENOTCONN userInfo:nil];
+ completion(nil, err);
+ }
+ return;
+ }
+
+ [proxy getCredentialWithRecordIdentifier:recordIdentifier
+ withReply:^(NSDictionary* payload) {
+ KPAFCredential* credential = [[KPAFCredential alloc] initWithDictionary:payload];
+ if (completion) {
+ if (credential) {
+ completion(credential, nil);
+ } else {
+ NSError* err =
+ [NSError errorWithDomain:NSPOSIXErrorDomain code:ENOENT userInfo:nil];
+ completion(nil, err);
+ }
+ }
+ }];
+}
+
+@end
+
+@interface CredentialProviderViewController :
+ ASCredentialProviderViewController
+
+@property(nonatomic) KPAFCredentialListState state;
+@property(nonatomic, copy) NSString* statusMessage;
+@property(nonatomic, strong) NSMutableArray* credentials;
+@property(nonatomic, copy) NSString* currentDomain;
+@property(nonatomic, strong) NSScrollView* scrollView;
+@property(nonatomic, strong) NSTableView* tableView;
+@property(nonatomic, strong) NSTextField* statusLabel;
+@property(nonatomic, strong) NSProgressIndicator* activityIndicator;
+@property(nonatomic) os_log_t log;
+
+@end
+
+@implementation CredentialProviderViewController
+
+- (instancetype)init
+{
+ self = [super initWithNibName:nil bundle:nil];
+ if (self) {
+ _credentials = [NSMutableArray array];
+ _state = KPAFCredentialListStateIdle;
+ _statusMessage = @"";
+ _log = os_log_create("org.keepassxc.keepassxc", "AutoFillUI");
+ }
+ return self;
+}
+
+- (void)loadView
+{
+ self.view = [[NSView alloc] initWithFrame:NSZeroRect];
+ self.view.translatesAutoresizingMaskIntoConstraints = NO;
+ [self setupTableView];
+ [self setupStatusLabel];
+ [self setupActivityIndicator];
+}
+
+- (void)viewDidLoad
+{
+ [super viewDidLoad];
+ [self updateState];
+}
+
+- (void)prepareCredentialListForServiceIdentifiers:(NSArray*)serviceIdentifiers
+{
+ self.currentDomain = serviceIdentifiers.firstObject.identifier;
+ [self fetchCredentialsForDomain:self.currentDomain];
+}
+
+- (void)provideCredentialWithoutUserInteractionForCredentialIdentity:(ASPasswordCredentialIdentity*)credentialIdentity
+ NS_SWIFT_NAME(provideCredentialWithoutUserInteraction(for:))
+{
+ NSString* identifier = credentialIdentity.recordIdentifier;
+ if (!identifier.length) {
+ NSError* error = [NSError errorWithDomain:ASExtensionErrorDomain
+ code:ASExtensionErrorCodeFailed
+ userInfo:nil];
+ [self.extensionContext cancelRequestWithError:error];
+ return;
+ }
+ [self fetchCredentialWithIdentifier:identifier silently:YES];
+}
+
+- (void)prepareInterfaceToProvideCredentialForCredentialIdentity:(ASPasswordCredentialIdentity*)credentialIdentity
+{
+ NSString* identifier = credentialIdentity.recordIdentifier;
+ if (!identifier.length) {
+ NSError* error = [NSError errorWithDomain:ASExtensionErrorDomain
+ code:ASExtensionErrorCodeFailed
+ userInfo:nil];
+ [self.extensionContext cancelRequestWithError:error];
+ return;
+ }
+ [self fetchCredentialWithIdentifier:identifier silently:NO];
+}
+
+- (void)setupTableView
+{
+ NSTableColumn* titleColumn = [[NSTableColumn alloc] initWithIdentifier:@"title"];
+ titleColumn.title = @"Item";
+ titleColumn.width = 240.0;
+
+ NSTableColumn* userColumn = [[NSTableColumn alloc] initWithIdentifier:@"username"];
+ userColumn.title = @"Username";
+ userColumn.width = 220.0;
+
+ self.tableView = [[NSTableView alloc] initWithFrame:NSZeroRect];
+ [self.tableView addTableColumn:titleColumn];
+ [self.tableView addTableColumn:userColumn];
+ self.tableView.delegate = self;
+ self.tableView.dataSource = self;
+ self.tableView.usesAlternatingRowBackgroundColors = YES;
+ self.tableView.selectionHighlightStyle = NSTableViewSelectionHighlightStyleRegular;
+ self.tableView.doubleAction = @selector(didDoubleClickRow:);
+ self.tableView.target = self;
+
+ self.scrollView = [[NSScrollView alloc] initWithFrame:NSZeroRect];
+ self.scrollView.translatesAutoresizingMaskIntoConstraints = NO;
+ self.scrollView.hasVerticalScroller = YES;
+ self.scrollView.documentView = self.tableView;
+ [self.view addSubview:self.scrollView];
+
+ [NSLayoutConstraint activateConstraints:@[
+ [self.scrollView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
+ [self.scrollView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
+ [self.scrollView.topAnchor constraintEqualToAnchor:self.view.topAnchor],
+ [self.scrollView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor],
+ ]];
+}
+
+- (void)setupStatusLabel
+{
+ self.statusLabel = [NSTextField labelWithString:@""];
+ self.statusLabel.translatesAutoresizingMaskIntoConstraints = NO;
+ self.statusLabel.alignment = NSTextAlignmentCenter;
+ self.statusLabel.lineBreakMode = NSLineBreakByWordWrapping;
+ self.statusLabel.hidden = YES;
+ [self.view addSubview:self.statusLabel];
+
+ [NSLayoutConstraint activateConstraints:@[
+ [self.statusLabel.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor],
+ [self.statusLabel.centerYAnchor constraintEqualToAnchor:self.view.centerYAnchor],
+ [self.statusLabel.leadingAnchor constraintGreaterThanOrEqualToAnchor:self.view.leadingAnchor constant:16.0],
+ [self.statusLabel.trailingAnchor constraintLessThanOrEqualToAnchor:self.view.trailingAnchor constant:-16.0],
+ ]];
+}
+
+- (void)setupActivityIndicator
+{
+ self.activityIndicator = [[NSProgressIndicator alloc] initWithFrame:NSZeroRect];
+ self.activityIndicator.translatesAutoresizingMaskIntoConstraints = NO;
+ self.activityIndicator.style = NSProgressIndicatorStyleSpinning;
+ self.activityIndicator.controlSize = NSControlSizeRegular;
+ self.activityIndicator.displayedWhenStopped = NO;
+ [self.view addSubview:self.activityIndicator];
+
+ [NSLayoutConstraint activateConstraints:@[
+ [self.activityIndicator.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor],
+ [self.activityIndicator.centerYAnchor constraintEqualToAnchor:self.view.centerYAnchor],
+ ]];
+}
+
+- (void)updateState
+{
+ __weak typeof(self) weakSelf = self;
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (!weakSelf) {
+ return;
+ }
+ switch (weakSelf.state) {
+ case KPAFCredentialListStateIdle:
+ [weakSelf.activityIndicator stopAnimation:nil];
+ weakSelf.statusLabel.hidden = YES;
+ weakSelf.scrollView.hidden = YES;
+ break;
+ case KPAFCredentialListStateLoading:
+ weakSelf.statusLabel.hidden = YES;
+ weakSelf.scrollView.hidden = YES;
+ [weakSelf.activityIndicator startAnimation:nil];
+ break;
+ case KPAFCredentialListStatePopulated:
+ [weakSelf.activityIndicator stopAnimation:nil];
+ weakSelf.statusLabel.hidden = YES;
+ weakSelf.scrollView.hidden = NO;
+ break;
+ case KPAFCredentialListStateEmpty:
+ case KPAFCredentialListStateError:
+ [weakSelf.activityIndicator stopAnimation:nil];
+ weakSelf.scrollView.hidden = YES;
+ weakSelf.statusLabel.stringValue = weakSelf.statusMessage ?: @"";
+ weakSelf.statusLabel.hidden = NO;
+ break;
+ }
+ });
+}
+
+- (void)transitionToState:(KPAFCredentialListState)state message:(NSString*)message
+{
+ self.state = state;
+ self.statusMessage = message ?: @"";
+ [self updateState];
+}
+
+- (void)fetchCredentialsForDomain:(NSString*)domain
+{
+ [self transitionToState:KPAFCredentialListStateLoading message:nil];
+ __weak typeof(self) weakSelf = self;
+ [[AutoFillServiceClient sharedClient] fetchCredentialsForDomain:domain
+ completion:^(NSArray* _Nullable credentials,
+ NSError* _Nullable error) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (!weakSelf) {
+ return;
+ }
+ if (error) {
+ os_log_error(weakSelf.log,
+ "Failed to fetch credentials: %{public}@",
+ error.localizedDescription);
+ [weakSelf transitionToState:KPAFCredentialListStateError
+ message:KPAFErrorMessage];
+ return;
+ }
+ [weakSelf.credentials removeAllObjects];
+ [weakSelf.credentials addObjectsFromArray:credentials];
+ [weakSelf.tableView reloadData];
+ if (weakSelf.credentials.count == 0) {
+ [weakSelf transitionToState:KPAFCredentialListStateEmpty
+ message:KPAFEmptyMessage];
+ } else {
+ [weakSelf transitionToState:KPAFCredentialListStatePopulated
+ message:nil];
+ }
+ });
+ }];
+}
+
+- (void)fetchCredentialWithIdentifier:(NSString*)identifier silently:(BOOL)silently
+{
+ if (!silently) {
+ [self transitionToState:KPAFCredentialListStateLoading message:nil];
+ }
+ __weak typeof(self) weakSelf = self;
+ [[AutoFillServiceClient sharedClient] fetchCredentialWithRecordIdentifier:identifier
+ completion:^(KPAFCredential* _Nullable credential,
+ NSError* _Nullable error) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (!weakSelf) {
+ return;
+ }
+ if (credential) {
+ [weakSelf completeWithCredential:credential];
+ } else {
+ os_log_error(weakSelf.log,
+ "Failed to fetch credential: %{public}@",
+ error.localizedDescription);
+ NSError* extensionError =
+ [NSError errorWithDomain:ASExtensionErrorDomain
+ code:ASExtensionErrorCodeCredentialIdentityNotFound
+ userInfo:nil];
+ [weakSelf.extensionContext
+ cancelRequestWithError:extensionError];
+ }
+ });
+ }];
+}
+
+- (void)completeWithCredential:(KPAFCredential*)credential
+{
+ ASPasswordCredential* passwordCredential =
+ [[ASPasswordCredential alloc] initWithUser:credential.username password:credential.password];
+ [self.extensionContext completeRequestWithSelectedCredential:passwordCredential completionHandler:nil];
+}
+
+- (void)didDoubleClickRow:(id)sender
+{
+ NSInteger row = self.tableView.clickedRow;
+ if (row < 0 || row >= static_cast(self.credentials.count)) {
+ return;
+ }
+ [self completeWithCredential:self.credentials[row]];
+}
+
+#pragma mark - NSTableViewDataSource
+
+- (NSInteger)numberOfRowsInTableView:(NSTableView*)tableView
+{
+ return static_cast(self.credentials.count);
+}
+
+#pragma mark - NSTableViewDelegate
+
+- (NSView*)tableView:(NSTableView*)tableView viewForTableColumn:(NSTableColumn*)tableColumn row:(NSInteger)row
+{
+ if (row < 0 || row >= static_cast(self.credentials.count)) {
+ return nil;
+ }
+
+ NSString* identifier = tableColumn.identifier;
+ KPAFCredential* credential = self.credentials[row];
+ NSString* text = nil;
+ if ([identifier isEqualToString:@"username"]) {
+ text = credential.username.length ? credential.username : @"";
+ } else {
+ text = credential.title.length ? credential.title : credential.domain;
+ }
+
+ NSTableCellView* cellView =
+ [tableView makeViewWithIdentifier:tableColumn.identifier owner:self];
+ if (!cellView) {
+ cellView = [[NSTableCellView alloc] initWithFrame:NSZeroRect];
+ cellView.identifier = tableColumn.identifier;
+ NSTextField* textField = [NSTextField labelWithString:text ?: @""];
+ textField.translatesAutoresizingMaskIntoConstraints = NO;
+ [cellView addSubview:textField];
+ cellView.textField = textField;
+ [NSLayoutConstraint activateConstraints:@[
+ [textField.leadingAnchor constraintEqualToAnchor:cellView.leadingAnchor constant:4.0],
+ [textField.trailingAnchor constraintEqualToAnchor:cellView.trailingAnchor constant:-4.0],
+ [textField.topAnchor constraintEqualToAnchor:cellView.topAnchor constant:2.0],
+ [textField.bottomAnchor constraintEqualToAnchor:cellView.bottomAnchor constant:-2.0],
+ ]];
+ }
+
+ cellView.textField.stringValue = text ?: @"";
+ return cellView;
+}
+
+- (BOOL)tableView:(NSTableView*)tableView shouldSelectRow:(NSInteger)row
+{
+ return row >= 0 && row < static_cast(self.credentials.count);
+}
+
+@end
diff --git a/src/autofill/mac/Info.plist.in b/src/autofill/mac/Info.plist.in
new file mode 100644
index 0000000000..5931b0f3dd
--- /dev/null
+++ b/src/autofill/mac/Info.plist.in
@@ -0,0 +1,45 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleDisplayName
+ KeePassXC AutoFill
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ @MACOSX_EXTENSION_BUNDLE_ID@
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ BNDL
+ LSMinimumSystemVersion
+ 12.0
+ CFBundleShortVersionString
+ @KEEPASSXC_VERSION@
+ CFBundleVersion
+ @KEEPASSXC_VERSION@
+ NSExtension
+
+ NSExtensionAttributes
+
+ ASCredentialProviderExtensionCapabilities
+
+ ProvidesPasswords
+
+ ProvidesPasskeys
+
+ ProvidesOneTimeCodes
+
+
+
+ NSExtensionPointIdentifier
+ com.apple.authentication-services-credential-provider-ui
+ NSExtensionPrincipalClass
+ CredentialProviderViewController
+
+
+
diff --git a/src/autofill/mac/MacAutoFill.entitlements b/src/autofill/mac/MacAutoFill.entitlements
new file mode 100644
index 0000000000..680a79fb45
--- /dev/null
+++ b/src/autofill/mac/MacAutoFill.entitlements
@@ -0,0 +1,14 @@
+
+
+
+
+ com.apple.developer.authentication-services.autofill-credential-provider
+
+ com.apple.application-identifier
+ @MACOSX_EXTENSION_BUNDLE_ID@
+ keychain-access-groups
+
+ @MACOSX_EXTENSION_BUNDLE_ID@
+
+
+
diff --git a/src/core/Config.cpp b/src/core/Config.cpp
index 3860a266fd..2024e289b5 100644
--- a/src/core/Config.cpp
+++ b/src/core/Config.cpp
@@ -156,6 +156,7 @@ static const QHash configStrings = {
{Config::Security_EnableCopyOnDoubleClick,{QS("Security/EnableCopyOnDoubleClick"), Roaming, false}},
{Config::Security_QuickUnlock, {QS("Security/QuickUnlock"), Local, true}},
{Config::Security_DatabasePasswordMinimumQuality, {QS("Security/DatabasePasswordMinimumQuality"), Local, 0}},
+ {Config::Security_macOSAutoFill, {QS("Security/macOSAutoFill"), Local, false}},
// Browser
{Config::Browser_Enabled, {QS("Browser/Enabled"), Roaming, false}},
diff --git a/src/core/Config.h b/src/core/Config.h
index 8f54f9c013..acdb2294fa 100644
--- a/src/core/Config.h
+++ b/src/core/Config.h
@@ -137,6 +137,7 @@ class Config : public QObject
Security_EnableCopyOnDoubleClick,
Security_QuickUnlock,
Security_DatabasePasswordMinimumQuality,
+ Security_macOSAutoFill,
Browser_Enabled,
Browser_ShowNotification,
diff --git a/src/gui/ApplicationSettingsWidget.cpp b/src/gui/ApplicationSettingsWidget.cpp
index 9f24bbc85e..adaa2126a6 100644
--- a/src/gui/ApplicationSettingsWidget.cpp
+++ b/src/gui/ApplicationSettingsWidget.cpp
@@ -33,6 +33,9 @@
#include "gui/osutils/OSUtils.h"
#include "gui/styles/StateColorPalette.h"
#include "quickunlock/QuickUnlockInterface.h"
+#ifdef Q_OS_MACOS
+#include "autofill/AutoFill.h"
+#endif
#include "FileDialog.h"
#include "MessageBox.h"
@@ -351,6 +354,12 @@ void ApplicationSettingsWidget::loadSettings()
m_secUi->quickUnlockCheckBox->setEnabled(getQuickUnlock()->isAvailable());
m_secUi->quickUnlockCheckBox->setChecked(config()->get(Config::Security_QuickUnlock).toBool());
+#if defined(Q_OS_MACOS)
+ m_generalUi->macOSAutoFillCheckBox->setChecked(config()->get(Config::Security_macOSAutoFill).toBool());
+#else
+ m_generalUi->macOSGroup->setVisible(false);
+#endif
+
for (const ExtraPage& page : asConst(m_extraPages)) {
page.loadSettings();
}
@@ -473,6 +482,18 @@ void ApplicationSettingsWidget::saveSettings()
config()->set(Config::Security_QuickUnlock, m_secUi->quickUnlockCheckBox->isChecked());
}
+#if defined(Q_OS_MACOS)
+ {
+ bool autoFillEnabled = m_generalUi->macOSAutoFillCheckBox->isChecked();
+ config()->set(Config::Security_macOSAutoFill, autoFillEnabled);
+ if (autoFillEnabled) {
+ getMainWindow()->autoFill()->start();
+ } else {
+ getMainWindow()->autoFill()->stop();
+ }
+ }
+#endif
+
// Security: clear storage if related settings are disabled
if (!config()->get(Config::RememberLastDatabases).toBool()) {
config()->remove(Config::LastDir);
diff --git a/src/gui/ApplicationSettingsWidgetGeneral.ui b/src/gui/ApplicationSettingsWidgetGeneral.ui
index 42ce4acc16..4c6fa7ca83 100644
--- a/src/gui/ApplicationSettingsWidgetGeneral.ui
+++ b/src/gui/ApplicationSettingsWidgetGeneral.ui
@@ -753,6 +753,22 @@
+ -
+
+
+ macOS Integration
+
+
+
-
+
+
+ Enable macOS AutoFill Integration
+
+
+
+
+
+
-
diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp
index 755b38a1e0..537913c36e 100644
--- a/src/gui/MainWindow.cpp
+++ b/src/gui/MainWindow.cpp
@@ -49,6 +49,10 @@
#include "gui/osutils/OSUtils.h"
#include "gui/remote/RemoteSettings.h"
+#ifdef Q_OS_MACOS
+#include "autofill/AutoFill.h"
+#endif
+
#ifdef WITH_XC_UPDATECHECK
#include "gui/UpdateCheckDialog.h"
#include "networking/UpdateChecker.h"
@@ -96,6 +100,10 @@ MainWindow::MainWindow()
#ifdef Q_OS_MACOS
macUtils()->configureWindowAndHelpMenus(this, m_ui->menuHelp);
+ m_autoFill = new AutoFill(this);
+ if (m_autoFill->isAvailable() && config()->get(Config::Security_macOSAutoFill).toBool()) {
+ m_autoFill->start();
+ }
#endif
#if defined(Q_OS_UNIX) && !defined(Q_OS_MACOS) && !defined(QT_NO_DBUS)
diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h
index 229106c52d..a07d5604be 100644
--- a/src/gui/MainWindow.h
+++ b/src/gui/MainWindow.h
@@ -38,6 +38,9 @@ namespace Ui
class InactivityTimer;
class SearchWidget;
class MainWindowEventFilter;
+#ifdef Q_OS_MACOS
+class AutoFill;
+#endif
class MainWindow : public QMainWindow
{
@@ -51,6 +54,10 @@ class MainWindow : public QMainWindow
MainWindow();
~MainWindow() override;
+#ifdef Q_OS_MACOS
+ AutoFill* autoFill() const { return m_autoFill; }
+#endif
+
QList getOpenDatabases();
void restoreConfigState();
void setAllowScreenCapture(bool state);
@@ -189,6 +196,9 @@ private slots:
QPointer m_progressBar;
QPointer m_progressBarLabel;
QPointer m_statusBarLabel;
+#ifdef Q_OS_MACOS
+ AutoFill* m_autoFill = nullptr;
+#endif
Q_DISABLE_COPY(MainWindow)
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 25b116d8e2..96d098944b 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -240,6 +240,7 @@ endif()
if(WITH_XC_NETWORKING OR WITH_XC_BROWSER)
add_unit_test(NAME testurltools SOURCES TestUrlTools.cpp LIBS ${TEST_LIBRARIES})
+ add_unit_test(NAME testautofillutils SOURCES TestAutoFillUtils.cpp LIBS ${TEST_LIBRARIES})
endif()
add_unit_test(NAME testcli SOURCES TestCli.cpp
diff --git a/tests/TestAutoFillUtils.cpp b/tests/TestAutoFillUtils.cpp
new file mode 100644
index 0000000000..54ee404f16
--- /dev/null
+++ b/tests/TestAutoFillUtils.cpp
@@ -0,0 +1,141 @@
+/*
+ * Copyright (C) 2024 KeePassXC Team
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 or (at your option)
+ * version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include "TestAutoFillUtils.h"
+
+#include "autofill/AutoFillUtils.h"
+
+#include
+
+QTEST_GUILESS_MAIN(TestAutoFillUtils)
+
+void TestAutoFillUtils::testNormalizeHost_data()
+{
+ QTest::addColumn("input");
+ QTest::addColumn("expected");
+
+ QTest::newRow("Simple host") << "example.com" << "example.com";
+ QTest::newRow("With trailing dot") << "example.com." << "example.com";
+ QTest::newRow("With multiple trailing dots") << "example.com..." << "example.com";
+ QTest::newRow("Uppercase") << "EXAMPLE.COM" << "example.com";
+ QTest::newRow("Mixed case") << "ExAmPlE.CoM" << "example.com";
+ QTest::newRow("Leading spaces") << " example.com" << "example.com";
+ QTest::newRow("Trailing spaces") << "example.com " << "example.com";
+ QTest::newRow("Both spaces and trailing dots") << " example.com.. " << "example.com";
+ QTest::newRow("Empty string") << "" << "";
+ QTest::newRow("Only spaces") << " " << "";
+ QTest::newRow("Only dots") << "..." << "";
+ QTest::newRow("Subdomain") << "www.example.com." << "www.example.com";
+}
+
+void TestAutoFillUtils::testNormalizeHost()
+{
+ QFETCH(QString, input);
+ QFETCH(QString, expected);
+
+ QCOMPARE(AutoFillUtils::normalizeHost(input), expected);
+}
+
+void TestAutoFillUtils::testHostFromUrl_data()
+{
+ QTest::addColumn("input");
+ QTest::addColumn("expected");
+
+ // Valid URLs
+ QTest::newRow("Simple HTTPS URL") << "https://example.com" << "example.com";
+ QTest::newRow("HTTPS with path") << "https://example.com/path/to/page" << "example.com";
+ QTest::newRow("HTTPS with port") << "https://example.com:8080" << "example.com";
+ QTest::newRow("HTTPS with query") << "https://example.com?query=1" << "example.com";
+ QTest::newRow("HTTP URL") << "http://example.com" << "example.com";
+ QTest::newRow("Subdomain") << "https://www.example.com" << "www.example.com";
+ QTest::newRow("Deep subdomain") << "https://api.v2.example.com" << "api.v2.example.com";
+
+ // Domain only (no scheme)
+ QTest::newRow("Domain without scheme") << "example.com" << "example.com";
+ QTest::newRow("Subdomain without scheme") << "www.example.com" << "www.example.com";
+
+ // Special cases
+ QTest::newRow("Localhost") << "localhost" << "localhost";
+ QTest::newRow("Localhost with scheme") << "http://localhost" << "localhost";
+ QTest::newRow("Localhost with port") << "http://localhost:3000" << "localhost";
+
+ // Invalid inputs
+ QTest::newRow("Empty string") << "" << "";
+ QTest::newRow("Only spaces") << " " << "";
+ QTest::newRow("Invalid characters") << "example<>.com" << "";
+ QTest::newRow("Unicode domain") << "example\xC3\xA9.com" << ""; // Contains non-ASCII
+
+ // Edge cases
+ QTest::newRow("Trailing dot URL") << "https://example.com." << "example.com";
+ QTest::newRow("Uppercase URL") << "HTTPS://EXAMPLE.COM" << "example.com";
+}
+
+void TestAutoFillUtils::testHostFromUrl()
+{
+ QFETCH(QString, input);
+ QFETCH(QString, expected);
+
+ QCOMPARE(AutoFillUtils::hostFromUrl(input), expected);
+}
+
+void TestAutoFillUtils::testHostsMatch_data()
+{
+ QTest::addColumn("requested");
+ QTest::addColumn("candidate");
+ QTest::addColumn("expected");
+
+ // Exact matches
+ QTest::newRow("Exact match") << "example.com" << "example.com" << true;
+ QTest::newRow("Exact match with subdomain") << "www.example.com" << "www.example.com" << true;
+
+ // Subdomain matching
+ QTest::newRow("Subdomain of candidate") << "www.example.com" << "example.com" << true;
+ QTest::newRow("Candidate is subdomain") << "example.com" << "www.example.com" << true;
+ QTest::newRow("Deep subdomain match") << "api.v2.example.com" << "example.com" << true;
+ QTest::newRow("Multiple subdomain levels") << "a.b.c.example.com" << "example.com" << true;
+
+ // Non-matches
+ QTest::newRow("Different domains") << "example.com" << "other.com" << false;
+ QTest::newRow("Similar but different") << "myexample.com" << "example.com" << false;
+ QTest::newRow("Suffix match but not subdomain") << "notexample.com" << "example.com" << false;
+ QTest::newRow("Partial match") << "example" << "example.com" << false;
+ QTest::newRow("TLD mismatch") << "example.org" << "example.com" << false;
+
+ // Empty cases
+ QTest::newRow("Empty requested") << "" << "example.com" << false;
+ QTest::newRow("Empty candidate") << "example.com" << "" << false;
+ QTest::newRow("Both empty") << "" << "" << false;
+
+ // TLD / single-label abuse (must not leak credentials across unrelated domains)
+ QTest::newRow("Bare TLD candidate") << "evil.com" << "com" << false;
+ QTest::newRow("Bare TLD requested") << "com" << "evil.com" << false;
+ QTest::newRow("IP partial overlap") << "1.2.3.4" << "2.3.4" << false;
+
+ // Edge cases
+ QTest::newRow("Same single label") << "localhost" << "localhost" << true;
+ QTest::newRow("Dot prefix attack") << ".example.com" << "example.com" << false;
+}
+
+void TestAutoFillUtils::testHostsMatch()
+{
+ QFETCH(QString, requested);
+ QFETCH(QString, candidate);
+ QFETCH(bool, expected);
+
+ QCOMPARE(AutoFillUtils::hostsMatch(requested, candidate), expected);
+}
+
diff --git a/tests/TestAutoFillUtils.h b/tests/TestAutoFillUtils.h
new file mode 100644
index 0000000000..164d2e72a9
--- /dev/null
+++ b/tests/TestAutoFillUtils.h
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2024 KeePassXC Team
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 or (at your option)
+ * version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#ifndef KEEPASSX_TESTAUTOFILLUTILS_H
+#define KEEPASSX_TESTAUTOFILLUTILS_H
+
+#include
+
+class TestAutoFillUtils : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void testNormalizeHost();
+ void testNormalizeHost_data();
+ void testHostFromUrl();
+ void testHostFromUrl_data();
+ void testHostsMatch();
+ void testHostsMatch_data();
+};
+
+#endif // KEEPASSX_TESTAUTOFILLUTILS_H