diff --git a/resources.qrc b/resources.qrc index f26f90192acfe..057feb7f1d695 100644 --- a/resources.qrc +++ b/resources.qrc @@ -59,6 +59,15 @@ src/gui/ResolveConflictsDialog.qml src/gui/ConflictDelegate.qml src/gui/ConflictItemFileInfo.qml + src/gui/wizard/qml/AccountWizardWindow.qml + src/gui/wizard/qml/AdvancedOptionsDialog.qml + src/gui/wizard/qml/BrowserAuthPage.qml + src/gui/wizard/qml/OptionRow.qml + src/gui/wizard/qml/ServerPage.qml + src/gui/wizard/qml/SyncOptionsPage.qml + src/gui/wizard/qml/WizardButton.qml + src/gui/wizard/qml/WizardDialogFrame.qml + src/gui/wizard/qml/WizardTextField.qml src/gui/macOS/ui/FileProviderSettings.qml src/gui/macOS/ui/FileProviderFileDelegate.qml src/gui/integration/FileActionsWindow.qml diff --git a/shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj/project.pbxproj b/shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj/project.pbxproj index 9f483ddc071a9..79d1bc9154ab8 100644 --- a/shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj/project.pbxproj +++ b/shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj/project.pbxproj @@ -746,9 +746,13 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = 649J6ZGB3X; ENABLE_TESTING_SEARCH_PATHS = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; @@ -795,9 +799,13 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = 649J6ZGB3X; ENABLE_NS_ASSERTIONS = NO; ENABLE_TESTING_SEARCH_PATHS = YES; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -839,9 +847,13 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = 649J6ZGB3X; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_DYNAMIC_NO_PIC = NO; @@ -900,9 +912,13 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = 649J6ZGB3X; ENABLE_NS_ASSERTIONS = NO; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1115,9 +1131,13 @@ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = 649J6ZGB3X; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; @@ -1169,9 +1189,13 @@ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = 649J6ZGB3X; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; @@ -1214,9 +1238,13 @@ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = 649J6ZGB3X; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; @@ -1267,9 +1295,13 @@ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = 649J6ZGB3X; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index 6c2bc3d8da353..3c4c38e7deb77 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -227,6 +227,8 @@ set(client_SRCS creds/webflowcredentialsdialog.cpp wizard/abstractcredswizardpage.h wizard/abstractcredswizardpage.cpp + wizard/accountwizardcontroller.h + wizard/accountwizardcontroller.cpp wizard/owncloudadvancedsetuppage.h wizard/owncloudadvancedsetuppage.cpp wizard/owncloudconnectionmethoddialog.h diff --git a/src/gui/owncloudgui.cpp b/src/gui/owncloudgui.cpp index f84eceb881dd9..cd154f48f281d 100644 --- a/src/gui/owncloudgui.cpp +++ b/src/gui/owncloudgui.cpp @@ -25,6 +25,7 @@ #include "wheelhandler.h" #include "syncconflictsmodel.h" #include "syncengine.h" +#include "wizard/accountwizardcontroller.h" #include "filedetails/datefieldbackend.h" #include "filedetails/filedetails.h" #include "filedetails/shareemodel.h" @@ -143,6 +144,7 @@ ownCloudGui::ownCloudGui(Application *parent) qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "SortedShareModel"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "SyncConflictsModel"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "FileActionsModel"); + qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "AccountWizardController"); qmlRegisterUncreatableType("com.nextcloud.desktopclient", 1, 0, "QAbstractItemModel", "QAbstractItemModel"); qmlRegisterUncreatableType("com.nextcloud.desktopclient", 1, 0, "activity", "Activity"); diff --git a/src/gui/owncloudsetupwizard.cpp b/src/gui/owncloudsetupwizard.cpp index d70dd358171be..d63cfc06a88d6 100644 --- a/src/gui/owncloudsetupwizard.cpp +++ b/src/gui/owncloudsetupwizard.cpp @@ -17,6 +17,8 @@ #include "owncloudsetupwizard.h" #include "owncloudpropagator_p.h" #include "sslerrordialog.h" +#include "systray.h" +#include "wizard/accountwizardcontroller.h" #include "wizard/owncloudwizard.h" #include "wizard/owncloudwizardcommon.h" #include "account.h" @@ -36,6 +38,11 @@ #include #include #include +#include +#include +#include +#include +#include using namespace Qt::StringLiterals; @@ -63,6 +70,9 @@ OwncloudSetupWizard::OwncloudSetupWizard(QObject *parent) OwncloudSetupWizard::~OwncloudSetupWizard() { + if (_qmlWizardWindow) { + _qmlWizardWindow->deleteLater(); + } _ocWizard->deleteLater(); } @@ -85,9 +95,17 @@ void OwncloudSetupWizard::runWizard(QObject *obj, const char *amember, QWidget * owncloudSetupWizard = new OwncloudSetupWizard(parent); connect(owncloudSetupWizard, SIGNAL(ownCloudWizardDone(int)), obj, amember); - connect(owncloudSetupWizard->_ocWizard, &OwncloudWizard::wizardClosed, obj, [] { owncloudSetupWizard.clear(); }); FolderMan::instance()->setSyncEnabled(false); + // The widget wizard stays as a fallback until the QML flow owns every setup branch. + if (owncloudSetupWizard->startQmlWizard()) { + connect(owncloudSetupWizard->_qmlController, &AccountWizardController::finished, obj, [] { + owncloudSetupWizard.clear(); + }); + return; + } + + connect(owncloudSetupWizard->_ocWizard, &OwncloudWizard::wizardClosed, obj, [] { owncloudSetupWizard.clear(); }); owncloudSetupWizard->startWizard(); } @@ -97,6 +115,13 @@ bool OwncloudSetupWizard::bringWizardToFrontIfVisible() return false; } + if (owncloudSetupWizard->_qmlWizardWindow) { + owncloudSetupWizard->_qmlWizardWindow->show(); + owncloudSetupWizard->_qmlWizardWindow->raise(); + owncloudSetupWizard->_qmlWizardWindow->requestActivate(); + return true; + } + ownCloudGui::raiseDialog(owncloudSetupWizard->_ocWizard); return true; } @@ -148,6 +173,66 @@ void OwncloudSetupWizard::startWizard() _ocWizard->raise(); } +bool OwncloudSetupWizard::startQmlWizard() +{ + auto *engine = Systray::instance()->trayEngine(); + if (!engine) { + qCWarning(lcWizard) << "Cannot start QML account wizard without a QML engine."; + return false; + } + + _qmlController = new AccountWizardController(this); + QQmlComponent component(engine, QStringLiteral("qrc:/qml/src/gui/wizard/qml/AccountWizardWindow.qml")); + QVariantMap initialProperties; + initialProperties.insert(QStringLiteral("controller"), QVariant::fromValue(_qmlController)); + auto *createdObject = component.createWithInitialProperties(initialProperties); + + if (component.isError()) { + qCWarning(lcWizard) << "Failed to load QML account wizard:" << component.errors(); + } + + _qmlWizardWindow = qobject_cast(createdObject); + if (!_qmlWizardWindow) { + if (createdObject) { + createdObject->deleteLater(); + } + _qmlController->deleteLater(); + _qmlController = nullptr; + return false; + } + +#if defined(Q_OS_MACOS) && QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) + _qmlWizardWindow->setFlag(Qt::ExpandedClientAreaHint, true); + _qmlWizardWindow->setFlag(Qt::NoTitleBarBackgroundHint, true); +#endif + + connect(_qmlController, &AccountWizardController::finished, this, [this](int result) { + emit ownCloudWizardDone(result); + if (_qmlWizardWindow) { + _qmlWizardWindow->deleteLater(); + _qmlWizardWindow.clear(); + } + deleteLater(); + }); + + connect(_qmlController, &AccountWizardController::legacyWizardRequested, this, [this] { + if (_qmlWizardWindow) { + _qmlWizardWindow->deleteLater(); + _qmlWizardWindow.clear(); + } + _qmlController->deleteLater(); + _qmlController = nullptr; + + connect(_ocWizard, &OwncloudWizard::wizardClosed, this, [] { owncloudSetupWizard.clear(); }); + startWizard(); + }); + + _qmlWizardWindow->show(); + _qmlWizardWindow->raise(); + _qmlWizardWindow->requestActivate(); + return true; +} + // also checks if an installation is valid and determines auth type in a second step void OwncloudSetupWizard::slotCheckServer(const QUrl &serverURL, const OCC::WizardProxySettingsDialog::WizardProxySettings &proxySettings) { diff --git a/src/gui/owncloudsetupwizard.h b/src/gui/owncloudsetupwizard.h index cdc64001fe604..ec1b4574bacc0 100644 --- a/src/gui/owncloudsetupwizard.h +++ b/src/gui/owncloudsetupwizard.h @@ -19,12 +19,15 @@ #include "wizard/wizardproxysettingsdialog.h" +class QQuickWindow; + namespace OCC { class AccountState; class TermsOfServiceChecker; class OwncloudWizard; +class AccountWizardController; /** * @brief The OwncloudSetupWizard class @@ -67,6 +70,7 @@ private slots: explicit OwncloudSetupWizard(QObject *parent = nullptr); ~OwncloudSetupWizard() override; void startWizard(); + bool startQmlWizard(); void testOwnCloudConnect(); void createRemoteFolder(); void finalizeSetup(bool); @@ -75,6 +79,8 @@ private slots: bool checkDowngradeAdvised(QNetworkReply *reply); OwncloudWizard *_ocWizard = nullptr; + AccountWizardController *_qmlController = nullptr; + QPointer _qmlWizardWindow; QString _initLocalFolder; QString _remoteFolder; }; diff --git a/src/gui/tray/usermodel.cpp b/src/gui/tray/usermodel.cpp index 8ad55aa7f9af4..f96242f9bc2b6 100644 --- a/src/gui/tray/usermodel.cpp +++ b/src/gui/tray/usermodel.cpp @@ -34,12 +34,18 @@ #endif #include +#include #include +#include +#include +#include #include -#include +#include #include #include #include +#include +#include #include #include @@ -234,6 +240,96 @@ bool isSyncStatusError(const OCC::SyncResult::Status status) return false; } +bool confirmAccountRemoval(const QString &accountName) +{ + QDialog dialog(QApplication::activeWindow()); + dialog.setObjectName(QStringLiteral("accountRemovalDialog")); + dialog.setWindowModality(Qt::WindowModal); + dialog.setWindowTitle({}); + dialog.setFixedWidth(500); + +#if defined(Q_OS_MACOS) && QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) + dialog.setWindowFlag(Qt::ExpandedClientAreaHint, true); + dialog.setWindowFlag(Qt::NoTitleBarBackgroundHint, true); +#endif + + auto *mainLayout = new QVBoxLayout(&dialog); + mainLayout->setContentsMargins(24, 24, 24, 24); + mainLayout->setSpacing(14); + + auto *headerLabel = new QLabel(OCC::UserModel::tr("Remove account connection?"), &dialog); + headerLabel->setObjectName(QStringLiteral("accountRemovalHeader")); + headerLabel->setWordWrap(true); + mainLayout->addWidget(headerLabel); + + auto *messageLabel = new QLabel(&dialog); + messageLabel->setObjectName(QStringLiteral("accountRemovalMessage")); + messageLabel->setTextFormat(Qt::PlainText); + messageLabel->setText( + OCC::UserModel::tr("Do you want to remove the connection to the account %1?").arg(accountName)); + messageLabel->setWordWrap(true); + mainLayout->addWidget(messageLabel); + + auto *noteLabel = new QLabel(OCC::UserModel::tr("No files will be deleted."), &dialog); + noteLabel->setObjectName(QStringLiteral("accountRemovalNote")); + noteLabel->setWordWrap(true); + mainLayout->addWidget(noteLabel); + + mainLayout->addSpacing(10); + + auto *buttonLayout = new QHBoxLayout; + buttonLayout->setContentsMargins(0, 0, 0, 0); + buttonLayout->setSpacing(8); + buttonLayout->addStretch(); + + auto *cancelButton = new QPushButton(OCC::UserModel::tr("Cancel"), &dialog); + auto *removeButton = new QPushButton(OCC::UserModel::tr("Remove connection"), &dialog); + removeButton->setObjectName(QStringLiteral("accountRemovalPrimaryButton")); + removeButton->setDefault(true); + + for (auto *button : {cancelButton, removeButton}) { + button->setMinimumHeight(36); + button->setMinimumWidth(112); + button->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); + auto buttonFont = button->font(); + buttonFont.setPointSize(16); + buttonFont.setWeight(QFont::Medium); + button->setFont(buttonFont); + } + + buttonLayout->addWidget(cancelButton); + buttonLayout->addWidget(removeButton); + mainLayout->addLayout(buttonLayout); + + QObject::connect(cancelButton, &QPushButton::clicked, &dialog, &QDialog::reject); + QObject::connect(removeButton, &QPushButton::clicked, &dialog, &QDialog::accept); + + dialog.setStyleSheet(QStringLiteral( + "QDialog#accountRemovalDialog { background: white; }" + "QLabel#accountRemovalHeader { color: #111111; font-size: 20px; font-weight: bold; }" + "QLabel#accountRemovalMessage { color: #111111; font-size: 15px; }" + "QLabel#accountRemovalNote { color: #606060; font-size: 15px; }" + "QPushButton {" + " min-height: 36px;" + " max-height: 36px;" + " border: 1px solid #d5e0e7;" + " border-radius: 8px;" + " padding: 0px 18px;" + " background-color: #e7eef4;" + " color: #111111;" + " font-size: 16pt;" + " font-weight: 500;" + " }" + "QPushButton:hover { background-color: #e7eef4; }" + "QPushButton:pressed { background-color: #dce8f0; }" + "QPushButton#accountRemovalPrimaryButton { background-color: #2b659a; border: none; color: white; }" + "QPushButton#accountRemovalPrimaryButton:hover { background-color: #2b659a; color: white; }" + "QPushButton#accountRemovalPrimaryButton:pressed { background-color: #245783; color: white; }" + )); + + return dialog.exec() == QDialog::Accepted; +} + } // namespace namespace OCC { @@ -2358,17 +2454,7 @@ void UserModel::removeAccount(const int id) return; } - QMessageBox messageBox(QMessageBox::Question, - tr("Confirm Account Removal"), - tr("

Do you really want to remove the connection to the account %1?

" - "

Note: This will not delete any files.

") - .arg(Utility::escape(_users[id]->name())), - QMessageBox::NoButton); - const auto * const yesButton = messageBox.addButton(tr("Remove connection"), QMessageBox::YesRole); - messageBox.addButton(tr("Cancel"), QMessageBox::NoRole); - - messageBox.exec(); - if (messageBox.clickedButton() != yesButton) { + if (!confirmAccountRemoval(_users[id]->name())) { return; } diff --git a/src/gui/wizard/accountwizardcontroller.cpp b/src/gui/wizard/accountwizardcontroller.cpp new file mode 100644 index 0000000000000..ff157e0c3c86f --- /dev/null +++ b/src/gui/wizard/accountwizardcontroller.cpp @@ -0,0 +1,1183 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "wizard/accountwizardcontroller.h" + +#include "account.h" +#include "accountmanager.h" +#include "clientproxy.h" +#include "common/utility.h" +#include "common/vfs.h" +#include "configfile.h" +#include "creds/credentialsfactory.h" +#include "creds/webflowcredentials.h" +#include "filesystem.h" +#include "folder.h" +#include "folderman.h" +#include "guiutility.h" +#include "networkjobs.h" +#include "owncloudpropagator_p.h" +#include "selectivesyncdialog.h" +#include "theme.h" + +#ifdef BUILD_FILE_PROVIDER_MODULE +#include "gui/macOS/fileprovidersettingscontroller.h" +#endif + +#ifdef Q_OS_MACOS +#include "common/utility_mac_sandbox.h" +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace Qt::StringLiterals; + +namespace OCC { + +Q_LOGGING_CATEGORY(lcAccountWizardController, "nextcloud.gui.accountwizardcontroller", QtInfoMsg) + +AccountWizardController::AccountWizardController(QObject *parent) + : QObject(parent) +{ + initialiseAccount(); + + ConfigFile cfg; + const auto largeFolderLimit = cfg.newBigFolderSizeLimit(); + _askBeforeLargeFolders = largeFolderLimit.first; + _largeFolderThresholdMb = static_cast(largeFolderLimit.second); + _askBeforeExternalStorage = cfg.confirmExternalStorage(); + + if (canUseVirtualFiles()) { + _syncMode = VirtualFiles; + } +} + +AccountWizardController::~AccountWizardController() = default; + +void AccountWizardController::initialiseAccount() +{ + ConfigFile cfg; + if (!cfg.overrideServerUrl().isEmpty()) { + Theme::instance()->setOverrideServerUrl(cfg.overrideServerUrl()); + Theme::instance()->setForceOverrideServerUrl(true); + Theme::instance()->setVfsEnabled(cfg.isVfsEnabled()); + Theme::instance()->setStartLoginFlowAutomatically(true); + } + + const auto defaultUrl = + Theme::instance()->multipleOverrideServers() ? QString{} : Theme::instance()->overrideServerUrl(); + + _remoteFolder = Theme::instance()->defaultServerFolder(); + setServerUrl(defaultUrl); + const auto hasForcedConcreteServerUrl = + Theme::instance()->forceOverrideServerUrl() && !defaultUrl.isEmpty() && !Theme::instance()->multipleOverrideServers(); + setServerUrlEditable(!hasForcedConcreteServerUrl); +} + +void AccountWizardController::ensureAccount() +{ + if (_account) { + return; + } + + _account = AccountManager::createAccount(); + _account->setCredentials(CredentialsFactory::create("dummy")); +} + +AccountWizardController::Step AccountWizardController::currentStep() const +{ + return _currentStep; +} + +QString AccountWizardController::serverUrl() const +{ + return _serverUrl; +} + +void AccountWizardController::setServerUrl(const QString &serverUrl) +{ + if (_serverUrl == serverUrl) { + return; + } + + _serverUrl = serverUrl; + emit serverUrlChanged(); +} + +bool AccountWizardController::serverUrlEditable() const +{ + return _serverUrlEditable; +} + +bool AccountWizardController::busy() const +{ + return _busy; +} + +QString AccountWizardController::errorText() const +{ + return _errorText; +} + +QUrl AccountWizardController::loginUrl() const +{ + return _loginUrl; +} + +QString AccountWizardController::authStatusText() const +{ + return _authStatusText; +} + +QString AccountWizardController::userDisplayName() const +{ + return _userDisplayName; +} + +QString AccountWizardController::serverDisplayName() const +{ + return _serverDisplayName; +} + +QString AccountWizardController::avatarUrl() const +{ + return _avatarUrl; +} + +QString AccountWizardController::syncEverythingDescription() const +{ + return _syncEverythingDescription.isEmpty() + ? tr("Will require local storage") + : _syncEverythingDescription; +} + +QString AccountWizardController::localSyncFolder() const +{ + return _localSyncFolder; +} + +QString AccountWizardController::localSyncFolderDisplay() const +{ + return QDir::toNativeSeparators(_localSyncFolder); +} + +QString AccountWizardController::localSyncFolderError() const +{ + return _localSyncFolderError; +} + +bool AccountWizardController::localSyncFolderRequired() const +{ +#ifdef BUILD_FILE_PROVIDER_MODULE + return _syncMode != VirtualFiles; +#else + return true; +#endif +} + +AccountWizardController::SyncMode AccountWizardController::syncMode() const +{ + return _syncMode; +} + +bool AccountWizardController::canFinish() const +{ + return !localSyncFolderRequired() || _localSyncFolderValid; +} + +bool AccountWizardController::canUseVirtualFiles() const +{ +#ifdef BUILD_FILE_PROVIDER_MODULE + return true; +#elif defined(Q_OS_WIN) + return bestAvailableVfsMode() == Vfs::WindowsCfApi; +#else + return bestAvailableVfsMode() != Vfs::Off && Theme::instance()->showVirtualFilesOption(); +#endif +} + +bool AccountWizardController::needsSyncOptions() const +{ + return _needsSyncOptions; +} + +bool AccountWizardController::canSkipFolderConfiguration() const +{ + return true; +} + +bool AccountWizardController::askBeforeLargeFolders() const +{ + return _askBeforeLargeFolders; +} + +int AccountWizardController::largeFolderThresholdMb() const +{ + return _largeFolderThresholdMb; +} + +bool AccountWizardController::askBeforeExternalStorage() const +{ + return _askBeforeExternalStorage; +} + +QString AccountWizardController::appName() const +{ + return Theme::instance()->appNameGUI(); +} + +QString AccountWizardController::serverUrlPlaceholder() const +{ + return Theme::instance()->wizardUrlHint(); +} + +QString AccountWizardController::normalizeServerUrlInput(const QString &serverUrl, const QString &davPath) +{ + auto result = serverUrl.simplified(); + if (result.endsWith("index.php"_L1)) { + result.chop(9); + } + + auto cleanedDavPath = davPath; + if (!cleanedDavPath.isEmpty() && result.endsWith(cleanedDavPath)) { + result.chop(cleanedDavPath.length()); + } + if (cleanedDavPath.endsWith('/'_L1)) { + cleanedDavPath.chop(1); + if (!cleanedDavPath.isEmpty() && result.endsWith(cleanedDavPath)) { + result.chop(cleanedDavPath.length()); + } + } + + return result; +} + +void AccountWizardController::submitServerUrl() +{ + if (_busy) { + return; + } + + auto normalizedServerUrl = normalizeServerUrlInput(_serverUrl); + const auto url = QUrl::fromUserInput(normalizedServerUrl); + if (!url.isValid() || url.host().isEmpty()) { + setErrorText(tr("Server address does not seem to be valid")); + return; + } + + ensureAccount(); + normalizedServerUrl = normalizeServerUrlInput(_serverUrl, _account->davPath()); + const auto accountUrl = QUrl::fromUserInput(normalizedServerUrl); + setServerUrl(accountUrl.toString()); + startServerCheck(accountUrl); +} + +void AccountWizardController::startServerCheck(const QUrl &serverUrl) +{ + _account->setUrl(serverUrl); + _account->setCredentials(CredentialsFactory::create("dummy")); + _account->setProxyType(_proxySettings._proxyType); + + switch (_proxySettings._proxyType) { + case QNetworkProxy::HttpCachingProxy: + case QNetworkProxy::FtpCachingProxy: + case QNetworkProxy::NoProxy: + case QNetworkProxy::DefaultProxy: + _account->networkAccessManager()->setProxy({QNetworkProxy::NoProxy}); + break; + case QNetworkProxy::Socks5Proxy: + case QNetworkProxy::HttpProxy: + _account->setProxyHostName(_proxySettings._host); + _account->setProxyPort(_proxySettings._port); + _account->setProxyNeedsAuth(_proxySettings._needsAuth == WizardProxySettingsDialog::ProxyAuthentication::AuthenticationRequired); + if (_account->proxyNeedsAuth()) { + _account->setProxyUser(_proxySettings._user); + _account->setProxyPassword(_proxySettings._password); + } + break; + } + + _account->setSslConfiguration(QSslConfiguration::defaultConfiguration()); + _account->networkAccessManager()->clearAccessCache(); + + setErrorText({}); + setBusy(true); + setAuthStatusText(tr("Checking server address") + QStringLiteral("…")); + + if (ClientProxy::isUsingSystemDefault() || _account->proxyType() == QNetworkProxy::DefaultProxy) { + ClientProxy::lookupSystemProxyAsync(_account->url(), this, SLOT(slotSystemProxyLookupDone(QNetworkProxy))); + } else { + _account->networkAccessManager()->setProxy(QNetworkProxy(QNetworkProxy::DefaultProxy)); + QMetaObject::invokeMethod(this, "slotFindServer", Qt::QueuedConnection); + } +} + +void AccountWizardController::slotSystemProxyLookupDone(const QNetworkProxy &proxy) +{ + _account->networkAccessManager()->setProxy(proxy); + slotFindServer(); +} + +void AccountWizardController::slotFindServer() +{ + auto *job = new CheckServerJob(_account, this); + job->setIgnoreCredentialFailure(true); + connect(job, &CheckServerJob::instanceFound, this, &AccountWizardController::slotFoundServer); + connect(job, &CheckServerJob::instanceNotFound, this, &AccountWizardController::slotFindServerBehindRedirect); + connect(job, &CheckServerJob::timeout, this, &AccountWizardController::slotNoServerFoundTimeout); + job->setTimeout((_account->url().scheme() == "https"_L1) ? 30 * 1000 : 10 * 1000); + job->start(); +} + +void AccountWizardController::slotFindServerBehindRedirect() +{ + auto redirectCheckJob = _account->sendRequest("GET", _account->url()); + redirectCheckJob->setTimeout(qMin(2000ll, redirectCheckJob->timeoutMsec())); + + auto permanentRedirects = std::make_shared(0); + connect(redirectCheckJob, &AbstractNetworkJob::redirected, this, + [permanentRedirects, account = _account](QNetworkReply *reply, const QUrl &targetUrl, int count) { + const auto httpCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (count == *permanentRedirects && (httpCode == 301 || httpCode == 308)) { + qCInfo(lcAccountWizardController) << account->url() << "was redirected to" << targetUrl; + account->setUrl(targetUrl); + *permanentRedirects += 1; + } + }); + + connect(redirectCheckJob, &SimpleNetworkJob::finishedSignal, this, [this] { + auto *job = new CheckServerJob(_account, this); + job->setIgnoreCredentialFailure(true); + connect(job, &CheckServerJob::instanceFound, this, &AccountWizardController::slotFoundServer); + connect(job, &CheckServerJob::instanceNotFound, this, &AccountWizardController::slotNoServerFound); + connect(job, &CheckServerJob::timeout, this, &AccountWizardController::slotNoServerFoundTimeout); + job->setTimeout((_account->url().scheme() == "https"_L1) ? 30 * 1000 : 10 * 1000); + job->start(); + }); +} + +void AccountWizardController::slotFoundServer(const QUrl &url, const QJsonObject &info) +{ + const auto serverVersion = CheckServerJob::version(info); + _account->setServerVersion(serverVersion); + + if (url != _account->url()) { + _account->setUrl(url); + setServerUrl(url.toString()); + } + + setServerDisplayName(url.host()); + setAuthStatusText(tr("Preparing browser login") + QStringLiteral("…")); + + if (_account->isPublicShareLink()) { + setBusy(false); + setErrorText(tr("Public share link setup is still handled by the classic account wizard.")); + emit legacyWizardRequested(); + return; + } + + slotDetermineAuthType(); +} + +void AccountWizardController::slotNoServerFound(QNetworkReply *reply) +{ + const auto job = qobject_cast(sender()); + QString message; + if (!_account->url().isValid()) { + message = tr("Invalid URL"); + } else { + message = tr("Failed to connect to %1 at %2:\n%3") + .arg(Theme::instance()->appNameGUI(), + _account->url().toString(), + job ? job->errorString() : QString{}); + } + + setBusy(false); + setErrorText(message); + _account->resetRejectedCertificates(); + + if (checkDowngradeAdvised(reply)) { + qCInfo(lcAccountWizardController) << "HTTPS connection failed and HTTP retry might be possible."; + } +} + +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()))); +} + +void AccountWizardController::slotDetermineAuthType() +{ + auto *job = new DetermineAuthTypeJob(_account, this); + connect(job, &DetermineAuthTypeJob::authType, this, [this](DetermineAuthTypeJob::AuthType type) { + switch (type) { + case DetermineAuthTypeJob::LoginFlowV2: + startFlow2Auth(); + break; +#ifdef WITH_WEBENGINE + case DetermineAuthTypeJob::WebViewFlow: + if (ConfigFile().forceLoginV2()) { + startFlow2Auth(); + break; + } + [[fallthrough]]; +#endif + case DetermineAuthTypeJob::Basic: + case DetermineAuthTypeJob::NoAuthType: + setBusy(false); + setErrorText(tr("This server requires an authentication method that is still handled by the classic account wizard.")); + emit legacyWizardRequested(); + break; + } + }); + job->start(); +} + +void AccountWizardController::startFlow2Auth() +{ + _account->setCredentials(CredentialsFactory::create("http")); + + const auto oldAuth = _flow2Auth.release(); + if (oldAuth) { + oldAuth->deleteLater(); + } + + _flow2Auth = std::make_unique(_account.data(), this); + connect(_flow2Auth.get(), &Flow2Auth::result, this, &AccountWizardController::slotFlow2AuthResult, Qt::QueuedConnection); + connect(_flow2Auth.get(), &Flow2Auth::statusChanged, this, &AccountWizardController::slotFlow2StatusChanged); + + setBusy(false); + setErrorText({}); + setCurrentStep(BrowserAuthStep); + _flow2Auth->start(); +} + +void AccountWizardController::openBrowserLogin() +{ + if (_flow2Auth) { + setErrorText({}); + _flow2Auth->openBrowser(); + } +} + +void AccountWizardController::copyLoginLink() +{ + if (_flow2Auth) { + setErrorText({}); + _flow2Auth->copyLinkToClipboard(); + } +} + +void AccountWizardController::openSignup() +{ + Utility::openBrowser(QUrl(QStringLiteral("https://nextcloud.com/register"))); +} + +void AccountWizardController::openSelfHostedServerGuide() +{ + Utility::openBrowser(QUrl(QStringLiteral("https://docs.nextcloud.com/server/latest/admin_manual/installation/#installation"))); +} + +void AccountWizardController::openProxySettings() +{ + const auto serverUrl = QUrl::fromUserInput(_serverUrl); + if (!_proxySettingsDialog) { + _proxySettingsDialog = new WizardProxySettingsDialog(serverUrl, _proxySettings); + _proxySettingsDialog->setAttribute(Qt::WA_DeleteOnClose); + connect(_proxySettingsDialog, &WizardProxySettingsDialog::proxySettingsAccepted, this, + [this](const WizardProxySettingsDialog::WizardProxySettings &proxySettings) { + _proxySettings = proxySettings; + }); + } else { + _proxySettingsDialog->setServerUrl(serverUrl); + _proxySettingsDialog->setProxySettings(_proxySettings); + } + + _proxySettingsDialog->open(); +} + +void AccountWizardController::pollNow() +{ + if (_flow2Auth) { + _flow2Auth->slotPollNow(); + } +} + +void AccountWizardController::slotFlow2AuthResult(Flow2Auth::Result result, const QString &errorString, const QString &user, const QString &appPassword) +{ + switch (result) { + case Flow2Auth::NotSupported: + setErrorText(tr("Unable to open the Browser, please copy the link to your Browser.")); + break; + case Flow2Auth::Error: + setBusy(false); + setErrorText(errorString); + break; + case Flow2Auth::LoggedIn: + connectToAuthenticatedAccount(_account->url().toString(), user, appPassword); + break; + } +} + +void AccountWizardController::slotFlow2StatusChanged(Flow2Auth::PollStatus status, int secondsLeft) +{ + if (_flow2Auth) { + setLoginUrl(_flow2Auth->authorisationLink()); + } + + switch (status) { + case Flow2Auth::statusPollCountdown: + setBusy(false); + setAuthStatusText(tr("Waiting for authorization") + QStringLiteral("… (%1)").arg(secondsLeft)); + break; + case Flow2Auth::statusPollNow: + setBusy(true); + setAuthStatusText(tr("Polling for authorization") + QStringLiteral("…")); + break; + case Flow2Auth::statusFetchToken: + setBusy(true); + setAuthStatusText(tr("Starting authorization") + QStringLiteral("…")); + break; + case Flow2Auth::statusCopyLinkToClipboard: + setBusy(false); + setAuthStatusText(tr("Link copied to clipboard.")); + break; + } +} + +void AccountWizardController::connectToAuthenticatedAccount(const QString &url, const QString &user, const QString &appPassword) +{ + setBusy(true); + setErrorText({}); + setAuthStatusText(tr("Checking account access") + QStringLiteral("…")); + + auto *credentials = new WebFlowCredentials(user, appPassword); + _account->setCredentials(credentials); + credentials->persist(); + + const auto fetchUserNameJob = new JsonApiJob(_account, QStringLiteral("/ocs/v1.php/cloud/user"), this); + connect(fetchUserNameJob, &JsonApiJob::jsonReceived, this, [this, url](const QJsonDocument &json, int statusCode) { + if (statusCode != 100) { + qCWarning(lcAccountWizardController) << "Could not fetch username."; + } + + sender()->deleteLater(); + + const auto objData = json.object().value("ocs"_L1).toObject().value("data"_L1).toObject(); + const auto userId = objData.value("id"_L1).toString(QString()); + const auto displayName = objData.value("display-name"_L1).toString(QString()); + _account->setDavUser(userId); + _account->setDavDisplayName(displayName); + setUserDisplayName(displayName.isEmpty() ? userId : displayName); + setServerUrl(url); + setAvatarUrl({}); + fetchUserAvatar(); + + testOwnCloudConnect(); + }); + fetchUserNameJob->start(); +} + +void AccountWizardController::testOwnCloudConnect() +{ + auto *job = new PropfindJob(_account, "/", this); + job->setIgnoreCredentialFailure(true); + job->setFollowRedirects(false); + job->setProperties(QList() << "getlastmodified"); + connect(job, &PropfindJob::result, this, &AccountWizardController::completeAuthentication); + connect(job, &PropfindJob::finishedWithError, this, &AccountWizardController::slotAuthError); + job->start(); +} + +void AccountWizardController::slotAuthError(QNetworkReply *reply) +{ + QString errorMessage; + + if (!reply) { + setBusy(false); + setErrorText(tr("There was an invalid response to an authenticated WebDAV request")); + return; + } + + if (reply->error() == QNetworkReply::ContentAccessDenied) { + const auto davException = OCC::getExceptionFromReply(reply); + if (!davException.first.isEmpty() && davException.first == QByteArrayLiteral(R"(OCA\TermsOfService\TermsNotSignedException)")) { + setBusy(false); + setErrorText(tr("Terms of service acceptance is still handled by the classic account wizard.")); + emit legacyWizardRequested(); + return; + } + } + + const auto redirectUrl = reply->attribute(QNetworkRequest::RedirectionTargetAttribute).toUrl(); + if (!redirectUrl.isEmpty()) { + auto adjustedRedirectUrl = redirectUrl; + auto path = adjustedRedirectUrl.path(); + const auto expectedPath = u'/' + _account->davPath(); + if (path.endsWith(expectedPath)) { + path.chop(expectedPath.size()); + adjustedRedirectUrl.setPath(path); + _account->setUrl(adjustedRedirectUrl); + testOwnCloudConnect(); + return; + } + + errorMessage = tr("The authenticated request to the server was redirected to \"%1\". The URL is bad, the server is misconfigured.") + .arg(Utility::escape(redirectUrl.toString())); + } else if (reply->error() == QNetworkReply::ContentNotFoundError) { + completeAuthentication(); + return; + } else if (reply->error() != QNetworkReply::NoError) { + const auto job = qobject_cast(sender()); + if (!_account->credentials()->stillValid(reply)) { + errorMessage = tr("Access forbidden by server. To verify that you have proper access, open the service in your browser."); + } else if (job) { + errorMessage = job->errorStringParsingBody(); + } + } else { + errorMessage = tr("There was an invalid response to an authenticated WebDAV request"); + } + + setBusy(false); + setCurrentStep(BrowserAuthStep); + setErrorText(errorMessage); +} + +void AccountWizardController::completeAuthentication() +{ + setBusy(false); + setAuthStatusText(tr("Account connected.")); + initialiseLocalSyncFolder(); + fetchRootFolderSize(); + +#if defined(Q_OS_WIN) || defined(Q_OS_MACOS) + setNeedsSyncOptions(!canUseVirtualFiles()); +#else + setNeedsSyncOptions(true); +#endif + + if (needsSyncOptions()) { + setCurrentStep(SyncOptionsStep); + } else { + finish(); + } +} + +void AccountWizardController::fetchUserAvatar() +{ + if (!_account || _account->davUser().isEmpty() || _account->isPublicShareLink()) { + return; + } + + auto avatarSize = 80; + if (Theme::isHidpi()) { + avatarSize *= 2; + } + + const auto avatarJob = new AvatarJob(_account, _account->davUser(), avatarSize, this); + avatarJob->setTimeout(20 * 1000); + connect(avatarJob, &AvatarJob::avatarPixmap, this, [this](const QImage &avatarImage) { + if (avatarImage.isNull()) { + return; + } + + _account->setAvatar(avatarImage); + + QByteArray pngData; + QBuffer pngBuffer(&pngData); + pngBuffer.open(QIODevice::WriteOnly); + AvatarJob::makeCircularAvatar(avatarImage).save(&pngBuffer, "PNG"); + setAvatarUrl(QStringLiteral("data:image/png;base64,") + QString::fromLatin1(pngData.toBase64())); + }); + avatarJob->start(); +} + +void AccountWizardController::fetchRootFolderSize() +{ + if (!_account) { + return; + } + + const auto quotaJob = new PropfindJob(_account, _remoteFolder, this); + quotaJob->setProperties(QList() << "http://owncloud.org/ns:size"); + + connect(quotaJob, &PropfindJob::result, this, [this](const QVariantMap &result) { + bool ok = false; + auto size = result.value("size"_L1).toLongLong(&ok); + if (!ok) { + const auto floatingPointSize = result.value("size"_L1).toDouble(&ok); + size = ok ? static_cast(floatingPointSize) : -1; + } + + if (size >= 0) { + setSyncEverythingDescription(tr("Will require %1 of storage").arg(Utility::octetsToString(size))); + } + }); + connect(quotaJob, &PropfindJob::finishedWithError, this, [this](QNetworkReply *) { + setSyncEverythingDescription({}); + }); + quotaJob->start(); +} + +void AccountWizardController::cancel() +{ + emit finished(QDialog::Rejected); +} + +void AccountWizardController::goBack() +{ + setErrorText({}); + if (_currentStep == BrowserAuthStep || _currentStep == SyncOptionsStep) { + const auto oldAuth = _flow2Auth.release(); + if (oldAuth) { + oldAuth->deleteLater(); + } + setCurrentStep(ServerStep); + setBusy(false); + setAuthStatusText({}); + } +} + +void AccountWizardController::finish() +{ + if (!_account) { + emit finished(QDialog::Rejected); + return; + } + + if (localSyncFolderRequired()) { + validateLocalSyncFolder(); + if (!_localSyncFolderValid || !ensureLocalSyncFolder()) { + return; + } + } + + if (_syncMode == SyncEverything) { + ConfigFile cfgFile; + cfgFile.setNewBigFolderSizeLimit(_askBeforeLargeFolders, _largeFolderThresholdMb); + cfgFile.setConfirmExternalStorage(_askBeforeExternalStorage); + } + + const auto accountState = applyAccountChanges(); + if (localSyncFolderRequired() && !createSyncFolder(accountState)) { + AccountManager::instance()->deleteAccount(accountState); + return; + } + + setCurrentStep(CompletedStep); + emit finished(QDialog::Accepted); +} + +void AccountWizardController::skipFolderConfiguration() +{ + if (!_account) { + emit finished(QDialog::Rejected); + return; + } + + applyAccountChanges(); + setCurrentStep(CompletedStep); + emit finished(QDialog::Accepted); +} + +AccountState *AccountWizardController::applyAccountChanges() +{ + auto manager = AccountManager::instance(); + AccountState *accountState = nullptr; + +#ifdef BUILD_FILE_PROVIDER_MODULE + if (_syncMode == VirtualFiles) { + accountState = manager->addAccount(_account); + const auto accountId = accountState->account()->userIdAtHostWithPort(); + Mac::FileProviderSettingsController::instance()->setVfsEnabledForAccount(accountId, true, false); + } else +#endif + { + accountState = manager->addAccount(_account); + } + + manager->saveAccount(_account); + _account = AccountManager::createAccount(); + return accountState; +} + +void AccountWizardController::initialiseLocalSyncFolder() +{ + QString localFolder = Theme::instance()->defaultClientFolder(); + if (!QDir(localFolder).isAbsolute()) { +#ifdef Q_OS_MACOS + localFolder = Utility::getRealHomeDirectory() + QLatin1Char('/') + localFolder; +#else + localFolder = QDir::homePath() + QLatin1Char('/') + localFolder; +#endif + } + + ConfigFile cfg; + const auto overrideLocalDir = !cfg.overrideLocalDir().isEmpty(); + if (overrideLocalDir) { + localFolder = cfg.overrideLocalDir(); + } + + const auto strategy = overrideLocalDir + ? FolderMan::GoodPathStrategy::AllowOverrideExistingPath + : FolderMan::GoodPathStrategy::AllowOnlyNewPath; + setLocalSyncFolder(FolderMan::instance()->findGoodPathForNewSyncFolder(localFolder, localFolderServerUrl(), strategy), overrideLocalDir); +} + +void AccountWizardController::setLocalSyncFolder(const QString &localSyncFolder, bool selectedByUser) +{ + const auto normalizedLocalSyncFolder = QDir::fromNativeSeparators(localSyncFolder); + const auto localSyncFolderSelected = _localSyncFolderSelected || selectedByUser; + if (_localSyncFolder == normalizedLocalSyncFolder && _localSyncFolderSelected == localSyncFolderSelected) { + validateLocalSyncFolder(); + return; + } + + _localSyncFolder = normalizedLocalSyncFolder; + _localSyncFolderSelected = localSyncFolderSelected; + emit localSyncFolderChanged(); + validateLocalSyncFolder(); +} + +void AccountWizardController::validateLocalSyncFolder() +{ + const auto oldCanFinish = canFinish(); + const auto pathValidity = FolderMan::instance()->checkPathValidityForNewFolder(_localSyncFolder, localFolderServerUrl()); + auto localSyncFolderError = pathValidity.second; +#ifdef Q_OS_MACOS + if (localSyncFolderRequired() && !_localSyncFolderSelected) { + localSyncFolderError = tr("Please choose a local sync folder."); + } +#endif + const auto localSyncFolderValid = !localSyncFolderRequired() || localSyncFolderError.isEmpty(); + + if (_localSyncFolderError != localSyncFolderError) { + _localSyncFolderError = localSyncFolderError; + emit localSyncFolderErrorChanged(); + } + + if (_localSyncFolderValid != localSyncFolderValid) { + _localSyncFolderValid = localSyncFolderValid; + if (oldCanFinish != canFinish()) { + emit canFinishChanged(); + } + } +} + +bool AccountWizardController::ensureLocalSyncFolder() +{ + const auto localFolder = FolderDefinition::prepareLocalPath(_localSyncFolder); + QDir dir(localFolder); + if (!dir.exists()) { + qCInfo(lcAccountWizardController) << "Creating local sync folder" << localFolder; + if (!dir.mkpath(".")) { + setErrorText(tr("Could not create local folder %1").arg(Utility::escape(QDir::toNativeSeparators(localFolder)))); + return false; + } + } + + FileSystem::setFolderMinimumPermissions(localFolder); + Utility::setupFavLink(localFolder); + return true; +} + +bool AccountWizardController::createSyncFolder(AccountState *accountState) +{ + if (!accountState) { + setErrorText(tr("Account setup failed while creating the sync folder.")); + return false; + } + + FolderDefinition folderDefinition; + folderDefinition.localPath = FolderDefinition::prepareLocalPath(_localSyncFolder); + folderDefinition.targetPath = FolderDefinition::prepareTargetPath(_remoteFolder); + folderDefinition.ignoreHiddenFiles = FolderMan::instance()->ignoreHiddenFiles(); + +#ifndef BUILD_FILE_PROVIDER_MODULE + if (_syncMode == VirtualFiles) { + folderDefinition.virtualFilesMode = bestAvailableVfsMode(); + } +#endif + +#ifdef Q_OS_WIN + if (FolderMan::instance()->navigationPaneHelper().showInExplorerNavigationPane()) { + folderDefinition.navigationPaneClsid = QUuid::createUuid(); + } +#endif + + auto *folderMan = FolderMan::instance(); + folderMan->setSyncEnabled(false); + const auto folder = folderMan->addFolder(accountState, folderDefinition); + folderMan->setSyncEnabled(true); + + if (!folder) { + setErrorText(tr("Could not create the sync folder.")); + return false; + } + + if (folderDefinition.virtualFilesMode != Vfs::Off && _syncMode == VirtualFiles) { + folder->setRootPinState(PinState::OnlineOnly); + } + + folder->journalDb()->setSelectiveSyncList(SyncJournalDb::SelectiveSyncBlackList, _selectiveSyncBlacklist); + folder->journalDb()->setSelectiveSyncList(SyncJournalDb::SelectiveSyncWhiteList, QStringList() << QLatin1String("/")); + folderMan->scheduleAllFolders(); + return true; +} + +QUrl AccountWizardController::localFolderServerUrl() const +{ + if (!_account) { + return {}; + } + + auto url = _account->url(); + url.setUserName(_account->credentials() ? _account->credentials()->user() : QString{}); + return url; +} + +void AccountWizardController::setSyncMode(int syncMode) +{ + if (syncMode < SyncEverything || syncMode > VirtualFiles) { + return; + } + + const auto newSyncMode = static_cast(syncMode); + if (_syncMode == newSyncMode) { + return; + } + + if (newSyncMode == VirtualFiles && !canUseVirtualFiles()) { + return; + } + + const auto oldLocalSyncFolderRequired = localSyncFolderRequired(); + const auto oldCanFinish = canFinish(); + _syncMode = newSyncMode; + emit syncModeChanged(); + + if (oldLocalSyncFolderRequired != localSyncFolderRequired()) { + emit localSyncFolderRequiredChanged(); + } + validateLocalSyncFolder(); + if (oldCanFinish != canFinish()) { + emit canFinishChanged(); + } +} + +void AccountWizardController::chooseLocalSyncFolder() +{ + QString startFolder = _localSyncFolder; + if (startFolder.isEmpty()) { +#ifdef Q_OS_MACOS + startFolder = Utility::getRealHomeDirectory(); +#else + startFolder = QDir::homePath(); +#endif + } + + const auto selectedFolder = QFileDialog::getExistingDirectory(nullptr, + tr("Local Sync Folder"), + startFolder, + QFileDialog::ShowDirsOnly); + if (selectedFolder.isEmpty()) { + return; + } + + setLocalSyncFolder(selectedFolder, true); +} + +void AccountWizardController::openSelectiveSync() +{ + if (!_account) { + return; + } + + setSyncMode(SelectiveSync); + if (!_selectiveSyncDialog) { + _selectiveSyncDialog = new SelectiveSyncDialog(_account, _remoteFolder, _selectiveSyncBlacklist); + _selectiveSyncDialog->setAttribute(Qt::WA_DeleteOnClose); + connect(_selectiveSyncDialog, &SelectiveSyncDialog::finished, this, [this] { + if (!_selectiveSyncDialog) { + return; + } + if (_selectiveSyncDialog->result() == QDialog::Accepted) { + _selectiveSyncBlacklist = _selectiveSyncDialog->createBlackList(); + } else if (_selectiveSyncBlacklist == QStringList("/")) { + _selectiveSyncBlacklist = _selectiveSyncDialog->oldBlackList(); + } + }); + } + _selectiveSyncDialog->open(); +} + +void AccountWizardController::openAdvancedOptions() +{ + emit advancedOptionsRequested(); +} + +void AccountWizardController::setAskBeforeLargeFolders(bool ask) +{ + if (_askBeforeLargeFolders == ask) { + return; + } + _askBeforeLargeFolders = ask; + emit askBeforeLargeFoldersChanged(); +} + +void AccountWizardController::setLargeFolderThresholdMb(int thresholdMb) +{ + const auto boundedThreshold = qMax(0, thresholdMb); + if (_largeFolderThresholdMb == boundedThreshold) { + return; + } + _largeFolderThresholdMb = boundedThreshold; + emit largeFolderThresholdMbChanged(); +} + +void AccountWizardController::setAskBeforeExternalStorage(bool ask) +{ + if (_askBeforeExternalStorage == ask) { + return; + } + _askBeforeExternalStorage = ask; + emit askBeforeExternalStorageChanged(); +} + +void AccountWizardController::setCurrentStep(Step step) +{ + if (_currentStep == step) { + return; + } + _currentStep = step; + emit currentStepChanged(); +} + +void AccountWizardController::setBusy(bool busy) +{ + if (_busy == busy) { + return; + } + _busy = busy; + emit busyChanged(); +} + +void AccountWizardController::setErrorText(const QString &errorText) +{ + if (_errorText == errorText) { + return; + } + _errorText = errorText; + emit errorTextChanged(); +} + +void AccountWizardController::setLoginUrl(const QUrl &loginUrl) +{ + if (_loginUrl == loginUrl) { + return; + } + _loginUrl = loginUrl; + emit loginUrlChanged(); +} + +void AccountWizardController::setAuthStatusText(const QString &authStatusText) +{ + if (_authStatusText == authStatusText) { + return; + } + _authStatusText = authStatusText; + emit authStatusTextChanged(); +} + +void AccountWizardController::setUserDisplayName(const QString &userDisplayName) +{ + if (_userDisplayName == userDisplayName) { + return; + } + _userDisplayName = userDisplayName; + emit userDisplayNameChanged(); +} + +void AccountWizardController::setServerDisplayName(const QString &serverDisplayName) +{ + if (_serverDisplayName == serverDisplayName) { + return; + } + _serverDisplayName = serverDisplayName; + emit serverDisplayNameChanged(); +} + +void AccountWizardController::setAvatarUrl(const QString &avatarUrl) +{ + if (_avatarUrl == avatarUrl) { + return; + } + _avatarUrl = avatarUrl; + emit avatarUrlChanged(); +} + +void AccountWizardController::setSyncEverythingDescription(const QString &syncEverythingDescription) +{ + if (_syncEverythingDescription == syncEverythingDescription) { + return; + } + _syncEverythingDescription = syncEverythingDescription; + emit syncEverythingDescriptionChanged(); +} + +void AccountWizardController::setNeedsSyncOptions(bool needsSyncOptions) +{ + if (_needsSyncOptions == needsSyncOptions) { + return; + } + _needsSyncOptions = needsSyncOptions; + emit needsSyncOptionsChanged(); +} + +void AccountWizardController::setServerUrlEditable(bool editable) +{ + if (_serverUrlEditable == editable) { + return; + } + _serverUrlEditable = editable; + emit serverUrlEditableChanged(); +} + +bool AccountWizardController::checkDowngradeAdvised(QNetworkReply *reply) const +{ + if (!reply || reply->url().scheme() != "https"_L1) { + return false; + } + + switch (reply->error()) { + case QNetworkReply::NoError: + case QNetworkReply::ContentNotFoundError: + case QNetworkReply::AuthenticationRequiredError: + case QNetworkReply::HostNotFoundError: + return false; + default: + break; + } + + return !reply->hasRawHeader("Strict-Transport-Security"_L1); +} + +} // namespace OCC diff --git a/src/gui/wizard/accountwizardcontroller.h b/src/gui/wizard/accountwizardcontroller.h new file mode 100644 index 0000000000000..5826f2d6fea5e --- /dev/null +++ b/src/gui/wizard/accountwizardcontroller.h @@ -0,0 +1,229 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#ifndef ACCOUNTWIZARDCONTROLLER_H +#define ACCOUNTWIZARDCONTROLLER_H + +#include +#include +#include +#include + +#include + +#include "accountfwd.h" +#include "creds/flow2auth.h" +#include "networkjobs.h" +#include "wizard/wizardproxysettingsdialog.h" + +class QNetworkReply; + +namespace OCC { + +class SelectiveSyncDialog; +class AccountState; + +/** + * Backend for the QML account wizard. + * + * The controller owns the account setup state and keeps credentials, network + * jobs and authentication details in C++. QML is intentionally limited to + * rendering these properties and invoking high-level actions. + */ +class AccountWizardController : public QObject +{ + Q_OBJECT + Q_PROPERTY(Step currentStep READ currentStep NOTIFY currentStepChanged) + Q_PROPERTY(QString serverUrl READ serverUrl WRITE setServerUrl NOTIFY serverUrlChanged) + Q_PROPERTY(bool serverUrlEditable READ serverUrlEditable NOTIFY serverUrlEditableChanged) + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) + Q_PROPERTY(QString errorText READ errorText NOTIFY errorTextChanged) + Q_PROPERTY(QUrl loginUrl READ loginUrl NOTIFY loginUrlChanged) + Q_PROPERTY(QString authStatusText READ authStatusText NOTIFY authStatusTextChanged) + Q_PROPERTY(QString userDisplayName READ userDisplayName NOTIFY userDisplayNameChanged) + Q_PROPERTY(QString serverDisplayName READ serverDisplayName NOTIFY serverDisplayNameChanged) + Q_PROPERTY(QString avatarUrl READ avatarUrl NOTIFY avatarUrlChanged) + Q_PROPERTY(QString syncEverythingDescription READ syncEverythingDescription NOTIFY syncEverythingDescriptionChanged) + Q_PROPERTY(QString localSyncFolder READ localSyncFolder NOTIFY localSyncFolderChanged) + Q_PROPERTY(QString localSyncFolderDisplay READ localSyncFolderDisplay NOTIFY localSyncFolderChanged) + Q_PROPERTY(QString localSyncFolderError READ localSyncFolderError NOTIFY localSyncFolderErrorChanged) + Q_PROPERTY(bool localSyncFolderRequired READ localSyncFolderRequired NOTIFY localSyncFolderRequiredChanged) + Q_PROPERTY(SyncMode syncMode READ syncMode NOTIFY syncModeChanged) + Q_PROPERTY(bool canFinish READ canFinish NOTIFY canFinishChanged) + Q_PROPERTY(bool canUseVirtualFiles READ canUseVirtualFiles CONSTANT) + Q_PROPERTY(bool needsSyncOptions READ needsSyncOptions NOTIFY needsSyncOptionsChanged) + Q_PROPERTY(bool canSkipFolderConfiguration READ canSkipFolderConfiguration CONSTANT) + Q_PROPERTY(bool askBeforeLargeFolders READ askBeforeLargeFolders NOTIFY askBeforeLargeFoldersChanged) + Q_PROPERTY(int largeFolderThresholdMb READ largeFolderThresholdMb NOTIFY largeFolderThresholdMbChanged) + Q_PROPERTY(bool askBeforeExternalStorage READ askBeforeExternalStorage NOTIFY askBeforeExternalStorageChanged) + Q_PROPERTY(QString appName READ appName CONSTANT) + Q_PROPERTY(QString serverUrlPlaceholder READ serverUrlPlaceholder CONSTANT) + +public: + enum Step { + ServerStep = 0, + BrowserAuthStep, + SyncOptionsStep, + CompletedStep + }; + Q_ENUM(Step) + + enum SyncMode { + SyncEverything = 0, + SelectiveSync, + VirtualFiles + }; + Q_ENUM(SyncMode) + + explicit AccountWizardController(QObject *parent = nullptr); + ~AccountWizardController() override; + + [[nodiscard]] Step currentStep() const; + [[nodiscard]] QString serverUrl() const; + void setServerUrl(const QString &serverUrl); + [[nodiscard]] bool serverUrlEditable() const; + [[nodiscard]] bool busy() const; + [[nodiscard]] QString errorText() const; + [[nodiscard]] QUrl loginUrl() const; + [[nodiscard]] QString authStatusText() const; + [[nodiscard]] QString userDisplayName() const; + [[nodiscard]] QString serverDisplayName() const; + [[nodiscard]] QString avatarUrl() const; + [[nodiscard]] QString syncEverythingDescription() const; + [[nodiscard]] QString localSyncFolder() const; + [[nodiscard]] QString localSyncFolderDisplay() const; + [[nodiscard]] QString localSyncFolderError() const; + [[nodiscard]] bool localSyncFolderRequired() const; + [[nodiscard]] SyncMode syncMode() const; + [[nodiscard]] bool canFinish() const; + [[nodiscard]] bool canUseVirtualFiles() const; + [[nodiscard]] bool needsSyncOptions() const; + [[nodiscard]] bool canSkipFolderConfiguration() const; + [[nodiscard]] bool askBeforeLargeFolders() const; + [[nodiscard]] int largeFolderThresholdMb() const; + [[nodiscard]] bool askBeforeExternalStorage() const; + [[nodiscard]] QString appName() const; + [[nodiscard]] QString serverUrlPlaceholder() const; + + [[nodiscard]] static QString normalizeServerUrlInput(const QString &serverUrl, const QString &davPath = {}); + + Q_INVOKABLE void submitServerUrl(); + Q_INVOKABLE void openBrowserLogin(); + Q_INVOKABLE void copyLoginLink(); + Q_INVOKABLE void openSignup(); + Q_INVOKABLE void openSelfHostedServerGuide(); + Q_INVOKABLE void openProxySettings(); + Q_INVOKABLE void cancel(); + Q_INVOKABLE void goBack(); + Q_INVOKABLE void finish(); + Q_INVOKABLE void skipFolderConfiguration(); + Q_INVOKABLE void setSyncMode(int syncMode); + Q_INVOKABLE void chooseLocalSyncFolder(); + Q_INVOKABLE void openSelectiveSync(); + Q_INVOKABLE void openAdvancedOptions(); + Q_INVOKABLE void setAskBeforeLargeFolders(bool ask); + Q_INVOKABLE void setLargeFolderThresholdMb(int thresholdMb); + Q_INVOKABLE void setAskBeforeExternalStorage(bool ask); + Q_INVOKABLE void pollNow(); + +signals: + void currentStepChanged(); + void serverUrlChanged(); + void serverUrlEditableChanged(); + void busyChanged(); + void errorTextChanged(); + void loginUrlChanged(); + void authStatusTextChanged(); + void userDisplayNameChanged(); + void serverDisplayNameChanged(); + void avatarUrlChanged(); + void syncEverythingDescriptionChanged(); + void localSyncFolderChanged(); + void localSyncFolderErrorChanged(); + void localSyncFolderRequiredChanged(); + void syncModeChanged(); + void canFinishChanged(); + void needsSyncOptionsChanged(); + void askBeforeLargeFoldersChanged(); + void largeFolderThresholdMbChanged(); + void askBeforeExternalStorageChanged(); + void finished(int result); + void legacyWizardRequested(); + void advancedOptionsRequested(); + +private slots: + void slotSystemProxyLookupDone(const QNetworkProxy &proxy); + void slotFindServer(); + void slotFindServerBehindRedirect(); + void slotFoundServer(const QUrl &url, const QJsonObject &info); + void slotNoServerFound(QNetworkReply *reply); + void slotNoServerFoundTimeout(const QUrl &url); + void slotDetermineAuthType(); + void slotFlow2AuthResult(OCC::Flow2Auth::Result result, const QString &errorString, const QString &user, const QString &appPassword); + void slotFlow2StatusChanged(OCC::Flow2Auth::PollStatus status, int secondsLeft); + void slotAuthError(QNetworkReply *reply); + +private: + void initialiseAccount(); + void ensureAccount(); + void startServerCheck(const QUrl &serverUrl); + void startFlow2Auth(); + void connectToAuthenticatedAccount(const QString &url, const QString &user, const QString &appPassword); + void testOwnCloudConnect(); + void completeAuthentication(); + void fetchUserAvatar(); + void fetchRootFolderSize(); + AccountState *applyAccountChanges(); + void initialiseLocalSyncFolder(); + void setLocalSyncFolder(const QString &localSyncFolder, bool selectedByUser = false); + void validateLocalSyncFolder(); + [[nodiscard]] bool ensureLocalSyncFolder(); + [[nodiscard]] bool createSyncFolder(AccountState *accountState); + [[nodiscard]] QUrl localFolderServerUrl() const; + void setCurrentStep(Step step); + void setBusy(bool busy); + void setErrorText(const QString &errorText); + void setLoginUrl(const QUrl &loginUrl); + void setAuthStatusText(const QString &authStatusText); + void setUserDisplayName(const QString &userDisplayName); + void setServerDisplayName(const QString &serverDisplayName); + void setAvatarUrl(const QString &avatarUrl); + void setSyncEverythingDescription(const QString &syncEverythingDescription); + void setNeedsSyncOptions(bool needsSyncOptions); + void setServerUrlEditable(bool editable); + [[nodiscard]] bool checkDowngradeAdvised(QNetworkReply *reply) const; + + AccountPtr _account; + std::unique_ptr _flow2Auth; + QPointer _selectiveSyncDialog; + QPointer _proxySettingsDialog; + Step _currentStep = ServerStep; + QString _serverUrl; + bool _serverUrlEditable = true; + bool _busy = false; + QString _errorText; + QUrl _loginUrl; + QString _authStatusText; + QString _userDisplayName; + QString _serverDisplayName; + QString _avatarUrl; + QString _syncEverythingDescription; + QString _localSyncFolder; + QString _localSyncFolderError; + bool _localSyncFolderValid = false; + bool _localSyncFolderSelected = false; + SyncMode _syncMode = SyncEverything; + bool _needsSyncOptions = false; + bool _askBeforeLargeFolders = true; + int _largeFolderThresholdMb = 500; + bool _askBeforeExternalStorage = true; + QString _remoteFolder; + QStringList _selectiveSyncBlacklist; + WizardProxySettingsDialog::WizardProxySettings _proxySettings; +}; + +} // namespace OCC + +#endif // ACCOUNTWIZARDCONTROLLER_H diff --git a/src/gui/wizard/qml/AccountWizardWindow.qml b/src/gui/wizard/qml/AccountWizardWindow.qml new file mode 100644 index 0000000000000..1a32aff73c9b2 --- /dev/null +++ b/src/gui/wizard/qml/AccountWizardWindow.qml @@ -0,0 +1,250 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import com.nextcloud.desktopclient + +ApplicationWindow { + id: root + + property var controller + property bool controllerFinished: false + + LayoutMirroring.enabled: Application.layoutDirection === Qt.RightToLeft + LayoutMirroring.childrenInherit: true + + width: 500 + height: 520 + minimumWidth: 480 + minimumHeight: 420 + title: "" + color: "white" + palette.window: "white" + palette.windowText: "#111111" + palette.base: "white" + palette.text: "#111111" + palette.button: "white" + palette.buttonText: "#111111" + palette.mid: "#8a949c" + palette.placeholderText: Qt.rgba(0, 0, 0, 0.50) + + background: Rectangle { + color: "white" + } + + onClosing: function(close) { + if (!controllerFinished && controller) { + controller.cancel() + } + } + + Shortcut { + sequence: StandardKey.Cancel + onActivated: { + if (root.controller) { + root.controller.cancel() + } + } + } + + Connections { + target: controller + + function onFinished() { + root.controllerFinished = true + root.close() + } + + function onAdvancedOptionsRequested() { + advancedOptionsDialog.open() + } + } + + AdvancedOptionsDialog { + id: advancedOptionsDialog + x: Math.round((root.width - width) / 2) + y: Math.round((root.height - height) / 2) + controller: root.controller + } + + WizardDialogFrame { + anchors.fill: parent + + Loader { + anchors.fill: parent + sourceComponent: { + if (!root.controller) { + return null + } + switch (root.controller.currentStep) { + case AccountWizardController.BrowserAuthStep: + return browserAuthPage + case AccountWizardController.SyncOptionsStep: + return syncOptionsPage + default: + return serverPage + } + } + } + + footer: [ + WizardButton { + visible: root.controller && root.controller.currentStep !== AccountWizardController.ServerStep + enabled: root.controller && !root.controller.busy + Layout.fillWidth: root.controller + && (root.controller.currentStep === AccountWizardController.BrowserAuthStep + || root.controller.currentStep === AccountWizardController.SyncOptionsStep) + Layout.preferredWidth: root.controller + && (root.controller.currentStep === AccountWizardController.BrowserAuthStep + || root.controller.currentStep === AccountWizardController.SyncOptionsStep) + ? 1 + : implicitWidth + text: root.controller && root.controller.currentStep === AccountWizardController.BrowserAuthStep + ? qsTr("Cancel") + : root.controller && root.controller.currentStep === AccountWizardController.SyncOptionsStep + ? qsTr("Cancel") + : qsTr("Back") + onClicked: { + if (root.controller.currentStep === AccountWizardController.BrowserAuthStep + || root.controller.currentStep === AccountWizardController.SyncOptionsStep) { + root.controller.cancel() + } else { + root.controller.goBack() + } + } + }, + + WizardButton { + visible: root.controller && root.controller.currentStep === AccountWizardController.SyncOptionsStep + enabled: root.controller && !root.controller.busy + text: qsTr("Set up later") + Layout.fillWidth: true + Layout.preferredWidth: 1 + onClicked: root.controller.skipFolderConfiguration() + }, + + WizardButton { + visible: root.controller && root.controller.currentStep === AccountWizardController.ServerStep + enabled: root.controller && !root.controller.busy + text: qsTr("Sign up") + textSuffix: "\u2197" + Layout.fillWidth: true + Layout.preferredWidth: 1 + onClicked: root.controller.openSignup() + }, + + WizardButton { + visible: root.controller && root.controller.currentStep === AccountWizardController.ServerStep + enabled: root.controller && !root.controller.busy + text: qsTr("Self-host") + textSuffix: "\u2197" + Layout.fillWidth: true + Layout.preferredWidth: 1 + onClicked: root.controller.openSelfHostedServerGuide() + }, + + Button { + visible: root.controller && root.controller.currentStep === AccountWizardController.ServerStep + enabled: root.controller && !root.controller.busy + flat: true + text: qsTr("Proxy settings") + font.pointSize: 16 + font.weight: Font.Medium + Layout.fillWidth: true + Layout.preferredWidth: 1 + Layout.preferredHeight: 36 + onClicked: root.controller.openProxySettings() + }, + + Item { + visible: root.controller + && root.controller.currentStep !== AccountWizardController.ServerStep + && root.controller.currentStep !== AccountWizardController.BrowserAuthStep + && root.controller.currentStep !== AccountWizardController.SyncOptionsStep + Layout.fillWidth: visible + }, + + WizardButton { + visible: root.controller && root.controller.currentStep === AccountWizardController.BrowserAuthStep + enabled: root.controller && !root.controller.busy && root.controller.loginUrl.toString() !== "" + text: qsTr("Copy link") + iconSource: "image://svgimage-custom-color/copy.svg/" + palette.buttonText + iconBeforeText: true + Layout.fillWidth: true + Layout.preferredWidth: 1 + onClicked: root.controller.copyLoginLink() + }, + + WizardButton { + visible: root.controller && root.controller.currentStep !== AccountWizardController.ServerStep + primary: true + enabled: root.controller + && !root.controller.busy + && (root.controller.currentStep !== AccountWizardController.SyncOptionsStep + || root.controller.canFinish) + Layout.fillWidth: root.controller + && (root.controller.currentStep === AccountWizardController.BrowserAuthStep + || root.controller.currentStep === AccountWizardController.SyncOptionsStep) + Layout.preferredWidth: root.controller + && (root.controller.currentStep === AccountWizardController.BrowserAuthStep + || root.controller.currentStep === AccountWizardController.SyncOptionsStep) + ? 1 + : implicitWidth + text: { + if (!root.controller) { + return "" + } + switch (root.controller.currentStep) { + case AccountWizardController.BrowserAuthStep: + return qsTr("Open") + case AccountWizardController.SyncOptionsStep: + return qsTr("Done") + default: + return qsTr("Log in") + } + } + textSuffix: root.controller && root.controller.currentStep === AccountWizardController.BrowserAuthStep + ? "\u2197" + : "" + onClicked: { + switch (root.controller.currentStep) { + case AccountWizardController.BrowserAuthStep: + root.controller.openBrowserLogin() + break + case AccountWizardController.SyncOptionsStep: + root.controller.finish() + break + default: + root.controller.submitServerUrl() + } + } + } + ] + } + + Component { + id: serverPage + ServerPage { + controller: root.controller + } + } + + Component { + id: browserAuthPage + BrowserAuthPage { + controller: root.controller + } + } + + Component { + id: syncOptionsPage + SyncOptionsPage { + controller: root.controller + } + } +} diff --git a/src/gui/wizard/qml/AdvancedOptionsDialog.qml b/src/gui/wizard/qml/AdvancedOptionsDialog.qml new file mode 100644 index 0000000000000..c287eec6d9501 --- /dev/null +++ b/src/gui/wizard/qml/AdvancedOptionsDialog.qml @@ -0,0 +1,84 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Style +import "../../tray" + +Dialog { + id: root + + required property var controller + readonly property color primaryTextColor: "#111111" + + modal: true + width: 360 + padding: 24 + header: null + footer: null + + background: Rectangle { + radius: 12 + color: "white" + } + + contentItem: ColumnLayout { + spacing: 14 + + EnforcedPlainTextLabel { + text: qsTr("Advanced options") + color: root.primaryTextColor + font.pixelSize: Style.pixelSize + 8 + font.bold: true + Layout.fillWidth: true + } + + CheckBox { + text: qsTr("Ask before syncing folders larger than") + checked: root.controller.askBeforeLargeFolders + font.pixelSize: Style.pixelSize + 2 + onToggled: root.controller.setAskBeforeLargeFolders(checked) + Layout.fillWidth: true + } + + SpinBox { + from: 0 + to: 1048576 + value: root.controller.largeFolderThresholdMb + enabled: root.controller.askBeforeLargeFolders + editable: true + font.pixelSize: Style.pixelSize + 2 + onValueModified: root.controller.setLargeFolderThresholdMb(value) + textFromValue: function(value) { return qsTr("%1 MB").arg(value) } + valueFromText: function(text) { return parseInt(text) } + Layout.fillWidth: true + } + + CheckBox { + text: qsTr("Ask before syncing external storage") + checked: root.controller.askBeforeExternalStorage + font.pixelSize: Style.pixelSize + 2 + onToggled: root.controller.setAskBeforeExternalStorage(checked) + Layout.fillWidth: true + } + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 8 + + Item { + Layout.fillWidth: true + } + + WizardButton { + primary: true + text: qsTr("Done") + onClicked: root.close() + } + } + } +} diff --git a/src/gui/wizard/qml/BrowserAuthPage.qml b/src/gui/wizard/qml/BrowserAuthPage.qml new file mode 100644 index 0000000000000..2e24b85dd98d0 --- /dev/null +++ b/src/gui/wizard/qml/BrowserAuthPage.qml @@ -0,0 +1,83 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Style +import "../../tray" + +Item { + id: root + + required property var controller + readonly property color primaryTextColor: "#111111" + readonly property color primaryButtonColor: "#2B659A" + + ColumnLayout { + anchors.fill: parent + anchors.margins: 24 + spacing: 14 + + Item { + Layout.fillHeight: true + } + + Image { + Layout.alignment: Qt.AlignHCenter + source: "image://svgimage-custom-color/globe.svg/" + root.primaryButtonColor + sourceSize.width: 72 + sourceSize.height: 72 + Layout.preferredWidth: 72 + Layout.preferredHeight: 72 + fillMode: Image.PreserveAspectFit + } + + EnforcedPlainTextLabel { + text: qsTr("Switch to your browser") + color: root.primaryTextColor + font.pixelSize: Style.pixelSize + 8 + font.bold: true + horizontalAlignment: Text.AlignHCenter + Layout.fillWidth: true + wrapMode: Text.WordWrap + } + + RowLayout { + visible: root.controller.busy && root.controller.authStatusText !== "" + Layout.fillWidth: true + spacing: 8 + + Item { + Layout.fillWidth: true + } + + BusyIndicator { + running: root.controller.busy + visible: running + Layout.preferredWidth: 20 + Layout.preferredHeight: 20 + } + + Item { + Layout.fillWidth: true + } + } + + EnforcedPlainTextLabel { + visible: root.controller.errorText !== "" + text: root.controller.errorText + color: Style.errorBoxBackgroundColor + font.pixelSize: Style.pixelSize + 1 + horizontalAlignment: Text.AlignHCenter + Layout.fillWidth: true + wrapMode: Text.WordWrap + } + + Item { + Layout.fillHeight: true + } + } +} diff --git a/src/gui/wizard/qml/OptionRow.qml b/src/gui/wizard/qml/OptionRow.qml new file mode 100644 index 0000000000000..f728f6b60faae --- /dev/null +++ b/src/gui/wizard/qml/OptionRow.qml @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Style +import "../../tray" + +Control { + id: root + + property alias title: titleLabel.text + property alias description: descriptionLabel.text + property string iconSource: "" + property bool selected: false + readonly property color primaryTextColor: "#111111" + readonly property color hintTextColor: Qt.rgba(0, 0, 0, 0.62) + + signal clicked() + + implicitHeight: descriptionLabel.text === "" ? 42 : 56 + padding: 10 + + background: Rectangle { + radius: 6 + border.width: 1 + border.color: root.selected ? "#d8e7f1" : "#e1e8ee" + color: root.selected + ? "#eef5fb" + : "#f5f8fa" + } + + contentItem: RowLayout { + spacing: 10 + + Rectangle { + Layout.preferredWidth: 18 + Layout.preferredHeight: 18 + Layout.alignment: Qt.AlignVCenter + radius: width / 2 + border.width: 2 + border.color: root.selected ? "#0076b5" : "#0076b5" + color: "transparent" + + Rectangle { + anchors.centerIn: parent + width: 8 + height: 8 + radius: width / 2 + color: "#0076b5" + visible: root.selected + } + } + + ColumnLayout { + spacing: 0 + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + + EnforcedPlainTextLabel { + id: titleLabel + Layout.fillWidth: true + color: root.primaryTextColor + font.bold: true + font.pixelSize: Style.pixelSize + 1 + elide: Text.ElideRight + } + + EnforcedPlainTextLabel { + id: descriptionLabel + visible: text !== "" + Layout.fillWidth: true + color: root.hintTextColor + font.pixelSize: Style.pixelSize + wrapMode: Text.WordWrap + maximumLineCount: 2 + } + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.clicked() + } +} diff --git a/src/gui/wizard/qml/ServerPage.qml b/src/gui/wizard/qml/ServerPage.qml new file mode 100644 index 0000000000000..8512f366233de --- /dev/null +++ b/src/gui/wizard/qml/ServerPage.qml @@ -0,0 +1,128 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import com.nextcloud.desktopclient +import Style +import "../../tray" + +Item { + id: root + + required property var controller + readonly property color primaryTextColor: "#111111" + readonly property color hintTextColor: Qt.rgba(0, 0, 0, 0.62) + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: 24 + anchors.rightMargin: 24 + anchors.topMargin: 24 + anchors.bottomMargin: 24 + spacing: 4 + + EnforcedPlainTextLabel { + text: qsTr("Log in to %1").arg(root.controller.appName) + color: root.primaryTextColor + font.pixelSize: Style.pixelSize + 8 + font.bold: true + Layout.fillWidth: true + wrapMode: Text.WordWrap + } + + EnforcedPlainTextLabel { + text: qsTr("Enter the link to your %1 web interface from the browser or the link to a folder shared with you.").arg(root.controller.appName) + color: root.hintTextColor + font.pixelSize: Style.pixelSize + 2 + Layout.fillWidth: true + wrapMode: Text.WordWrap + } + + Item { + Layout.fillWidth: true + Layout.preferredHeight: 60 + Layout.topMargin: 22 + + RowLayout { + anchors.fill: parent + anchors.topMargin: 6 + spacing: 8 + + WizardTextField { + id: serverUrlField + Layout.fillWidth: true + text: root.controller.serverUrl + enabled: !root.controller.busy + readOnly: !root.controller.serverUrlEditable + placeholderText: root.controller.serverUrlPlaceholder + inputMethodHints: Qt.ImhUrlCharactersOnly | Qt.ImhNoAutoUppercase + selectByMouse: true + onTextEdited: root.controller.serverUrl = text + onAccepted: root.controller.submitServerUrl() + } + + WizardButton { + primary: true + Layout.preferredWidth: 76 + enabled: !root.controller.busy + text: qsTr("Log in") + onClicked: root.controller.submitServerUrl() + } + } + + Rectangle { + x: 8 + y: 0 + width: serverAddressLabel.implicitWidth + 8 + height: serverAddressLabel.implicitHeight + color: "white" + + EnforcedPlainTextLabel { + id: serverAddressLabel + anchors.centerIn: parent + text: qsTr("Server address") + color: root.hintTextColor + font.pixelSize: Style.pixelSize + } + } + } + + EnforcedPlainTextLabel { + visible: root.controller.errorText !== "" + text: root.controller.errorText + color: Style.errorBoxBackgroundColor + font.pixelSize: Style.pixelSize + 1 + Layout.fillWidth: true + wrapMode: Text.WordWrap + } + + RowLayout { + visible: root.controller.busy && root.controller.authStatusText !== "" + Layout.fillWidth: true + spacing: 8 + + BusyIndicator { + running: root.controller.busy + visible: running + Layout.preferredWidth: 20 + Layout.preferredHeight: 20 + } + + EnforcedPlainTextLabel { + text: root.controller.authStatusText + color: root.hintTextColor + font.pixelSize: Style.pixelSize + 1 + Layout.fillWidth: true + wrapMode: Text.WordWrap + } + } + + Item { + Layout.fillHeight: true + } + } +} diff --git a/src/gui/wizard/qml/SyncOptionsPage.qml b/src/gui/wizard/qml/SyncOptionsPage.qml new file mode 100644 index 0000000000000..944238be5b269 --- /dev/null +++ b/src/gui/wizard/qml/SyncOptionsPage.qml @@ -0,0 +1,174 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Style +import com.nextcloud.desktopclient +import "../../tray" + +Item { + id: root + + required property var controller + readonly property string serverLabel: root.controller.serverDisplayName !== "" + ? root.controller.serverDisplayName + : root.controller.serverUrl.replace(/^https?:\/\//, "").replace(/\/$/, "") + readonly property color primaryTextColor: "#111111" + readonly property color hintTextColor: Qt.rgba(0, 0, 0, 0.62) + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: 28 + anchors.rightMargin: 28 + anchors.topMargin: 40 + anchors.bottomMargin: 16 + spacing: 2 + + Item { + Layout.preferredWidth: 80 + Layout.preferredHeight: 80 + Layout.alignment: Qt.AlignHCenter + + Rectangle { + anchors.fill: parent + radius: width / 2 + color: "#dfe8ee" + visible: accountAvatar.status !== Image.Ready + + EnforcedPlainTextLabel { + anchors.centerIn: parent + text: root.controller.userDisplayName !== "" ? root.controller.userDisplayName.charAt(0).toUpperCase() : "" + color: root.primaryTextColor + font.pixelSize: Style.pixelSize + 22 + font.bold: true + } + } + + Image { + id: accountAvatar + anchors.fill: parent + source: root.controller.avatarUrl + sourceSize.width: 80 + sourceSize.height: 80 + fillMode: Image.PreserveAspectCrop + cache: false + visible: status === Image.Ready + } + } + + EnforcedPlainTextLabel { + text: root.controller.userDisplayName + color: root.primaryTextColor + font.pixelSize: Style.pixelSize + 6 + font.bold: true + horizontalAlignment: Text.AlignHCenter + Layout.fillWidth: true + wrapMode: Text.WordWrap + } + + EnforcedPlainTextLabel { + text: root.serverLabel + color: root.hintTextColor + font.pixelSize: Style.pixelSize + 2 + horizontalAlignment: Text.AlignHCenter + Layout.fillWidth: true + elide: Text.ElideMiddle + } + + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: 24 + spacing: 8 + + OptionRow { + Layout.fillWidth: true + visible: root.controller.canUseVirtualFiles + title: qsTr("Virtual files") + description: qsTr("Download files on-demand") + selected: root.controller.syncMode === AccountWizardController.VirtualFiles + onClicked: root.controller.setSyncMode(AccountWizardController.VirtualFiles) + } + + OptionRow { + Layout.fillWidth: true + title: qsTr("Synchronize everything") + description: root.controller.syncEverythingDescription + selected: root.controller.syncMode === AccountWizardController.SyncEverything + onClicked: root.controller.setSyncMode(AccountWizardController.SyncEverything) + } + + OptionRow { + Layout.fillWidth: true + title: qsTr("Choose what to sync") + description: "" + selected: root.controller.syncMode === AccountWizardController.SelectiveSync + onClicked: root.controller.openSelectiveSync() + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: 18 + spacing: 6 + visible: root.controller.localSyncFolderRequired + + EnforcedPlainTextLabel { + text: qsTr("Local sync folder") + color: root.primaryTextColor + font.pixelSize: Style.pixelSize + 1 + font.bold: true + Layout.fillWidth: true + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 36 + radius: 6 + border.width: 1 + border.color: root.controller.localSyncFolderError === "" ? "#e1e8ee" : "#d84b4b" + color: "#f5f8fa" + + EnforcedPlainTextLabel { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + verticalAlignment: Text.AlignVCenter + text: root.controller.localSyncFolderDisplay + color: root.primaryTextColor + font.pixelSize: Style.pixelSize + elide: Text.ElideMiddle + } + } + + WizardButton { + text: qsTr("Choose") + Layout.preferredWidth: 96 + Layout.preferredHeight: 36 + onClicked: root.controller.chooseLocalSyncFolder() + } + } + + EnforcedPlainTextLabel { + visible: root.controller.localSyncFolderError !== "" + text: root.controller.localSyncFolderError + color: "#b00020" + font.pixelSize: Style.pixelSize + Layout.fillWidth: true + wrapMode: Text.WordWrap + maximumLineCount: 2 + } + } + + Item { + Layout.fillHeight: true + } + } +} diff --git a/src/gui/wizard/qml/WizardButton.qml b/src/gui/wizard/qml/WizardButton.qml new file mode 100644 index 0000000000000..0e3a5085807fb --- /dev/null +++ b/src/gui/wizard/qml/WizardButton.qml @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Controls +import Style + +Button { + id: root + + property bool primary: false + property string iconSource: "" + property bool iconBeforeText: false + property string textSuffix: "" + readonly property color primaryColor: "#2B659A" + readonly property color primaryPressedColor: "#245783" + readonly property color secondaryColor: "#e7eef4" + readonly property color secondaryPressedColor: "#dce8f0" + readonly property color secondaryBorderColor: "#d5e0e7" + readonly property color disabledColor: "#eef3f7" + readonly property color disabledBorderColor: "#dde7ee" + + implicitHeight: 36 + leftPadding: 18 + rightPadding: 18 + font.pointSize: 16 + font.weight: Font.Medium + + contentItem: Item { + implicitWidth: contentRow.implicitWidth + implicitHeight: contentRow.implicitHeight + + Row { + id: contentRow + + anchors.centerIn: parent + spacing: 6 + + Image { + visible: root.iconSource !== "" && root.iconBeforeText + source: root.iconSource + sourceSize.width: 16 + sourceSize.height: 16 + width: visible ? 16 : 0 + height: 16 + anchors.verticalCenter: parent.verticalCenter + fillMode: Image.PreserveAspectFit + } + + Text { + text: root.textSuffix === "" ? root.text : root.text + " " + root.textSuffix + font: root.font + color: root.enabled + ? (root.primary ? "white" : root.palette.buttonText) + : "#8a949c" + anchors.verticalCenter: parent.verticalCenter + elide: Text.ElideRight + } + + Image { + visible: root.iconSource !== "" && !root.iconBeforeText + source: root.iconSource + sourceSize.width: 16 + sourceSize.height: 16 + width: visible ? 16 : 0 + height: 16 + anchors.verticalCenter: parent.verticalCenter + fillMode: Image.PreserveAspectFit + } + } + } + + background: Rectangle { + radius: 8 + border.width: root.primary ? 0 : 1 + border.color: root.enabled ? root.secondaryBorderColor : root.disabledBorderColor + color: { + if (!root.enabled) { + return root.disabledColor + } + if (root.primary) { + return root.down ? root.primaryPressedColor : root.primaryColor + } + return root.down + ? root.secondaryPressedColor + : root.secondaryColor + } + } +} diff --git a/src/gui/wizard/qml/WizardDialogFrame.qml b/src/gui/wizard/qml/WizardDialogFrame.qml new file mode 100644 index 0000000000000..77432179d8aea --- /dev/null +++ b/src/gui/wizard/qml/WizardDialogFrame.qml @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Pane { + id: root + + default property alias contents: body.data + property alias footer: footerLayout.data + readonly property int windowMargin: 24 + readonly property int footerButtonHeight: 36 + + padding: 0 + + background: Rectangle { + color: "transparent" + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + Item { + id: body + Layout.fillWidth: true + Layout.fillHeight: true + } + + Item { + Layout.fillWidth: true + Layout.preferredHeight: root.footerButtonHeight + root.windowMargin + + RowLayout { + id: footerLayout + anchors.fill: parent + anchors.leftMargin: root.windowMargin + anchors.rightMargin: root.windowMargin + anchors.topMargin: 0 + anchors.bottomMargin: root.windowMargin + spacing: 8 + } + } + } +} diff --git a/src/gui/wizard/qml/WizardTextField.qml b/src/gui/wizard/qml/WizardTextField.qml new file mode 100644 index 0000000000000..c23a76f75668a --- /dev/null +++ b/src/gui/wizard/qml/WizardTextField.qml @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Controls +import Style + +TextField { + id: root + + implicitHeight: Style.standardPrimaryButtonHeight + leftPadding: 12 + rightPadding: 12 + topPadding: 0 + bottomPadding: 0 + verticalAlignment: TextInput.AlignVCenter + font.pixelSize: Style.pixelSize + 3 + color: "black" + placeholderTextColor: Qt.rgba(0, 0, 0, 0.50) + selectionColor: Style.ncBlue + selectedTextColor: "white" + + background: Rectangle { + radius: 8 + color: "white" + border.width: 1 + border.color: root.activeFocus ? Style.ncBlue : Qt.rgba(0, 0, 0, 0.24) + } +} diff --git a/src/gui/wizard/wizardproxysettingsdialog.cpp b/src/gui/wizard/wizardproxysettingsdialog.cpp index 241ae5a868375..f07fbdbeeb876 100644 --- a/src/gui/wizard/wizardproxysettingsdialog.cpp +++ b/src/gui/wizard/wizardproxysettingsdialog.cpp @@ -5,59 +5,218 @@ #include "wizardproxysettingsdialog.h" -#include +#include "ui_proxysettings.h" + +#include +#include +#include #include +#include +#include +#include +#include +#include +#include namespace OCC { Q_LOGGING_CATEGORY(lcWizardProxySettings, "nextcloud.gui.wizard.proxysettings", QtInfoMsg) +namespace { +const QString invalidInputStyle = QStringLiteral( + "min-height: 40px;" + "max-height: 40px;" + "border: 1px solid red;" + "border-radius: 8px;" + "padding: 0px 12px;" + "background: white;" + "font-size: 15pt;" +); + +class WindowDragHandle : public QWidget +{ +public: + using QWidget::QWidget; + +protected: + void mousePressEvent(QMouseEvent *event) override + { + if (event->button() == Qt::LeftButton) { + if (const auto *window = this->window(); window && window->windowHandle()) { + window->windowHandle()->startSystemMove(); + event->accept(); + return; + } + } + + QWidget::mousePressEvent(event); + } +}; +} + WizardProxySettingsDialog::WizardProxySettingsDialog(QUrl serverURL, WizardProxySettings proxySettings, QWidget *parent) : QDialog(parent) + , _ui(std::make_unique()) { - _ui.setupUi(this); - - setWindowModality(Qt::WindowModal); - setWindowTitle(tr("Proxy Settings", "Dialog window title for proxy settings")); +#if defined(Q_OS_MACOS) && QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) + setWindowFlag(Qt::ExpandedClientAreaHint, true); + setWindowFlag(Qt::NoTitleBarBackgroundHint, true); +#endif - _ui.hostLineEdit->setPlaceholderText(tr("Hostname of proxy server")); - _ui.userLineEdit->setPlaceholderText(tr("Username for proxy server")); - _ui.passwordLineEdit->setPlaceholderText(tr("Password for proxy server")); + _ui->setupUi(this); - _ui.typeComboBox->addItem(tr("HTTP(S) proxy"), QNetworkProxy::HttpProxy); - _ui.typeComboBox->addItem(tr("SOCKS5 proxy"), QNetworkProxy::Socks5Proxy); + setWindowModality(Qt::WindowModal); + setWindowTitle({}); + setMinimumSize(500, 448); + resize(500, 448); - _ui.authRequiredcheckBox->setEnabled(true); + if (layout()) { + layout()->setContentsMargins(24, 24, 24, 24); + layout()->setSpacing(14); + } + _ui->gridLayout_2->setContentsMargins(0, 8, 0, 0); + _ui->gridLayout_2->setHorizontalSpacing(8); + _ui->gridLayout_2->setVerticalSpacing(8); + _ui->verticalLayout_2->setContentsMargins(0, 8, 0, 0); + _ui->verticalLayout_2->setSpacing(10); + _ui->horizontalLayout_3->setSpacing(8); + _ui->horizontalLayout_4->setSpacing(8); + _ui->horizontalLayout_10->setSpacing(8); + _ui->horizontalSpacer_4->changeSize(0, 0, QSizePolicy::Fixed, QSizePolicy::Minimum); + _ui->horizontalSpacer_5->changeSize(0, 0, QSizePolicy::Fixed, QSizePolicy::Minimum); + _ui->horizontalSpacer_6->changeSize(0, 0, QSizePolicy::Fixed, QSizePolicy::Minimum); + + _ui->hostLineEdit->setPlaceholderText(tr("Hostname of proxy server")); + _ui->userLineEdit->setPlaceholderText(tr("Username for proxy server")); + _ui->passwordLineEdit->setPlaceholderText(tr("Password for proxy server")); + _ui->typeComboBox->setMinimumHeight(40); + _ui->hostLineEdit->setMinimumHeight(40); + _ui->portSpinBox->setMinimumHeight(40); + _ui->userLineEdit->setMinimumHeight(40); + _ui->passwordLineEdit->setMinimumHeight(40); + _ui->buttonBox->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + + _ui->typeComboBox->addItem(tr("HTTP(S) proxy"), QNetworkProxy::HttpProxy); + _ui->typeComboBox->addItem(tr("SOCKS5 proxy"), QNetworkProxy::Socks5Proxy); + + _ui->authRequiredcheckBox->setEnabled(true); // Explicitly set up the enabled status of the proxy auth widgets to ensure // toggling the parent enables/disables the children - _ui.userLineEdit->setEnabled(true); - _ui.passwordLineEdit->setEnabled(true); - _ui.authWidgets->setEnabled(_ui.authRequiredcheckBox->isChecked()); - connect(_ui.authRequiredcheckBox, &QAbstractButton::toggled, _ui.authWidgets, &QWidget::setEnabled); + _ui->userLineEdit->setEnabled(true); + _ui->passwordLineEdit->setEnabled(true); + _ui->authWidgets->setEnabled(_ui->authRequiredcheckBox->isChecked()); + connect(_ui->authRequiredcheckBox, &QAbstractButton::toggled, _ui->authWidgets, &QWidget::setEnabled); - connect(_ui.manualProxyRadioButton, &QAbstractButton::toggled, _ui.manualSettings, &QWidget::setVisible); - connect(_ui.manualProxyRadioButton, &QAbstractButton::toggled, this, &WizardProxySettingsDialog::validateProxySettings); + connect(_ui->manualProxyRadioButton, &QAbstractButton::toggled, _ui->manualSettings, &QWidget::setVisible); + connect(_ui->manualProxyRadioButton, &QAbstractButton::toggled, this, &WizardProxySettingsDialog::validateProxySettings); - connect(_ui.typeComboBox, static_cast(&QComboBox::currentIndexChanged), this, &WizardProxySettingsDialog::validateProxySettings); - connect(_ui.authRequiredcheckBox, &QAbstractButton::toggled, this, &WizardProxySettingsDialog::validateProxySettings); + connect(_ui->typeComboBox, static_cast(&QComboBox::currentIndexChanged), this, &WizardProxySettingsDialog::validateProxySettings); + connect(_ui->authRequiredcheckBox, &QAbstractButton::toggled, this, &WizardProxySettingsDialog::validateProxySettings); // Warn about empty proxy host - connect(_ui.hostLineEdit, &QLineEdit::textChanged, this, &WizardProxySettingsDialog::validateProxySettings); - connect(_ui.userLineEdit, &QLineEdit::textChanged, this, &WizardProxySettingsDialog::validateProxySettings); - connect(_ui.passwordLineEdit, &QLineEdit::textChanged, this, &WizardProxySettingsDialog::validateProxySettings); - connect(_ui.portSpinBox, &QSpinBox::valueChanged, this, &WizardProxySettingsDialog::validateProxySettings); - connect(_ui.authRequiredcheckBox, &QAbstractButton::toggled, this, &WizardProxySettingsDialog::validateProxySettings); + connect(_ui->hostLineEdit, &QLineEdit::textChanged, this, &WizardProxySettingsDialog::validateProxySettings); + connect(_ui->userLineEdit, &QLineEdit::textChanged, this, &WizardProxySettingsDialog::validateProxySettings); + connect(_ui->passwordLineEdit, &QLineEdit::textChanged, this, &WizardProxySettingsDialog::validateProxySettings); + connect(_ui->portSpinBox, &QSpinBox::valueChanged, this, &WizardProxySettingsDialog::validateProxySettings); + connect(_ui->authRequiredcheckBox, &QAbstractButton::toggled, this, &WizardProxySettingsDialog::validateProxySettings); - connect(_ui.buttonBox, &QDialogButtonBox::accepted, + connect(_ui->buttonBox, &QDialogButtonBox::accepted, this, &WizardProxySettingsDialog::settingsDone); - connect(_ui.buttonBox, &QDialogButtonBox::rejected, + connect(_ui->buttonBox, &QDialogButtonBox::rejected, this, &WizardProxySettingsDialog::reject); setServerUrl(std::move(serverURL)); setProxySettings(std::move(proxySettings)); + customizeStyle(); + _ui->buttonBox->setCenterButtons(false); + if (_ui->buttonBox->layout()) { + _ui->buttonBox->layout()->setAlignment(Qt::AlignRight); + _ui->buttonBox->layout()->setSpacing(8); + } + + for (auto *button : _ui->buttonBox->buttons()) { + auto buttonFont = button->font(); + buttonFont.setPointSize(16); + buttonFont.setWeight(QFont::Medium); + button->setFont(buttonFont); + button->setMinimumHeight(36); + button->setMinimumWidth(112); + button->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); + } + +#if defined(Q_OS_MACOS) && QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) + _windowDragHandle = new WindowDragHandle(this); + _windowDragHandle->setObjectName(QLatin1String("wizard_proxy_window_drag_handle")); + _windowDragHandle->setFixedHeight(28); + _windowDragHandle->setGeometry(0, 0, width(), _windowDragHandle->height()); + _windowDragHandle->raise(); +#endif +} + +WizardProxySettingsDialog::~WizardProxySettingsDialog() = default; + +void WizardProxySettingsDialog::resizeEvent(QResizeEvent *event) +{ + QDialog::resizeEvent(event); + +#if defined(Q_OS_MACOS) && QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) + if (_windowDragHandle) { + _windowDragHandle->setGeometry(0, 0, width(), _windowDragHandle->height()); + _windowDragHandle->raise(); + } +#endif +} + +void WizardProxySettingsDialog::customizeStyle() +{ + setStyleSheet(QStringLiteral( + "QDialog, QWidget#ProxySettings { background: white; }" + "QGroupBox { border: none; margin-top: 28px; padding-top: 0px; font-size: 20px; font-weight: bold; }" + "QGroupBox::title { subcontrol-origin: margin; subcontrol-position: top left; padding: 0px; }" + "QRadioButton, QCheckBox, QLabel { font-size: 15pt; }" + "QLineEdit, QSpinBox, QComboBox {" + " min-height: 40px;" + " max-height: 40px;" + " border: 1px solid rgb(196, 196, 196);" + " border-radius: 8px;" + " padding: 0px 12px;" + " background: white;" + " font-size: 15pt;" + " }" + "QComboBox { padding-right: 36px; }" + "QComboBox::drop-down {" + " subcontrol-origin: padding;" + " subcontrol-position: top right;" + " width: 32px;" + " border-left: none;" + " }" + "QComboBox::down-arrow {" + " image: url(:/client/theme/black/caret-down.svg);" + " width: 16px;" + " height: 16px;" + " }" + "QLineEdit:focus, QSpinBox:focus, QComboBox:focus { border: 1px solid palette(highlight); }" + "QDialogButtonBox { border: none; padding: 0px; }" + "QPushButton {" + " min-height: 36px;" + " max-height: 36px;" + " border: none;" + " border-radius: 8px;" + " padding: 0px 18px;" + " background-color: #e7eef4;" + " font-size: 16pt;" + " font-weight: 500;" + " }" + "QPushButton:hover { background-color: #e7eef4; }" + "QPushButton:pressed { background-color: #dce8f0; }" + "QPushButton:default { background-color: #2b659a; color: white; }" + "QPushButton:default:hover { background-color: #2b659a; color: white; }" + "QPushButton:default:pressed { background-color: #245783; color: white; }" + "QPushButton:disabled { color: rgb(150, 150, 150); background: rgb(242, 242, 242); }" + )); } void WizardProxySettingsDialog::setServerUrl(QUrl serverUrl) @@ -72,41 +231,34 @@ void WizardProxySettingsDialog::setServerUrl(QUrl serverUrl) void WizardProxySettingsDialog::setProxySettings(WizardProxySettings proxySettings) { - if (_settings == proxySettings) { - return; - } - _settings = std::move(proxySettings); - if (!_settings._user.isEmpty()) { - _ui.userLineEdit->setText(_settings._user); - } - if (!_settings._password.isEmpty()) { - _ui.passwordLineEdit->setText(_settings._password); - } - if (!_settings._host.isEmpty()) { - _ui.hostLineEdit->setText(_settings._host); + _ui->userLineEdit->setText(_settings._user); + _ui->passwordLineEdit->setText(_settings._password); + _ui->hostLineEdit->setText(_settings._host); + if (_settings._port > 0) { + _ui->portSpinBox->setValue(_settings._port); } - _ui.authRequiredcheckBox->setChecked(_settings._needsAuth != ProxyAuthentication::NoAuthentication); + _ui->authRequiredcheckBox->setChecked(_settings._needsAuth != ProxyAuthentication::NoAuthentication); switch (_settings._proxyType) { case QNetworkProxy::NoProxy: - _ui.noProxyRadioButton->setChecked(true); - _ui.noProxyRadioButton->setFocus(); - _ui.manualSettings->setVisible(false); + _ui->noProxyRadioButton->setChecked(true); + _ui->noProxyRadioButton->setFocus(); + _ui->manualSettings->setVisible(false); break; case QNetworkProxy::ProxyType::DefaultProxy: - _ui.systemProxyRadioButton->setChecked(true); - _ui.systemProxyRadioButton->setFocus(); - _ui.manualSettings->setVisible(false); + _ui->systemProxyRadioButton->setChecked(true); + _ui->systemProxyRadioButton->setFocus(); + _ui->manualSettings->setVisible(false); break; case QNetworkProxy::Socks5Proxy: case QNetworkProxy::HttpProxy: - _ui.manualProxyRadioButton->setChecked(true); - _ui.manualProxyRadioButton->setFocus(); - _ui.manualSettings->setVisible(true); + _ui->manualProxyRadioButton->setChecked(true); + _ui->manualProxyRadioButton->setFocus(); + _ui->manualSettings->setVisible(true); break; case QNetworkProxy::HttpCachingProxy: case QNetworkProxy::FtpCachingProxy: @@ -118,45 +270,45 @@ void WizardProxySettingsDialog::setProxySettings(WizardProxySettings proxySettin void WizardProxySettingsDialog::checkEmptyProxyHost() { - if (_ui.hostLineEdit->isEnabled() && _ui.hostLineEdit->text().isEmpty()) { - _ui.hostLineEdit->setStyleSheet("border: 1px solid red"); + if (_ui->manualProxyRadioButton->isChecked() && _ui->hostLineEdit->isEnabled() && _ui->hostLineEdit->text().isEmpty()) { + _ui->hostLineEdit->setStyleSheet(invalidInputStyle); } else { - _ui.hostLineEdit->setStyleSheet(QString()); + _ui->hostLineEdit->setStyleSheet(QString()); } } void WizardProxySettingsDialog::checkEmptyProxyCredentials() { - if (!_ui.authRequiredcheckBox->isChecked()) { - _ui.userLineEdit->setStyleSheet(QString()); - _ui.passwordLineEdit->setStyleSheet(QString()); + if (!_ui->authRequiredcheckBox->isChecked()) { + _ui->userLineEdit->setStyleSheet(QString()); + _ui->passwordLineEdit->setStyleSheet(QString()); return; } - if (_ui.userLineEdit->text().isEmpty()) { - _ui.userLineEdit->setStyleSheet("border: 1px solid red"); - } else { - _ui.userLineEdit->setStyleSheet(QString()); - } - - if (_ui.passwordLineEdit->text().isEmpty()) { - _ui.passwordLineEdit->setStyleSheet("border: 1px solid red"); - } else { - _ui.passwordLineEdit->setStyleSheet(QString()); - } + if (_ui->userLineEdit->text().isEmpty()) { + _ui->userLineEdit->setStyleSheet(invalidInputStyle); + } else { + _ui->userLineEdit->setStyleSheet(QString()); + } + + if (_ui->passwordLineEdit->text().isEmpty()) { + _ui->passwordLineEdit->setStyleSheet(invalidInputStyle); + } else { + _ui->passwordLineEdit->setStyleSheet(QString()); + } } void WizardProxySettingsDialog::checkAccountLocalhost() { auto visible = false; - if (_ui.manualProxyRadioButton->isChecked()) { + if (_ui->manualProxyRadioButton->isChecked()) { const auto host = _serverURL.host(); // Some typical url for localhost if (host == "localhost" || host.startsWith("127.") || host == "[::1]") { visible = true; } } - _ui.labelLocalhost->setVisible(visible); + _ui->labelLocalhost->setVisible(visible); } void WizardProxySettingsDialog::validateProxySettings() @@ -165,37 +317,38 @@ void WizardProxySettingsDialog::validateProxySettings() checkEmptyProxyCredentials(); checkAccountLocalhost(); - _settings._user = _ui.userLineEdit->text(); - _settings._password = _ui.passwordLineEdit->text(); - _settings._host = _ui.hostLineEdit->text(); - _settings._port = _ui.portSpinBox->value(); - _settings._needsAuth = _ui.authRequiredcheckBox->isChecked() ? ProxyAuthentication::AuthenticationRequired : ProxyAuthentication::NoAuthentication; + _settings._user = _ui->userLineEdit->text(); + _settings._password = _ui->passwordLineEdit->text(); + _settings._host = _ui->hostLineEdit->text(); + _settings._port = _ui->portSpinBox->value(); + _settings._needsAuth = _ui->authRequiredcheckBox->isChecked() ? ProxyAuthentication::AuthenticationRequired : ProxyAuthentication::NoAuthentication; _settings._proxyType = QNetworkProxy::NoProxy; _valid = false; - if (_ui.noProxyRadioButton->isChecked()) { + if (_ui->noProxyRadioButton->isChecked()) { _settings._proxyType = QNetworkProxy::NoProxy; _valid = true; - } else if (_ui.systemProxyRadioButton->isChecked()) { + } else if (_ui->systemProxyRadioButton->isChecked()) { _settings._proxyType = QNetworkProxy::DefaultProxy; _valid = true; - } else if (_ui.manualProxyRadioButton->isChecked()) { - _settings._proxyType = _ui.typeComboBox->itemData(_ui.typeComboBox->currentIndex()).value(); + } else if (_ui->manualProxyRadioButton->isChecked()) { + _settings._proxyType = _ui->typeComboBox->itemData(_ui->typeComboBox->currentIndex()).value(); _valid = true; if (_settings._host.isEmpty()) { _settings._proxyType = QNetworkProxy::NoProxy; _valid = false; } - if (_ui.authRequiredcheckBox->isChecked() && (_settings._user.isEmpty() || _settings._password.isEmpty())) { + if (_ui->authRequiredcheckBox->isChecked() && (_settings._user.isEmpty() || _settings._password.isEmpty())) { _settings._proxyType = QNetworkProxy::NoProxy; _valid = false; } } - const auto okButton = _ui.buttonBox->button(QDialogButtonBox::Ok); + const auto okButton = _ui->buttonBox->button(QDialogButtonBox::Ok); if (okButton) { okButton->setEnabled(_valid); + okButton->setDefault(true); } } diff --git a/src/gui/wizard/wizardproxysettingsdialog.h b/src/gui/wizard/wizardproxysettingsdialog.h index 01dd921369092..0bd08fdb97fe1 100644 --- a/src/gui/wizard/wizardproxysettingsdialog.h +++ b/src/gui/wizard/wizardproxysettingsdialog.h @@ -7,8 +7,13 @@ #include #include +#include -#include "ui_proxysettings.h" +#include + +class QResizeEvent; +class QWidget; +class Ui_ProxySettings; namespace OCC { @@ -26,7 +31,7 @@ class WizardProxySettingsDialog : public QDialog QString _user; QString _password; QString _host; - quint16 _port; + quint16 _port = 8080; ProxyAuthentication _needsAuth = ProxyAuthentication::NoAuthentication; QNetworkProxy::ProxyType _proxyType = QNetworkProxy::NoProxy; @@ -36,6 +41,7 @@ class WizardProxySettingsDialog : public QDialog explicit WizardProxySettingsDialog(QUrl serverURL, WizardProxySettings proxySettings, QWidget *parent = nullptr); + ~WizardProxySettingsDialog() override; void setServerUrl(QUrl serverUrl); @@ -56,14 +62,23 @@ private Q_SLOTS: void settingsDone(); +protected: + void resizeEvent(QResizeEvent *event) override; + private: - Ui_ProxySettings _ui{}; + void customizeStyle(); + + std::unique_ptr _ui; QUrl _serverURL; bool _valid = false; WizardProxySettings _settings; + +#if defined(Q_OS_MACOS) && QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) + QWidget *_windowDragHandle = nullptr; +#endif }; } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 22702af53a303..9a59cc7b49e76 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -151,6 +151,7 @@ nextcloud_add_test(LongPath) nextcloud_add_benchmark(LargeSync) nextcloud_add_test(Account) +nextcloud_add_test(AccountWizardController) nextcloud_add_test(Folder) nextcloud_add_test(FolderMan) nextcloud_add_test(RemoteWipe) diff --git a/test/testaccountwizardcontroller.cpp b/test/testaccountwizardcontroller.cpp new file mode 100644 index 0000000000000..bb87b394d037c --- /dev/null +++ b/test/testaccountwizardcontroller.cpp @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "gui/wizard/accountwizardcontroller.h" + +#include +#include +#include + +using namespace OCC; + +class TestAccountWizardController : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase() + { + QStandardPaths::setTestModeEnabled(true); + } + + void normalizesCommonServerUrlSuffixes() + { + QCOMPARE(AccountWizardController::normalizeServerUrlInput(QStringLiteral(" https://cloud.example/index.php ")), + QStringLiteral("https://cloud.example/")); + QCOMPARE(AccountWizardController::normalizeServerUrlInput(QStringLiteral("https://cloud.example/remote.php/dav/"), QStringLiteral("remote.php/dav/")), + QStringLiteral("https://cloud.example/")); + } + + void invalidServerUrlStaysOnServerStep() + { + AccountWizardController controller; + QSignalSpy errorSpy(&controller, &AccountWizardController::errorTextChanged); + + controller.setServerUrl(QStringLiteral("not a url")); + controller.submitServerUrl(); + + QCOMPARE(controller.currentStep(), AccountWizardController::ServerStep); + QVERIFY(!controller.errorText().isEmpty()); + QCOMPARE(errorSpy.count(), 1); + } + + void advancedSafeguardsEmitChanges() + { + AccountWizardController controller; + QSignalSpy askLargeSpy(&controller, &AccountWizardController::askBeforeLargeFoldersChanged); + QSignalSpy thresholdSpy(&controller, &AccountWizardController::largeFolderThresholdMbChanged); + QSignalSpy externalSpy(&controller, &AccountWizardController::askBeforeExternalStorageChanged); + + controller.setAskBeforeLargeFolders(!controller.askBeforeLargeFolders()); + controller.setLargeFolderThresholdMb(controller.largeFolderThresholdMb() + 1); + controller.setAskBeforeExternalStorage(!controller.askBeforeExternalStorage()); + + QCOMPARE(askLargeSpy.count(), 1); + QCOMPARE(thresholdSpy.count(), 1); + QCOMPARE(externalSpy.count(), 1); + } +}; + +QTEST_MAIN(TestAccountWizardController) +#include "testaccountwizardcontroller.moc" diff --git a/test/testiconutils.cpp b/test/testiconutils.cpp index bd6d81ece96f4..138204b5760b1 100644 --- a/test/testiconutils.cpp +++ b/test/testiconutils.cpp @@ -71,6 +71,13 @@ private slots: QVERIFY(!OCC::Ui::IconUtils::createSvgImageWithCustomColor(whiteImages.at(0), QColorConstants::Svg::blue).isNull()); } + void testCreateSvgPixmapWithWizardSyncOptionIcons() + { + QVERIFY(!OCC::Ui::IconUtils::createSvgImageWithCustomColor(QStringLiteral("folder.svg"), QColor(QStringLiteral("#111111"))).isNull()); + QVERIFY(!OCC::Ui::IconUtils::createSvgImageWithCustomColor(QStringLiteral("sync.svg"), QColor(QStringLiteral("#111111"))).isNull()); + QVERIFY(!OCC::Ui::IconUtils::createSvgImageWithCustomColor(QStringLiteral("wizard-files.svg"), QColor(QStringLiteral("#111111"))).isNull()); + } + void testPixmapForBackground() { const QDir blackSvgDir(QString(QString{OCC::Theme::themePrefix}) + QStringLiteral("black")); diff --git a/theme.qrc.in b/theme.qrc.in index 8ebb6e3d7b590..f5afe78ff3e23 100644 --- a/theme.qrc.in +++ b/theme.qrc.in @@ -154,6 +154,7 @@ theme/colored/folder@2x.png theme/colored/wizard-files.png theme/colored/wizard-files@2x.png + theme/colored/wizard-files.svg theme/colored/wizard-groupware.png theme/colored/wizard-groupware@2x.png theme/colored/wizard-nextcloud.png @@ -214,6 +215,7 @@ theme/black/user.svg theme/black/wizard-files.png theme/black/wizard-files@2x.png + theme/black/wizard-files.svg theme/black/wizard-groupware.png theme/black/wizard-groupware@2x.png theme/black/wizard-groupware.svg @@ -254,6 +256,7 @@ theme/white/user.svg theme/white/wizard-files.png theme/white/wizard-files@2x.png + theme/white/wizard-files.svg theme/white/wizard-groupware.png theme/white/wizard-groupware@2x.png theme/white/wizard-groupware.svg @@ -286,6 +289,7 @@ theme/reply.svg theme/magnifying-glass.svg theme/external.svg + theme/globe.svg theme/delete.svg theme/send.svg theme/talk-app.svg diff --git a/theme/globe.svg b/theme/globe.svg new file mode 100644 index 0000000000000..fed1991f254fc --- /dev/null +++ b/theme/globe.svg @@ -0,0 +1,5 @@ + +