From 58df86fb97aa67c975c1b097a52749883d291026 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Mon, 27 Jul 2026 17:57:02 +0800 Subject: [PATCH 01/13] fix(macos): Define local network usage description Signed-off-by: Claudio Cambra --- cmake/modules/MacOSXBundleInfo.plist.in | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmake/modules/MacOSXBundleInfo.plist.in b/cmake/modules/MacOSXBundleInfo.plist.in index 6be98bb4b17c7..19aa12042482f 100644 --- a/cmake/modules/MacOSXBundleInfo.plist.in +++ b/cmake/modules/MacOSXBundleInfo.plist.in @@ -45,6 +45,8 @@ NSRequiresAquaSystemAppearance + NSLocalNetworkUsageDescription + Nextcloud needs access to your local network to connect to Nextcloud servers hosted there. SUShowReleaseNotes SUPublicDSAKeyFile From c7c2018b1b677e9bbf1f6467123ce2dd17be16f5 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Mon, 27 Jul 2026 18:14:10 +0800 Subject: [PATCH 02/13] fix(macos): Add local network permission checking functions Signed-off-by: Claudio Cambra --- src/gui/CMakeLists.txt | 5 +- src/gui/macOS/localnetworkpermission.h | 36 +++++ src/gui/macOS/localnetworkpermission.mm | 167 ++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 src/gui/macOS/localnetworkpermission.h create mode 100644 src/gui/macOS/localnetworkpermission.mm diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index eb40bf61f527c..d6f6f8ff199b0 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -294,6 +294,9 @@ IF( APPLE ) list(APPEND client_SRCS cocoainitializer_mac.mm) list(APPEND client_SRCS systray_mac_common.mm) list(APPEND client_SRCS notificationsoundplayer_mac.mm) + list(APPEND client_SRCS + macOS/localnetworkpermission.h + macOS/localnetworkpermission.mm) list(APPEND client_SRCS # macOS tray account popup: one type per header/implementation pair. # Shared foundation first, then the base hover view, rows, popups and the @@ -751,7 +754,7 @@ if (APPLE) else() target_link_libraries(nextcloudCore PUBLIC "-framework UserNotifications") endif() - target_link_libraries(nextcloudCore PRIVATE "-framework AVFoundation" "-framework Foundation") + target_link_libraries(nextcloudCore PRIVATE "-framework AVFoundation" "-framework Foundation" "-framework Network") target_compile_definitions(nextcloudCore PRIVATE NEXTCLOUD_HAS_NATIVE_SOUND_BACKEND) endif() diff --git a/src/gui/macOS/localnetworkpermission.h b/src/gui/macOS/localnetworkpermission.h new file mode 100644 index 0000000000000..9ec4bb7604586 --- /dev/null +++ b/src/gui/macOS/localnetworkpermission.h @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#ifndef LOCALNETWORKPERMISSION_H +#define LOCALNETWORKPERMISSION_H + +#include +#include + +#include + +class QObject; + +namespace OCC::Mac { + +/** Returns whether the connection-specific check is available on this macOS version. */ +bool localNetworkPermissionCheckAvailable(); + +/** + * Checks whether macOS denied local network access for a failed connection. + * + * The callback runs on @p context's thread. It receives false on macOS before + * version 15, when the destination is not local, or when the denial cannot be + * determined. + */ +void checkLocalNetworkPermissionDeniedForConnection(const QUrl &url, QObject *context, + std::function callback); + +/** Returns an actionable error message for a denied local network connection. */ +QString localNetworkPermissionDeniedError(); + +} // namespace OCC::Mac + +#endif // LOCALNETWORKPERMISSION_H diff --git a/src/gui/macOS/localnetworkpermission.mm b/src/gui/macOS/localnetworkpermission.mm new file mode 100644 index 0000000000000..e035bc0e0a014 --- /dev/null +++ b/src/gui/macOS/localnetworkpermission.mm @@ -0,0 +1,167 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "localnetworkpermission.h" + +#include +#include +#include + +#include + +#include +#include +#include + +namespace { + +void invokeCallback(const QPointer &context, std::function callback, bool denied) +{ + if (!context) { + return; + } + + QMetaObject::invokeMethod(context, [context, callback = std::move(callback), denied] { + if (context) { + callback(denied); + } + }, Qt::QueuedConnection); +} + +bool isLocalNetworkDenied(nw_path_t path) +{ + return path + && nw_path_get_status(path) == nw_path_status_unsatisfied + && nw_path_get_unsatisfied_reason(path) == nw_path_unsatisfied_reason_local_network_denied; +} + +struct ConnectionProbe +{ + nw_connection_t connection = nullptr; + QPointer context; + std::function callback; + bool completed = false; + + void finish(bool denied) + { + if (completed) { + return; + } + + completed = true; + nw_connection_cancel(connection); + nw_release(connection); + connection = nullptr; + invokeCallback(context, std::move(callback), denied); + } + + void finishForCurrentPath() + { + if (completed) { + return; + } + + const auto path = nw_connection_copy_current_path(connection); + const auto denied = isLocalNetworkDenied(path); + if (path) { + nw_release(path); + } + finish(denied); + } +}; + +} // namespace + +namespace OCC::Mac { + +bool localNetworkPermissionCheckAvailable() +{ + if (@available(macOS 15.0, *)) { + return true; + } + + return false; +} + +void checkLocalNetworkPermissionDeniedForConnection(const QUrl &url, QObject *context, + std::function callback) +{ + if (!localNetworkPermissionCheckAvailable()) { + invokeCallback(context, std::move(callback), false); + return; + } + + const auto host = url.host(QUrl::FullyEncoded); + if (!context || host.isEmpty()) { + invokeCallback(context, std::move(callback), false); + return; + } + + const auto port = QString::number(url.port(url.scheme() == QStringLiteral("https") ? 443 : 80)); + const auto hostUtf8 = host.toUtf8(); + const auto portUtf8 = port.toUtf8(); + const auto endpoint = nw_endpoint_create_host(hostUtf8.constData(), portUtf8.constData()); + const auto parameters = nw_parameters_create_secure_tcp(NW_PARAMETERS_DISABLE_PROTOCOL, + NW_PARAMETERS_DEFAULT_CONFIGURATION); + if (!endpoint || !parameters) { + if (endpoint) { + nw_release(endpoint); + } + if (parameters) { + nw_release(parameters); + } + invokeCallback(context, std::move(callback), false); + return; + } + + const auto connection = nw_connection_create(endpoint, parameters); + nw_release(endpoint); + nw_release(parameters); + if (!connection) { + invokeCallback(context, std::move(callback), false); + return; + } + + const auto probe = std::make_shared(); + probe->connection = connection; + probe->context = context; + probe->callback = std::move(callback); + + const auto queue = dispatch_get_main_queue(); + nw_connection_set_queue(connection, queue); + nw_connection_set_path_changed_handler(connection, ^(nw_path_t path) { + if (isLocalNetworkDenied(path)) { + probe->finish(true); + } + }); + nw_connection_set_state_changed_handler(connection, ^(nw_connection_state_t state, nw_error_t) { + switch (state) { + case nw_connection_state_ready: + probe->finish(false); + break; + case nw_connection_state_waiting: + case nw_connection_state_failed: + probe->finishForCurrentPath(); + break; + case nw_connection_state_invalid: + case nw_connection_state_preparing: + case nw_connection_state_cancelled: + break; + } + }); + nw_connection_start(connection); + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 2 * NSEC_PER_SEC), queue, ^{ + probe->finishForCurrentPath(); + }); +} + +QString localNetworkPermissionDeniedError() +{ + return QCoreApplication::translate("LocalNetworkPermission", + "Local Network access is disabled. Enable it in System Settings → Privacy & Security → Local Network."); +} + +} // namespace OCC::Mac From 57848b655590a9d9a07cfe42196ac2e8586aa4eb Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Mon, 27 Jul 2026 18:14:57 +0800 Subject: [PATCH 03/13] fix(macos): Verify local network access permission when no status acquired from server Signed-off-by: Claudio Cambra --- src/gui/connectionvalidator.cpp | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/gui/connectionvalidator.cpp b/src/gui/connectionvalidator.cpp index 4369881d89f7e..1461995e856aa 100644 --- a/src/gui/connectionvalidator.cpp +++ b/src/gui/connectionvalidator.cpp @@ -18,6 +18,9 @@ #include "userinfo.h" #include "networkjobs.h" #include "clientproxy.h" +#ifdef Q_OS_MACOS +#include "macOS/localnetworkpermission.h" +#endif #include #include "systray.h" @@ -160,13 +163,26 @@ void ConnectionValidator::slotNoStatusFound(QNetworkReply *reply) return; } + QString error; if (!_account->credentials()->stillValid(reply)) { // Note: Why would this happen on a status.php request? - _errors.append(tr("Authentication error: Either username or password are wrong.")); + error = tr("Authentication error: Either username or password are wrong."); } else { //_errors.append(tr("Unable to connect to %1").arg(_account->url().toString())); - _errors.append(job->errorString()); + error = job->errorString(); + } + +#ifdef Q_OS_MACOS + if (Mac::localNetworkPermissionCheckAvailable()) { + Mac::checkLocalNetworkPermissionDeniedForConnection(_account->url(), this, [this, error](const bool denied) { + _errors.append(denied ? Mac::localNetworkPermissionDeniedError() : error); + reportResult(StatusNotFound); + }); + return; } +#endif + + _errors.append(error); reportResult(StatusNotFound); } From 201e1fd199e6f53c8a05b9900d0b666270ace63f Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Mon, 27 Jul 2026 18:16:10 +0800 Subject: [PATCH 04/13] fix(macos): Check local network permission on connection validator job timeout Signed-off-by: Claudio Cambra --- src/gui/connectionvalidator.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/gui/connectionvalidator.cpp b/src/gui/connectionvalidator.cpp index 1461995e856aa..707a5df6be8a6 100644 --- a/src/gui/connectionvalidator.cpp +++ b/src/gui/connectionvalidator.cpp @@ -188,13 +188,22 @@ void ConnectionValidator::slotNoStatusFound(QNetworkReply *reply) void ConnectionValidator::slotJobTimeout(const QUrl &url) { - Q_UNUSED(url); //_errors.append(tr("Unable to connect to %1").arg(url.toString())); +#ifdef Q_OS_MACOS + if (Mac::localNetworkPermissionCheckAvailable()) { + Mac::checkLocalNetworkPermissionDeniedForConnection(url, this, [this](const bool denied) { + _errors.append(denied ? Mac::localNetworkPermissionDeniedError() : tr("Timeout")); + reportResult(Timeout); + }); + return; + } +#endif + + Q_UNUSED(url); _errors.append(tr("Timeout")); reportResult(Timeout); } - void ConnectionValidator::checkAuthentication() { AbstractCredentials *creds = _account->credentials(); From 573385dc2f2e10de396d1bfb2580db55bece032b Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Mon, 27 Jul 2026 18:16:45 +0800 Subject: [PATCH 05/13] fix(macos): Check local network permission when no server found in account wizard Signed-off-by: Claudio Cambra --- src/gui/wizard/accountwizardcontroller.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/gui/wizard/accountwizardcontroller.cpp b/src/gui/wizard/accountwizardcontroller.cpp index 1e6f11f4af57a..7768bc63f0c45 100644 --- a/src/gui/wizard/accountwizardcontroller.cpp +++ b/src/gui/wizard/accountwizardcontroller.cpp @@ -19,6 +19,9 @@ #include "folderman.h" #include "guiutility.h" #include "networkjobs.h" +#ifdef Q_OS_MACOS +#include "macOS/localnetworkpermission.h" +#endif #include "owncloudpropagator_p.h" #include "selectivesyncdialog.h" #include "theme.h" @@ -879,6 +882,17 @@ void AccountWizardController::slotNoServerFound(QNetworkReply *reply) setErrorText(message); _account->resetRejectedCertificates(); +#ifdef Q_OS_MACOS + if (Mac::localNetworkPermissionCheckAvailable()) { + const auto failedUrl = _account->url(); + Mac::checkLocalNetworkPermissionDeniedForConnection(failedUrl, this, [this, failedUrl](bool denied) { + if (denied && _account && _account->url() == failedUrl) { + setErrorText(Mac::localNetworkPermissionDeniedError()); + } + }); + } +#endif + static_cast(handleSecureConnectionFailure(reply, checkDowngradeAdvised(reply))); } From 7a7a784d8d6d7974c0cd3f954f5420ae56a26d24 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Mon, 27 Jul 2026 18:16:56 +0800 Subject: [PATCH 06/13] fix(macos): Check local network permission when no server found timeout happens in account wizard Signed-off-by: Claudio Cambra --- src/gui/wizard/accountwizardcontroller.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/gui/wizard/accountwizardcontroller.cpp b/src/gui/wizard/accountwizardcontroller.cpp index 7768bc63f0c45..32dd73340e0dc 100644 --- a/src/gui/wizard/accountwizardcontroller.cpp +++ b/src/gui/wizard/accountwizardcontroller.cpp @@ -901,6 +901,15 @@ void AccountWizardController::slotNoServerFoundTimeout(const QUrl &url) setBusy(false); setErrorText(tr("Timeout while trying to connect to %1 at %2.") .arg(Utility::escape(Theme::instance()->appNameGUI()), Utility::escape(url.toString()))); +#ifdef Q_OS_MACOS + if (Mac::localNetworkPermissionCheckAvailable()) { + Mac::checkLocalNetworkPermissionDeniedForConnection(url, this, [this, url](bool denied) { + if (denied && _account && _account->url() == url) { + setErrorText(Mac::localNetworkPermissionDeniedError()); + } + }); + } +#endif static_cast(handleSecureConnectionFailure(nullptr, false)); } From 73d6911e40b8de8ae0af07c5b34ef8e905bbfac3 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Mon, 27 Jul 2026 19:00:53 +0800 Subject: [PATCH 07/13] fix(macos): Make handleSecureConnectionFailure void, avoid unnecessary casting Signed-off-by: Claudio Cambra --- src/gui/wizard/accountwizardcontroller.cpp | 9 ++++----- src/gui/wizard/accountwizardcontroller.h | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/gui/wizard/accountwizardcontroller.cpp b/src/gui/wizard/accountwizardcontroller.cpp index 32dd73340e0dc..7424f9a522dd9 100644 --- a/src/gui/wizard/accountwizardcontroller.cpp +++ b/src/gui/wizard/accountwizardcontroller.cpp @@ -893,7 +893,7 @@ void AccountWizardController::slotNoServerFound(QNetworkReply *reply) } #endif - static_cast(handleSecureConnectionFailure(reply, checkDowngradeAdvised(reply))); + handleSecureConnectionFailure(reply, checkDowngradeAdvised(reply)); } void AccountWizardController::slotNoServerFoundTimeout(const QUrl &url) @@ -910,7 +910,7 @@ void AccountWizardController::slotNoServerFoundTimeout(const QUrl &url) }); } #endif - static_cast(handleSecureConnectionFailure(nullptr, false)); + handleSecureConnectionFailure(nullptr, false); } void AccountWizardController::slotDetermineAuthType() @@ -1925,16 +1925,15 @@ void AccountWizardController::discardFlow2Auth() setAuthPolling(false); } -bool AccountWizardController::handleSecureConnectionFailure(QNetworkReply *reply, bool retryHttpOnly) +void AccountWizardController::handleSecureConnectionFailure(QNetworkReply *reply, bool retryHttpOnly) { const auto failedUrl = _account ? _account->url() : reply ? reply->url() : QUrl{}; if (failedUrl.scheme() != "https"_L1 || !_account) { - return false; + return; } _secureConnectionFailedUrl = failedUrl; emit secureConnectionFailed(failedUrl.host(), retryHttpOnly); - return true; } void AccountWizardController::retrySecureConnectionWithoutTls() diff --git a/src/gui/wizard/accountwizardcontroller.h b/src/gui/wizard/accountwizardcontroller.h index b9c3ca2be3906..2af19a273c932 100644 --- a/src/gui/wizard/accountwizardcontroller.h +++ b/src/gui/wizard/accountwizardcontroller.h @@ -298,7 +298,7 @@ private slots: void emitProxySettingsChangedIfNeeded(bool previousValidity, bool previousLocalhostWarning); void discardFlow2Auth(); [[nodiscard]] bool checkDowngradeAdvised(QNetworkReply *reply) const; - [[nodiscard]] bool handleSecureConnectionFailure(QNetworkReply *reply, bool retryHttpOnly); + void handleSecureConnectionFailure(QNetworkReply *reply, bool retryHttpOnly); AccountPtr _account; std::unique_ptr _flow2Auth; From 7f8323f8836fb82c167ed9bda8b77eddea7c04e4 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Mon, 27 Jul 2026 19:05:06 +0800 Subject: [PATCH 08/13] fix: Make local network permission check platform agnostic Leave stubs for non-macOS platforms for now Signed-off-by: Claudio Cambra --- src/gui/CMakeLists.txt | 4 +- src/gui/connectionvalidator.cpp | 37 +++++------------- src/gui/connectionvalidator.h | 2 + src/gui/localnetworkpermission.cpp | 25 ++++++++++++ src/gui/localnetworkpermission.h | 30 +++++++++++++++ src/gui/macOS/localnetworkpermission.h | 36 ------------------ src/gui/macOS/localnetworkpermission.mm | 27 +++++++------ src/gui/wizard/accountwizardcontroller.cpp | 44 ++++++++++------------ src/gui/wizard/accountwizardcontroller.h | 1 + 9 files changed, 101 insertions(+), 105 deletions(-) create mode 100644 src/gui/localnetworkpermission.cpp create mode 100644 src/gui/localnetworkpermission.h delete mode 100644 src/gui/macOS/localnetworkpermission.h diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index d6f6f8ff199b0..4598b49384d62 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -86,6 +86,7 @@ set(client_SRCS conflictsolver.cpp connectionvalidator.h connectionvalidator.cpp + localnetworkpermission.h editlocallyjob.h editlocallyjob.cpp editlocallymanager.h @@ -295,7 +296,6 @@ IF( APPLE ) list(APPEND client_SRCS systray_mac_common.mm) list(APPEND client_SRCS notificationsoundplayer_mac.mm) list(APPEND client_SRCS - macOS/localnetworkpermission.h macOS/localnetworkpermission.mm) list(APPEND client_SRCS # macOS tray account popup: one type per header/implementation pair. @@ -394,7 +394,7 @@ IF( APPLE ) endif() ENDIF() IF( NOT APPLE ) - list(APPEND client_SRCS trayaccountpopup_qt.cpp) + list(APPEND client_SRCS trayaccountpopup_qt.cpp localnetworkpermission.cpp) ENDIF() IF( NOT WIN32 AND NOT APPLE ) diff --git a/src/gui/connectionvalidator.cpp b/src/gui/connectionvalidator.cpp index 707a5df6be8a6..9a9345ab44c8f 100644 --- a/src/gui/connectionvalidator.cpp +++ b/src/gui/connectionvalidator.cpp @@ -18,9 +18,7 @@ #include "userinfo.h" #include "networkjobs.h" #include "clientproxy.h" -#ifdef Q_OS_MACOS -#include "macOS/localnetworkpermission.h" -#endif +#include "localnetworkpermission.h" #include #include "systray.h" @@ -172,36 +170,19 @@ void ConnectionValidator::slotNoStatusFound(QNetworkReply *reply) error = job->errorString(); } -#ifdef Q_OS_MACOS - if (Mac::localNetworkPermissionCheckAvailable()) { - Mac::checkLocalNetworkPermissionDeniedForConnection(_account->url(), this, [this, error](const bool denied) { - _errors.append(denied ? Mac::localNetworkPermissionDeniedError() : error); - reportResult(StatusNotFound); - }); - return; - } -#endif - - _errors.append(error); - reportResult(StatusNotFound); + LocalNetworkPermission::checkDeniedForConnection(_account->url(), this, [this, error](const bool denied) { + _errors.append(denied ? LocalNetworkPermission::deniedError() : error); + reportResult(StatusNotFound); + }); } void ConnectionValidator::slotJobTimeout(const QUrl &url) { //_errors.append(tr("Unable to connect to %1").arg(url.toString())); -#ifdef Q_OS_MACOS - if (Mac::localNetworkPermissionCheckAvailable()) { - Mac::checkLocalNetworkPermissionDeniedForConnection(url, this, [this](const bool denied) { - _errors.append(denied ? Mac::localNetworkPermissionDeniedError() : tr("Timeout")); - reportResult(Timeout); - }); - return; - } -#endif - - Q_UNUSED(url); - _errors.append(tr("Timeout")); - reportResult(Timeout); + LocalNetworkPermission::checkDeniedForConnection(url, this, [this](const bool denied) { + _errors.append(denied ? LocalNetworkPermission::deniedError() : tr("Timeout")); + reportResult(Timeout); + }); } void ConnectionValidator::checkAuthentication() diff --git a/src/gui/connectionvalidator.h b/src/gui/connectionvalidator.h index c58c534eaf066..fadc3ca62294b 100644 --- a/src/gui/connectionvalidator.h +++ b/src/gui/connectionvalidator.h @@ -15,6 +15,8 @@ #include "accountfwd.h" #include "clientsideencryption.h" +#include + namespace OCC { /** diff --git a/src/gui/localnetworkpermission.cpp b/src/gui/localnetworkpermission.cpp new file mode 100644 index 0000000000000..2d1f696d4e456 --- /dev/null +++ b/src/gui/localnetworkpermission.cpp @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "localnetworkpermission.h" + +namespace OCC::LocalNetworkPermission { + +void checkDeniedForConnection(const QUrl &url, QObject *context, std::function callback) +{ + Q_UNUSED(url) + + if (context) { + callback(false); + } +} + +QString deniedError() +{ + return QCoreApplication::translate("LocalNetworkPermission", + "Local Network access is disabled. Enable it to connect to the server."); +} + +} // namespace OCC::LocalNetworkPermission diff --git a/src/gui/localnetworkpermission.h b/src/gui/localnetworkpermission.h new file mode 100644 index 0000000000000..83d3153d037f6 --- /dev/null +++ b/src/gui/localnetworkpermission.h @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#ifndef LOCALNETWORKPERMISSION_H +#define LOCALNETWORKPERMISSION_H + +#include +#include + +#include + +class QObject; + +namespace OCC::LocalNetworkPermission { + +/** + * Checks whether Local Network permission denied a failed connection. + * + * The callback receives false when the platform cannot determine the denial. + */ +void checkDeniedForConnection(const QUrl &url, QObject *context, std::function callback); + +/** Returns an actionable error message for a denied local network connection. */ +QString deniedError(); + +} // namespace OCC::LocalNetworkPermission + +#endif // LOCALNETWORKPERMISSION_H diff --git a/src/gui/macOS/localnetworkpermission.h b/src/gui/macOS/localnetworkpermission.h deleted file mode 100644 index 9ec4bb7604586..0000000000000 --- a/src/gui/macOS/localnetworkpermission.h +++ /dev/null @@ -1,36 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -#ifndef LOCALNETWORKPERMISSION_H -#define LOCALNETWORKPERMISSION_H - -#include -#include - -#include - -class QObject; - -namespace OCC::Mac { - -/** Returns whether the connection-specific check is available on this macOS version. */ -bool localNetworkPermissionCheckAvailable(); - -/** - * Checks whether macOS denied local network access for a failed connection. - * - * The callback runs on @p context's thread. It receives false on macOS before - * version 15, when the destination is not local, or when the denial cannot be - * determined. - */ -void checkLocalNetworkPermissionDeniedForConnection(const QUrl &url, QObject *context, - std::function callback); - -/** Returns an actionable error message for a denied local network connection. */ -QString localNetworkPermissionDeniedError(); - -} // namespace OCC::Mac - -#endif // LOCALNETWORKPERMISSION_H diff --git a/src/gui/macOS/localnetworkpermission.mm b/src/gui/macOS/localnetworkpermission.mm index e035bc0e0a014..8b6c87a52cbac 100644 --- a/src/gui/macOS/localnetworkpermission.mm +++ b/src/gui/macOS/localnetworkpermission.mm @@ -37,6 +37,15 @@ bool isLocalNetworkDenied(nw_path_t path) && nw_path_get_unsatisfied_reason(path) == nw_path_unsatisfied_reason_local_network_denied; } +bool checkAvailable() +{ + if (@available(macOS 15.0, *)) { + return true; + } + + return false; +} + struct ConnectionProbe { nw_connection_t connection = nullptr; @@ -74,21 +83,11 @@ void finishForCurrentPath() } // namespace -namespace OCC::Mac { - -bool localNetworkPermissionCheckAvailable() -{ - if (@available(macOS 15.0, *)) { - return true; - } - - return false; -} +namespace OCC::LocalNetworkPermission { -void checkLocalNetworkPermissionDeniedForConnection(const QUrl &url, QObject *context, - std::function callback) +void checkDeniedForConnection(const QUrl &url, QObject *context, std::function callback) { - if (!localNetworkPermissionCheckAvailable()) { + if (!checkAvailable()) { invokeCallback(context, std::move(callback), false); return; } @@ -158,7 +157,7 @@ void checkLocalNetworkPermissionDeniedForConnection(const QUrl &url, QObject *co }); } -QString localNetworkPermissionDeniedError() +QString deniedError() { return QCoreApplication::translate("LocalNetworkPermission", "Local Network access is disabled. Enable it in System Settings → Privacy & Security → Local Network."); diff --git a/src/gui/wizard/accountwizardcontroller.cpp b/src/gui/wizard/accountwizardcontroller.cpp index 7424f9a522dd9..945bea877ba65 100644 --- a/src/gui/wizard/accountwizardcontroller.cpp +++ b/src/gui/wizard/accountwizardcontroller.cpp @@ -18,10 +18,8 @@ #include "folder.h" #include "folderman.h" #include "guiutility.h" +#include "localnetworkpermission.h" #include "networkjobs.h" -#ifdef Q_OS_MACOS -#include "macOS/localnetworkpermission.h" -#endif #include "owncloudpropagator_p.h" #include "selectivesyncdialog.h" #include "theme.h" @@ -882,18 +880,7 @@ void AccountWizardController::slotNoServerFound(QNetworkReply *reply) setErrorText(message); _account->resetRejectedCertificates(); -#ifdef Q_OS_MACOS - if (Mac::localNetworkPermissionCheckAvailable()) { - const auto failedUrl = _account->url(); - Mac::checkLocalNetworkPermissionDeniedForConnection(failedUrl, this, [this, failedUrl](bool denied) { - if (denied && _account && _account->url() == failedUrl) { - setErrorText(Mac::localNetworkPermissionDeniedError()); - } - }); - } -#endif - - handleSecureConnectionFailure(reply, checkDowngradeAdvised(reply)); + handleFailedServerConnection(_account->url(), checkDowngradeAdvised(reply)); } void AccountWizardController::slotNoServerFoundTimeout(const QUrl &url) @@ -901,16 +888,7 @@ void AccountWizardController::slotNoServerFoundTimeout(const QUrl &url) setBusy(false); setErrorText(tr("Timeout while trying to connect to %1 at %2.") .arg(Utility::escape(Theme::instance()->appNameGUI()), Utility::escape(url.toString()))); -#ifdef Q_OS_MACOS - if (Mac::localNetworkPermissionCheckAvailable()) { - Mac::checkLocalNetworkPermissionDeniedForConnection(url, this, [this, url](bool denied) { - if (denied && _account && _account->url() == url) { - setErrorText(Mac::localNetworkPermissionDeniedError()); - } - }); - } -#endif - handleSecureConnectionFailure(nullptr, false); + handleFailedServerConnection(url, false); } void AccountWizardController::slotDetermineAuthType() @@ -1925,6 +1903,22 @@ void AccountWizardController::discardFlow2Auth() setAuthPolling(false); } +void AccountWizardController::handleFailedServerConnection(const QUrl &url, bool retryHttpOnly) +{ + LocalNetworkPermission::checkDeniedForConnection(url, this, [this, url, retryHttpOnly](bool denied) { + if (_account && _account->url() != url) { + return; + } + + if (denied) { + setErrorText(LocalNetworkPermission::deniedError()); + return; + } + + handleSecureConnectionFailure(nullptr, retryHttpOnly); + }); +} + void AccountWizardController::handleSecureConnectionFailure(QNetworkReply *reply, bool retryHttpOnly) { const auto failedUrl = _account ? _account->url() : reply ? reply->url() : QUrl{}; diff --git a/src/gui/wizard/accountwizardcontroller.h b/src/gui/wizard/accountwizardcontroller.h index 2af19a273c932..875ca7c0464a6 100644 --- a/src/gui/wizard/accountwizardcontroller.h +++ b/src/gui/wizard/accountwizardcontroller.h @@ -299,6 +299,7 @@ private slots: void discardFlow2Auth(); [[nodiscard]] bool checkDowngradeAdvised(QNetworkReply *reply) const; void handleSecureConnectionFailure(QNetworkReply *reply, bool retryHttpOnly); + void handleFailedServerConnection(const QUrl &url, bool retryHttpOnly); AccountPtr _account; std::unique_ptr _flow2Auth; From 0c42bef4891cca13380f8330d52470a8f7b324eb Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Mon, 27 Jul 2026 19:08:02 +0800 Subject: [PATCH 09/13] fix: Test local network permissions handling in account wizard Signed-off-by: Claudio Cambra --- src/gui/wizard/accountwizardcontroller.cpp | 9 +++- src/gui/wizard/accountwizardcontroller.h | 6 ++- test/testaccountwizardcontroller.cpp | 60 +++++++++++++++++++++- 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/src/gui/wizard/accountwizardcontroller.cpp b/src/gui/wizard/accountwizardcontroller.cpp index 945bea877ba65..4ff0db6edf143 100644 --- a/src/gui/wizard/accountwizardcontroller.cpp +++ b/src/gui/wizard/accountwizardcontroller.cpp @@ -53,6 +53,8 @@ #include #include +#include + using namespace Qt::StringLiterals; namespace OCC { @@ -1905,7 +1907,7 @@ void AccountWizardController::discardFlow2Auth() void AccountWizardController::handleFailedServerConnection(const QUrl &url, bool retryHttpOnly) { - LocalNetworkPermission::checkDeniedForConnection(url, this, [this, url, retryHttpOnly](bool denied) { + checkLocalNetworkPermissionDenied(url, [this, url, retryHttpOnly](bool denied) { if (_account && _account->url() != url) { return; } @@ -1919,6 +1921,11 @@ void AccountWizardController::handleFailedServerConnection(const QUrl &url, bool }); } +void AccountWizardController::checkLocalNetworkPermissionDenied(const QUrl &url, std::function callback) +{ + LocalNetworkPermission::checkDeniedForConnection(url, this, std::move(callback)); +} + void AccountWizardController::handleSecureConnectionFailure(QNetworkReply *reply, bool retryHttpOnly) { const auto failedUrl = _account ? _account->url() : reply ? reply->url() : QUrl{}; diff --git a/src/gui/wizard/accountwizardcontroller.h b/src/gui/wizard/accountwizardcontroller.h index 875ca7c0464a6..92292da818487 100644 --- a/src/gui/wizard/accountwizardcontroller.h +++ b/src/gui/wizard/accountwizardcontroller.h @@ -14,6 +14,7 @@ #include #include +#include #include #include "accountfwd.h" @@ -255,6 +256,10 @@ private slots: void slotRemoteFolderExists(QNetworkReply *reply); void slotCreateRemoteFolderFinished(QNetworkReply *reply); +protected: + virtual void checkLocalNetworkPermissionDenied(const QUrl &url, std::function callback); + virtual void handleSecureConnectionFailure(QNetworkReply *reply, bool retryHttpOnly); + private: void initialiseAccount(); void ensureAccount(); @@ -298,7 +303,6 @@ private slots: void emitProxySettingsChangedIfNeeded(bool previousValidity, bool previousLocalhostWarning); void discardFlow2Auth(); [[nodiscard]] bool checkDowngradeAdvised(QNetworkReply *reply) const; - void handleSecureConnectionFailure(QNetworkReply *reply, bool retryHttpOnly); void handleFailedServerConnection(const QUrl &url, bool retryHttpOnly); AccountPtr _account; diff --git a/test/testaccountwizardcontroller.cpp b/test/testaccountwizardcontroller.cpp index 802dfec61d07e..e4fce2cb4fcfa 100644 --- a/test/testaccountwizardcontroller.cpp +++ b/test/testaccountwizardcontroller.cpp @@ -3,8 +3,9 @@ * SPDX-License-Identifier: GPL-2.0-or-later */ -#include "gui/wizard/accountwizardcontroller.h" #include "configfile.h" +#include "gui/localnetworkpermission.h" +#include "gui/wizard/accountwizardcontroller.h" #include "theme.h" #ifdef BUILD_FILE_PROVIDER_MODULE @@ -19,6 +20,24 @@ using namespace OCC; +class LocalNetworkPermissionAccountWizardController : public AccountWizardController +{ +public: + bool localNetworkPermissionDenied = false; + int secureConnectionRecoveryCount = 0; + +protected: + void checkLocalNetworkPermissionDenied(const QUrl &, std::function callback) override + { + callback(localNetworkPermissionDenied); + } + + void handleSecureConnectionFailure(QNetworkReply *, bool) override + { + ++secureConnectionRecoveryCount; + } +}; + class TestAccountWizardController : public QObject { Q_OBJECT @@ -49,6 +68,45 @@ private slots: QStringLiteral("http://cloud.example")); } + void localNetworkPermissionCheckIgnoresInvalidUrls() + { + auto callbackCalled = false; + auto denied = true; + + LocalNetworkPermission::checkDeniedForConnection({}, this, [&callbackCalled, &denied](bool result) { + callbackCalled = true; + denied = result; + }); + + QTRY_VERIFY(callbackCalled); + QVERIFY(!denied); + } + + void localNetworkPermissionFailureSkipsSecureConnectionRecovery() + { + LocalNetworkPermissionAccountWizardController controller; + controller.localNetworkPermissionDenied = true; + + QVERIFY(QMetaObject::invokeMethod(&controller, + "slotNoServerFoundTimeout", + Qt::DirectConnection, + Q_ARG(QUrl, QUrl(QStringLiteral("https://cloud.example"))))); + + QCOMPARE(controller.secureConnectionRecoveryCount, 0); + QCOMPARE(controller.errorText(), LocalNetworkPermission::deniedError()); + } + + void otherServerFailureOffersSecureConnectionRecovery() + { + LocalNetworkPermissionAccountWizardController controller; + + QVERIFY(QMetaObject::invokeMethod(&controller, + "slotNoServerFoundTimeout", + Qt::DirectConnection, + Q_ARG(QUrl, QUrl(QStringLiteral("https://cloud.example"))))); + + QCOMPARE(controller.secureConnectionRecoveryCount, 1); + } void invalidServerUrlStaysOnServerStep() { QFETCH(QString, serverUrl); From cb6e0514160787802e3b20563151af395eb8f023 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Mon, 27 Jul 2026 19:08:58 +0800 Subject: [PATCH 10/13] fix: Test local network permission handling in connection validator Signed-off-by: Claudio Cambra --- src/gui/connectionvalidator.cpp | 11 +++++- src/gui/connectionvalidator.h | 3 ++ test/CMakeLists.txt | 1 + test/testconnectionvalidator.cpp | 65 ++++++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 test/testconnectionvalidator.cpp diff --git a/src/gui/connectionvalidator.cpp b/src/gui/connectionvalidator.cpp index 9a9345ab44c8f..98375ac786551 100644 --- a/src/gui/connectionvalidator.cpp +++ b/src/gui/connectionvalidator.cpp @@ -12,6 +12,8 @@ #include #include +#include + #include "connectionvalidator.h" #include "account.h" #include "accountstate.h" @@ -170,7 +172,7 @@ void ConnectionValidator::slotNoStatusFound(QNetworkReply *reply) error = job->errorString(); } - LocalNetworkPermission::checkDeniedForConnection(_account->url(), this, [this, error](const bool denied) { + checkLocalNetworkPermissionDenied(_account->url(), [this, error](const bool denied) { _errors.append(denied ? LocalNetworkPermission::deniedError() : error); reportResult(StatusNotFound); }); @@ -179,12 +181,17 @@ void ConnectionValidator::slotNoStatusFound(QNetworkReply *reply) void ConnectionValidator::slotJobTimeout(const QUrl &url) { //_errors.append(tr("Unable to connect to %1").arg(url.toString())); - LocalNetworkPermission::checkDeniedForConnection(url, this, [this](const bool denied) { + checkLocalNetworkPermissionDenied(url, [this](const bool denied) { _errors.append(denied ? LocalNetworkPermission::deniedError() : tr("Timeout")); reportResult(Timeout); }); } +void ConnectionValidator::checkLocalNetworkPermissionDenied(const QUrl &url, std::function callback) +{ + LocalNetworkPermission::checkDeniedForConnection(url, this, std::move(callback)); +} + void ConnectionValidator::checkAuthentication() { AbstractCredentials *creds = _account->credentials(); diff --git a/src/gui/connectionvalidator.h b/src/gui/connectionvalidator.h index fadc3ca62294b..fe88299a0aa93 100644 --- a/src/gui/connectionvalidator.h +++ b/src/gui/connectionvalidator.h @@ -158,6 +158,9 @@ protected slots: void termsOfServiceCheckDone(); +protected: + virtual void checkLocalNetworkPermissionDenied(const QUrl &url, std::function callback); + private: #ifndef TOKEN_AUTH_ONLY void reportConnected(); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index bbfd290ec6c14..cfa3a89a95d0e 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -155,6 +155,7 @@ nextcloud_add_benchmark(LargeSync) nextcloud_add_test(Account) nextcloud_add_test(AccountManager) nextcloud_add_test(AccountWizardController) +nextcloud_add_test(ConnectionValidator) nextcloud_add_test(Folder) nextcloud_add_test(FolderMan) nextcloud_add_test(ForceSyncNow) diff --git a/test/testconnectionvalidator.cpp b/test/testconnectionvalidator.cpp new file mode 100644 index 0000000000000..6099d249fa8fc --- /dev/null +++ b/test/testconnectionvalidator.cpp @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "account.h" +#include "gui/connectionvalidator.h" +#include "gui/localnetworkpermission.h" +#include "testhelper.h" + +#include +#include +#include + +using namespace OCC; + +class LocalNetworkPermissionConnectionValidator : public ConnectionValidator +{ +public: + using ConnectionValidator::ConnectionValidator; + + bool localNetworkPermissionDenied = false; + + void reportTimeout(const QUrl &url) + { + slotJobTimeout(url); + } + +protected: + void checkLocalNetworkPermissionDenied(const QUrl &, std::function callback) override + { + callback(localNetworkPermissionDenied); + } +}; + +class TestConnectionValidator : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase() + { + QStandardPaths::setTestModeEnabled(true); + } + + void localNetworkPermissionFailureReplacesTimeout() + { + const auto account = Account::create(); + account->setUrl(QUrl(QStringLiteral("https://cloud.example"))); + const auto accountState = AccountStatePtr(new FakeAccountState(account)); + LocalNetworkPermissionConnectionValidator validator(accountState, {}); + validator.localNetworkPermissionDenied = true; + QSignalSpy resultSpy(&validator, &ConnectionValidator::connectionResult); + + validator.reportTimeout(account->url()); + + QCOMPARE(resultSpy.count(), 1); + const auto result = resultSpy.takeFirst(); + QCOMPARE(result.at(0).value(), ConnectionValidator::Timeout); + QCOMPARE(result.at(1).toStringList(), QStringList({LocalNetworkPermission::deniedError()})); + } +}; + +QTEST_MAIN(TestConnectionValidator) +#include "testconnectionvalidator.moc" From f2603489c801f73b8556833f3ad023c778fb740c Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Mon, 27 Jul 2026 19:59:43 +0800 Subject: [PATCH 11/13] fix: avoid virtual methods for testing in account wizard controller and connection validator Signed-off-by: Claudio Cambra --- src/gui/connectionvalidator.cpp | 12 ++----- src/gui/connectionvalidator.h | 8 +++-- src/gui/wizard/accountwizardcontroller.cpp | 10 ++---- src/gui/wizard/accountwizardcontroller.h | 10 +++--- test/testaccountwizardcontroller.cpp | 42 ++++++++++++++-------- test/testconnectionvalidator.cpp | 27 +++++++------- 6 files changed, 57 insertions(+), 52 deletions(-) diff --git a/src/gui/connectionvalidator.cpp b/src/gui/connectionvalidator.cpp index 98375ac786551..f2cf90c61c29f 100644 --- a/src/gui/connectionvalidator.cpp +++ b/src/gui/connectionvalidator.cpp @@ -12,8 +12,6 @@ #include #include -#include - #include "connectionvalidator.h" #include "account.h" #include "accountstate.h" @@ -38,6 +36,7 @@ ConnectionValidator::ConnectionValidator(AccountStatePtr accountState, const QSt , _accountState(accountState) , _account(accountState->account()) , _termsOfServiceChecker(_account) + , _localNetworkPermissionCheck(LocalNetworkPermission::checkDeniedForConnection) { connect(&_termsOfServiceChecker, &TermsOfServiceChecker::done, this, &ConnectionValidator::termsOfServiceCheckDone); @@ -172,7 +171,7 @@ void ConnectionValidator::slotNoStatusFound(QNetworkReply *reply) error = job->errorString(); } - checkLocalNetworkPermissionDenied(_account->url(), [this, error](const bool denied) { + _localNetworkPermissionCheck(_account->url(), this, [this, error](const bool denied) { _errors.append(denied ? LocalNetworkPermission::deniedError() : error); reportResult(StatusNotFound); }); @@ -181,17 +180,12 @@ void ConnectionValidator::slotNoStatusFound(QNetworkReply *reply) void ConnectionValidator::slotJobTimeout(const QUrl &url) { //_errors.append(tr("Unable to connect to %1").arg(url.toString())); - checkLocalNetworkPermissionDenied(url, [this](const bool denied) { + _localNetworkPermissionCheck(url, this, [this](const bool denied) { _errors.append(denied ? LocalNetworkPermission::deniedError() : tr("Timeout")); reportResult(Timeout); }); } -void ConnectionValidator::checkLocalNetworkPermissionDenied(const QUrl &url, std::function callback) -{ - LocalNetworkPermission::checkDeniedForConnection(url, this, std::move(callback)); -} - void ConnectionValidator::checkAuthentication() { AbstractCredentials *creds = _account->credentials(); diff --git a/src/gui/connectionvalidator.h b/src/gui/connectionvalidator.h index fe88299a0aa93..0fbecebd30013 100644 --- a/src/gui/connectionvalidator.h +++ b/src/gui/connectionvalidator.h @@ -158,10 +158,11 @@ protected slots: void termsOfServiceCheckDone(); -protected: - virtual void checkLocalNetworkPermissionDenied(const QUrl &url, std::function callback); - private: + using LocalNetworkPermissionCheck = std::function)>; + + friend class ConnectionValidatorTestAccess; + #ifndef TOKEN_AUTH_ONLY void reportConnected(); #endif @@ -182,6 +183,7 @@ protected slots: AccountStatePtr _accountState; AccountPtr _account; TermsOfServiceChecker _termsOfServiceChecker; + LocalNetworkPermissionCheck _localNetworkPermissionCheck; bool _isCheckingServerAndAuth = false; }; } diff --git a/src/gui/wizard/accountwizardcontroller.cpp b/src/gui/wizard/accountwizardcontroller.cpp index 4ff0db6edf143..b35e3ed5b3656 100644 --- a/src/gui/wizard/accountwizardcontroller.cpp +++ b/src/gui/wizard/accountwizardcontroller.cpp @@ -53,8 +53,6 @@ #include #include -#include - using namespace Qt::StringLiterals; namespace OCC { @@ -95,6 +93,7 @@ bool localFolderContainsData(const QString &localSyncFolder) AccountWizardController::AccountWizardController(QObject *parent) : QObject(parent) + , _localNetworkPermissionCheck(LocalNetworkPermission::checkDeniedForConnection) { initialiseAccount(); @@ -1907,7 +1906,7 @@ void AccountWizardController::discardFlow2Auth() void AccountWizardController::handleFailedServerConnection(const QUrl &url, bool retryHttpOnly) { - checkLocalNetworkPermissionDenied(url, [this, url, retryHttpOnly](bool denied) { + _localNetworkPermissionCheck(url, this, [this, url, retryHttpOnly](bool denied) { if (_account && _account->url() != url) { return; } @@ -1921,11 +1920,6 @@ void AccountWizardController::handleFailedServerConnection(const QUrl &url, bool }); } -void AccountWizardController::checkLocalNetworkPermissionDenied(const QUrl &url, std::function callback) -{ - LocalNetworkPermission::checkDeniedForConnection(url, this, std::move(callback)); -} - void AccountWizardController::handleSecureConnectionFailure(QNetworkReply *reply, bool retryHttpOnly) { const auto failedUrl = _account ? _account->url() : reply ? reply->url() : QUrl{}; diff --git a/src/gui/wizard/accountwizardcontroller.h b/src/gui/wizard/accountwizardcontroller.h index 92292da818487..ea6cb3c58bed8 100644 --- a/src/gui/wizard/accountwizardcontroller.h +++ b/src/gui/wizard/accountwizardcontroller.h @@ -256,11 +256,11 @@ private slots: void slotRemoteFolderExists(QNetworkReply *reply); void slotCreateRemoteFolderFinished(QNetworkReply *reply); -protected: - virtual void checkLocalNetworkPermissionDenied(const QUrl &url, std::function callback); - virtual void handleSecureConnectionFailure(QNetworkReply *reply, bool retryHttpOnly); - private: + using LocalNetworkPermissionCheck = std::function)>; + + friend class AccountWizardControllerTestAccess; + void initialiseAccount(); void ensureAccount(); void initialiseOverrideServerChoices(); @@ -304,8 +304,10 @@ private slots: void discardFlow2Auth(); [[nodiscard]] bool checkDowngradeAdvised(QNetworkReply *reply) const; void handleFailedServerConnection(const QUrl &url, bool retryHttpOnly); + void handleSecureConnectionFailure(QNetworkReply *reply, bool retryHttpOnly); AccountPtr _account; + LocalNetworkPermissionCheck _localNetworkPermissionCheck; std::unique_ptr _flow2Auth; QPointer _selectiveSyncDialog; enum class ProxyAuthentication { diff --git a/test/testaccountwizardcontroller.cpp b/test/testaccountwizardcontroller.cpp index e4fce2cb4fcfa..5afa400dc7a06 100644 --- a/test/testaccountwizardcontroller.cpp +++ b/test/testaccountwizardcontroller.cpp @@ -3,6 +3,7 @@ * SPDX-License-Identifier: GPL-2.0-or-later */ +#include "account.h" #include "configfile.h" #include "gui/localnetworkpermission.h" #include "gui/wizard/accountwizardcontroller.h" @@ -20,24 +21,27 @@ using namespace OCC; -class LocalNetworkPermissionAccountWizardController : public AccountWizardController +namespace OCC { + +class AccountWizardControllerTestAccess { public: - bool localNetworkPermissionDenied = false; - int secureConnectionRecoveryCount = 0; - -protected: - void checkLocalNetworkPermissionDenied(const QUrl &, std::function callback) override + static void setLocalNetworkPermissionDenied(AccountWizardController &controller, bool denied) { - callback(localNetworkPermissionDenied); + controller._localNetworkPermissionCheck = [denied](const QUrl &, QObject *, std::function callback) { + callback(denied); + }; } - void handleSecureConnectionFailure(QNetworkReply *, bool) override + static void setAccountUrl(AccountWizardController &controller, const QUrl &url) { - ++secureConnectionRecoveryCount; + controller._account = Account::create(); + controller._account->setUrl(url); } }; +} + class TestAccountWizardController : public QObject { Q_OBJECT @@ -84,28 +88,36 @@ private slots: void localNetworkPermissionFailureSkipsSecureConnectionRecovery() { - LocalNetworkPermissionAccountWizardController controller; - controller.localNetworkPermissionDenied = true; + AccountWizardController controller; + AccountWizardControllerTestAccess::setLocalNetworkPermissionDenied(controller, true); + QSignalSpy recoverySpy(&controller, &AccountWizardController::secureConnectionFailed); QVERIFY(QMetaObject::invokeMethod(&controller, "slotNoServerFoundTimeout", Qt::DirectConnection, Q_ARG(QUrl, QUrl(QStringLiteral("https://cloud.example"))))); - QCOMPARE(controller.secureConnectionRecoveryCount, 0); + QCOMPARE(recoverySpy.count(), 0); QCOMPARE(controller.errorText(), LocalNetworkPermission::deniedError()); } void otherServerFailureOffersSecureConnectionRecovery() { - LocalNetworkPermissionAccountWizardController controller; + AccountWizardController controller; + const auto serverUrl = QUrl(QStringLiteral("https://cloud.example")); + AccountWizardControllerTestAccess::setLocalNetworkPermissionDenied(controller, false); + AccountWizardControllerTestAccess::setAccountUrl(controller, serverUrl); + QSignalSpy recoverySpy(&controller, &AccountWizardController::secureConnectionFailed); QVERIFY(QMetaObject::invokeMethod(&controller, "slotNoServerFoundTimeout", Qt::DirectConnection, - Q_ARG(QUrl, QUrl(QStringLiteral("https://cloud.example"))))); + Q_ARG(QUrl, serverUrl))); - QCOMPARE(controller.secureConnectionRecoveryCount, 1); + QCOMPARE(recoverySpy.count(), 1); + const auto arguments = recoverySpy.takeFirst(); + QCOMPARE(arguments.at(0).toString(), serverUrl.host()); + QCOMPARE(arguments.at(1).toBool(), false); } void invalidServerUrlStaysOnServerStep() { diff --git a/test/testconnectionvalidator.cpp b/test/testconnectionvalidator.cpp index 6099d249fa8fc..a48c69afe31b6 100644 --- a/test/testconnectionvalidator.cpp +++ b/test/testconnectionvalidator.cpp @@ -14,25 +14,26 @@ using namespace OCC; -class LocalNetworkPermissionConnectionValidator : public ConnectionValidator +namespace OCC { + +class ConnectionValidatorTestAccess { public: - using ConnectionValidator::ConnectionValidator; - - bool localNetworkPermissionDenied = false; - - void reportTimeout(const QUrl &url) + static void setLocalNetworkPermissionDenied(ConnectionValidator &validator, bool denied) { - slotJobTimeout(url); + validator._localNetworkPermissionCheck = [denied](const QUrl &, QObject *, std::function callback) { + callback(denied); + }; } -protected: - void checkLocalNetworkPermissionDenied(const QUrl &, std::function callback) override + static void reportTimeout(ConnectionValidator &validator, const QUrl &url) { - callback(localNetworkPermissionDenied); + validator.slotJobTimeout(url); } }; +} + class TestConnectionValidator : public QObject { Q_OBJECT @@ -48,11 +49,11 @@ private slots: const auto account = Account::create(); account->setUrl(QUrl(QStringLiteral("https://cloud.example"))); const auto accountState = AccountStatePtr(new FakeAccountState(account)); - LocalNetworkPermissionConnectionValidator validator(accountState, {}); - validator.localNetworkPermissionDenied = true; + ConnectionValidator validator(accountState, {}); + ConnectionValidatorTestAccess::setLocalNetworkPermissionDenied(validator, true); QSignalSpy resultSpy(&validator, &ConnectionValidator::connectionResult); - validator.reportTimeout(account->url()); + ConnectionValidatorTestAccess::reportTimeout(validator, account->url()); QCOMPARE(resultSpy.count(), 1); const auto result = resultSpy.takeFirst(); From 3844c36916e6962a3d662df7dd78c5f8bfed5f2e Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Mon, 27 Jul 2026 22:14:37 +0800 Subject: [PATCH 12/13] fix(macos): Fix local network permission check when VPN is active Signed-off-by: Claudio Cambra --- src/gui/macOS/localnetworkpermission.mm | 46 +++++++++++++++---------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/src/gui/macOS/localnetworkpermission.mm b/src/gui/macOS/localnetworkpermission.mm index 8b6c87a52cbac..dbc4f1b0ef820 100644 --- a/src/gui/macOS/localnetworkpermission.mm +++ b/src/gui/macOS/localnetworkpermission.mm @@ -66,19 +66,6 @@ void finish(bool denied) invokeCallback(context, std::move(callback), denied); } - void finishForCurrentPath() - { - if (completed) { - return; - } - - const auto path = nw_connection_copy_current_path(connection); - const auto denied = isLocalNetworkDenied(path); - if (path) { - nw_release(path); - } - finish(denied); - } }; } // namespace @@ -104,6 +91,9 @@ void checkDeniedForConnection(const QUrl &url, QObject *context, std::functionfinish(false); break; case nw_connection_state_waiting: - case nw_connection_state_failed: - probe->finishForCurrentPath(); - break; + case nw_connection_state_failed: { + if (probe->completed) { + break; + } + + const auto path = nw_connection_copy_current_path(connection); + const auto denied = isLocalNetworkDenied(path); + if (path) { + nw_release(path); + } + if (denied) { + probe->finish(true); + } + } break; case nw_connection_state_invalid: case nw_connection_state_preparing: case nw_connection_state_cancelled: @@ -153,7 +154,16 @@ void checkDeniedForConnection(const QUrl &url, QObject *context, std::functionfinishForCurrentPath(); + if (probe->completed) { + return; + } + + const auto path = nw_connection_copy_current_path(connection); + const auto denied = isLocalNetworkDenied(path); + if (path) { + nw_release(path); + } + probe->finish(denied); }); } @@ -163,4 +173,4 @@ QString deniedError() "Local Network access is disabled. Enable it in System Settings → Privacy & Security → Local Network."); } -} // namespace OCC::Mac +} // namespace OCC::LocalNetworkPermission From af01eb72fccb9917538edd95e8474010ba3c4e71 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Mon, 27 Jul 2026 22:38:07 +0800 Subject: [PATCH 13/13] docs(macOS): Add doc for local network permissions on macOS Signed-off-by: Claudio Cambra --- doc/local-network-permission.md | 262 ++++++++++++++++++++++++++++++++ doc/macOS-development.md | 1 + 2 files changed, 263 insertions(+) create mode 100644 doc/local-network-permission.md diff --git a/doc/local-network-permission.md b/doc/local-network-permission.md new file mode 100644 index 0000000000000..090f5d4ad4b38 --- /dev/null +++ b/doc/local-network-permission.md @@ -0,0 +1,262 @@ + + +# Local Network Permission Handling + +## Purpose + +On macOS 15 and later, the user can deny an application access to devices on +the local network. A connection attempt affected by this setting otherwise +looks much like an unreachable server to the desktop client. The local network +permission check lets the client replace a generic connection error with an +actionable message: + +> Local Network access is disabled. Enable it in System Settings → Privacy & +> Security → Local Network. + +This check is diagnostic rather than proactive. It runs after a server +connection has already failed or timed out. It does not request permission and +does not report a global permission state. Apple does not provide a general API +for querying that state; instead, the check observes the path of a connection +to the server the user entered. See +[TN3179: Understanding local network privacy](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy). + +The implementation was introduced in response to +[nextcloud/desktop#10452](https://github.com/nextcloud/desktop/issues/10452). + +## Architecture + +### Platform boundary + +`LocalNetworkPermission` exposes two functions from +`src/gui/localnetworkpermission.h`: + +- `checkDeniedForConnection()` reports through a callback whether local network + permission denied a specific failed connection. +- `deniedError()` returns the platform-appropriate error shown to the user. + +CMake selects the implementation: + +- On macOS, `src/gui/macOS/localnetworkpermission.mm` uses Network.framework. +- On other platforms, `src/gui/localnetworkpermission.cpp` reports `false`, + preserving the existing connection error. + +This keeps platform conditionals out of `ConnectionValidator` and +`AccountWizardController`. A `false` result means that local network denial was +not established; it does not prove that permission is enabled. + +### macOS connection probe + +The macOS implementation is available on macOS 15 and later. It performs the +following steps: + +1. Extract the host and port from the failed URL. The default port is `443` for + HTTPS and `80` otherwise. +2. Create a Network.framework TCP connection to that endpoint. The probe does + not perform an HTTP request or a TLS handshake. +3. Set `prefer_no_proxy` on the connection parameters. This makes + Network.framework try the direct path first, so a VPN-provided local proxy + cannot immediately hide the local-network denial. Network.framework may + still try a configured proxy if the direct attempt fails. +4. Observe connection path and state updates on the main dispatch queue. +5. Finish with `true` when an unsatisfied path reports + `nw_path_unsatisfied_reason_local_network_denied`. +6. Finish with `false` when the connection becomes ready. +7. On a `waiting` or `failed` state, finish only if the current path explicitly + reports local-network denial. A path can be temporarily inconclusive, so + completing with `false` at this point would introduce a race with a later + path update. +8. After two seconds, inspect the path once more and finish. This bounds the + diagnostic delay when the server is merely absent or unreachable. + +`ConnectionProbe` owns the Network.framework connection and callback. Its +`completed` flag ensures exactly-once completion. Finishing cancels and +releases the connection before dispatching the result back through Qt. + +The callback context is held as a `QPointer`. The result is queued onto +that context and is discarded if the context has been destroyed, preventing a +callback into a deleted controller or validator. + +### Consumers + +`ConnectionValidator` invokes the check when: + +- the status request fails; or +- its connection job times out. + +When denial is established, the permission message replaces the generic +network error. The validator's status value is unchanged. + +`AccountWizardController` invokes the check after its server connection fails. +When denial is established, it displays the permission message and does not +offer secure-connection recovery, such as retrying without TLS. Otherwise, the +existing recovery flow continues. + +The wizard also verifies that the account URL still matches the URL whose +probe completed. This prevents a delayed result from an earlier attempt from +changing the state of a newer attempt. + +### Test seam + +Both consumers store the permission check in a private `std::function`, +initialized to `LocalNetworkPermission::checkDeniedForConnection`. Their test +access classes are friends and replace that callable with a synchronous +deterministic result. + +This keeps the production constructors and public API unchanged. It also tests +the behavior of each consumer without subclassing production classes or +making one-line methods virtual solely for tests. + +## Test plan + +### Automated coverage + +`AccountWizardControllerTest` covers: + +- An invalid URL produces a non-denied result. +- A denied result displays `deniedError()` and suppresses secure-connection + recovery. +- A non-denied result preserves the secure-connection recovery flow. + +`ConnectionValidatorTest` covers: + +- A denied result replaces the generic timeout text with `deniedError()`. + +Build and run the focused tests from the repository root: + +```sh +cmake -S . -B build-testing +cmake --build build-testing \ + --target AccountWizardControllerTest ConnectionValidatorTest +ctest --test-dir build-testing --output-on-failure \ + -R '^(ConnectionValidator|AccountWizardController)Test$' +``` + +### Manual coverage + +The automated tests do not cover: + +- macOS Local Network privacy enforcement or its System Settings toggle; +- Network.framework path-update ordering and unsatisfied reasons; +- behavior with a real VPN or system proxy; +- the direct-path preference and proxy fallback; +- code-signing identity and executable UUID tracking; +- differences between launching from Finder, Terminal, Xcode, or another + parent process; +- the complete two-second probe against a real network. + +These behaviors depend on operating-system privacy state, routing, signing, +and the active network environment. Checking only that a Network.framework +parameter was set would test an implementation detail, not the intended VPN +behavior. The native path therefore requires the manual regression test below. + +## Reproducing local-network denial + +### Requirements + +- macOS 15 or later. +- A validly signed application with a stable Apple-issued identity. +- A unique UUID in the main executable. +- `NSLocalNetworkUsageDescription` in the application `Info.plist`. +- A target address on a network directly attached through Wi-Fi or Ethernet. + A private address routed elsewhere is not necessarily a local-network + address for this privacy feature. + +The target does not need to run a Nextcloud server. Using an unused address on +the directly attached subnet is useful because it isolates privacy diagnosis +from server behavior. + +Verify the application before testing: + +```sh +codesign --verify --deep --strict --verbose=2 /path/to/Nextcloud.app +/usr/bin/dwarfdump --uuid /path/to/Nextcloud.app/Contents/MacOS/Nextcloud +``` + +Both commands must succeed, and the UUID output must not be empty. + +### Launch the application correctly + +Quit all running instances and launch the tested application by +double-clicking its bundle in Finder. + +Do not start the executable directly from Terminal or SSH. macOS automatically +allows local-network access for command-line tools launched from those +environments and for their child processes. In that situation, +Network.framework can report the connection as ineligible for privacy +enforcement even though the application's Local Network toggle is disabled. +This produces a false-negative test. + +For the same reason, Finder launch is preferred for this regression test over +developer launch mechanisms whose responsible process may affect privacy +attribution. + +### Test procedure + +1. Determine the Mac's Wi-Fi or Ethernet address and subnet. +2. Choose an unused address on that same directly attached subnet. +3. Open **System Settings → Privacy & Security → Local Network**. +4. Disable Local Network access for Nextcloud. +5. Quit Nextcloud, then launch the tested app bundle from Finder. +6. In the account wizard, enter the unused address, for example + `https://192.168.0.64`. +7. Start the connection. + +Expected result: + +- The wizard reports that Local Network access is disabled. +- It does not show the secure-connection recovery dialog. + +Repeat with a VPN active. The expected result is the same. The permission probe +should try the direct local route before a VPN-provided proxy can handle the +connection. + +As a comparison, enable Local Network access and repeat. Because the chosen +address has no server, the result should now be an ordinary timeout or +connection failure rather than the permission message. + +## Troubleshooting + +### The result is a timeout or TLS recovery dialog + +Confirm all of the following: + +- The application was launched from Finder, not by executing its binary in a + shell. +- The tested bundle has a valid signature. +- The running process belongs to the bundle just verified. +- The target address is on a directly attached Wi-Fi or Ethernet subnet. +- The Local Network toggle for the tested application is disabled. + +### Inspect Network.framework activity + +The following command reads the relevant unified logs for a bounded test +interval: + +```sh +/usr/bin/log show \ + --start '2026-07-27 22:20:45' \ + --end '2026-07-27 22:22:35' \ + --style compact --info --debug \ + --predicate 'process == "Nextcloud" AND subsystem BEGINSWITH "com.apple.network"' +``` + +Replace the timestamps with the actual test interval. Useful evidence includes: + +- `prefer no proxy`, confirming that the direct path preference is active; +- `local network denied` or an unsatisfied local-network-denial reason; +- a proxy endpoint such as `127.0.0.1`, showing proxy fallback; +- `Privacy Stance: Not Eligible`, which indicates that the operation was not + subject to normal Local Network privacy enforcement and commonly points to + the launch or identity conditions described above. + +### VPN interpretation + +A VPN can install a system proxy even when the local target remains routed over +Wi-Fi. `prefer_no_proxy` means “try direct first,” not “prohibit all proxies.” +Seeing a later proxy attempt is therefore expected when the direct attempt +fails. What matters for this feature is that macOS has an opportunity to +evaluate the direct local path and report denial before proxy fallback masks +the original condition. diff --git a/doc/macOS-development.md b/doc/macOS-development.md index 1a2ae0cf24092..00a427c58168e 100644 --- a/doc/macOS-development.md +++ b/doc/macOS-development.md @@ -179,5 +179,6 @@ The way Transifex handles Xcode string catalogs creates a high risk of accidenta - **Direct `mac-crafter` CLI usage / branding builds** → [`admin/osx/mac-crafter/README.md`](../admin/osx/mac-crafter/README.md) - **Qt + macOS App Sandbox internals** → [`doc/macOS-Sandbox-Qt.md`](./macOS-Sandbox-Qt.md) +- **Local Network permission diagnostics and testing** → [`doc/local-network-permission.md`](./local-network-permission.md) - **Finder integration (FinderSync) extension — verifying & troubleshooting loading** → [`doc/macOS-FinderSync-extension.md`](./macOS-FinderSync-extension.md) - **NextcloudFileProviderKit Swift package** → [`shell_integration/MacOSX/NextcloudFileProviderKit/README.md`](../shell_integration/MacOSX/NextcloudFileProviderKit/README.md)