From 5127f765be98cf08511b011ef229e854116ac8b1 Mon Sep 17 00:00:00 2001 From: Camila Ayres Date: Thu, 4 Dec 2025 22:02:20 +0100 Subject: [PATCH 01/16] refactor(migration): add a class to handle the migration logic. It should cover upgrade, downgrade as well as upgrade from unbranded to branded, from legacy to branded and from legacy to unbranded. - Detect upgrade only scenario: If no legacy file is found, it means it might be upgrade only. - Refactor find and readlegacy config files in Migration. - Make all Migration class members static. - Rename Migration class members. - Update code base to reflect the refactoring and move of functions. - Add tests. - Add extended comment with Migration flow. Signed-off-by: Camila Ayres --- src/cmd/cmd.cpp | 6 +- src/gui/accountmanager.cpp | 156 +++++++------------- src/gui/accountstate.cpp | 11 +- src/gui/application.cpp | 28 ++-- src/gui/folderman.cpp | 4 +- src/gui/folderman.h | 3 + src/libsync/CMakeLists.txt | 2 + src/libsync/configfile.cpp | 95 ++++-------- src/libsync/configfile.h | 27 +--- src/libsync/settings/migration.cpp | 228 +++++++++++++++++++++++++++++ src/libsync/settings/migration.h | 124 ++++++++++++++++ test/CMakeLists.txt | 1 + test/testmigration.cpp | 217 +++++++++++++++++++++++++++ 13 files changed, 687 insertions(+), 215 deletions(-) create mode 100644 src/libsync/settings/migration.cpp create mode 100644 src/libsync/settings/migration.h create mode 100644 test/testmigration.cpp diff --git a/src/cmd/cmd.cpp b/src/cmd/cmd.cpp index c3e9c50b9a124..1d7b1cca899ab 100644 --- a/src/cmd/cmd.cpp +++ b/src/cmd/cmd.cpp @@ -23,6 +23,7 @@ #include "accountsetupcommandlinemanager.h" #include "folderman.h" #include "configfile.h" // ONLY ACCESS THE STATIC FUNCTIONS! +#include "settings/migration.h" #ifdef TOKEN_AUTH_ONLY # include "creds/tokencredentials.h" #else @@ -371,13 +372,12 @@ void selectiveSyncFixup(OCC::SyncJournalDb *journal, const QStringList &newList) auto result = false; auto folderManager = FolderMan::instance(); - ConfigFile configFile; - configFile.setMigrationPhase(ConfigFile::MigrationPhase::SetupUsers); + Migration::setPhase(Migration::Phase::SetupUsers); if (!setupAccountsOnly()) { return result; } - configFile.setMigrationPhase(ConfigFile::MigrationPhase::SetupFolders); + Migration::setPhase(Migration::Phase::SetupFolders); const auto foldersListSize = folderManager->setupFolders(); folderManager->setSyncEnabled(true); diff --git a/src/gui/accountmanager.cpp b/src/gui/accountmanager.cpp index 0ad4fefc91c53..1a0fd61832509 100644 --- a/src/gui/accountmanager.cpp +++ b/src/gui/accountmanager.cpp @@ -20,6 +20,7 @@ #if !DISABLE_ACCOUNT_MIGRATION #include "legacyaccountselectiondialog.h" #endif +#include "settings/migration.h" #include #include @@ -85,7 +86,6 @@ constexpr auto serverDesktopEnterpriseUpdateChannelC = "desktopEnterpriseChannel constexpr auto generalC = "General"; } - namespace OCC { Q_LOGGING_CATEGORY(lcAccountManager, "nextcloud.gui.account.manager", QtInfoMsg) @@ -195,110 +195,64 @@ bool AccountManager::restoreFromLegacySettings() { qCInfo(lcAccountManager) << "Migrate: restoreFromLegacySettings, checking settings group" << Theme::instance()->appName(); - // try to open the correctly themed settings auto settings = ConfigFile::settingsWithGroup(Theme::instance()->appName()); - auto wasLegacyImportDialogDisplayed = false; - const auto displayLegacyImportDialog = Theme::instance()->displayLegacyImportDialog(); QStringList selectedAccountIds; - - // if the settings file could not be opened, the childKeys list is empty - // then try to load settings from a very old place - if (settings->childKeys().isEmpty()) { - // Legacy settings used QDesktopServices to get the location for the config folder in 2.4 and before - const auto legacy2_4CfgSettingsLocation = QString(QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + QStringLiteral("/data")); - const auto legacy2_4CfgFileParentFolder = legacy2_4CfgSettingsLocation.left(legacy2_4CfgSettingsLocation.lastIndexOf('/')); - - // 2.5+ (rest of 2.x series) - const auto legacy2_5CfgSettingsLocation = QStandardPaths::writableLocation(Utility::isWindows() ? QStandardPaths::AppDataLocation : QStandardPaths::AppConfigLocation); - const auto legacy2_5CfgFileParentFolder = legacy2_5CfgSettingsLocation.left(legacy2_5CfgSettingsLocation.lastIndexOf('/')); - - // Now try the locations we use today - const auto fullLegacyCfgFile = QDir::fromNativeSeparators(settings->fileName()); - const auto legacyCfgFileParentFolder = fullLegacyCfgFile.left(fullLegacyCfgFile.lastIndexOf('/')); - const auto legacyCfgFileGrandParentFolder = legacyCfgFileParentFolder.left(legacyCfgFileParentFolder.lastIndexOf('/')); - - const auto legacyCfgFileNamePath = QString(QStringLiteral("/") + legacyCfgFileNameC); - const auto legacyCfgFileRelativePath = QString(legacyRelativeConfigLocationC); - - auto legacyLocations = QVector{legacy2_4CfgFileParentFolder + legacyCfgFileRelativePath, - legacy2_5CfgFileParentFolder + legacyCfgFileRelativePath, - legacyCfgFileParentFolder + legacyCfgFileNamePath, - legacyCfgFileGrandParentFolder + legacyCfgFileRelativePath}; - - if (Theme::instance()->isBranded()) { - const auto unbrandedCfgFileNamePath = QString(QStringLiteral("/") + unbrandedCfgFileNameC); - const auto unbrandedCfgFileRelativePath = QString(unbrandedRelativeConfigLocationC); - legacyLocations.append({legacyCfgFileParentFolder + unbrandedCfgFileNamePath, legacyCfgFileGrandParentFolder + unbrandedCfgFileRelativePath}); - } - - for (const auto &configFile : std::as_const(legacyLocations)) { - auto oCSettings = std::make_unique(configFile, QSettings::IniFormat); - if (oCSettings->status() != QSettings::Status::NoError) { - qCInfo(lcAccountManager) << "Error reading legacy configuration file" << oCSettings->status(); - break; - } - - oCSettings->beginGroup(QLatin1String(accountsC)); - const auto childGroups = oCSettings->childGroups(); - const auto accountsListSize = childGroups.size(); - oCSettings->endGroup(); //accountsC - if (const QFileInfo configFileInfo(configFile); - configFileInfo.exists() && configFileInfo.isReadable()) { - - qCInfo(lcAccountManager) << "Migrate: checking old config " << configFile; - if (!forceLegacyImport() && accountsListSize > 0 && displayLegacyImportDialog) { - wasLegacyImportDialogDisplayed = true; - if (accountsListSize == 1) { - const auto importQuestion = - tr("An account was detected from a legacy desktop client.\n" - "Should the account be imported?"); - QMessageBox importMessageBox(QMessageBox::Question, tr("Legacy import"), importQuestion); - importMessageBox.addButton(tr("Import"), QMessageBox::AcceptRole); - const auto skipButton = importMessageBox.addButton(tr("Skip"), QMessageBox::DestructiveRole); - importMessageBox.exec(); - if (importMessageBox.clickedButton() == skipButton) { - return false; - } - selectedAccountIds = childGroups; - } else { - QVector accountsToDisplay; - oCSettings->beginGroup(QLatin1String(accountsC)); - for (const auto &accId : childGroups) { - oCSettings->beginGroup(accId); - const auto displayName = oCSettings->value(QLatin1String(displayNameC)).toString(); - const auto urlStr = oCSettings->value(QLatin1String(urlC)).toString(); - oCSettings->endGroup(); //accId - const auto label = QString("%1 - %2").arg(displayName, urlStr); - accountsToDisplay.push_back({accId, label}); - } - oCSettings->endGroup(); //accountsC - - LegacyAccountSelectionDialog accountSelectionDialog(accountsToDisplay); - if (accountSelectionDialog.exec() != QDialog::Accepted) { - return false; - } - selectedAccountIds = accountSelectionDialog.selectedAccountIds(); - if (selectedAccountIds.isEmpty()) { - return false; - } - } - } else { - selectedAccountIds = childGroups; + Migration migration; + if (const auto legacyData = migration.legacyData(); !legacyData.isNull()) { + + const auto displayLegacyImportDialog = Theme::instance()->displayLegacyImportDialog(); + + auto oCSettings = std::move(legacyData); + + oCSettings->beginGroup(QLatin1String(accountsC)); + const auto childGroups = oCSettings->childGroups(); + const auto accountsListSize = childGroups.size(); + oCSettings->endGroup(); // accountsC + + qCInfo(lcAccountManager) << "Migrate: checking old config"; + if (!forceLegacyImport() && displayLegacyImportDialog && accountsListSize > 0) { + wasLegacyImportDialogDisplayed = true; + if (childGroups.size() == 1) { + const auto importQuestion = + tr("An account was detected from a legacy desktop client.\n" + "Should the account be imported?"); + QMessageBox importMessageBox(QMessageBox::Question, tr("Legacy import"), importQuestion); + importMessageBox.addButton(tr("Import"), QMessageBox::AcceptRole); + const auto skipButton = importMessageBox.addButton(tr("Skip"), QMessageBox::DestructiveRole); + importMessageBox.exec(); + if (importMessageBox.clickedButton() == skipButton) { + return false; } - - const auto legacyVersion = oCSettings->value(ConfigFile::clientVersionC, {}).toString(); - ConfigFile().setClientPreviousVersionString(legacyVersion); - qCInfo(lcAccountManager) << "Migrating from" << legacyVersion; - qCInfo(lcAccountManager) << "Copy settings" << oCSettings->allKeys().join(", "); - settings = std::move(oCSettings); - ConfigFile::setDiscoveredLegacyConfigPath(configFileInfo.canonicalPath()); - break; + selectedAccountIds = childGroups; } else { - qCInfo(lcAccountManager) << "Migrate: could not read old config " << configFile; + QVector accountsToDisplay; + oCSettings->beginGroup(QLatin1String(accountsC)); + for (const auto &accId : childGroups) { + oCSettings->beginGroup(accId); + const auto displayName = oCSettings->value(QLatin1String(displayNameC)).toString(); + const auto urlStr = oCSettings->value(QLatin1String(urlC)).toString(); + oCSettings->endGroup(); // accId + const auto label = QString("%1 - %2").arg(displayName, urlStr); + accountsToDisplay.push_back({accId, label}); + } + oCSettings->endGroup(); // accountsC + + LegacyAccountSelectionDialog accountSelectionDialog(accountsToDisplay); + if (accountSelectionDialog.exec() != QDialog::Accepted) { + return false; + } + selectedAccountIds = accountSelectionDialog.selectedAccountIds(); + if (selectedAccountIds.isEmpty()) { + return false; + } } + } else { + selectedAccountIds = childGroups; } + + settings.reset(oCSettings.get()); } ConfigFile configFile; @@ -344,7 +298,7 @@ bool AccountManager::restoreFromLegacySettings() configFile.setDownloadLimit(settings->value(ConfigFile::downloadLimitC, configFile.downloadLimit()).toInt()); // Try to load the single account. - configFile.setMigrationPhase(ConfigFile::MigrationPhase::SetupUsers); + migration.setPhase(Migration::Phase::SetupUsers); if (!settings->childKeys().isEmpty()) { settings->beginGroup(accountsC); const auto childGroups = selectedAccountIds.isEmpty() ? settings->childGroups() : selectedAccountIds; @@ -545,7 +499,8 @@ void AccountManager::migrateNetworkSettings(const AccountPtr &account, const QSe // Override user settings with global (QNetworkProxy::DefaultProxy) settings // if user is set to use global settings ConfigFile configFile; - if (accountProxyType == QNetworkProxy::DefaultProxy && configFile.isMigrationInProgress()) { + Migration migration; + if (accountProxyType == QNetworkProxy::DefaultProxy && migration.isInProgress()) { accountProxyType = static_cast(configFile.proxyType()); accountProxyHost = configFile.proxyHostName(); accountProxyPort = configFile.proxyPort(); @@ -693,8 +648,9 @@ AccountPtr AccountManager::loadAccountHelper(QSettings &settings) acc->setDownloadLimit(settings.value(networkDownloadLimitC).toInt()); ConfigFile configFile; + Migration migration; const auto proxyPasswordKey = QString(acc->userIdAtHostWithPort() + networkProxyPasswordKeychainKeySuffixC); - const auto appName = configFile.isUnbrandedToBrandedMigrationInProgress() ? ConfigFile::unbrandedAppName + const auto appName = migration.isUnbrandedToBrandedMigration() ? ConfigFile::unbrandedAppName : Theme::instance()->appName(); const auto job = new QKeychain::ReadPasswordJob(appName, this); job->setKey(proxyPasswordKey); diff --git a/src/gui/accountstate.cpp b/src/gui/accountstate.cpp index ec3311938db0c..fd68cbbdce70e 100644 --- a/src/gui/accountstate.cpp +++ b/src/gui/accountstate.cpp @@ -17,6 +17,7 @@ #include "ocsuserstatusconnector.h" #include "pushnotifications.h" #include "networkjobs.h" +#include "settings/migration.h" #include #include @@ -301,9 +302,10 @@ void AccountState::checkConnectivity() if (!account()->credentials()->wasFetched()) { _waitingForNewCredentials = true; ConfigFile configFile; - const auto shouldTryUnbrandedToBrandedMigration = configFile.shouldTryUnbrandedToBrandedMigration(); + Migration migration; + const auto shouldTryUnbrandedToBrandedMigration = migration.shouldTryUnbrandedToBrandedMigration(); qCDebug(lcAccountState) << "shouldTryUnbrandedToBrandedMigration?" << shouldTryUnbrandedToBrandedMigration; - qCDebug(lcAccountState) << "migrationPhase?" << configFile.migrationPhase(); + qCDebug(lcAccountState) << "migration Phase?" << migration.phase(); const auto appName = shouldTryUnbrandedToBrandedMigration ? configFile.unbrandedAppName : ""; account()->credentials()->fetchFromKeychain(appName); return; @@ -499,8 +501,9 @@ void AccountState::slotCredentialsFetched(AbstractCredentials *) << "attempting to connect"; _waitingForNewCredentials = false; ConfigFile configFile; - if (configFile.isMigrationInProgress()) { - configFile.setMigrationPhase(ConfigFile::MigrationPhase::Done); + Migration migration; + if (migration.isInProgress()) { + migration.setPhase(Migration::Phase::Done); } checkConnectivity(); } diff --git a/src/gui/application.cpp b/src/gui/application.cpp index 0e4b56610a477..3dea4ba9441fe 100644 --- a/src/gui/application.cpp +++ b/src/gui/application.cpp @@ -33,6 +33,7 @@ #include "common/vfs.h" #include "config.h" +#include "settings/migration.h" #if defined(Q_OS_WIN) #include @@ -143,21 +144,20 @@ namespace { bool Application::configVersionMigration() { ConfigFile configFile; - const auto shouldTryToMigrate = configFile.shouldTryToMigrate(); + Migration migration; + const auto shouldTryToMigrate = migration.shouldTryToMigrate(); if (!shouldTryToMigrate) { qCInfo(lcApplication) << "This is not an upgrade/downgrade/migration. Proceed to read current application config file."; - configFile.setMigrationPhase(ConfigFile::MigrationPhase::Done); + migration.setPhase(Migration::Phase::Done); return false; } - configFile.setMigrationPhase(ConfigFile::MigrationPhase::SetupConfigFile); + migration.setPhase(Migration::Phase::SetupConfigFile); QStringList deleteKeys, ignoreKeys; AccountManager::backwardMigrationSettingsKeys(&deleteKeys, &ignoreKeys); FolderMan::backwardMigrationSettingsKeys(&deleteKeys, &ignoreKeys); - configFile.setClientPreviousVersionString(configFile.clientVersionString()); - - qCDebug(lcApplication) << "Migration is in progress:" << configFile.isMigrationInProgress(); - const auto versionChanged = configFile.hasVersionChanged(); + qCDebug(lcApplication) << "Migration is in progress:" << migration.isInProgress(); + const auto versionChanged = migration.versionChanged(); if (versionChanged) { qCInfo(lcApplication) << "Version changed. Removing updater settings from config."; configFile.cleanUpdaterConfiguration(); @@ -203,7 +203,7 @@ bool Application::configVersionMigration() "Continuing will mean %2 these settings.
" "
" "The current configuration file was already backed up to %3.") - .arg((configFile.isDowngrade() ? tr("newer", "newer software version") : tr("older", "older software version")), + .arg((Migration().isDowngrade() ? tr("newer", "newer software version") : tr("older", "older software version")), deleteKeys.isEmpty()? tr("ignoring") : tr("deleting"), backupFilesList.join("
"))); box.addButton(tr("Quit"), QMessageBox::AcceptRole); @@ -672,18 +672,18 @@ void Application::setupAccountsAndFolders() { _folderManager = FolderMan::instance(); ConfigFile configFile; - configFile.setMigrationPhase(ConfigFile::MigrationPhase::SetupUsers); + Migration migration; + migration.setPhase(Migration::Phase::SetupUsers); const auto accountsRestoreResult = restoreLegacyAccount(); if (accountsRestoreResult == AccountManager::AccountsNotFound || accountsRestoreResult == AccountManager::AccountsRestoreFailure) { qCWarning(lcApplication) << "Migration result: " << accountsRestoreResult; qCDebug(lcApplication) << "is migration disabled?" << DISABLE_ACCOUNT_MIGRATION; qCWarning(lcApplication) << "No accounts were migrated, prompting user to set up accounts and folders from scratch."; - configFile.setMigrationPhase(ConfigFile::MigrationPhase::Done); - + migration.setPhase(Migration::Phase::Done); return; } - configFile.setMigrationPhase(ConfigFile::MigrationPhase::SetupFolders); + migration.setPhase(Migration::Phase::SetupFolders); const auto foldersListSize = FolderMan::instance()->setupFolders(); FolderMan::instance()->setSyncEnabled(true); @@ -698,9 +698,9 @@ void Application::setupAccountsAndFolders() const auto accounts = AccountManager::instance()->accounts(); const auto accountsListSize = accounts.size(); if (accountsRestoreResult == AccountManager::AccountsRestoreSuccessFromLegacyVersion + && accountsListSize > 0 && Theme::instance()->displayLegacyImportDialog() - && !AccountManager::instance()->forceLegacyImport() - && accountsListSize > 0) { + && !AccountManager::instance()->forceLegacyImport()) { const auto accountsRestoreMessage = accountsListSize > 1 ? tr("%1 accounts", "number of accounts imported").arg(QString::number(accountsListSize)) : tr("1 account"); diff --git a/src/gui/folderman.cpp b/src/gui/folderman.cpp index ffe045f402f44..3cce763964d35 100644 --- a/src/gui/folderman.cpp +++ b/src/gui/folderman.cpp @@ -21,6 +21,7 @@ #include #include #include "updatee2eefolderusersmetadatajob.h" +#include "settings/migration.h" #ifdef Q_OS_MACOS #include @@ -439,7 +440,8 @@ int FolderMan::setupFoldersMigration() auto configPath = _folderConfigPath; #if !DISABLE_ACCOUNT_MIGRATION - if (const auto legacyConfigPath = ConfigFile::discoveredLegacyConfigPath();!legacyConfigPath.isEmpty()) { + Migration migration; + if (const auto legacyConfigPath = migration.discoveredLegacyConfigPath(); !legacyConfigPath.isEmpty()) { configPath = legacyConfigPath; qCInfo(lcFolderMan) << "Starting folder migration from legacy path:" << legacyConfigPath; } diff --git a/src/gui/folderman.h b/src/gui/folderman.h index 5681762d8e422..e5f6d2a04819b 100644 --- a/src/gui/folderman.h +++ b/src/gui/folderman.h @@ -30,6 +30,7 @@ class TestRemoteWipe; class FolderManTestHelper; class TestFileActionsModel; class TestBrowserReAuthController; +class TestMigration; namespace OCC { @@ -38,6 +39,7 @@ class SyncResult; class SocketApi; class LockWatcher; class UpdateE2eeFolderUsersMetadataJob; +class Migration; /** * @brief The FolderMan class @@ -421,6 +423,7 @@ private slots: friend class ::FolderManTestHelper; friend class ::TestFileActionsModel; friend class ::TestBrowserReAuthController; + friend class ::TestMigration; }; } // namespace OCC diff --git a/src/libsync/CMakeLists.txt b/src/libsync/CMakeLists.txt index be060e8ce275e..ee5f79a08272d 100644 --- a/src/libsync/CMakeLists.txt +++ b/src/libsync/CMakeLists.txt @@ -166,6 +166,8 @@ set(libsync_SRCS creds/keychainchunk.cpp caseclashconflictsolver.h caseclashconflictsolver.cpp + settings/migration.h + settings/migration.cpp ) if (WIN32) diff --git a/src/libsync/configfile.cpp b/src/libsync/configfile.cpp index 920ff4aa9ef20..05e922995c4d8 100644 --- a/src/libsync/configfile.cpp +++ b/src/libsync/configfile.cpp @@ -14,6 +14,7 @@ #include "theme.h" #include "updatechannel.h" #include "version.h" +#include "settings/migration.h" #ifndef TOKEN_AUTH_ONLY #include @@ -91,8 +92,6 @@ namespace chrono = std::chrono; Q_LOGGING_CATEGORY(lcConfigFile, "nextcloud.sync.configfile", QtInfoMsg) QString ConfigFile::_confDir = {}; -QString ConfigFile::_discoveredLegacyConfigPath = {}; -ConfigFile::MigrationPhase ConfigFile::_migrationPhase = ConfigFile::MigrationPhase::NotStarted; static chrono::milliseconds millisecondsValue(const QSettings &setting, const char *key, chrono::milliseconds defaultValue) @@ -327,7 +326,8 @@ void ConfigFile::restoreGeometryHeader(QHeaderView *header) QVariant ConfigFile::getPolicySetting(const QString &setting, const QVariant &defaultValue) const { if (Utility::isWindows()) { - const auto appName = isUnbrandedToBrandedMigrationInProgress() ? unbrandedAppName : Theme::instance()->appNameGUI(); + Migration migration; + const auto appName = migration.isUnbrandedToBrandedMigration() ? unbrandedAppName : Theme::instance()->appNameGUI(); // check for policies first and return immediately if a value is found. QSettings userPolicy(QString::fromLatin1(R"(HKEY_CURRENT_USER\Software\Policies\%1\%2)").arg(APPLICATION_VENDOR, appName), QSettings::NativeFormat); @@ -381,7 +381,10 @@ QString ConfigFile::excludeFile(Scope scope) const return ConfigFile::excludeFileFromSystem(); } - const auto excludeFilePath = scope == LegacyScope ? discoveredLegacyConfigPath() : configPath(); + Migration migration; + const auto excludeFilePath = scope == LegacyScope + ? migration.discoveredLegacyConfigPath() + : configPath(); // prefer sync-exclude.lst, but if it does not exist, check for exclude.lst QFileInfo exclFileInfo(excludeFilePath, syncExclFile); @@ -857,7 +860,8 @@ QVariant ConfigFile::getValue(const QString ¶m, const QString &group, const QVariant &defaultValue) const { QVariant systemSetting; - const auto appName = isUnbrandedToBrandedMigrationInProgress() ? unbrandedAppName : Theme::instance()->appNameGUI(); + Migration migration; + const auto appName = migration.isUnbrandedToBrandedMigration() ? unbrandedAppName : Theme::instance()->appNameGUI(); if (Utility::isMac()) { QSettings systemSettings(QLatin1String("/Library/Preferences/" APPLICATION_REV_DOMAIN ".plist"), QSettings::NativeFormat); if (!group.isEmpty()) { @@ -1332,20 +1336,6 @@ void ConfigFile::setupDefaultExcludeFilePaths(ExcludedFiles &excludedFiles) excludedFiles.addExcludeFilePath(userList); } -QString ConfigFile::discoveredLegacyConfigPath() -{ - return _discoveredLegacyConfigPath; -} - -void ConfigFile::setDiscoveredLegacyConfigPath(const QString &discoveredLegacyConfigPath) -{ - if (_discoveredLegacyConfigPath == discoveredLegacyConfigPath) { - return; - } - - _discoveredLegacyConfigPath = discoveredLegacyConfigPath; -} - void ConfigFile::removeFileProviderDomainMapping() { QSettings settings(configFile(), QSettings::IniFormat); @@ -1385,60 +1375,27 @@ void ConfigFile::setMacFileProviderModeEnabled(const bool enabled) settings.sync(); } -bool ConfigFile::isUpgrade() const -{ - const auto currentVersion = QVersionNumber::fromString(MIRALL_VERSION_STRING); - const auto previousVersion = QVersionNumber::fromString(clientPreviousVersionString()); - return currentVersion > previousVersion; -} - -bool ConfigFile::isDowngrade() const -{ - const auto currentVersion = QVersionNumber::fromString(MIRALL_VERSION_STRING); - const auto previousVersion = QVersionNumber::fromString(clientPreviousVersionString()); - return previousVersion > currentVersion; -} - -bool ConfigFile::shouldTryUnbrandedToBrandedMigration() const -{ - return migrationPhase() == ConfigFile::MigrationPhase::SetupFolders - && Theme::instance()->appName() != unbrandedAppName - && !discoveredLegacyConfigPath().isEmpty(); -} - -bool ConfigFile::isUnbrandedToBrandedMigrationInProgress() const -{ - return isMigrationInProgress() && Theme::instance()->appName() != unbrandedAppName; -} - -bool ConfigFile::shouldTryToMigrate() const -{ - return hasVersionChanged() && (isUpgrade() || isDowngrade()); -} - -bool ConfigFile::hasVersionChanged() const +QStringList ConfigFile::backupConfigFiles() { - const auto currentVersion = QVersionNumber::fromString(MIRALL_VERSION_STRING); //app running - const auto clientConfigVersion = QVersionNumber::fromString(clientVersionString()); //config version - return clientConfigVersion != currentVersion; -} - -bool ConfigFile::isMigrationInProgress() const -{ - return _migrationPhase != MigrationPhase::NotStarted && _migrationPhase != MigrationPhase::Done; -} + const auto theme = Theme::instance(); + setLaunchOnSystemStartup(launchOnSystemStartup()); + Utility::setLaunchOnStartup(theme->appName(), theme->appNameGUI(), launchOnSystemStartup()); + setPromptDeleteFiles(false); -void ConfigFile::setMigrationPhase(const MigrationPhase phase) -{ - // do not rollback - if (phase > _migrationPhase) { - _migrationPhase = phase; + QStringList backupFilesList; + QDir configDir(configPath()); + const auto anyConfigFileNameList = configDir.entryInfoList({"*.cfg"}, QDir::Files); + for (const auto &oldConfig : anyConfigFileNameList) { + const auto oldConfigFileName = oldConfig.fileName(); + const auto oldConfigFilePath = oldConfig.filePath(); + const auto newConfigFileName = configFile(); + backupFilesList.append(backup(oldConfigFileName)); + if (oldConfigFilePath != newConfigFileName && !QFile::rename(oldConfigFilePath, newConfigFileName)) { + qCWarning(lcConfigFile) << "Failed to rename configuration file from" << oldConfigFilePath << "to" << newConfigFileName; + } } -} -ConfigFile::MigrationPhase ConfigFile::migrationPhase() const -{ - return _migrationPhase; + return backupFilesList; } } diff --git a/src/libsync/configfile.h b/src/libsync/configfile.h index 552273b2ef387..05d93e7dcdfde 100644 --- a/src/libsync/configfile.h +++ b/src/libsync/configfile.h @@ -23,6 +23,7 @@ class ExcludedFiles; namespace OCC { class AbstractCredentials; +class Migration; /** * @brief The ConfigFile class @@ -251,10 +252,6 @@ class OWNCLOUDSYNC_EXPORT ConfigFile /// Add the system and user exclude file path to the ExcludedFiles instance. static void setupDefaultExcludeFilePaths(ExcludedFiles &excludedFiles); - /// Set during first time migration of legacy accounts in AccountManager - [[nodiscard]] static QString discoveredLegacyConfigPath(); - static void setDiscoveredLegacyConfigPath(const QString &discoveredLegacyConfigPath); - /// File Provider Domain UUID to Account ID mapping /** @@ -266,6 +263,8 @@ class OWNCLOUDSYNC_EXPORT ConfigFile [[nodiscard]] bool fileProviderDomainsAppSandboxMigrationCompleted() const; void setFileProviderDomainsAppSandboxMigrationCompleted(bool completed); + [[nodiscard]] QStringList backupConfigFiles(); + /// App-level macOS File Provider mode: when enabled, every account gets a file /// provider domain and classic sync folders are unavailable (the File Provider /// extension and the FinderSync extension cannot run at the same time). @@ -273,24 +272,6 @@ class OWNCLOUDSYNC_EXPORT ConfigFile [[nodiscard]] bool macFileProviderModeEnabledIsSet() const; void setMacFileProviderModeEnabled(bool enabled); - /// Helper function for migration/upgrade proccess - enum MigrationPhase { - NotStarted, - SetupConfigFile, - SetupUsers, - SetupFolders, - Done - }; - [[nodiscard]] bool isUpgrade() const; - [[nodiscard]] bool isDowngrade() const; - [[nodiscard]] bool shouldTryUnbrandedToBrandedMigration() const; - [[nodiscard]] bool isUnbrandedToBrandedMigrationInProgress() const; - [[nodiscard]] bool shouldTryToMigrate() const; - /// Does the current app has a different version of the config version - [[nodiscard]] bool hasVersionChanged() const; - [[nodiscard]] bool isMigrationInProgress() const; - [[nodiscard]] MigrationPhase migrationPhase() const; - void setMigrationPhase(const MigrationPhase phase); static constexpr char unbrandedAppName[] = "Nextcloud"; static constexpr char legacyAppName[] = "Owncloud"; @@ -335,8 +316,6 @@ class OWNCLOUDSYNC_EXPORT ConfigFile using SharedCreds = QSharedPointer; static QString _confDir; - static QString _discoveredLegacyConfigPath; - static MigrationPhase _migrationPhase; }; } #endif // CONFIGFILE_H diff --git a/src/libsync/settings/migration.cpp b/src/libsync/settings/migration.cpp new file mode 100644 index 0000000000000..ebf2145789bc1 --- /dev/null +++ b/src/libsync/settings/migration.cpp @@ -0,0 +1,228 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include + +#include "migration.h" +#include "theme.h" +#include "configfile.h" +#include "version.h" +#include "common/utility.h" + +#include +#include +#include + +namespace { + constexpr auto legacyCfgFileNameC = "owncloud.cfg"; + constexpr auto legacyRelativeConfigLocationC = "/ownCloud/owncloud.cfg"; + constexpr auto unbrandedRelativeConfigLocationC = "/Nextcloud/nextcloud.cfg"; + constexpr auto unbrandedCfgFileNameC = "nextcloud.cfg"; +} + +namespace OCC { + +Q_LOGGING_CATEGORY(lcMigration, "nextcloud.settings.migration", QtInfoMsg) + +Migration::Phase Migration::_phase = Phase::NotStarted; +Migration::BrandingType Migration::_brandingType = BrandingType::UnbrandedToUnbranded; +Migration::UpgradeType Migration::_upgradeType = UpgradeType::NoChange; +QString Migration::_discoveredLegacyConfigPath = {}; +Migration::LegacyData Migration::_legacyData = {}; + +QVersionNumber Migration::currentVersion() const +{ + return QVersionNumber::fromString(MIRALL_VERSION_STRING); +} + +QVersionNumber Migration::previousVersion() const +{ + return QVersionNumber::fromString(ConfigFile().clientPreviousVersionString()); +} + +QVersionNumber Migration::configVersion() const +{ + return QVersionNumber::fromString(ConfigFile().clientVersionString()); +} + +void Migration::setPhase(const Phase phase) +{ + if (phase > _phase) { + _phase = phase; + } +} + +Migration::Phase Migration::phase() const +{ + return _phase; +} + +void Migration::setBrandingType(const BrandingType type) +{ + _brandingType = type; +} + +Migration::BrandingType Migration::brandingType() const +{ + return _brandingType; +} + +Migration::UpgradeType Migration::upgradeType() const +{ + return _upgradeType; +} + +void Migration::setUpgradeType(const UpgradeType type) +{ + _upgradeType = type; +} + +bool Migration::isUpgrade() const +{ + return currentVersion() > previousVersion(); +} + +bool Migration::isDowngrade() const +{ + return previousVersion() > currentVersion(); +} + +bool Migration::versionChanged() const +{ + return isUpgrade() || isDowngrade(); +} + +bool Migration::shouldTryUnbrandedToBrandedMigration() +{ + const auto isUnbrandedToBranded = phase() == Migration::Phase::SetupFolders + && Theme::instance()->appName() != ConfigFile::unbrandedAppName + && !_discoveredLegacyConfigPath.isEmpty(); + + if (isUnbrandedToBranded) { + setBrandingType(BrandingType::UnbrandedToBranded); + } + return _brandingType == BrandingType::UnbrandedToBranded; +} + +bool Migration::isUnbrandedToBrandedMigration() const +{ + return isInProgress() && brandingType() == BrandingType::UnbrandedToBranded; +} + +bool Migration::shouldTryToMigrate() const +{ + return !isClientVersionSet() && (isUpgrade() || isDowngrade()); +} + +bool Migration::isClientVersionSet() const +{ + const auto configVersionNumber = configVersion(); + const auto previousVersionNumber = previousVersion(); + return !configVersionNumber.isNull() && !previousVersionNumber.isNull() + && configVersionNumber == previousVersionNumber; +} + +bool Migration::isInProgress() const +{ + const auto currentPhase = phase(); + return currentPhase != Phase::NotStarted + && currentPhase != Phase::Done; +} + +void Migration::resetForTesting() +{ + _phase = Phase::NotStarted; + _brandingType = BrandingType::UnbrandedToUnbranded; + _upgradeType = UpgradeType::NoChange; + _discoveredLegacyConfigPath = {}; + _legacyData = {}; +} + +Migration::LegacyData Migration::legacyData() const +{ + qCInfo(lcMigration) << "Migrate: restoreFromLegacySettings, checking settings group" << Theme::instance()->appName(); + + // try to open the correctly themed settings + auto settings = ConfigFile::settingsWithGroup(Theme::instance()->appName()); + LegacyData legacyData; + + // if the settings file could not be opened, the childKeys list is empty + // then try to load settings from a very old place + if (settings->childKeys().isEmpty()) { + // Legacy settings used QDesktopServices to get the location for the config folder in 2.4 and before + const auto legacy2_4CfgSettingsLocation = QString(QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + QStringLiteral("/data")); + const auto legacy2_4CfgFileParentFolder = legacy2_4CfgSettingsLocation.left(legacy2_4CfgSettingsLocation.lastIndexOf('/')); + + // 2.5+ (rest of 2.x series) + const auto legacy2_5CfgSettingsLocation = + QStandardPaths::writableLocation(Utility::isWindows() ? QStandardPaths::AppDataLocation : QStandardPaths::AppConfigLocation); + const auto legacy2_5CfgFileParentFolder = legacy2_5CfgSettingsLocation.left(legacy2_5CfgSettingsLocation.lastIndexOf('/')); + + // Now try the locations we use today + const auto fullLegacyCfgFile = QDir::fromNativeSeparators(settings->fileName()); + const auto legacyCfgFileParentFolder = fullLegacyCfgFile.left(fullLegacyCfgFile.lastIndexOf('/')); + const auto legacyCfgFileGrandParentFolder = legacyCfgFileParentFolder.left(legacyCfgFileParentFolder.lastIndexOf('/')); + + const auto legacyCfgFileNamePath = QString(QStringLiteral("/") + legacyCfgFileNameC); + const auto legacyCfgFileRelativePath = QString(legacyRelativeConfigLocationC); + + auto legacyLocations = QVector{legacy2_4CfgFileParentFolder + legacyCfgFileRelativePath, + legacy2_5CfgFileParentFolder + legacyCfgFileRelativePath, + legacyCfgFileParentFolder + legacyCfgFileNamePath, + legacyCfgFileGrandParentFolder + legacyCfgFileRelativePath}; + + if (Theme::instance()->isBranded()) { + const auto unbrandedCfgFileNamePath = QString(QStringLiteral("/") + unbrandedCfgFileNameC); + const auto unbrandedCfgFileRelativePath = QString(unbrandedRelativeConfigLocationC); + legacyLocations.append({legacyCfgFileParentFolder + unbrandedCfgFileNamePath, legacyCfgFileGrandParentFolder + unbrandedCfgFileRelativePath}); + } + + for (const auto &configFileString : std::as_const(legacyLocations)) { + auto oCSettings = std::make_unique(configFileString, QSettings::IniFormat); + if (oCSettings->status() != QSettings::Status::NoError) { + qCInfo(lcMigration) << "Error reading legacy configuration file" << oCSettings->status(); + break; + } + + if (const QFileInfo configFileInfo(configFileString); configFileInfo.exists() && configFileInfo.isReadable()) { + ConfigFile configFile; + const auto legacyVersion = oCSettings->value(ConfigFile::clientVersionC, {}).toString(); + configFile.setClientPreviousVersionString(legacyVersion); + qCInfo(lcMigration) << "Migrating from legacy version" << legacyVersion; + qCDebug(lcMigration) << "Copy settings" << oCSettings->allKeys().join(", "); + Migration migration; + migration.setDiscoveredLegacyConfigPath(configFileInfo.canonicalPath()); + legacyData.reset(oCSettings.get()); + migration.setLegacyData(legacyData); + break; + } else { + qCInfo(lcMigration) << "Migrate: could not read old config " << configFileString; + } + } + } + + return legacyData; +} + +QString Migration::discoveredLegacyConfigPath() const +{ + return _discoveredLegacyConfigPath; +} + +void Migration::setDiscoveredLegacyConfigPath(const QString &discoveredLegacyConfigPath) +{ + if (_discoveredLegacyConfigPath == discoveredLegacyConfigPath) { + return; + } + + _discoveredLegacyConfigPath = discoveredLegacyConfigPath; +} + +void Migration::setLegacyData(const LegacyData legacyData) +{ + _legacyData = legacyData; +} + +} diff --git a/src/libsync/settings/migration.h b/src/libsync/settings/migration.h new file mode 100644 index 0000000000000..fb7012bf37c8b --- /dev/null +++ b/src/libsync/settings/migration.h @@ -0,0 +1,124 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#ifndef MIGRATION_H +#define MIGRATION_H + +#include +#include +#include +#include "owncloudlib.h" + +namespace OCC { + +class OWNCLOUDSYNC_EXPORT Migration +{ + Q_GADGET +public: + Migration() { }; + + enum class Phase { + NotStarted, + SetupConfigFile, + SetupUsers, + SetupFolders, + Done + }; + Q_ENUM(Phase) + + enum class BrandingType { + UnbrandedToUnbranded, + UnbrandedToBranded, + LegacyToUnbranded, + LegacyToBranded + }; + Q_ENUM(BrandingType) + + enum class UpgradeType { + NoChange, + Upgrade, + Downgrade + }; + Q_ENUM(UpgradeType) + + using LegacyData = QSharedPointer; + + [[nodiscard]] QVersionNumber previousVersion() const; + [[nodiscard]] QVersionNumber currentVersion() const; + [[nodiscard]] QVersionNumber configVersion() const; + + /** + * Application::configVersionMigration [start] + * |-- no migration needed ----------------------> Phase::Done + * | + * Phase::SetupConfigFile + * | backup config files, remove incompatible keys + * | + * Application::setupAccountsAndFolders + * | + * Phase::SetupUsers + * | + * Application::restoreLegacyAccount + * | + * AccountManager::restore + * |-- accounts in current config -----------> load accounts + * | + * AccountManager::restoreFromLegacySettings + * | + * Migration::legacyData + * | search legacy config locations + * |-- no legacy config found -----------> return + * | + * store legacy QSettings + config path + * | + * Phase::SetupFolders + * | + * FolderMan::setupFolders + * |-- legacy path known -> setupFoldersMigration + * | + * load folder definitions + * | + * AccountState::slotCredentialsFetched [end] + * | + * Phase::Done + */ + [[nodiscard]] Phase phase() const; + void setPhase(const Phase phase); + + [[nodiscard]] BrandingType brandingType() const; + void setBrandingType(const BrandingType type); + + [[nodiscard]] UpgradeType upgradeType() const; + void setUpgradeType(const UpgradeType type); + + /// Returns QSettings from a legacy config file + [[nodiscard]] LegacyData legacyData() const; + void setLegacyData(const LegacyData legacyData); + + /// Set during first time migration of legacy accounts in AccountManager + [[nodiscard]] QString discoveredLegacyConfigPath() const; + void setDiscoveredLegacyConfigPath(const QString &discoveredLegacyConfigPath); + + [[nodiscard]] bool isUpgrade() const; + [[nodiscard]] bool isDowngrade() const; + [[nodiscard]] bool versionChanged() const; + [[nodiscard]] bool shouldTryUnbrandedToBrandedMigration(); + [[nodiscard]] bool isUnbrandedToBrandedMigration() const; + [[nodiscard]] bool shouldTryToMigrate() const; + [[nodiscard]] bool isClientVersionSet() const; + [[nodiscard]] bool isInProgress() const; + + /// Resets all shared state to initial values. Only intended for use in unit tests. + static void resetForTesting(); + +private: + static Phase _phase; + static BrandingType _brandingType; + static UpgradeType _upgradeType; + static QString _discoveredLegacyConfigPath; + static LegacyData _legacyData; +}; +} +#endif // MIGRATION_H diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 2df389d17d7dd..0ad03a71f19a6 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -180,6 +180,7 @@ nextcloud_add_test(Folder) nextcloud_add_test(FolderMan) nextcloud_add_test(ForceSyncNow) nextcloud_add_test(RemoteWipe) +nextcloud_add_test(Migration) if(NOT BUILD_FILE_PROVIDER_MODULE) # the File Provider build crashes this test in CI for some reason diff --git a/test/testmigration.cpp b/test/testmigration.cpp new file mode 100644 index 0000000000000..0975c70775b2a --- /dev/null +++ b/test/testmigration.cpp @@ -0,0 +1,217 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include +#include +#include +#include + +#include "common/utility.h" +#include "folderman.h" +#include "account.h" +#include "accountstate.h" +#include "accountmanager.h" +#include "configfile.h" +#include "syncenginetestutils.h" +#include "testhelper.h" +#include "version.h" +#include "settings/migration.h" + +using namespace OCC; + +class TestMigration: public QObject +{ + Q_OBJECT + + ConfigFile _configFile; + QTemporaryDir _temporaryDir; + std::unique_ptr _folderMan; + +private: + static constexpr char standardAppName[] = "Nextcloud"; + static constexpr char legacyAppConfigContent[] = "[General]\n" + "clientVersion=5.3.2.15463\n" + "issuesWidgetFilter=FatalError, BlacklistedError, Excluded, Message, FilenameReserved\n" + "logHttp=false\n" + "optionalDesktopNotifications=true\n" + "\n" + "[Accounts]e\n" + "0\\Folders\\2ba4b09a-1223-aaaa-abcd-c2df238816d8\\davUrl=@Variant(http://oc.de/remote.php/dav/files/admin/)\n" + "0\\Folders\\2ba4b09a-1223-aaaa-abcd-c2df238816d8\\deployed=false\n" + "0\\Folders\\2ba4b09a-1223-aaaa-abcd-c2df238816d8\\displayString=ownCloud\n" + "0\\Folders\2ba4b09a-1223-aaaa-abcd-c2df238816d8\\ignoreHiddenFiles=true\n" + "0\\Folders\2ba4b09a-1223-aaaa-abcd-c2df238816d8\\journalPath=.sync_journal.db\n" + "0\\Folders\2ba4b09a-1223-aaaa-abcd-c2df238816d8\\localPath=/ownCloud/\n" + "0\\Folders\2ba4b09a-1223-aaaa-abcd-c2df238816d8\\paused=false\n" + "0\\Folders\\2ba4b09a-1223-aaaa-abcd-c2df238816d8\\priority=0\n" + "0\\Folders\\2ba4b09a-1223-aaaa-abcd-c2df238816d8\\targetPath=/\n" + "0\\Folders\\2ba4b09a-1223-aaaa-abcd-c2df238816d8\\version=13\n" + "0\\Folders\\2ba4b09a-1223-aaaa-abcd-c2df238816d8\\virtualFilesMode=off\n" + "0\\capabilities=@QVariant()\n" + "0\\dav_user=admin\n" + "0\\default_sync_root=/ownCloud\n" + "0\\display-name=admin\n" + "0\\http_CredentialVersion=1\n" + "0\\http_oauth=false\n" + "0\\http_user=admin\n" + "0\\supportsSpaces=true\n" + "0\\url=http://oc.de/\n" + "0\\user=admin\n" + "0\\userExplicitlySignedOut=false\n" + "0\\uuid=@Variant()\n" + "0\\version=13\n" + "version=13\n" + "\n" + "[Credentials]\n" + "ownCloud_credentials%oc.de%2ba4b09a-1223-aaaa-abcd-c2df238816d8\\http\\password=true"; + +private slots: + void setupStandardConfigFolder() + { + QVERIFY(QDir(_temporaryDir.path()).mkpath(standardAppName)); + const auto standardConfigFolder = QString(_temporaryDir.path() + "/" + standardAppName); + _configFile.setConfDir(standardConfigFolder); + } + + void setupStandarConfig(const QString &version) + { + setupStandardConfigFolder(); + QSettings settings(_configFile.configFile(), QSettings::IniFormat); + _configFile.setClientVersionString(version); + _configFile.setOptionalServerNotifications(true); + _configFile.setShowChatNotifications(true); + _configFile.setShowCallNotifications(true); + _configFile.setShowInExplorerNavigationPane(true); + _configFile.setShowInExplorerNavigationPane(true); + _configFile.setRemotePollInterval(std::chrono::milliseconds(1000)); + _configFile.setAutoUpdateCheck(true, QString()); + _configFile.setUpdateChannel("beta"); + _configFile.setOverrideServerUrl("http://example.de"); + _configFile.setOverrideLocalDir("A"); + _configFile.setVfsEnabled(true); + _configFile.setProxyType(0); + _configFile.setVfsEnabled(true); + _configFile.setUseUploadLimit(0); + _configFile.setUploadLimit(1); + _configFile.setUseDownloadLimit(0); + _configFile.setUseDownloadLimit(1); + _configFile.setNewBigFolderSizeLimit(true, 500); + _configFile.setNotifyExistingFoldersOverLimit(true); + _configFile.setStopSyncingExistingFoldersOverLimit(true); + _configFile.setConfirmExternalStorage(true); + _configFile.setMoveToTrash(true); + _configFile.setForceLoginV2(true); + _configFile.setPromptDeleteFiles(true); + _configFile.setDeleteFilesThreshold(1); + _configFile.setMonoIcons(true); + _configFile.setAutomaticLogDir(true); + _configFile.setLogDir(_temporaryDir.path()); + _configFile.setLogDebug(true); + _configFile.setLogExpire(72); + _configFile.setLogFlush(true); + _configFile.setCertificatePath(_temporaryDir.path()); + _configFile.setCertificatePasswd("123456"); + _configFile.setLaunchOnSystemStartup(true); + _configFile.setServerHasValidSubscription(true); + _configFile.setDesktopEnterpriseChannel("stable"); + _configFile.setLanguage("pt"); + settings.sync(); + QVERIFY(_configFile.exists()); + QScopedPointer fakeQnam(new FakeQNAM({})); + OCC::AccountPtr account = OCC::Account::create(); + account->setDavUser("user"); + account->setDavDisplayName("Nextcloud user"); + // TODO: detangle UI from logic + //account->setProxyType(QNetworkProxy::ProxyType::HttpProxy); + //account->setProxyUser("proxyuser"); + account->setDownloadLimit(120); + account->setUploadLimit(120); + account->setDownloadLimitSetting(OCC::Account::AccountNetworkTransferLimitSetting::ManualLimit); + account->setServerVersion("30"); + account->setCredentials(new FakeCredentials{fakeQnam.data()}); + account->setUrl(QUrl(("http://example.de"))); + const auto accountState = OCC::AccountManager::instance()->addAccount(account); + OCC::AccountManager::instance()->saveAccount(accountState->account()); + OCC::FolderDefinition folderDefinition; + folderDefinition.localPath = "/standardAppName"; + folderDefinition.targetPath = "/"; + folderDefinition.alias = standardAppName; + _folderMan.reset(new FolderMan{}); + QVERIFY(_folderMan->addFolder(accountState, folderDefinition)); + } + + void initTestCase() + { + OCC::Logger::instance()->setLogFlush(true); + OCC::Logger::instance()->setLogDebug(true); + + QStandardPaths::setTestModeEnabled(true); + } + + void testSetPhase() + { + Migration migration; + QCOMPARE(migration.phase(), OCC::Migration::Phase::NotStarted); + migration.setPhase(OCC::Migration::Phase::SetupConfigFile); + QCOMPARE(migration.phase(), OCC::Migration::Phase::SetupConfigFile); + migration.setPhase(OCC::Migration::Phase::SetupUsers); + QCOMPARE(migration.phase(), OCC::Migration::Phase::SetupUsers); + migration.setPhase(OCC::Migration::Phase::SetupFolders); + QCOMPARE(migration.phase(), OCC::Migration::Phase::SetupFolders); + migration.setPhase(OCC::Migration::Phase::Done); + QCOMPARE(migration.phase(), OCC::Migration::Phase::Done); + } + + void testSetUpgradeType() + { + Migration migration; + QCOMPARE(migration.upgradeType(), OCC::Migration::UpgradeType::NoChange); + migration.setUpgradeType(OCC::Migration::UpgradeType::Upgrade); + QCOMPARE(migration.upgradeType(), OCC::Migration::UpgradeType::Upgrade); + migration.setUpgradeType(OCC::Migration::UpgradeType::Downgrade); + QCOMPARE(migration.upgradeType(), OCC::Migration::UpgradeType::Downgrade); + } + + void testSetBrandingType() + { + Migration migration; + QCOMPARE(migration.brandingType(), OCC::Migration::BrandingType::UnbrandedToUnbranded); + migration.setBrandingType(OCC::Migration::BrandingType::LegacyToUnbranded); + QCOMPARE(migration.brandingType(), OCC::Migration::BrandingType::LegacyToUnbranded); + migration.setBrandingType(OCC::Migration::BrandingType::LegacyToBranded); + QCOMPARE(migration.brandingType(), OCC::Migration::BrandingType::LegacyToBranded); + migration.setBrandingType(OCC::Migration::BrandingType::UnbrandedToBranded); + QCOMPARE(migration.brandingType(), OCC::Migration::BrandingType::UnbrandedToBranded); + } + + void testSetDiscoveredLegacyConfigPath() + { + Migration migration; + QCOMPARE(migration.discoveredLegacyConfigPath(), QString()); + const auto legacyConfigPath = QString("/path/to/legacy/config"); + migration.setDiscoveredLegacyConfigPath(legacyConfigPath); + QCOMPARE(migration.discoveredLegacyConfigPath(), legacyConfigPath); + } + + void testUpgrade() + { + // create Nextcloud config with older version + setupStandarConfig("1.0.0"); + Migration migration; + QCOMPARE(migration.isUpgrade(), true); + + // backup old config + const auto backupFilesList = _configFile.backupConfigFiles(); + QCOMPARE_GE(backupFilesList.size(), 1); + + // successfully upgrade to new config + const auto afterUpgradeVersionNumber = MIRALL_VERSION_STRING; + _configFile.setClientVersionString(afterUpgradeVersionNumber); + QCOMPARE(_configFile.clientVersionString(), MIRALL_VERSION_STRING); + } +}; + +QTEST_GUILESS_MAIN(TestMigration) +#include "testmigration.moc" From 50753e83d0d922e49e7f9b84df60af1763e69d77 Mon Sep 17 00:00:00 2001 From: Camila Ayres Date: Tue, 12 May 2026 12:52:24 +0200 Subject: [PATCH 02/16] fix(testmigration): fix QSettings state between tests and wrong setter. Add test cases covering: isInProgress for all phases, rollback prevention, isUpgrade/isDowngrade without side effects, downgrade scenario, version unchanged path, shouldTryToMigrate for both true and false cases, and shared static state across instances. Signed-off-by: Camila Ayres --- test/testmigration.cpp | 128 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 123 insertions(+), 5 deletions(-) diff --git a/test/testmigration.cpp b/test/testmigration.cpp index 0975c70775b2a..11101a1921ba4 100644 --- a/test/testmigration.cpp +++ b/test/testmigration.cpp @@ -37,14 +37,14 @@ class TestMigration: public QObject "logHttp=false\n" "optionalDesktopNotifications=true\n" "\n" - "[Accounts]e\n" + "[Accounts]\n" "0\\Folders\\2ba4b09a-1223-aaaa-abcd-c2df238816d8\\davUrl=@Variant(http://oc.de/remote.php/dav/files/admin/)\n" "0\\Folders\\2ba4b09a-1223-aaaa-abcd-c2df238816d8\\deployed=false\n" "0\\Folders\\2ba4b09a-1223-aaaa-abcd-c2df238816d8\\displayString=ownCloud\n" - "0\\Folders\2ba4b09a-1223-aaaa-abcd-c2df238816d8\\ignoreHiddenFiles=true\n" - "0\\Folders\2ba4b09a-1223-aaaa-abcd-c2df238816d8\\journalPath=.sync_journal.db\n" - "0\\Folders\2ba4b09a-1223-aaaa-abcd-c2df238816d8\\localPath=/ownCloud/\n" - "0\\Folders\2ba4b09a-1223-aaaa-abcd-c2df238816d8\\paused=false\n" + "0\\Folders\\2ba4b09a-1223-aaaa-abcd-c2df238816d8\\ignoreHiddenFiles=true\n" + "0\\Folders\\2ba4b09a-1223-aaaa-abcd-c2df238816d8\\journalPath=.sync_journal.db\n" + "0\\Folders\\2ba4b09a-1223-aaaa-abcd-c2df238816d8\\localPath=/ownCloud/\n" + "0\\Folders\\2ba4b09a-1223-aaaa-abcd-c2df238816d8\\paused=false\n" "0\\Folders\\2ba4b09a-1223-aaaa-abcd-c2df238816d8\\priority=0\n" "0\\Folders\\2ba4b09a-1223-aaaa-abcd-c2df238816d8\\targetPath=/\n" "0\\Folders\\2ba4b09a-1223-aaaa-abcd-c2df238816d8\\version=13\n" @@ -150,6 +150,17 @@ private slots: QStandardPaths::setTestModeEnabled(true); } + // Reset all Migration static state and QSettings config before every test + // so tests are fully independent of each other. + void init() + { + Migration::resetForTesting(); + setupStandardConfigFolder(); + QSettings settings(_configFile.configFile(), QSettings::IniFormat); + settings.clear(); + settings.sync(); + } + void testSetPhase() { Migration migration; @@ -211,6 +222,113 @@ private slots: _configFile.setClientVersionString(afterUpgradeVersionNumber); QCOMPARE(_configFile.clientVersionString(), MIRALL_VERSION_STRING); } + + void testIsInProgress_notStarted() + { + Migration migration; + QCOMPARE(migration.phase(), Migration::Phase::NotStarted); + QCOMPARE(migration.isInProgress(), false); + } + + void testIsInProgress_trueForMidPhases() + { + Migration migration; + migration.setPhase(Migration::Phase::SetupConfigFile); + QCOMPARE(migration.isInProgress(), true); + + Migration::resetForTesting(); + migration.setPhase(Migration::Phase::SetupUsers); + QCOMPARE(migration.isInProgress(), true); + + Migration::resetForTesting(); + migration.setPhase(Migration::Phase::SetupFolders); + QCOMPARE(migration.isInProgress(), true); + } + + void testIsInProgress_falseWhenDone() + { + Migration migration; + migration.setPhase(Migration::Phase::Done); + QCOMPARE(migration.isInProgress(), false); + } + + void testPhaseRollbackPrevented() + { + Migration migration; + migration.setPhase(Migration::Phase::Done); + migration.setPhase(Migration::Phase::SetupUsers); // attempt rollback + QCOMPARE(migration.phase(), Migration::Phase::Done); + } + + void testIsUpgrade_noSideEffects() + { + setupStandarConfig("1.0.0"); + Migration migration; + + // upgrading: current > previous + QCOMPARE(migration.isUpgrade(), true); + QCOMPARE(migration.isDowngrade(), false); + QCOMPARE(migration.versionChanged(), true); + + // calling isDowngrade after isUpgrade must not corrupt the result + QCOMPARE(migration.isUpgrade(), true); + + // upgradeType is not touched by isUpgrade/isDowngrade + QCOMPARE(migration.upgradeType(), Migration::UpgradeType::NoChange); + } + + void testIsDowngrade_noSideEffects() + { + // simulate a downgrade: the previously installed version was newer than the current binary + _configFile.setClientPreviousVersionString("99.0.0"); + + Migration migration; + QCOMPARE(migration.isDowngrade(), true); + QCOMPARE(migration.isUpgrade(), false); + QCOMPARE(migration.versionChanged(), true); + + // upgradeType is not touched by isDowngrade + QCOMPARE(migration.upgradeType(), Migration::UpgradeType::NoChange); + } + + void testVersionUnchanged() + { + setupStandardConfigFolder(); + _configFile.setClientVersionString(MIRALL_VERSION_STRING); + _configFile.setClientPreviousVersionString(MIRALL_VERSION_STRING); + + Migration migration; + QCOMPARE(migration.isUpgrade(), false); + QCOMPARE(migration.isDowngrade(), false); + QCOMPARE(migration.versionChanged(), false); + } + + void testShouldTryToMigrate_trueOnUpgrade() + { + setupStandarConfig("1.0.0"); + Migration migration; + QCOMPARE(migration.shouldTryToMigrate(), true); + } + + void testShouldTryToMigrate_falseWhenVersionsMatch() + { + setupStandardConfigFolder(); + _configFile.setClientVersionString(MIRALL_VERSION_STRING); + _configFile.setClientPreviousVersionString(MIRALL_VERSION_STRING); + + Migration migration; + QCOMPARE(migration.shouldTryToMigrate(), false); + } + + void testStaticStateSharedAcrossInstances() + { + Migration a; + a.setPhase(Migration::Phase::SetupUsers); + + Migration b; + QCOMPARE(b.phase(), Migration::Phase::SetupUsers); + QCOMPARE(b.isInProgress(), true); + } }; QTEST_GUILESS_MAIN(TestMigration) From c335bfeab5788b4183d10709b6719663ad6600a3 Mon Sep 17 00:00:00 2001 From: Camila Ayres Date: Tue, 12 May 2026 10:00:32 +0200 Subject: [PATCH 03/16] fix(migration): drop use of Migration() in configVersionMigration. Use the existing local variable instead. Signed-off-by: Camila Ayres --- src/gui/application.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gui/application.cpp b/src/gui/application.cpp index 3dea4ba9441fe..91dbfb9982bdc 100644 --- a/src/gui/application.cpp +++ b/src/gui/application.cpp @@ -203,7 +203,7 @@ bool Application::configVersionMigration() "Continuing will mean %2 these settings.
" "
" "The current configuration file was already backed up to %3.") - .arg((Migration().isDowngrade() ? tr("newer", "newer software version") : tr("older", "older software version")), + .arg((migration.isDowngrade() ? tr("newer", "newer software version") : tr("older", "older software version")), deleteKeys.isEmpty()? tr("ignoring") : tr("deleting"), backupFilesList.join("
"))); box.addButton(tr("Quit"), QMessageBox::AcceptRole); From f19ff24c977ef0478d2bbb09b0f1ffbb7351e827 Mon Sep 17 00:00:00 2001 From: Camila Ayres Date: Tue, 12 May 2026 14:17:43 +0200 Subject: [PATCH 04/16] fix(migration): remove const from legacyData(). legacyData() calls setDiscoveredLegacyConfigPath() and setLegacyData() on this, which are non const methods. Marking the function const was wrong since it has side effects and GCC/Clang both reject it with -Werror. Remove the const qualifier. Signed-off-by: Camila Ayres --- src/libsync/settings/migration.cpp | 2 +- src/libsync/settings/migration.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libsync/settings/migration.cpp b/src/libsync/settings/migration.cpp index ebf2145789bc1..b92b40486dbfe 100644 --- a/src/libsync/settings/migration.cpp +++ b/src/libsync/settings/migration.cpp @@ -140,7 +140,7 @@ void Migration::resetForTesting() _legacyData = {}; } -Migration::LegacyData Migration::legacyData() const +Migration::LegacyData Migration::legacyData() { qCInfo(lcMigration) << "Migrate: restoreFromLegacySettings, checking settings group" << Theme::instance()->appName(); diff --git a/src/libsync/settings/migration.h b/src/libsync/settings/migration.h index fb7012bf37c8b..8375233740e20 100644 --- a/src/libsync/settings/migration.h +++ b/src/libsync/settings/migration.h @@ -94,7 +94,7 @@ class OWNCLOUDSYNC_EXPORT Migration void setUpgradeType(const UpgradeType type); /// Returns QSettings from a legacy config file - [[nodiscard]] LegacyData legacyData() const; + [[nodiscard]] LegacyData legacyData(); void setLegacyData(const LegacyData legacyData); /// Set during first time migration of legacy accounts in AccountManager From 7148ab7d0dbc2a9a36d2a4a95c9f9aae1c6e9974 Mon Sep 17 00:00:00 2001 From: Camila Ayres Date: Tue, 12 May 2026 21:06:04 +0200 Subject: [PATCH 05/16] fix(accountmanager): remove legacy config path constants moved to Migration. These four constexpr variables were already moved to migration.cpp as part of the refactor. The leftover copies in accountmanager.cpp were causing -Wunused-const-variable errors on Linux and macOS. Signed-off-by: Camila Ayres --- src/gui/accountmanager.cpp | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/gui/accountmanager.cpp b/src/gui/accountmanager.cpp index 1a0fd61832509..f58c2c0f7a0ca 100644 --- a/src/gui/accountmanager.cpp +++ b/src/gui/accountmanager.cpp @@ -70,11 +70,6 @@ constexpr auto webflowAuthPrefix = "webflow_"; constexpr auto networkProxyPasswordKeychainKeySuffixC = "_proxy_password"; -constexpr auto legacyRelativeConfigLocationC = "/ownCloud/owncloud.cfg"; -constexpr auto legacyCfgFileNameC = "owncloud.cfg"; - -constexpr auto unbrandedRelativeConfigLocationC = "/Nextcloud/nextcloud.cfg"; -constexpr auto unbrandedCfgFileNameC = "nextcloud.cfg"; // The maximum versions that this client can read constexpr auto maxAccountsVersion = 13; From d4779d6ce0bec573985ae40cfa83d58c212570c7 Mon Sep 17 00:00:00 2001 From: Camila Ayres Date: Wed, 12 Aug 2026 15:52:32 +0000 Subject: [PATCH 06/16] fix(migration): transfer ownership of legacy settings. QSettings had two owners and was freed twice at shutdown or on the next reset. Make LegacyData a std::unique_ptr, move it to the caller, drop the unused _legacyData static, and std::move it into settings at the call site so a single owner remains. Add tests for legacy config discovery and the no legacy config case. Assisted-by: Claude Code:claude-opus-4-8 Signed-off-by: Camila Ayres --- src/gui/accountmanager.cpp | 8 +++--- src/libsync/settings/migration.cpp | 13 ++-------- src/libsync/settings/migration.h | 7 +++-- test/testmigration.cpp | 41 +++++++++++++++++++++++++++++- 4 files changed, 49 insertions(+), 20 deletions(-) diff --git a/src/gui/accountmanager.cpp b/src/gui/accountmanager.cpp index f58c2c0f7a0ca..51307562748f3 100644 --- a/src/gui/accountmanager.cpp +++ b/src/gui/accountmanager.cpp @@ -195,10 +195,10 @@ bool AccountManager::restoreFromLegacySettings() auto wasLegacyImportDialogDisplayed = false; QStringList selectedAccountIds; Migration migration; - if (const auto legacyData = migration.legacyData(); !legacyData.isNull()) { - + if (auto legacyData = migration.legacyData(); legacyData) { + const auto displayLegacyImportDialog = Theme::instance()->displayLegacyImportDialog(); - + auto oCSettings = std::move(legacyData); oCSettings->beginGroup(QLatin1String(accountsC)); @@ -247,7 +247,7 @@ bool AccountManager::restoreFromLegacySettings() selectedAccountIds = childGroups; } - settings.reset(oCSettings.get()); + settings = std::move(oCSettings); } ConfigFile configFile; diff --git a/src/libsync/settings/migration.cpp b/src/libsync/settings/migration.cpp index b92b40486dbfe..f894ff229fb4d 100644 --- a/src/libsync/settings/migration.cpp +++ b/src/libsync/settings/migration.cpp @@ -30,7 +30,6 @@ Migration::Phase Migration::_phase = Phase::NotStarted; Migration::BrandingType Migration::_brandingType = BrandingType::UnbrandedToUnbranded; Migration::UpgradeType Migration::_upgradeType = UpgradeType::NoChange; QString Migration::_discoveredLegacyConfigPath = {}; -Migration::LegacyData Migration::_legacyData = {}; QVersionNumber Migration::currentVersion() const { @@ -137,7 +136,6 @@ void Migration::resetForTesting() _brandingType = BrandingType::UnbrandedToUnbranded; _upgradeType = UpgradeType::NoChange; _discoveredLegacyConfigPath = {}; - _legacyData = {}; } Migration::LegacyData Migration::legacyData() @@ -192,10 +190,8 @@ Migration::LegacyData Migration::legacyData() configFile.setClientPreviousVersionString(legacyVersion); qCInfo(lcMigration) << "Migrating from legacy version" << legacyVersion; qCDebug(lcMigration) << "Copy settings" << oCSettings->allKeys().join(", "); - Migration migration; - migration.setDiscoveredLegacyConfigPath(configFileInfo.canonicalPath()); - legacyData.reset(oCSettings.get()); - migration.setLegacyData(legacyData); + setDiscoveredLegacyConfigPath(configFileInfo.canonicalPath()); + legacyData = std::move(oCSettings); break; } else { qCInfo(lcMigration) << "Migrate: could not read old config " << configFileString; @@ -220,9 +216,4 @@ void Migration::setDiscoveredLegacyConfigPath(const QString &discoveredLegacyCon _discoveredLegacyConfigPath = discoveredLegacyConfigPath; } -void Migration::setLegacyData(const LegacyData legacyData) -{ - _legacyData = legacyData; -} - } diff --git a/src/libsync/settings/migration.h b/src/libsync/settings/migration.h index 8375233740e20..455c1db5864fb 100644 --- a/src/libsync/settings/migration.h +++ b/src/libsync/settings/migration.h @@ -9,6 +9,7 @@ #include #include #include +#include #include "owncloudlib.h" namespace OCC { @@ -43,7 +44,7 @@ class OWNCLOUDSYNC_EXPORT Migration }; Q_ENUM(UpgradeType) - using LegacyData = QSharedPointer; + using LegacyData = std::unique_ptr; [[nodiscard]] QVersionNumber previousVersion() const; [[nodiscard]] QVersionNumber currentVersion() const; @@ -93,9 +94,8 @@ class OWNCLOUDSYNC_EXPORT Migration [[nodiscard]] UpgradeType upgradeType() const; void setUpgradeType(const UpgradeType type); - /// Returns QSettings from a legacy config file + /// Returns QSettings from a legacy config file. Ownership is transferred to the caller. [[nodiscard]] LegacyData legacyData(); - void setLegacyData(const LegacyData legacyData); /// Set during first time migration of legacy accounts in AccountManager [[nodiscard]] QString discoveredLegacyConfigPath() const; @@ -118,7 +118,6 @@ class OWNCLOUDSYNC_EXPORT Migration static BrandingType _brandingType; static UpgradeType _upgradeType; static QString _discoveredLegacyConfigPath; - static LegacyData _legacyData; }; } #endif // MIGRATION_H diff --git a/test/testmigration.cpp b/test/testmigration.cpp index 11101a1921ba4..46100b39c62f2 100644 --- a/test/testmigration.cpp +++ b/test/testmigration.cpp @@ -102,7 +102,6 @@ private slots: _configFile.setStopSyncingExistingFoldersOverLimit(true); _configFile.setConfirmExternalStorage(true); _configFile.setMoveToTrash(true); - _configFile.setForceLoginV2(true); _configFile.setPromptDeleteFiles(true); _configFile.setDeleteFilesThreshold(1); _configFile.setMonoIcons(true); @@ -329,6 +328,46 @@ private slots: QCOMPARE(b.phase(), Migration::Phase::SetupUsers); QCOMPARE(b.isInProgress(), true); } + + void testLegacyData_discoversAndParsesConfig() + { + // No current config keys, so legacyData() searches the legacy locations. + setupStandardConfigFolder(); + + // Place a legacy owncloud.cfg next to the themed config file. + const auto configDir = QFileInfo(_configFile.configFile()).absolutePath(); + const auto legacyConfigPath = configDir + QStringLiteral("/owncloud.cfg"); + QFile legacyFile(legacyConfigPath); + QVERIFY(legacyFile.open(QIODevice::WriteOnly | QIODevice::Text)); + legacyFile.write(legacyAppConfigContent); + legacyFile.close(); + + Migration migration; + const auto legacy = migration.legacyData(); + + // Sole ownership is transferred to the caller and the file is parsed. + QVERIFY(legacy != nullptr); + QCOMPARE(legacy->value(QStringLiteral("clientVersion")).toString(), QStringLiteral("5.3.2.15463")); + legacy->beginGroup(QStringLiteral("Accounts")); + QVERIFY(legacy->childGroups().contains(QStringLiteral("0"))); + legacy->endGroup(); + QCOMPARE(migration.discoveredLegacyConfigPath(), QFileInfo(legacyConfigPath).canonicalPath()); + } + + void testLegacyData_returnsNullWhenNoLegacyConfig() + { + setupStandardConfigFolder(); + // The config dir is shared between tests, so drop any legacy file a + // previous test may have written. + const auto configDir = QFileInfo(_configFile.configFile()).absolutePath(); + QFile::remove(configDir + QStringLiteral("/owncloud.cfg")); + + Migration migration; + const auto legacy = migration.legacyData(); + + QVERIFY(!legacy); + QCOMPARE(migration.discoveredLegacyConfigPath(), QString()); + } }; QTEST_GUILESS_MAIN(TestMigration) From be3670a940532986e78fd74b03fdb8b32bb4e004 Mon Sep 17 00:00:00 2001 From: Camila Ayres Date: Wed, 12 Aug 2026 16:05:23 +0000 Subject: [PATCH 07/16] fix(migration): trigger migration when config version differs from binary. Once an upgrade settled those two values to be equal, later upgrades were no longer detected, while a freshly upgraded config whose clientVersion already matched the running binary still fired a redundant migration pass. Add tests for the already current config and the upgrade from equal versions cases. Assisted-by: Claude Code:claude-opus-4-8 Signed-off-by: Camila Ayres --- src/libsync/settings/migration.cpp | 12 +++--------- src/libsync/settings/migration.h | 1 - test/testmigration.cpp | 24 ++++++++++++++++++++++++ 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/src/libsync/settings/migration.cpp b/src/libsync/settings/migration.cpp index f894ff229fb4d..e2131aa38e785 100644 --- a/src/libsync/settings/migration.cpp +++ b/src/libsync/settings/migration.cpp @@ -112,15 +112,9 @@ bool Migration::isUnbrandedToBrandedMigration() const bool Migration::shouldTryToMigrate() const { - return !isClientVersionSet() && (isUpgrade() || isDowngrade()); -} - -bool Migration::isClientVersionSet() const -{ - const auto configVersionNumber = configVersion(); - const auto previousVersionNumber = previousVersion(); - return !configVersionNumber.isNull() && !previousVersionNumber.isNull() - && configVersionNumber == previousVersionNumber; + // Migrate when the config was written by a different client version than + // the one running now, and that difference is an actual up or downgrade. + return configVersion() != currentVersion() && (isUpgrade() || isDowngrade()); } bool Migration::isInProgress() const diff --git a/src/libsync/settings/migration.h b/src/libsync/settings/migration.h index 455c1db5864fb..b92e648c27373 100644 --- a/src/libsync/settings/migration.h +++ b/src/libsync/settings/migration.h @@ -107,7 +107,6 @@ class OWNCLOUDSYNC_EXPORT Migration [[nodiscard]] bool shouldTryUnbrandedToBrandedMigration(); [[nodiscard]] bool isUnbrandedToBrandedMigration() const; [[nodiscard]] bool shouldTryToMigrate() const; - [[nodiscard]] bool isClientVersionSet() const; [[nodiscard]] bool isInProgress() const; /// Resets all shared state to initial values. Only intended for use in unit tests. diff --git a/test/testmigration.cpp b/test/testmigration.cpp index 46100b39c62f2..2b5458129bd26 100644 --- a/test/testmigration.cpp +++ b/test/testmigration.cpp @@ -319,6 +319,30 @@ private slots: QCOMPARE(migration.shouldTryToMigrate(), false); } + void testShouldTryToMigrate_falseWhenConfigMatchesRunningVersion() + { + // clientVersion already equals the running binary: nothing to migrate, + // even though an older previous version is recorded. + setupStandardConfigFolder(); + _configFile.setClientVersionString(MIRALL_VERSION_STRING); + _configFile.setClientPreviousVersionString(QStringLiteral("1.0.0")); + + Migration migration; + QCOMPARE(migration.shouldTryToMigrate(), false); + } + + void testShouldTryToMigrate_trueWhenUpgradingFromMatchingVersions() + { + // A genuine upgrade in which clientVersion equals clientPreviousVersion + // (the previous run settled), so migration must still run. + setupStandardConfigFolder(); + _configFile.setClientVersionString(QStringLiteral("1.0.0")); + _configFile.setClientPreviousVersionString(QStringLiteral("1.0.0")); + + Migration migration; + QCOMPARE(migration.shouldTryToMigrate(), true); + } + void testStaticStateSharedAcrossInstances() { Migration a; From 961c941913559181df40945b0215c0d62bff4a26 Mon Sep 17 00:00:00 2001 From: Camila Ayres Date: Wed, 12 Aug 2026 16:09:08 +0000 Subject: [PATCH 08/16] fix(migration): resolve unbranded to branded state. Make both queries self contained and const. Assisted-by: Claude Code:claude-opus-4-8 Signed-off-by: Camila Ayres --- src/libsync/settings/migration.cpp | 13 ++++--------- src/libsync/settings/migration.h | 2 +- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/libsync/settings/migration.cpp b/src/libsync/settings/migration.cpp index e2131aa38e785..4a4f3665fbd4e 100644 --- a/src/libsync/settings/migration.cpp +++ b/src/libsync/settings/migration.cpp @@ -93,21 +93,16 @@ bool Migration::versionChanged() const return isUpgrade() || isDowngrade(); } -bool Migration::shouldTryUnbrandedToBrandedMigration() +bool Migration::shouldTryUnbrandedToBrandedMigration() const { - const auto isUnbrandedToBranded = phase() == Migration::Phase::SetupFolders - && Theme::instance()->appName() != ConfigFile::unbrandedAppName + return phase() == Migration::Phase::SetupFolders + && Theme::instance()->appName() != ConfigFile::unbrandedAppName && !_discoveredLegacyConfigPath.isEmpty(); - - if (isUnbrandedToBranded) { - setBrandingType(BrandingType::UnbrandedToBranded); - } - return _brandingType == BrandingType::UnbrandedToBranded; } bool Migration::isUnbrandedToBrandedMigration() const { - return isInProgress() && brandingType() == BrandingType::UnbrandedToBranded; + return isInProgress() && Theme::instance()->appName() != ConfigFile::unbrandedAppName; } bool Migration::shouldTryToMigrate() const diff --git a/src/libsync/settings/migration.h b/src/libsync/settings/migration.h index b92e648c27373..44ec490084083 100644 --- a/src/libsync/settings/migration.h +++ b/src/libsync/settings/migration.h @@ -104,7 +104,7 @@ class OWNCLOUDSYNC_EXPORT Migration [[nodiscard]] bool isUpgrade() const; [[nodiscard]] bool isDowngrade() const; [[nodiscard]] bool versionChanged() const; - [[nodiscard]] bool shouldTryUnbrandedToBrandedMigration(); + [[nodiscard]] bool shouldTryUnbrandedToBrandedMigration() const; [[nodiscard]] bool isUnbrandedToBrandedMigration() const; [[nodiscard]] bool shouldTryToMigrate() const; [[nodiscard]] bool isInProgress() const; From 82f1999ea2c35d84b95ec6e3e3a335c80d1b15ab Mon Sep 17 00:00:00 2001 From: Camila Ayres Date: Wed, 12 Aug 2026 16:13:46 +0000 Subject: [PATCH 09/16] refactor(migration): reuse ConfigFile::backupConfigFiles in config migration. Assisted-by: Claude Code:claude-opus-4-8 Signed-off-by: Camila Ayres --- src/gui/application.cpp | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/src/gui/application.cpp b/src/gui/application.cpp index 91dbfb9982bdc..9fb4bfe6b451b 100644 --- a/src/gui/application.cpp +++ b/src/gui/application.cpp @@ -167,29 +167,7 @@ bool Application::configVersionMigration() return true; } - // 'Launch on system startup' defaults to true > 3.11.x - const auto theme = Theme::instance(); - configFile.setLaunchOnSystemStartup(configFile.launchOnSystemStartup()); - Utility::setLaunchOnStartup(theme->appName(), theme->appNameGUI(), configFile.launchOnSystemStartup()); - - // default is now off to displaying dialog warning user of too many files deletion - configFile.setPromptDeleteFiles(false); - - // back up all old config files - QStringList backupFilesList; - QDir configDir(configFile.configPath()); - const auto anyConfigFileNameList = configDir.entryInfoList({"*.cfg"}, QDir::Files); - for (const auto &oldConfig : anyConfigFileNameList) { - const auto oldConfigFileName = oldConfig.fileName(); - const auto oldConfigFilePath = oldConfig.filePath(); - const auto newConfigFileName = configFile.configFile(); - backupFilesList.append(configFile.backup(oldConfigFileName)); - if (oldConfigFilePath != newConfigFileName) { - if (!QFile::rename(oldConfigFilePath, newConfigFileName)) { - qCWarning(lcApplication) << "Failed to rename configuration file from" << oldConfigFilePath << "to" << newConfigFileName; - } - } - } + const auto backupFilesList = configFile.backupConfigFiles(); // We want to message the user either for destructive changes, // or if we're ignoring something and the client version changed. From f80b93eabf7b49bdc3de6582e8e821cb8525c2e6 Mon Sep 17 00:00:00 2001 From: Camila Ayres Date: Wed, 12 Aug 2026 16:33:29 +0000 Subject: [PATCH 10/16] refactor(migration): convert Migration to a static helper class. Add per test teardown to the tests (folder manager reset, AccountManager shutdown) so created accounts do not bleed across tests through the singleton, and drop the now meaningless state shared across instances test. Assisted-by: Claude Code:claude-opus-4-8 Signed-off-by: Camila Ayres --- src/gui/accountmanager.cpp | 15 +-- src/gui/accountstate.cpp | 11 +- src/gui/application.cpp | 21 ++-- src/gui/folderman.cpp | 3 +- src/libsync/configfile.cpp | 16 +-- src/libsync/configfile.h | 1 + src/libsync/settings/migration.cpp | 48 ++++---- src/libsync/settings/migration.h | 43 ++++---- test/testmigration.cpp | 172 +++++++++++++---------------- 9 files changed, 150 insertions(+), 180 deletions(-) diff --git a/src/gui/accountmanager.cpp b/src/gui/accountmanager.cpp index 51307562748f3..f2648b095fb5e 100644 --- a/src/gui/accountmanager.cpp +++ b/src/gui/accountmanager.cpp @@ -194,8 +194,7 @@ bool AccountManager::restoreFromLegacySettings() auto settings = ConfigFile::settingsWithGroup(Theme::instance()->appName()); auto wasLegacyImportDialogDisplayed = false; QStringList selectedAccountIds; - Migration migration; - if (auto legacyData = migration.legacyData(); legacyData) { + if (auto legacyData = Migration::legacyData(); legacyData) { const auto displayLegacyImportDialog = Theme::instance()->displayLegacyImportDialog(); @@ -247,6 +246,10 @@ bool AccountManager::restoreFromLegacySettings() selectedAccountIds = childGroups; } + const QFileInfo legacyConfigInfo(oCSettings->fileName()); + Migration::setDiscoveredLegacyConfigPath(legacyConfigInfo.canonicalPath()); + ConfigFile().setClientPreviousVersionString(oCSettings->value(ConfigFile::clientVersionC).toString()); + settings = std::move(oCSettings); } @@ -293,7 +296,7 @@ bool AccountManager::restoreFromLegacySettings() configFile.setDownloadLimit(settings->value(ConfigFile::downloadLimitC, configFile.downloadLimit()).toInt()); // Try to load the single account. - migration.setPhase(Migration::Phase::SetupUsers); + Migration::setPhase(Migration::Phase::SetupUsers); if (!settings->childKeys().isEmpty()) { settings->beginGroup(accountsC); const auto childGroups = selectedAccountIds.isEmpty() ? settings->childGroups() : selectedAccountIds; @@ -494,8 +497,7 @@ void AccountManager::migrateNetworkSettings(const AccountPtr &account, const QSe // Override user settings with global (QNetworkProxy::DefaultProxy) settings // if user is set to use global settings ConfigFile configFile; - Migration migration; - if (accountProxyType == QNetworkProxy::DefaultProxy && migration.isInProgress()) { + if (accountProxyType == QNetworkProxy::DefaultProxy && Migration::isInProgress()) { accountProxyType = static_cast(configFile.proxyType()); accountProxyHost = configFile.proxyHostName(); accountProxyPort = configFile.proxyPort(); @@ -643,9 +645,8 @@ AccountPtr AccountManager::loadAccountHelper(QSettings &settings) acc->setDownloadLimit(settings.value(networkDownloadLimitC).toInt()); ConfigFile configFile; - Migration migration; const auto proxyPasswordKey = QString(acc->userIdAtHostWithPort() + networkProxyPasswordKeychainKeySuffixC); - const auto appName = migration.isUnbrandedToBrandedMigration() ? ConfigFile::unbrandedAppName + const auto appName = Migration::isUnbrandedToBrandedMigration() ? ConfigFile::unbrandedAppName : Theme::instance()->appName(); const auto job = new QKeychain::ReadPasswordJob(appName, this); job->setKey(proxyPasswordKey); diff --git a/src/gui/accountstate.cpp b/src/gui/accountstate.cpp index fd68cbbdce70e..38a93566c2ab0 100644 --- a/src/gui/accountstate.cpp +++ b/src/gui/accountstate.cpp @@ -302,10 +302,9 @@ void AccountState::checkConnectivity() if (!account()->credentials()->wasFetched()) { _waitingForNewCredentials = true; ConfigFile configFile; - Migration migration; - const auto shouldTryUnbrandedToBrandedMigration = migration.shouldTryUnbrandedToBrandedMigration(); + const auto shouldTryUnbrandedToBrandedMigration = Migration::shouldTryUnbrandedToBrandedMigration(); qCDebug(lcAccountState) << "shouldTryUnbrandedToBrandedMigration?" << shouldTryUnbrandedToBrandedMigration; - qCDebug(lcAccountState) << "migration Phase?" << migration.phase(); + qCDebug(lcAccountState) << "migration Phase?" << Migration::phase(); const auto appName = shouldTryUnbrandedToBrandedMigration ? configFile.unbrandedAppName : ""; account()->credentials()->fetchFromKeychain(appName); return; @@ -500,10 +499,8 @@ void AccountState::slotCredentialsFetched(AbstractCredentials *) qCInfo(lcAccountState) << "Fetched credentials for" << _account->url().toString() << "attempting to connect"; _waitingForNewCredentials = false; - ConfigFile configFile; - Migration migration; - if (migration.isInProgress()) { - migration.setPhase(Migration::Phase::Done); + if (Migration::isInProgress()) { + Migration::setPhase(Migration::Phase::Done); } checkConnectivity(); } diff --git a/src/gui/application.cpp b/src/gui/application.cpp index 9fb4bfe6b451b..ab8d6b2f09a27 100644 --- a/src/gui/application.cpp +++ b/src/gui/application.cpp @@ -144,20 +144,19 @@ namespace { bool Application::configVersionMigration() { ConfigFile configFile; - Migration migration; - const auto shouldTryToMigrate = migration.shouldTryToMigrate(); + const auto shouldTryToMigrate = Migration::shouldTryToMigrate(); if (!shouldTryToMigrate) { qCInfo(lcApplication) << "This is not an upgrade/downgrade/migration. Proceed to read current application config file."; - migration.setPhase(Migration::Phase::Done); + Migration::setPhase(Migration::Phase::Done); return false; } - migration.setPhase(Migration::Phase::SetupConfigFile); + Migration::setPhase(Migration::Phase::SetupConfigFile); QStringList deleteKeys, ignoreKeys; AccountManager::backwardMigrationSettingsKeys(&deleteKeys, &ignoreKeys); FolderMan::backwardMigrationSettingsKeys(&deleteKeys, &ignoreKeys); - qCDebug(lcApplication) << "Migration is in progress:" << migration.isInProgress(); - const auto versionChanged = migration.versionChanged(); + qCDebug(lcApplication) << "Migration is in progress:" << Migration::isInProgress(); + const auto versionChanged = Migration::versionChanged(); if (versionChanged) { qCInfo(lcApplication) << "Version changed. Removing updater settings from config."; configFile.cleanUpdaterConfiguration(); @@ -167,6 +166,7 @@ bool Application::configVersionMigration() return true; } + configFile.applyMigrationDefaults(); const auto backupFilesList = configFile.backupConfigFiles(); // We want to message the user either for destructive changes, @@ -181,7 +181,7 @@ bool Application::configVersionMigration() "Continuing will mean %2 these settings.
" "
" "The current configuration file was already backed up to %3.") - .arg((migration.isDowngrade() ? tr("newer", "newer software version") : tr("older", "older software version")), + .arg((Migration::configVersion() > Migration::currentVersion() ? tr("newer", "newer software version") : tr("older", "older software version")), deleteKeys.isEmpty()? tr("ignoring") : tr("deleting"), backupFilesList.join("
"))); box.addButton(tr("Quit"), QMessageBox::AcceptRole); @@ -650,18 +650,17 @@ void Application::setupAccountsAndFolders() { _folderManager = FolderMan::instance(); ConfigFile configFile; - Migration migration; - migration.setPhase(Migration::Phase::SetupUsers); + Migration::setPhase(Migration::Phase::SetupUsers); const auto accountsRestoreResult = restoreLegacyAccount(); if (accountsRestoreResult == AccountManager::AccountsNotFound || accountsRestoreResult == AccountManager::AccountsRestoreFailure) { qCWarning(lcApplication) << "Migration result: " << accountsRestoreResult; qCDebug(lcApplication) << "is migration disabled?" << DISABLE_ACCOUNT_MIGRATION; qCWarning(lcApplication) << "No accounts were migrated, prompting user to set up accounts and folders from scratch."; - migration.setPhase(Migration::Phase::Done); + Migration::setPhase(Migration::Phase::Done); return; } - migration.setPhase(Migration::Phase::SetupFolders); + Migration::setPhase(Migration::Phase::SetupFolders); const auto foldersListSize = FolderMan::instance()->setupFolders(); FolderMan::instance()->setSyncEnabled(true); diff --git a/src/gui/folderman.cpp b/src/gui/folderman.cpp index 3cce763964d35..492e94eccd24f 100644 --- a/src/gui/folderman.cpp +++ b/src/gui/folderman.cpp @@ -440,8 +440,7 @@ int FolderMan::setupFoldersMigration() auto configPath = _folderConfigPath; #if !DISABLE_ACCOUNT_MIGRATION - Migration migration; - if (const auto legacyConfigPath = migration.discoveredLegacyConfigPath(); !legacyConfigPath.isEmpty()) { + if (const auto legacyConfigPath = Migration::discoveredLegacyConfigPath(); !legacyConfigPath.isEmpty()) { configPath = legacyConfigPath; qCInfo(lcFolderMan) << "Starting folder migration from legacy path:" << legacyConfigPath; } diff --git a/src/libsync/configfile.cpp b/src/libsync/configfile.cpp index 05e922995c4d8..c50b72ac04450 100644 --- a/src/libsync/configfile.cpp +++ b/src/libsync/configfile.cpp @@ -326,8 +326,7 @@ void ConfigFile::restoreGeometryHeader(QHeaderView *header) QVariant ConfigFile::getPolicySetting(const QString &setting, const QVariant &defaultValue) const { if (Utility::isWindows()) { - Migration migration; - const auto appName = migration.isUnbrandedToBrandedMigration() ? unbrandedAppName : Theme::instance()->appNameGUI(); + const auto appName = Migration::isUnbrandedToBrandedMigration() ? unbrandedAppName : Theme::instance()->appNameGUI(); // check for policies first and return immediately if a value is found. QSettings userPolicy(QString::fromLatin1(R"(HKEY_CURRENT_USER\Software\Policies\%1\%2)").arg(APPLICATION_VENDOR, appName), QSettings::NativeFormat); @@ -381,9 +380,8 @@ QString ConfigFile::excludeFile(Scope scope) const return ConfigFile::excludeFileFromSystem(); } - Migration migration; - const auto excludeFilePath = scope == LegacyScope - ? migration.discoveredLegacyConfigPath() + const auto excludeFilePath = scope == LegacyScope + ? Migration::discoveredLegacyConfigPath() : configPath(); // prefer sync-exclude.lst, but if it does not exist, check for exclude.lst @@ -860,8 +858,7 @@ QVariant ConfigFile::getValue(const QString ¶m, const QString &group, const QVariant &defaultValue) const { QVariant systemSetting; - Migration migration; - const auto appName = migration.isUnbrandedToBrandedMigration() ? unbrandedAppName : Theme::instance()->appNameGUI(); + const auto appName = Migration::isUnbrandedToBrandedMigration() ? unbrandedAppName : Theme::instance()->appNameGUI(); if (Utility::isMac()) { QSettings systemSettings(QLatin1String("/Library/Preferences/" APPLICATION_REV_DOMAIN ".plist"), QSettings::NativeFormat); if (!group.isEmpty()) { @@ -1375,13 +1372,16 @@ void ConfigFile::setMacFileProviderModeEnabled(const bool enabled) settings.sync(); } -QStringList ConfigFile::backupConfigFiles() +void ConfigFile::applyMigrationDefaults() { const auto theme = Theme::instance(); setLaunchOnSystemStartup(launchOnSystemStartup()); Utility::setLaunchOnStartup(theme->appName(), theme->appNameGUI(), launchOnSystemStartup()); setPromptDeleteFiles(false); +} +QStringList ConfigFile::backupConfigFiles() +{ QStringList backupFilesList; QDir configDir(configPath()); const auto anyConfigFileNameList = configDir.entryInfoList({"*.cfg"}, QDir::Files); diff --git a/src/libsync/configfile.h b/src/libsync/configfile.h index 05d93e7dcdfde..3b88839141dd9 100644 --- a/src/libsync/configfile.h +++ b/src/libsync/configfile.h @@ -264,6 +264,7 @@ class OWNCLOUDSYNC_EXPORT ConfigFile void setFileProviderDomainsAppSandboxMigrationCompleted(bool completed); [[nodiscard]] QStringList backupConfigFiles(); + void applyMigrationDefaults(); /// App-level macOS File Provider mode: when enabled, every account gets a file /// provider domain and classic sync folders are unavailable (the File Provider diff --git a/src/libsync/settings/migration.cpp b/src/libsync/settings/migration.cpp index 4a4f3665fbd4e..6b9baaf29cf5d 100644 --- a/src/libsync/settings/migration.cpp +++ b/src/libsync/settings/migration.cpp @@ -31,17 +31,12 @@ Migration::BrandingType Migration::_brandingType = BrandingType::UnbrandedToUnbr Migration::UpgradeType Migration::_upgradeType = UpgradeType::NoChange; QString Migration::_discoveredLegacyConfigPath = {}; -QVersionNumber Migration::currentVersion() const +QVersionNumber Migration::currentVersion() { return QVersionNumber::fromString(MIRALL_VERSION_STRING); } -QVersionNumber Migration::previousVersion() const -{ - return QVersionNumber::fromString(ConfigFile().clientPreviousVersionString()); -} - -QVersionNumber Migration::configVersion() const +QVersionNumber Migration::configVersion() { return QVersionNumber::fromString(ConfigFile().clientVersionString()); } @@ -53,7 +48,7 @@ void Migration::setPhase(const Phase phase) } } -Migration::Phase Migration::phase() const +Migration::Phase Migration::phase() { return _phase; } @@ -63,12 +58,12 @@ void Migration::setBrandingType(const BrandingType type) _brandingType = type; } -Migration::BrandingType Migration::brandingType() const +Migration::BrandingType Migration::brandingType() { return _brandingType; } -Migration::UpgradeType Migration::upgradeType() const +Migration::UpgradeType Migration::upgradeType() { return _upgradeType; } @@ -78,41 +73,41 @@ void Migration::setUpgradeType(const UpgradeType type) _upgradeType = type; } -bool Migration::isUpgrade() const +bool Migration::isUpgrade() { - return currentVersion() > previousVersion(); + return currentVersion() > configVersion(); } -bool Migration::isDowngrade() const +bool Migration::isDowngrade() { - return previousVersion() > currentVersion(); + return configVersion() > currentVersion(); } -bool Migration::versionChanged() const +bool Migration::versionChanged() { return isUpgrade() || isDowngrade(); } -bool Migration::shouldTryUnbrandedToBrandedMigration() const +bool Migration::shouldTryUnbrandedToBrandedMigration() { return phase() == Migration::Phase::SetupFolders && Theme::instance()->appName() != ConfigFile::unbrandedAppName && !_discoveredLegacyConfigPath.isEmpty(); } -bool Migration::isUnbrandedToBrandedMigration() const +bool Migration::isUnbrandedToBrandedMigration() { - return isInProgress() && Theme::instance()->appName() != ConfigFile::unbrandedAppName; + return isInProgress() && !_discoveredLegacyConfigPath.isEmpty() && Theme::instance()->appName() != ConfigFile::unbrandedAppName; } -bool Migration::shouldTryToMigrate() const +bool Migration::shouldTryToMigrate() { // Migrate when the config was written by a different client version than // the one running now, and that difference is an actual up or downgrade. return configVersion() != currentVersion() && (isUpgrade() || isDowngrade()); } -bool Migration::isInProgress() const +bool Migration::isInProgress() { const auto currentPhase = phase(); return currentPhase != Phase::NotStarted @@ -169,17 +164,12 @@ Migration::LegacyData Migration::legacyData() for (const auto &configFileString : std::as_const(legacyLocations)) { auto oCSettings = std::make_unique(configFileString, QSettings::IniFormat); if (oCSettings->status() != QSettings::Status::NoError) { - qCInfo(lcMigration) << "Error reading legacy configuration file" << oCSettings->status(); - break; + qCInfo(lcMigration) << "Error reading legacy configuration file" << configFileString << oCSettings->status(); + continue; } if (const QFileInfo configFileInfo(configFileString); configFileInfo.exists() && configFileInfo.isReadable()) { - ConfigFile configFile; - const auto legacyVersion = oCSettings->value(ConfigFile::clientVersionC, {}).toString(); - configFile.setClientPreviousVersionString(legacyVersion); - qCInfo(lcMigration) << "Migrating from legacy version" << legacyVersion; - qCDebug(lcMigration) << "Copy settings" << oCSettings->allKeys().join(", "); - setDiscoveredLegacyConfigPath(configFileInfo.canonicalPath()); + qCInfo(lcMigration) << "Discovered legacy config at" << configFileInfo.canonicalPath(); legacyData = std::move(oCSettings); break; } else { @@ -191,7 +181,7 @@ Migration::LegacyData Migration::legacyData() return legacyData; } -QString Migration::discoveredLegacyConfigPath() const +QString Migration::discoveredLegacyConfigPath() { return _discoveredLegacyConfigPath; } diff --git a/src/libsync/settings/migration.h b/src/libsync/settings/migration.h index 44ec490084083..5eefbb62a97a9 100644 --- a/src/libsync/settings/migration.h +++ b/src/libsync/settings/migration.h @@ -18,7 +18,9 @@ class OWNCLOUDSYNC_EXPORT Migration { Q_GADGET public: - Migration() { }; + // All state is process global; this class is a collection of static + // helpers and is not meant to be instantiated. + Migration() = delete; enum class Phase { NotStarted, @@ -46,9 +48,8 @@ class OWNCLOUDSYNC_EXPORT Migration using LegacyData = std::unique_ptr; - [[nodiscard]] QVersionNumber previousVersion() const; - [[nodiscard]] QVersionNumber currentVersion() const; - [[nodiscard]] QVersionNumber configVersion() const; + [[nodiscard]] static QVersionNumber currentVersion(); + [[nodiscard]] static QVersionNumber configVersion(); /** * Application::configVersionMigration [start] @@ -85,29 +86,29 @@ class OWNCLOUDSYNC_EXPORT Migration * | * Phase::Done */ - [[nodiscard]] Phase phase() const; - void setPhase(const Phase phase); + [[nodiscard]] static Phase phase(); + static void setPhase(const Phase phase); - [[nodiscard]] BrandingType brandingType() const; - void setBrandingType(const BrandingType type); + [[nodiscard]] static BrandingType brandingType(); + static void setBrandingType(const BrandingType type); - [[nodiscard]] UpgradeType upgradeType() const; - void setUpgradeType(const UpgradeType type); + [[nodiscard]] static UpgradeType upgradeType(); + static void setUpgradeType(const UpgradeType type); /// Returns QSettings from a legacy config file. Ownership is transferred to the caller. - [[nodiscard]] LegacyData legacyData(); + [[nodiscard]] static LegacyData legacyData(); /// Set during first time migration of legacy accounts in AccountManager - [[nodiscard]] QString discoveredLegacyConfigPath() const; - void setDiscoveredLegacyConfigPath(const QString &discoveredLegacyConfigPath); - - [[nodiscard]] bool isUpgrade() const; - [[nodiscard]] bool isDowngrade() const; - [[nodiscard]] bool versionChanged() const; - [[nodiscard]] bool shouldTryUnbrandedToBrandedMigration() const; - [[nodiscard]] bool isUnbrandedToBrandedMigration() const; - [[nodiscard]] bool shouldTryToMigrate() const; - [[nodiscard]] bool isInProgress() const; + [[nodiscard]] static QString discoveredLegacyConfigPath(); + static void setDiscoveredLegacyConfigPath(const QString &discoveredLegacyConfigPath); + + [[nodiscard]] static bool isUpgrade(); + [[nodiscard]] static bool isDowngrade(); + [[nodiscard]] static bool versionChanged(); + [[nodiscard]] static bool shouldTryUnbrandedToBrandedMigration(); + [[nodiscard]] static bool isUnbrandedToBrandedMigration(); + [[nodiscard]] static bool shouldTryToMigrate(); + [[nodiscard]] static bool isInProgress(); /// Resets all shared state to initial values. Only intended for use in unit tests. static void resetForTesting(); diff --git a/test/testmigration.cpp b/test/testmigration.cpp index 2b5458129bd26..f7be3cfbe3526 100644 --- a/test/testmigration.cpp +++ b/test/testmigration.cpp @@ -75,7 +75,7 @@ private slots: _configFile.setConfDir(standardConfigFolder); } - void setupStandarConfig(const QString &version) + void setupStandardConfig(const QString &version) { setupStandardConfigFolder(); QSettings settings(_configFile.configFile(), QSettings::IniFormat); @@ -84,7 +84,6 @@ private slots: _configFile.setShowChatNotifications(true); _configFile.setShowCallNotifications(true); _configFile.setShowInExplorerNavigationPane(true); - _configFile.setShowInExplorerNavigationPane(true); _configFile.setRemotePollInterval(std::chrono::milliseconds(1000)); _configFile.setAutoUpdateCheck(true, QString()); _configFile.setUpdateChannel("beta"); @@ -92,7 +91,6 @@ private slots: _configFile.setOverrideLocalDir("A"); _configFile.setVfsEnabled(true); _configFile.setProxyType(0); - _configFile.setVfsEnabled(true); _configFile.setUseUploadLimit(0); _configFile.setUploadLimit(1); _configFile.setUseDownloadLimit(0); @@ -134,7 +132,9 @@ private slots: const auto accountState = OCC::AccountManager::instance()->addAccount(account); OCC::AccountManager::instance()->saveAccount(accountState->account()); OCC::FolderDefinition folderDefinition; - folderDefinition.localPath = "/standardAppName"; + const auto localFolder = QString(_temporaryDir.path() + "/syncfolder"); + QVERIFY(QDir().mkpath(localFolder)); + folderDefinition.localPath = localFolder; folderDefinition.targetPath = "/"; folderDefinition.alias = standardAppName; _folderMan.reset(new FolderMan{}); @@ -160,57 +160,62 @@ private slots: settings.sync(); } + // Release the folders and accounts created by setupStandardConfig so they + // do not leak into the AccountManager singleton across tests. + void cleanup() + { + _folderMan.reset(); + if (const auto accountManager = AccountManager::instance()) { + accountManager->shutdown(); + } + } + void testSetPhase() { - Migration migration; - QCOMPARE(migration.phase(), OCC::Migration::Phase::NotStarted); - migration.setPhase(OCC::Migration::Phase::SetupConfigFile); - QCOMPARE(migration.phase(), OCC::Migration::Phase::SetupConfigFile); - migration.setPhase(OCC::Migration::Phase::SetupUsers); - QCOMPARE(migration.phase(), OCC::Migration::Phase::SetupUsers); - migration.setPhase(OCC::Migration::Phase::SetupFolders); - QCOMPARE(migration.phase(), OCC::Migration::Phase::SetupFolders); - migration.setPhase(OCC::Migration::Phase::Done); - QCOMPARE(migration.phase(), OCC::Migration::Phase::Done); + QCOMPARE(Migration::phase(), OCC::Migration::Phase::NotStarted); + Migration::setPhase(OCC::Migration::Phase::SetupConfigFile); + QCOMPARE(Migration::phase(), OCC::Migration::Phase::SetupConfigFile); + Migration::setPhase(OCC::Migration::Phase::SetupUsers); + QCOMPARE(Migration::phase(), OCC::Migration::Phase::SetupUsers); + Migration::setPhase(OCC::Migration::Phase::SetupFolders); + QCOMPARE(Migration::phase(), OCC::Migration::Phase::SetupFolders); + Migration::setPhase(OCC::Migration::Phase::Done); + QCOMPARE(Migration::phase(), OCC::Migration::Phase::Done); } void testSetUpgradeType() { - Migration migration; - QCOMPARE(migration.upgradeType(), OCC::Migration::UpgradeType::NoChange); - migration.setUpgradeType(OCC::Migration::UpgradeType::Upgrade); - QCOMPARE(migration.upgradeType(), OCC::Migration::UpgradeType::Upgrade); - migration.setUpgradeType(OCC::Migration::UpgradeType::Downgrade); - QCOMPARE(migration.upgradeType(), OCC::Migration::UpgradeType::Downgrade); + QCOMPARE(Migration::upgradeType(), OCC::Migration::UpgradeType::NoChange); + Migration::setUpgradeType(OCC::Migration::UpgradeType::Upgrade); + QCOMPARE(Migration::upgradeType(), OCC::Migration::UpgradeType::Upgrade); + Migration::setUpgradeType(OCC::Migration::UpgradeType::Downgrade); + QCOMPARE(Migration::upgradeType(), OCC::Migration::UpgradeType::Downgrade); } void testSetBrandingType() { - Migration migration; - QCOMPARE(migration.brandingType(), OCC::Migration::BrandingType::UnbrandedToUnbranded); - migration.setBrandingType(OCC::Migration::BrandingType::LegacyToUnbranded); - QCOMPARE(migration.brandingType(), OCC::Migration::BrandingType::LegacyToUnbranded); - migration.setBrandingType(OCC::Migration::BrandingType::LegacyToBranded); - QCOMPARE(migration.brandingType(), OCC::Migration::BrandingType::LegacyToBranded); - migration.setBrandingType(OCC::Migration::BrandingType::UnbrandedToBranded); - QCOMPARE(migration.brandingType(), OCC::Migration::BrandingType::UnbrandedToBranded); + QCOMPARE(Migration::brandingType(), OCC::Migration::BrandingType::UnbrandedToUnbranded); + Migration::setBrandingType(OCC::Migration::BrandingType::LegacyToUnbranded); + QCOMPARE(Migration::brandingType(), OCC::Migration::BrandingType::LegacyToUnbranded); + Migration::setBrandingType(OCC::Migration::BrandingType::LegacyToBranded); + QCOMPARE(Migration::brandingType(), OCC::Migration::BrandingType::LegacyToBranded); + Migration::setBrandingType(OCC::Migration::BrandingType::UnbrandedToBranded); + QCOMPARE(Migration::brandingType(), OCC::Migration::BrandingType::UnbrandedToBranded); } void testSetDiscoveredLegacyConfigPath() { - Migration migration; - QCOMPARE(migration.discoveredLegacyConfigPath(), QString()); + QCOMPARE(Migration::discoveredLegacyConfigPath(), QString()); const auto legacyConfigPath = QString("/path/to/legacy/config"); - migration.setDiscoveredLegacyConfigPath(legacyConfigPath); - QCOMPARE(migration.discoveredLegacyConfigPath(), legacyConfigPath); + Migration::setDiscoveredLegacyConfigPath(legacyConfigPath); + QCOMPARE(Migration::discoveredLegacyConfigPath(), legacyConfigPath); } void testUpgrade() { // create Nextcloud config with older version - setupStandarConfig("1.0.0"); - Migration migration; - QCOMPARE(migration.isUpgrade(), true); + setupStandardConfig("1.0.0"); + QCOMPARE(Migration::isUpgrade(), true); // backup old config const auto backupFilesList = _configFile.backupConfigFiles(); @@ -224,70 +229,64 @@ private slots: void testIsInProgress_notStarted() { - Migration migration; - QCOMPARE(migration.phase(), Migration::Phase::NotStarted); - QCOMPARE(migration.isInProgress(), false); + QCOMPARE(Migration::phase(), Migration::Phase::NotStarted); + QCOMPARE(Migration::isInProgress(), false); } void testIsInProgress_trueForMidPhases() { - Migration migration; - migration.setPhase(Migration::Phase::SetupConfigFile); - QCOMPARE(migration.isInProgress(), true); + Migration::setPhase(Migration::Phase::SetupConfigFile); + QCOMPARE(Migration::isInProgress(), true); Migration::resetForTesting(); - migration.setPhase(Migration::Phase::SetupUsers); - QCOMPARE(migration.isInProgress(), true); + Migration::setPhase(Migration::Phase::SetupUsers); + QCOMPARE(Migration::isInProgress(), true); Migration::resetForTesting(); - migration.setPhase(Migration::Phase::SetupFolders); - QCOMPARE(migration.isInProgress(), true); + Migration::setPhase(Migration::Phase::SetupFolders); + QCOMPARE(Migration::isInProgress(), true); } void testIsInProgress_falseWhenDone() { - Migration migration; - migration.setPhase(Migration::Phase::Done); - QCOMPARE(migration.isInProgress(), false); + Migration::setPhase(Migration::Phase::Done); + QCOMPARE(Migration::isInProgress(), false); } void testPhaseRollbackPrevented() { - Migration migration; - migration.setPhase(Migration::Phase::Done); - migration.setPhase(Migration::Phase::SetupUsers); // attempt rollback - QCOMPARE(migration.phase(), Migration::Phase::Done); + Migration::setPhase(Migration::Phase::Done); + Migration::setPhase(Migration::Phase::SetupUsers); // attempt rollback + QCOMPARE(Migration::phase(), Migration::Phase::Done); } void testIsUpgrade_noSideEffects() { - setupStandarConfig("1.0.0"); - Migration migration; + setupStandardConfig("1.0.0"); // upgrading: current > previous - QCOMPARE(migration.isUpgrade(), true); - QCOMPARE(migration.isDowngrade(), false); - QCOMPARE(migration.versionChanged(), true); + QCOMPARE(Migration::isUpgrade(), true); + QCOMPARE(Migration::isDowngrade(), false); + QCOMPARE(Migration::versionChanged(), true); // calling isDowngrade after isUpgrade must not corrupt the result - QCOMPARE(migration.isUpgrade(), true); + QCOMPARE(Migration::isUpgrade(), true); // upgradeType is not touched by isUpgrade/isDowngrade - QCOMPARE(migration.upgradeType(), Migration::UpgradeType::NoChange); + QCOMPARE(Migration::upgradeType(), Migration::UpgradeType::NoChange); } void testIsDowngrade_noSideEffects() { - // simulate a downgrade: the previously installed version was newer than the current binary - _configFile.setClientPreviousVersionString("99.0.0"); + // simulate a downgrade: the config was written by a newer client than the current binary + _configFile.setClientVersionString("99.0.0"); - Migration migration; - QCOMPARE(migration.isDowngrade(), true); - QCOMPARE(migration.isUpgrade(), false); - QCOMPARE(migration.versionChanged(), true); + QCOMPARE(Migration::isDowngrade(), true); + QCOMPARE(Migration::isUpgrade(), false); + QCOMPARE(Migration::versionChanged(), true); // upgradeType is not touched by isDowngrade - QCOMPARE(migration.upgradeType(), Migration::UpgradeType::NoChange); + QCOMPARE(Migration::upgradeType(), Migration::UpgradeType::NoChange); } void testVersionUnchanged() @@ -296,17 +295,15 @@ private slots: _configFile.setClientVersionString(MIRALL_VERSION_STRING); _configFile.setClientPreviousVersionString(MIRALL_VERSION_STRING); - Migration migration; - QCOMPARE(migration.isUpgrade(), false); - QCOMPARE(migration.isDowngrade(), false); - QCOMPARE(migration.versionChanged(), false); + QCOMPARE(Migration::isUpgrade(), false); + QCOMPARE(Migration::isDowngrade(), false); + QCOMPARE(Migration::versionChanged(), false); } void testShouldTryToMigrate_trueOnUpgrade() { - setupStandarConfig("1.0.0"); - Migration migration; - QCOMPARE(migration.shouldTryToMigrate(), true); + setupStandardConfig("1.0.0"); + QCOMPARE(Migration::shouldTryToMigrate(), true); } void testShouldTryToMigrate_falseWhenVersionsMatch() @@ -315,8 +312,7 @@ private slots: _configFile.setClientVersionString(MIRALL_VERSION_STRING); _configFile.setClientPreviousVersionString(MIRALL_VERSION_STRING); - Migration migration; - QCOMPARE(migration.shouldTryToMigrate(), false); + QCOMPARE(Migration::shouldTryToMigrate(), false); } void testShouldTryToMigrate_falseWhenConfigMatchesRunningVersion() @@ -327,8 +323,7 @@ private slots: _configFile.setClientVersionString(MIRALL_VERSION_STRING); _configFile.setClientPreviousVersionString(QStringLiteral("1.0.0")); - Migration migration; - QCOMPARE(migration.shouldTryToMigrate(), false); + QCOMPARE(Migration::shouldTryToMigrate(), false); } void testShouldTryToMigrate_trueWhenUpgradingFromMatchingVersions() @@ -339,18 +334,7 @@ private slots: _configFile.setClientVersionString(QStringLiteral("1.0.0")); _configFile.setClientPreviousVersionString(QStringLiteral("1.0.0")); - Migration migration; - QCOMPARE(migration.shouldTryToMigrate(), true); - } - - void testStaticStateSharedAcrossInstances() - { - Migration a; - a.setPhase(Migration::Phase::SetupUsers); - - Migration b; - QCOMPARE(b.phase(), Migration::Phase::SetupUsers); - QCOMPARE(b.isInProgress(), true); + QCOMPARE(Migration::shouldTryToMigrate(), true); } void testLegacyData_discoversAndParsesConfig() @@ -366,8 +350,7 @@ private slots: legacyFile.write(legacyAppConfigContent); legacyFile.close(); - Migration migration; - const auto legacy = migration.legacyData(); + const auto legacy = Migration::legacyData(); // Sole ownership is transferred to the caller and the file is parsed. QVERIFY(legacy != nullptr); @@ -375,7 +358,7 @@ private slots: legacy->beginGroup(QStringLiteral("Accounts")); QVERIFY(legacy->childGroups().contains(QStringLiteral("0"))); legacy->endGroup(); - QCOMPARE(migration.discoveredLegacyConfigPath(), QFileInfo(legacyConfigPath).canonicalPath()); + QVERIFY(Migration::discoveredLegacyConfigPath().isEmpty()); } void testLegacyData_returnsNullWhenNoLegacyConfig() @@ -386,11 +369,10 @@ private slots: const auto configDir = QFileInfo(_configFile.configFile()).absolutePath(); QFile::remove(configDir + QStringLiteral("/owncloud.cfg")); - Migration migration; - const auto legacy = migration.legacyData(); + const auto legacy = Migration::legacyData(); QVERIFY(!legacy); - QCOMPARE(migration.discoveredLegacyConfigPath(), QString()); + QCOMPARE(Migration::discoveredLegacyConfigPath(), QString()); } }; From 6f54d4c3a63a4309d813e38affbbba4910fc3c72 Mon Sep 17 00:00:00 2001 From: Camila Ayres Date: Wed, 12 Aug 2026 17:58:16 +0000 Subject: [PATCH 11/16] feat(settings): add ManagedSettings resolver with source precedence. Resolves the effective value of a setting from a set of injectable sources and returns metadata (winning source, default or locked). Precedence: locked policy, then user config, then the highest default, then the builtin default. Assisted-by: Claude Code:claude-opus-4-8 Signed-off-by: Camila Ayres --- src/libsync/CMakeLists.txt | 2 + src/libsync/settings/managedsettings.cpp | 63 ++++++++++ src/libsync/settings/managedsettings.h | 85 ++++++++++++++ test/CMakeLists.txt | 1 + test/testmanagedsettings.cpp | 141 +++++++++++++++++++++++ 5 files changed, 292 insertions(+) create mode 100644 src/libsync/settings/managedsettings.cpp create mode 100644 src/libsync/settings/managedsettings.h create mode 100644 test/testmanagedsettings.cpp diff --git a/src/libsync/CMakeLists.txt b/src/libsync/CMakeLists.txt index ee5f79a08272d..2837bb874e969 100644 --- a/src/libsync/CMakeLists.txt +++ b/src/libsync/CMakeLists.txt @@ -168,6 +168,8 @@ set(libsync_SRCS caseclashconflictsolver.cpp settings/migration.h settings/migration.cpp + settings/managedsettings.h + settings/managedsettings.cpp ) if (WIN32) diff --git a/src/libsync/settings/managedsettings.cpp b/src/libsync/settings/managedsettings.cpp new file mode 100644 index 0000000000000..8e7bec880d3af --- /dev/null +++ b/src/libsync/settings/managedsettings.cpp @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "settings/managedsettings.h" + +namespace OCC { + +namespace { +// Higher tier always wins; within a tier the higher source priority wins. +int tierOf(const LockState lockState, const SettingSourceKind kind) +{ + switch (lockState) { + case LockState::Locked: + return 2; + case LockState::Unlocked: + return kind == SettingSourceKind::UserConfig ? 1 : 0; + } + return 0; +} +} + +SettingSource::~SettingSource() = default; + +void ManagedSettings::addSource(std::unique_ptr source) +{ + _sources.push_back(std::move(source)); +} + +ManagedValue ManagedSettings::resolve(const SettingSpec &spec, const QString &group) const +{ + const SettingSource *winner = nullptr; + QVariant winnerValue; + auto winnerTier = -1; + auto winnerPriority = 0; + + for (const auto &source : _sources) { + const auto lockState = source->lockState(); + if (lockState == LockState::Locked && !spec.lockable) { + continue; + } + const auto value = source->read(spec.key, group); + if (!value.has_value()) { + continue; + } + const auto tier = tierOf(lockState, source->kind()); + const auto priority = source->priority(); + if (tier > winnerTier || (tier == winnerTier && priority > winnerPriority)) { + winner = source.get(); + winnerValue = *value; + winnerTier = tier; + winnerPriority = priority; + } + } + + if (!winner) { + return {spec.key, spec.builtinDefault, SettingSourceKind::BuiltinDefault, LockState::Unlocked, false}; + } + return {spec.key, winnerValue, winner->kind(), winner->lockState(), true}; +} + +} // namespace OCC diff --git a/src/libsync/settings/managedsettings.h b/src/libsync/settings/managedsettings.h new file mode 100644 index 0000000000000..fa7592715c526 --- /dev/null +++ b/src/libsync/settings/managedsettings.h @@ -0,0 +1,85 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#ifndef MANAGEDSETTINGS_H +#define MANAGEDSETTINGS_H + +#include +#include + +#include +#include +#include + +#include "owncloudlib.h" + +namespace OCC { + +enum class SettingSourceKind { + BuiltinDefault, + PlatformDefault, + UserConfig, + PlatformPolicy, + ServerDefault, // phase 2 + ServerLocked, // phase 2 +}; + +enum class LockState { + Unlocked, + Locked, +}; + +enum class SettingScope { + Device, + User, + Account, + Folder, +}; + +struct ManagedValue { + QString key; + QVariant value; + SettingSourceKind source = SettingSourceKind::BuiltinDefault; + LockState lockState = LockState::Unlocked; + bool present = false; // false when only the builtin default applied + + [[nodiscard]] bool isLocked() const { return lockState == LockState::Locked; } +}; + +struct SettingSpec { + QString key; + QVariant builtinDefault; + bool lockable = false; + SettingScope scope = SettingScope::User; +}; + +class OWNCLOUDSYNC_EXPORT SettingSource +{ +public: + virtual ~SettingSource(); + + // std::nullopt means the source does not define key. + [[nodiscard]] virtual std::optional read(const QString &key, const QString &group) const = 0; + [[nodiscard]] virtual SettingSourceKind kind() const = 0; + [[nodiscard]] virtual LockState lockState() const = 0; + [[nodiscard]] virtual int priority() const = 0; +}; + +// Resolves the effective value of a setting: locked policy, then user config, +// then the highest default, then the builtin default. +class OWNCLOUDSYNC_EXPORT ManagedSettings +{ +public: + void addSource(std::unique_ptr source); + + [[nodiscard]] ManagedValue resolve(const SettingSpec &spec, const QString &group = {}) const; + +private: + std::vector> _sources; +}; + +} // namespace OCC + +#endif // MANAGEDSETTINGS_H diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 0ad03a71f19a6..e13e4351e9251 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -181,6 +181,7 @@ nextcloud_add_test(FolderMan) nextcloud_add_test(ForceSyncNow) nextcloud_add_test(RemoteWipe) nextcloud_add_test(Migration) +nextcloud_add_test(ManagedSettings) if(NOT BUILD_FILE_PROVIDER_MODULE) # the File Provider build crashes this test in CI for some reason diff --git a/test/testmanagedsettings.cpp b/test/testmanagedsettings.cpp new file mode 100644 index 0000000000000..f581e339708d2 --- /dev/null +++ b/test/testmanagedsettings.cpp @@ -0,0 +1,141 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include +#include +#include + +#include "settings/managedsettings.h" + +using namespace OCC; + +class MapSource : public SettingSource +{ +public: + MapSource(SettingSourceKind kind, LockState lockState, int priority, QVariantMap values) + : _kind(kind) + , _lockState(lockState) + , _priority(priority) + , _values(std::move(values)) + { + } + + std::optional read(const QString &key, const QString &) const override + { + if (!_values.contains(key)) { + return std::nullopt; + } + return _values.value(key); + } + SettingSourceKind kind() const override { return _kind; } + LockState lockState() const override { return _lockState; } + int priority() const override { return _priority; } + +private: + SettingSourceKind _kind; + LockState _lockState; + int _priority; + QVariantMap _values; +}; + +class TestManagedSettings : public QObject +{ + Q_OBJECT + + static SettingSpec skipSpec() + { + return {QStringLiteral("skipUpdateCheck"), false, true, SettingScope::User}; + } + +private slots: + void testBuiltinDefaultWhenNoSourceHasValue() + { + ManagedSettings resolver; + + const auto result = resolver.resolve(skipSpec()); + + QCOMPARE(result.value.toBool(), false); + QCOMPARE(result.present, false); + QCOMPARE(result.source, SettingSourceKind::BuiltinDefault); + QCOMPARE(result.lockState, LockState::Unlocked); + } + + void testUserBeatsPlatformDefault() + { + ManagedSettings resolver; + resolver.addSource(std::make_unique(SettingSourceKind::PlatformDefault, LockState::Unlocked, 20, + QVariantMap{{QStringLiteral("skipUpdateCheck"), true}})); + resolver.addSource(std::make_unique(SettingSourceKind::UserConfig, LockState::Unlocked, 50, + QVariantMap{{QStringLiteral("skipUpdateCheck"), false}})); + + const auto r = resolver.resolve(skipSpec()); + + QCOMPARE(r.value.toBool(), false); + QCOMPARE(r.source, SettingSourceKind::UserConfig); + QCOMPARE(r.lockState, LockState::Unlocked); + QCOMPARE(r.present, true); + } + + void testLockedPolicyBeatsUser() + { + ManagedSettings resolver; + resolver.addSource(std::make_unique(SettingSourceKind::UserConfig, LockState::Unlocked, 50, + QVariantMap{{QStringLiteral("skipUpdateCheck"), false}})); + resolver.addSource(std::make_unique(SettingSourceKind::PlatformPolicy, LockState::Locked, 200, + QVariantMap{{QStringLiteral("skipUpdateCheck"), true}})); + + const auto r = resolver.resolve(skipSpec()); + + QCOMPARE(r.value.toBool(), true); + QCOMPARE(r.source, SettingSourceKind::PlatformPolicy); + QVERIFY(r.isLocked()); + } + + void testHighestPriorityLockedWins() + { + ManagedSettings resolver; + resolver.addSource(std::make_unique(SettingSourceKind::ServerLocked, LockState::Locked, 100, + QVariantMap{{QStringLiteral("skipUpdateCheck"), false}})); + resolver.addSource(std::make_unique(SettingSourceKind::PlatformPolicy, LockState::Locked, 200, + QVariantMap{{QStringLiteral("skipUpdateCheck"), true}})); + + const auto r = resolver.resolve(skipSpec()); + + QCOMPARE(r.value.toBool(), true); // device policy (200) beats server locked (100) + QCOMPARE(r.source, SettingSourceKind::PlatformPolicy); + } + + void testHighestPriorityDefaultWinsWhenNoUser() + { + ManagedSettings resolver; + resolver.addSource(std::make_unique(SettingSourceKind::PlatformDefault, LockState::Unlocked, 20, + QVariantMap{{QStringLiteral("updateChannel"), QStringLiteral("beta")}})); + resolver.addSource(std::make_unique(SettingSourceKind::ServerDefault, LockState::Unlocked, 30, + QVariantMap{{QStringLiteral("updateChannel"), QStringLiteral("stable")}})); + + const auto r = resolver.resolve({QStringLiteral("updateChannel"), QStringLiteral("stable"), false, SettingScope::User}); + + QCOMPARE(r.value.toString(), QStringLiteral("stable")); // ServerDefault(30) beats PlatformDefault(20) + QCOMPARE(r.source, SettingSourceKind::ServerDefault); + } + + void testLockedIgnoredWhenSettingNotLockable() + { + ManagedSettings resolver; + resolver.addSource(std::make_unique(SettingSourceKind::UserConfig, LockState::Unlocked, 50, + QVariantMap{{QStringLiteral("updateChannel"), QStringLiteral("beta")}})); + resolver.addSource(std::make_unique(SettingSourceKind::PlatformPolicy, LockState::Locked, 200, + QVariantMap{{QStringLiteral("updateChannel"), QStringLiteral("stable")}})); + + const auto r = resolver.resolve({QStringLiteral("updateChannel"), QStringLiteral("stable"), false, SettingScope::User}); + + QCOMPARE(r.value.toString(), QStringLiteral("beta")); + QCOMPARE(r.source, SettingSourceKind::UserConfig); + QVERIFY(!r.isLocked()); + } +}; + +QTEST_GUILESS_MAIN(TestManagedSettings) +#include "testmanagedsettings.moc" From 2b32369ec6ecdda8b59aa7ce444bf950e761a12d Mon Sep 17 00:00:00 2001 From: Camila Ayres Date: Wed, 12 Aug 2026 18:31:30 +0000 Subject: [PATCH 12/16] feat(settings): add managed settings schema for update settings Assisted-by: Claude Code:claude-opus-4-8 Signed-off-by: Camila Ayres --- src/libsync/CMakeLists.txt | 2 ++ .../settings/managedsettingsschema.cpp | 29 +++++++++++++++++++ src/libsync/settings/managedsettingsschema.h | 22 ++++++++++++++ test/testmanagedsettings.cpp | 15 ++++++++++ 4 files changed, 68 insertions(+) create mode 100644 src/libsync/settings/managedsettingsschema.cpp create mode 100644 src/libsync/settings/managedsettingsschema.h diff --git a/src/libsync/CMakeLists.txt b/src/libsync/CMakeLists.txt index 2837bb874e969..caac39486b251 100644 --- a/src/libsync/CMakeLists.txt +++ b/src/libsync/CMakeLists.txt @@ -170,6 +170,8 @@ set(libsync_SRCS settings/migration.cpp settings/managedsettings.h settings/managedsettings.cpp + settings/managedsettingsschema.h + settings/managedsettingsschema.cpp ) if (WIN32) diff --git a/src/libsync/settings/managedsettingsschema.cpp b/src/libsync/settings/managedsettingsschema.cpp new file mode 100644 index 0000000000000..8d77a64b043e2 --- /dev/null +++ b/src/libsync/settings/managedsettingsschema.cpp @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "settings/managedsettingsschema.h" + +namespace OCC::ManagedSettingsSchema { + +const QList &all() +{ + static const QList specs = { + {QStringLiteral("skipUpdateCheck"), false, true, SettingScope::User}, + {QStringLiteral("autoUpdateCheck"), true, true, SettingScope::User}, + }; + return specs; +} + +std::optional find(const QString &key) +{ + for (const auto &spec : all()) { + if (spec.key == key) { + return spec; + } + } + return std::nullopt; +} + +} diff --git a/src/libsync/settings/managedsettingsschema.h b/src/libsync/settings/managedsettingsschema.h new file mode 100644 index 0000000000000..79881cb59af60 --- /dev/null +++ b/src/libsync/settings/managedsettingsschema.h @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#ifndef MANAGEDSETTINGSSCHEMA_H +#define MANAGEDSETTINGSSCHEMA_H + +#include +#include + +#include "owncloudlib.h" +#include "settings/managedsettings.h" + +namespace OCC::ManagedSettingsSchema { + +[[nodiscard]] OWNCLOUDSYNC_EXPORT const QList &all(); +[[nodiscard]] OWNCLOUDSYNC_EXPORT std::optional find(const QString &key); + +} + +#endif // MANAGEDSETTINGSSCHEMA_H diff --git a/test/testmanagedsettings.cpp b/test/testmanagedsettings.cpp index f581e339708d2..ae59ec2a13fa7 100644 --- a/test/testmanagedsettings.cpp +++ b/test/testmanagedsettings.cpp @@ -8,6 +8,7 @@ #include #include "settings/managedsettings.h" +#include "settings/managedsettingsschema.h" using namespace OCC; @@ -135,6 +136,20 @@ private slots: QCOMPARE(r.source, SettingSourceKind::UserConfig); QVERIFY(!r.isLocked()); } + + void testSchemaHasUpdateSettings() + { + const auto skip = ManagedSettingsSchema::find(QStringLiteral("skipUpdateCheck")); + QVERIFY(skip.has_value()); + QCOMPARE(skip->builtinDefault.toBool(), false); + QVERIFY(skip->lockable); + + const auto autoCheck = ManagedSettingsSchema::find(QStringLiteral("autoUpdateCheck")); + QVERIFY(autoCheck.has_value()); + QCOMPARE(autoCheck->builtinDefault.toBool(), true); + + QVERIFY(!ManagedSettingsSchema::find(QStringLiteral("nonexistent")).has_value()); + } }; QTEST_GUILESS_MAIN(TestManagedSettings) From e40393b30dd41641904df0975c4cc05c501451c1 Mon Sep 17 00:00:00 2001 From: Camila Ayres Date: Wed, 12 Aug 2026 18:34:32 +0000 Subject: [PATCH 13/16] feat(settings): add user config setting source Assisted-by: Claude Code:claude-opus-4-8 Signed-off-by: Camila Ayres --- src/libsync/CMakeLists.txt | 2 ++ src/libsync/settings/settingsources.cpp | 44 +++++++++++++++++++++++++ src/libsync/settings/settingsources.h | 32 ++++++++++++++++++ test/testmanagedsettings.cpp | 23 +++++++++++++ 4 files changed, 101 insertions(+) create mode 100644 src/libsync/settings/settingsources.cpp create mode 100644 src/libsync/settings/settingsources.h diff --git a/src/libsync/CMakeLists.txt b/src/libsync/CMakeLists.txt index caac39486b251..5e05ce941bf1a 100644 --- a/src/libsync/CMakeLists.txt +++ b/src/libsync/CMakeLists.txt @@ -172,6 +172,8 @@ set(libsync_SRCS settings/managedsettings.cpp settings/managedsettingsschema.h settings/managedsettingsschema.cpp + settings/settingsources.h + settings/settingsources.cpp ) if (WIN32) diff --git a/src/libsync/settings/settingsources.cpp b/src/libsync/settings/settingsources.cpp new file mode 100644 index 0000000000000..e63982fceb856 --- /dev/null +++ b/src/libsync/settings/settingsources.cpp @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "settings/settingsources.h" + +#include + +namespace OCC { + +UserConfigSource::UserConfigSource(QString configFilePath) + : _configFilePath(std::move(configFilePath)) +{ +} + +std::optional UserConfigSource::read(const QString &key, const QString &group) const +{ + QSettings settings(_configFilePath, QSettings::IniFormat); + if (!group.isEmpty()) { + settings.beginGroup(group); + } + if (!settings.contains(key)) { + return std::nullopt; + } + return settings.value(key); +} + +SettingSourceKind UserConfigSource::kind() const +{ + return SettingSourceKind::UserConfig; +} + +LockState UserConfigSource::lockState() const +{ + return LockState::Unlocked; +} + +int UserConfigSource::priority() const +{ + return 50; +} + +} // namespace OCC diff --git a/src/libsync/settings/settingsources.h b/src/libsync/settings/settingsources.h new file mode 100644 index 0000000000000..a967604043602 --- /dev/null +++ b/src/libsync/settings/settingsources.h @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#ifndef SETTINGSOURCES_H +#define SETTINGSOURCES_H + +#include + +#include "owncloudlib.h" +#include "settings/managedsettings.h" + +namespace OCC { + +class OWNCLOUDSYNC_EXPORT UserConfigSource : public SettingSource +{ +public: + explicit UserConfigSource(QString configFilePath); + + [[nodiscard]] std::optional read(const QString &key, const QString &group) const override; + [[nodiscard]] SettingSourceKind kind() const override; + [[nodiscard]] LockState lockState() const override; + [[nodiscard]] int priority() const override; + +private: + QString _configFilePath; +}; + +} // namespace OCC + +#endif // SETTINGSOURCES_H diff --git a/test/testmanagedsettings.cpp b/test/testmanagedsettings.cpp index ae59ec2a13fa7..0caca650ba7a3 100644 --- a/test/testmanagedsettings.cpp +++ b/test/testmanagedsettings.cpp @@ -4,11 +4,13 @@ */ #include +#include #include #include #include "settings/managedsettings.h" #include "settings/managedsettingsschema.h" +#include "settings/settingsources.h" using namespace OCC; @@ -150,6 +152,27 @@ private slots: QVERIFY(!ManagedSettingsSchema::find(QStringLiteral("nonexistent")).has_value()); } + + void testUserConfigSourceReadsIniValueAndGroup() + { + QTemporaryDir dir; + const auto path = dir.path() + QStringLiteral("/user.cfg"); + { + QSettings settings(path, QSettings::IniFormat); + settings.setValue(QStringLiteral("skipUpdateCheck"), true); + settings.beginGroup(QStringLiteral("Accounts")); + settings.setValue(QStringLiteral("autoUpdateCheck"), false); + settings.endGroup(); + settings.sync(); + } + const UserConfigSource source(path); + + QCOMPARE(source.kind(), SettingSourceKind::UserConfig); + QCOMPARE(source.lockState(), LockState::Unlocked); + QCOMPARE(source.read(QStringLiteral("skipUpdateCheck"), QString())->toBool(), true); + QCOMPARE(source.read(QStringLiteral("autoUpdateCheck"), QStringLiteral("Accounts"))->toBool(), false); + QVERIFY(!source.read(QStringLiteral("missing"), QString()).has_value()); + } }; QTEST_GUILESS_MAIN(TestManagedSettings) From bcff9949333da65d032ca3f728b2006f93d3ae2e Mon Sep 17 00:00:00 2001 From: Camila Ayres Date: Wed, 12 Aug 2026 18:39:12 +0000 Subject: [PATCH 14/16] feat(settings): add platform device sources and factory Native registry, plist and conf adapters plus buildDeviceSources, replicating the app name selection of ConfigFile::getValue and getPolicySetting. The OS adapters are not unit tested on the Linux build; only the factory is smoke tested. Assisted-by: Claude Code:claude-opus-4-8 Signed-off-by: Camila Ayres --- src/libsync/settings/settingsources.cpp | 73 +++++++++++++++++++++++++ src/libsync/settings/settingsources.h | 22 ++++++++ test/testmanagedsettings.cpp | 8 +++ 3 files changed, 103 insertions(+) diff --git a/src/libsync/settings/settingsources.cpp b/src/libsync/settings/settingsources.cpp index e63982fceb856..7a89304c0542d 100644 --- a/src/libsync/settings/settingsources.cpp +++ b/src/libsync/settings/settingsources.cpp @@ -5,6 +5,11 @@ #include "settings/settingsources.h" +#include "config.h" +#include "configfile.h" +#include "theme.h" +#include "settings/migration.h" + #include namespace OCC { @@ -41,4 +46,72 @@ int UserConfigSource::priority() const return 50; } +NativeSettingsSource::NativeSettingsSource(QString location, SettingSourceKind kind, LockState lockState, int priority) + : _location(std::move(location)) + , _kind(kind) + , _lockState(lockState) + , _priority(priority) +{ +} + +std::optional NativeSettingsSource::read(const QString &key, const QString &group) const +{ + QSettings settings(_location, QSettings::NativeFormat); + if (!group.isEmpty()) { + settings.beginGroup(group); + } + if (!settings.contains(key)) { + return std::nullopt; + } + return settings.value(key); +} + +SettingSourceKind NativeSettingsSource::kind() const +{ + return _kind; +} + +LockState NativeSettingsSource::lockState() const +{ + return _lockState; +} + +int NativeSettingsSource::priority() const +{ + return _priority; +} + +std::vector> buildDeviceSources() +{ + [[maybe_unused]] const auto app = Migration::isUnbrandedToBrandedMigration() + ? QString::fromLatin1(ConfigFile::unbrandedAppName) + : Theme::instance()->appNameGUI(); + + std::vector> sources; +#if defined(Q_OS_WIN) + sources.push_back(std::make_unique( + QStringLiteral(R"(HKEY_CURRENT_USER\Software\Policies\%1\%2)").arg(QString::fromLatin1(APPLICATION_VENDOR), app), + SettingSourceKind::PlatformPolicy, LockState::Locked, 210)); + sources.push_back(std::make_unique( + QStringLiteral(R"(HKEY_LOCAL_MACHINE\Software\Policies\%1\%2)").arg(QString::fromLatin1(APPLICATION_VENDOR), app), + SettingSourceKind::PlatformPolicy, LockState::Locked, 200)); + sources.push_back(std::make_unique( + QStringLiteral(R"(HKEY_LOCAL_MACHINE\Software\%1\%2)").arg(QString::fromLatin1(APPLICATION_VENDOR), app), + SettingSourceKind::PlatformDefault, LockState::Unlocked, 20)); +#elif defined(Q_OS_MAC) + // TODO(mdm): confirm the forced managed preferences API before treating a managed plist as locked. + sources.push_back(std::make_unique( + QStringLiteral("/Library/Managed Preferences/" APPLICATION_REV_DOMAIN ".plist"), + SettingSourceKind::PlatformPolicy, LockState::Locked, 200)); + sources.push_back(std::make_unique( + QStringLiteral("/Library/Preferences/" APPLICATION_REV_DOMAIN ".plist"), + SettingSourceKind::PlatformDefault, LockState::Unlocked, 20)); +#else + sources.push_back(std::make_unique( + QStringLiteral(SYSCONFDIR "/%1/%1.conf").arg(app), + SettingSourceKind::PlatformDefault, LockState::Unlocked, 20)); +#endif + return sources; +} + } // namespace OCC diff --git a/src/libsync/settings/settingsources.h b/src/libsync/settings/settingsources.h index a967604043602..406813e92e09e 100644 --- a/src/libsync/settings/settingsources.h +++ b/src/libsync/settings/settingsources.h @@ -27,6 +27,28 @@ class OWNCLOUDSYNC_EXPORT UserConfigSource : public SettingSource QString _configFilePath; }; +// Reads a native OS store (Windows registry, macOS plist, Linux conf) at a fixed location. +class OWNCLOUDSYNC_EXPORT NativeSettingsSource : public SettingSource +{ +public: + NativeSettingsSource(QString location, SettingSourceKind kind, LockState lockState, int priority); + + [[nodiscard]] std::optional read(const QString &key, const QString &group) const override; + [[nodiscard]] SettingSourceKind kind() const override; + [[nodiscard]] LockState lockState() const override; + [[nodiscard]] int priority() const override; + +private: + QString _location; + SettingSourceKind _kind; + LockState _lockState; + int _priority; +}; + +// Ordered device sources for the running platform, using the same app name +// selection as ConfigFile::getValue and getPolicySetting. +[[nodiscard]] OWNCLOUDSYNC_EXPORT std::vector> buildDeviceSources(); + } // namespace OCC #endif // SETTINGSOURCES_H diff --git a/test/testmanagedsettings.cpp b/test/testmanagedsettings.cpp index 0caca650ba7a3..08758d00d83f0 100644 --- a/test/testmanagedsettings.cpp +++ b/test/testmanagedsettings.cpp @@ -173,6 +173,14 @@ private slots: QCOMPARE(source.read(QStringLiteral("autoUpdateCheck"), QStringLiteral("Accounts"))->toBool(), false); QVERIFY(!source.read(QStringLiteral("missing"), QString()).has_value()); } + + // The platform adapters read real OS stores, so they cannot be exercised on + // the Linux test build. This only checks the factory compiles and runs. + void testBuildDeviceSourcesReturnsSources() + { + const auto sources = buildDeviceSources(); + QVERIFY(!sources.empty()); + } }; QTEST_GUILESS_MAIN(TestManagedSettings) From 8f1afe312fd1c255367e5280428aee31c1855cb1 Mon Sep 17 00:00:00 2001 From: Camila Ayres Date: Wed, 12 Aug 2026 18:51:54 +0000 Subject: [PATCH 15/16] refactor(settings): resolve update settings through the managed settings resolver skipUpdateCheck and autoUpdateCheck now go through ManagedSettings with the device sources and the user config, preserving the precedence policy over user over OS default over builtin. The user source carries the connection group so a locked policy still overrides a group scoped user value. Assisted-by: Claude Code:claude-opus-4-8 Signed-off-by: Camila Ayres --- src/libsync/configfile.cpp | 27 ++++++++++++-------- src/libsync/configfile.h | 1 + src/libsync/settings/settingsources.cpp | 8 +++--- src/libsync/settings/settingsources.h | 4 ++- test/testmanagedsettings.cpp | 34 +++++++++++++++++++++++++ 5 files changed, 60 insertions(+), 14 deletions(-) diff --git a/src/libsync/configfile.cpp b/src/libsync/configfile.cpp index c50b72ac04450..1091389f5d8e2 100644 --- a/src/libsync/configfile.cpp +++ b/src/libsync/configfile.cpp @@ -15,6 +15,9 @@ #include "updatechannel.h" #include "version.h" #include "settings/migration.h" +#include "settings/managedsettings.h" +#include "settings/managedsettingsschema.h" +#include "settings/settingsources.h" #ifndef TOKEN_AUTH_ONLY #include @@ -624,12 +627,7 @@ chrono::milliseconds ConfigFile::updateCheckInterval(const QString &connectionGr bool ConfigFile::skipUpdateCheck(const QString &connectionGroupName) const { - const auto groupName = connectionGroupName.isEmpty() ? defaultConnectionGroupName() : connectionGroupName; - QVariant fallback = getValue(QLatin1String(skipUpdateCheckC), groupName, false); - fallback = getValue(QLatin1String(skipUpdateCheckC), QString(), fallback); - - QVariant value = getPolicySetting(QLatin1String(skipUpdateCheckC), fallback); - return value.toBool(); + return resolveManagedBool(QLatin1String(skipUpdateCheckC), connectionGroupName, false); } void ConfigFile::setSkipUpdateCheck(bool skip, const QString &connectionGroupName) @@ -643,13 +641,22 @@ void ConfigFile::setSkipUpdateCheck(bool skip, const QString &connectionGroupNam } bool ConfigFile::autoUpdateCheck(const QString &connectionGroupName) const +{ + return resolveManagedBool(QLatin1String(autoUpdateCheckC), connectionGroupName, true); +} + +bool ConfigFile::resolveManagedBool(const QString &key, const QString &connectionGroupName, bool builtinDefault) const { const auto groupName = connectionGroupName.isEmpty() ? defaultConnectionGroupName() : connectionGroupName; - QVariant fallback = getValue(QLatin1String(autoUpdateCheckC), groupName, true); - fallback = getValue(QLatin1String(autoUpdateCheckC), QString(), fallback); + const auto spec = ManagedSettingsSchema::find(key).value_or(SettingSpec{key, builtinDefault, true, SettingScope::User}); + + ManagedSettings resolver; + for (auto &deviceSource : buildDeviceSources()) { + resolver.addSource(std::move(deviceSource)); + } + resolver.addSource(std::make_unique(configFile(), groupName)); - QVariant value = getPolicySetting(QLatin1String(autoUpdateCheckC), fallback); - return value.toBool(); + return resolver.resolve(spec).value.toBool(); } void ConfigFile::setAutoUpdateCheck(bool autoCheck, const QString &connectionGroupName) diff --git a/src/libsync/configfile.h b/src/libsync/configfile.h index 3b88839141dd9..ecf6c39a8918e 100644 --- a/src/libsync/configfile.h +++ b/src/libsync/configfile.h @@ -310,6 +310,7 @@ class OWNCLOUDSYNC_EXPORT ConfigFile private: [[nodiscard]] QVariant getValue(const QString ¶m, const QString &group = QString(), const QVariant &defaultValue = QVariant()) const; + [[nodiscard]] bool resolveManagedBool(const QString &key, const QString &connectionGroupName, bool builtinDefault) const; void setValue(const QString &key, const QVariant &value); [[nodiscard]] QString keychainProxyPasswordKey() const; diff --git a/src/libsync/settings/settingsources.cpp b/src/libsync/settings/settingsources.cpp index 7a89304c0542d..fa9bc9be8f6fa 100644 --- a/src/libsync/settings/settingsources.cpp +++ b/src/libsync/settings/settingsources.cpp @@ -14,16 +14,18 @@ namespace OCC { -UserConfigSource::UserConfigSource(QString configFilePath) +UserConfigSource::UserConfigSource(QString configFilePath, QString group) : _configFilePath(std::move(configFilePath)) + , _group(std::move(group)) { } std::optional UserConfigSource::read(const QString &key, const QString &group) const { + const auto effectiveGroup = _group.isEmpty() ? group : _group; QSettings settings(_configFilePath, QSettings::IniFormat); - if (!group.isEmpty()) { - settings.beginGroup(group); + if (!effectiveGroup.isEmpty()) { + settings.beginGroup(effectiveGroup); } if (!settings.contains(key)) { return std::nullopt; diff --git a/src/libsync/settings/settingsources.h b/src/libsync/settings/settingsources.h index 406813e92e09e..611e4bd79ea60 100644 --- a/src/libsync/settings/settingsources.h +++ b/src/libsync/settings/settingsources.h @@ -16,7 +16,8 @@ namespace OCC { class OWNCLOUDSYNC_EXPORT UserConfigSource : public SettingSource { public: - explicit UserConfigSource(QString configFilePath); + // A non-empty group is always used and overrides the group passed to read(). + explicit UserConfigSource(QString configFilePath, QString group = {}); [[nodiscard]] std::optional read(const QString &key, const QString &group) const override; [[nodiscard]] SettingSourceKind kind() const override; @@ -25,6 +26,7 @@ class OWNCLOUDSYNC_EXPORT UserConfigSource : public SettingSource private: QString _configFilePath; + QString _group; }; // Reads a native OS store (Windows registry, macOS plist, Linux conf) at a fixed location. diff --git a/test/testmanagedsettings.cpp b/test/testmanagedsettings.cpp index 08758d00d83f0..2f5edd2ab15c8 100644 --- a/test/testmanagedsettings.cpp +++ b/test/testmanagedsettings.cpp @@ -5,9 +5,11 @@ #include #include +#include #include #include +#include "configfile.h" #include "settings/managedsettings.h" #include "settings/managedsettingsschema.h" #include "settings/settingsources.h" @@ -53,6 +55,11 @@ class TestManagedSettings : public QObject } private slots: + void initTestCase() + { + QStandardPaths::setTestModeEnabled(true); + } + void testBuiltinDefaultWhenNoSourceHasValue() { ManagedSettings resolver; @@ -181,6 +188,33 @@ private slots: const auto sources = buildDeviceSources(); QVERIFY(!sources.empty()); } + + void testUserConfigSourceBakedGroupOverridesReadGroup() + { + QTemporaryDir dir; + const auto path = dir.path() + QStringLiteral("/user.cfg"); + { + QSettings settings(path, QSettings::IniFormat); + settings.beginGroup(QStringLiteral("Nextcloud")); + settings.setValue(QStringLiteral("skipUpdateCheck"), true); + settings.endGroup(); + settings.sync(); + } + const UserConfigSource source(path, QStringLiteral("Nextcloud")); + + QCOMPARE(source.read(QStringLiteral("skipUpdateCheck"), QStringLiteral("Other"))->toBool(), true); + } + + void testConfigFileRoutesSkipUpdateCheckThroughResolver() + { + QTemporaryDir dir; + ConfigFile config; + config.setConfDir(dir.path()); + + QCOMPARE(config.skipUpdateCheck(), false); + config.setSkipUpdateCheck(true, QString()); + QCOMPARE(config.skipUpdateCheck(), true); + } }; QTEST_GUILESS_MAIN(TestManagedSettings) From 24b18b8bbbacdc5bca4bfaa7a09f63ee6780aa46 Mon Sep 17 00:00:00 2001 From: Camila Ayres Date: Wed, 12 Aug 2026 18:54:56 +0000 Subject: [PATCH 16/16] feat(settings): add resolveAll for managed settings diagnostics Assisted-by: Claude Code:claude-opus-4-8 Signed-off-by: Camila Ayres --- src/libsync/settings/managedsettings.cpp | 10 ++++++++++ src/libsync/settings/managedsettings.h | 4 ++++ test/testmanagedsettings.cpp | 17 +++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/src/libsync/settings/managedsettings.cpp b/src/libsync/settings/managedsettings.cpp index 8e7bec880d3af..a2dc9fc9cb171 100644 --- a/src/libsync/settings/managedsettings.cpp +++ b/src/libsync/settings/managedsettings.cpp @@ -60,4 +60,14 @@ ManagedValue ManagedSettings::resolve(const SettingSpec &spec, const QString &gr return {spec.key, winnerValue, winner->kind(), winner->lockState(), true}; } +QList ManagedSettings::resolveAll(const QList &specs, const QString &group) const +{ + QList results; + results.reserve(specs.size()); + for (const auto &spec : specs) { + results.append(resolve(spec, group)); + } + return results; +} + } // namespace OCC diff --git a/src/libsync/settings/managedsettings.h b/src/libsync/settings/managedsettings.h index fa7592715c526..de3cbc1b929e2 100644 --- a/src/libsync/settings/managedsettings.h +++ b/src/libsync/settings/managedsettings.h @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -76,6 +77,9 @@ class OWNCLOUDSYNC_EXPORT ManagedSettings [[nodiscard]] ManagedValue resolve(const SettingSpec &spec, const QString &group = {}) const; + // Resolves every spec, for a diagnostics export of effective values and sources. + [[nodiscard]] QList resolveAll(const QList &specs, const QString &group = {}) const; + private: std::vector> _sources; }; diff --git a/test/testmanagedsettings.cpp b/test/testmanagedsettings.cpp index 2f5edd2ab15c8..a6327bf8927ab 100644 --- a/test/testmanagedsettings.cpp +++ b/test/testmanagedsettings.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -215,6 +216,22 @@ private slots: config.setSkipUpdateCheck(true, QString()); QCOMPARE(config.skipUpdateCheck(), true); } + + void testResolveAllReturnsMetadataPerSpec() + { + ManagedSettings resolver; + resolver.addSource(std::make_unique(SettingSourceKind::PlatformPolicy, LockState::Locked, 200, + QVariantMap{{QStringLiteral("skipUpdateCheck"), true}})); + + const auto all = resolver.resolveAll(ManagedSettingsSchema::all()); + QCOMPARE(all.size(), ManagedSettingsSchema::all().size()); + + const auto skip = std::find_if(all.cbegin(), all.cend(), + [](const ManagedValue &value) { return value.key == QStringLiteral("skipUpdateCheck"); }); + QVERIFY(skip != all.cend()); + QVERIFY(skip->isLocked()); + QCOMPARE(skip->source, SettingSourceKind::PlatformPolicy); + } }; QTEST_GUILESS_MAIN(TestManagedSettings)