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
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)
diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt
index eb40bf61f527c..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
@@ -294,6 +295,8 @@ 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.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
@@ -391,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 )
@@ -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/connectionvalidator.cpp b/src/gui/connectionvalidator.cpp
index 4369881d89f7e..f2cf90c61c29f 100644
--- a/src/gui/connectionvalidator.cpp
+++ b/src/gui/connectionvalidator.cpp
@@ -18,6 +18,7 @@
#include "userinfo.h"
#include "networkjobs.h"
#include "clientproxy.h"
+#include "localnetworkpermission.h"
#include
#include "systray.h"
@@ -35,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);
@@ -160,25 +162,30 @@ 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();
}
- reportResult(StatusNotFound);
+
+ _localNetworkPermissionCheck(_account->url(), this, [this, error](const bool denied) {
+ _errors.append(denied ? LocalNetworkPermission::deniedError() : error);
+ reportResult(StatusNotFound);
+ });
}
void ConnectionValidator::slotJobTimeout(const QUrl &url)
{
- Q_UNUSED(url);
//_errors.append(tr("Unable to connect to %1").arg(url.toString()));
- _errors.append(tr("Timeout"));
- reportResult(Timeout);
+ _localNetworkPermissionCheck(url, this, [this](const bool denied) {
+ _errors.append(denied ? LocalNetworkPermission::deniedError() : tr("Timeout"));
+ reportResult(Timeout);
+ });
}
-
void ConnectionValidator::checkAuthentication()
{
AbstractCredentials *creds = _account->credentials();
diff --git a/src/gui/connectionvalidator.h b/src/gui/connectionvalidator.h
index c58c534eaf066..0fbecebd30013 100644
--- a/src/gui/connectionvalidator.h
+++ b/src/gui/connectionvalidator.h
@@ -15,6 +15,8 @@
#include "accountfwd.h"
#include "clientsideencryption.h"
+#include
+
namespace OCC {
/**
@@ -157,6 +159,10 @@ protected slots:
void termsOfServiceCheckDone();
private:
+ using LocalNetworkPermissionCheck = std::function)>;
+
+ friend class ConnectionValidatorTestAccess;
+
#ifndef TOKEN_AUTH_ONLY
void reportConnected();
#endif
@@ -177,6 +183,7 @@ protected slots:
AccountStatePtr _accountState;
AccountPtr _account;
TermsOfServiceChecker _termsOfServiceChecker;
+ LocalNetworkPermissionCheck _localNetworkPermissionCheck;
bool _isCheckingServerAndAuth = false;
};
}
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.mm b/src/gui/macOS/localnetworkpermission.mm
new file mode 100644
index 0000000000000..dbc4f1b0ef820
--- /dev/null
+++ b/src/gui/macOS/localnetworkpermission.mm
@@ -0,0 +1,176 @@
+/*
+ * 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;
+}
+
+bool checkAvailable()
+{
+ if (@available(macOS 15.0, *)) {
+ return true;
+ }
+
+ return false;
+}
+
+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);
+ }
+
+};
+
+} // namespace
+
+namespace OCC::LocalNetworkPermission {
+
+void checkDeniedForConnection(const QUrl &url, QObject *context, std::function callback)
+{
+ if (!checkAvailable()) {
+ 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 (parameters) {
+ nw_parameters_set_prefer_no_proxy(parameters, true);
+ }
+ 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: {
+ 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:
+ break;
+ }
+ });
+ nw_connection_start(connection);
+
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 2 * NSEC_PER_SEC), queue, ^{
+ 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);
+ });
+}
+
+QString deniedError()
+{
+ return QCoreApplication::translate("LocalNetworkPermission",
+ "Local Network access is disabled. Enable it in System Settings → Privacy & Security → Local Network.");
+}
+
+} // namespace OCC::LocalNetworkPermission
diff --git a/src/gui/wizard/accountwizardcontroller.cpp b/src/gui/wizard/accountwizardcontroller.cpp
index 1e6f11f4af57a..b35e3ed5b3656 100644
--- a/src/gui/wizard/accountwizardcontroller.cpp
+++ b/src/gui/wizard/accountwizardcontroller.cpp
@@ -18,6 +18,7 @@
#include "folder.h"
#include "folderman.h"
#include "guiutility.h"
+#include "localnetworkpermission.h"
#include "networkjobs.h"
#include "owncloudpropagator_p.h"
#include "selectivesyncdialog.h"
@@ -92,6 +93,7 @@ bool localFolderContainsData(const QString &localSyncFolder)
AccountWizardController::AccountWizardController(QObject *parent)
: QObject(parent)
+ , _localNetworkPermissionCheck(LocalNetworkPermission::checkDeniedForConnection)
{
initialiseAccount();
@@ -879,7 +881,7 @@ void AccountWizardController::slotNoServerFound(QNetworkReply *reply)
setErrorText(message);
_account->resetRejectedCertificates();
- static_cast(handleSecureConnectionFailure(reply, checkDowngradeAdvised(reply)));
+ handleFailedServerConnection(_account->url(), checkDowngradeAdvised(reply));
}
void AccountWizardController::slotNoServerFoundTimeout(const QUrl &url)
@@ -887,7 +889,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())));
- static_cast(handleSecureConnectionFailure(nullptr, false));
+ handleFailedServerConnection(url, false);
}
void AccountWizardController::slotDetermineAuthType()
@@ -1902,16 +1904,31 @@ void AccountWizardController::discardFlow2Auth()
setAuthPolling(false);
}
-bool AccountWizardController::handleSecureConnectionFailure(QNetworkReply *reply, bool retryHttpOnly)
+void AccountWizardController::handleFailedServerConnection(const QUrl &url, bool retryHttpOnly)
+{
+ _localNetworkPermissionCheck(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{};
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..ea6cb3c58bed8 100644
--- a/src/gui/wizard/accountwizardcontroller.h
+++ b/src/gui/wizard/accountwizardcontroller.h
@@ -14,6 +14,7 @@
#include
#include
+#include
#include
#include "accountfwd.h"
@@ -256,6 +257,10 @@ private slots:
void slotCreateRemoteFolderFinished(QNetworkReply *reply);
private:
+ using LocalNetworkPermissionCheck = std::function)>;
+
+ friend class AccountWizardControllerTestAccess;
+
void initialiseAccount();
void ensureAccount();
void initialiseOverrideServerChoices();
@@ -298,9 +303,11 @@ private slots:
void emitProxySettingsChangedIfNeeded(bool previousValidity, bool previousLocalhostWarning);
void discardFlow2Auth();
[[nodiscard]] bool checkDowngradeAdvised(QNetworkReply *reply) const;
- [[nodiscard]] bool handleSecureConnectionFailure(QNetworkReply *reply, bool retryHttpOnly);
+ 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/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/testaccountwizardcontroller.cpp b/test/testaccountwizardcontroller.cpp
index 802dfec61d07e..5afa400dc7a06 100644
--- a/test/testaccountwizardcontroller.cpp
+++ b/test/testaccountwizardcontroller.cpp
@@ -3,8 +3,10 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
-#include "gui/wizard/accountwizardcontroller.h"
+#include "account.h"
#include "configfile.h"
+#include "gui/localnetworkpermission.h"
+#include "gui/wizard/accountwizardcontroller.h"
#include "theme.h"
#ifdef BUILD_FILE_PROVIDER_MODULE
@@ -19,6 +21,27 @@
using namespace OCC;
+namespace OCC {
+
+class AccountWizardControllerTestAccess
+{
+public:
+ static void setLocalNetworkPermissionDenied(AccountWizardController &controller, bool denied)
+ {
+ controller._localNetworkPermissionCheck = [denied](const QUrl &, QObject *, std::function callback) {
+ callback(denied);
+ };
+ }
+
+ static void setAccountUrl(AccountWizardController &controller, const QUrl &url)
+ {
+ controller._account = Account::create();
+ controller._account->setUrl(url);
+ }
+};
+
+}
+
class TestAccountWizardController : public QObject
{
Q_OBJECT
@@ -49,6 +72,53 @@ 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()
+ {
+ 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(recoverySpy.count(), 0);
+ QCOMPARE(controller.errorText(), LocalNetworkPermission::deniedError());
+ }
+
+ void otherServerFailureOffersSecureConnectionRecovery()
+ {
+ 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, serverUrl)));
+
+ QCOMPARE(recoverySpy.count(), 1);
+ const auto arguments = recoverySpy.takeFirst();
+ QCOMPARE(arguments.at(0).toString(), serverUrl.host());
+ QCOMPARE(arguments.at(1).toBool(), false);
+ }
void invalidServerUrlStaysOnServerStep()
{
QFETCH(QString, serverUrl);
diff --git a/test/testconnectionvalidator.cpp b/test/testconnectionvalidator.cpp
new file mode 100644
index 0000000000000..a48c69afe31b6
--- /dev/null
+++ b/test/testconnectionvalidator.cpp
@@ -0,0 +1,66 @@
+/*
+ * 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;
+
+namespace OCC {
+
+class ConnectionValidatorTestAccess
+{
+public:
+ static void setLocalNetworkPermissionDenied(ConnectionValidator &validator, bool denied)
+ {
+ validator._localNetworkPermissionCheck = [denied](const QUrl &, QObject *, std::function callback) {
+ callback(denied);
+ };
+ }
+
+ static void reportTimeout(ConnectionValidator &validator, const QUrl &url)
+ {
+ validator.slotJobTimeout(url);
+ }
+};
+
+}
+
+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));
+ ConnectionValidator validator(accountState, {});
+ ConnectionValidatorTestAccess::setLocalNetworkPermissionDenied(validator, true);
+ QSignalSpy resultSpy(&validator, &ConnectionValidator::connectionResult);
+
+ ConnectionValidatorTestAccess::reportTimeout(validator, 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"