diff --git a/.github/actions/install-dependencies/action.yml b/.github/actions/install-dependencies/action.yml index e5927b506..24979ab6f 100644 --- a/.github/actions/install-dependencies/action.yml +++ b/.github/actions/install-dependencies/action.yml @@ -47,12 +47,13 @@ runs: zypper refresh zypper install -y --force-resolution \ cmake make ninja gcc-c++ rpm-build libopenssl-devel \ - glib2-devel libXtst-devel libxkbfile-devel qt6-base-devel qt6-tools-devel gtk3-devel \ + glib2-devel libXtst-devel libxkbfile-devel qt6-base-devel qt6-tools-devel \ + qt6-linguist-devel gtk3-devel \ googletest-devel googlemock-devel libei-devel libportal-devel help2man elif [ ${{ inputs.like }} == "arch" ]; then pacman -Syu --noconfirm base-devel cmake ninja \ gcc openssl glib2 libxtst libxkbfile gtest libei libportal \ - qt6-base qt6-tools qt6-svg gtk3 help2man doxygen graphviz rsync + qt6-base qt6-tools qt6-svg qt6-translations qt6-declarative gtk3 help2man doxygen graphviz rsync else echo "Unknown like" fi diff --git a/CMakeLists.txt b/CMakeLists.txt index bb878f191..499a6d53b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -166,6 +166,7 @@ configure_libs() add_subdirectory(doc) add_subdirectory(src) +add_subdirectory(translations) # Install License, License is in the App Bundle on mac os (src/gui) if(WIN32) diff --git a/REUSE.toml b/REUSE.toml index 88bbbc77e..a80d19309 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -26,6 +26,7 @@ path = [ , "src/apps/deskflow-server/deskflow-server.exe.manifest" , "src/apps/res/manpage.txt" , "src/apps/res/deskflow.plist.in" + , "translations/*.ts" ] SPDX-FileCopyrightText = "Deskflow Developers" SPDX-License-Identifier = "MIT" diff --git a/deploy/linux/arch/PKGBUILD.in b/deploy/linux/arch/PKGBUILD.in index 744bb840e..174f9ffc1 100644 --- a/deploy/linux/arch/PKGBUILD.in +++ b/deploy/linux/arch/PKGBUILD.in @@ -34,6 +34,7 @@ depends=( openssl qt6-base qt6-svg + qt6-translations ) options=('!debug') diff --git a/doc/dev/build.md b/doc/dev/build.md index ac473f314..9cf4e9ea9 100644 --- a/doc/dev/build.md +++ b/doc/dev/build.md @@ -30,6 +30,7 @@ CMake options: | ENABLE_COVERAGE | Enable test coverage | OFF | `gcov` | | SKIP_BUILD_TESTS | Skip running of tests at build time | OFF | | | VCPKG_QT | Build Qt w/ vcpkg (windows only) | OFF | | +| CLEAN_TRS | Remove obsolete strings from tr files | OFF | | Example cmake configuration. `cmake -S. -Bbuild -DCMAKE_INSTALL_PREFIX=` diff --git a/sonar-project.properties b/sonar-project.properties index 4b593651b..82e227410 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -2,8 +2,8 @@ sonar.organization=deskflow sonar.projectKey=deskflow_deskflow sonar.sources=src/apps,src/lib sonar.tests=src/unittests -sonar.exclusions=subprojects/**,build/** -sonar.coverage.exclusions=subprojects/**,src/unittests/**,src/apps/deskflow-gui/**,src/apps/res/** +sonar.exclusions=subprojects/**,build/**,translations/** +sonar.coverage.exclusions=subprojects/**,src/unittests/**,src/apps/deskflow-gui/**,src/apps/res/**,translations/** sonar.cpd.exclusions=**/*Test*.cpp sonar.host.url=https://sonarcloud.io sonar.cfamily.compile-commands=build/compile_commands.json diff --git a/src/apps/deskflow-gui/deskflow-gui.cpp b/src/apps/deskflow-gui/deskflow-gui.cpp index 3e2bf1d55..a26596077 100644 --- a/src/apps/deskflow-gui/deskflow-gui.cpp +++ b/src/apps/deskflow-gui/deskflow-gui.cpp @@ -9,6 +9,7 @@ #include "VersionInfo.h" #include "common/Constants.h" #include "common/ExitCodes.h" +#include "common/I18N.h" #include "common/UrlConstants.h" #include "gui/Diagnostic.h" #include "gui/DotEnv.h" diff --git a/src/lib/common/CMakeLists.txt b/src/lib/common/CMakeLists.txt index f39d067c3..c03363823 100644 --- a/src/lib/common/CMakeLists.txt +++ b/src/lib/common/CMakeLists.txt @@ -14,6 +14,8 @@ unset(SERVER_BINARY) add_library(common STATIC Common.h ExitCodes.h + I18N.h + I18N.cpp Settings.h Settings.cpp QSettingsProxy.cpp diff --git a/src/lib/common/I18N.cpp b/src/lib/common/I18N.cpp new file mode 100644 index 000000000..4fa276e9f --- /dev/null +++ b/src/lib/common/I18N.cpp @@ -0,0 +1,195 @@ +/* + * Deskflow -- mouse and keyboard sharing utility + * SPDX-FileCopyrightText: (C) 2025 Chris Rizzitello + * SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception + */ + +#include "I18N.h" + +#include "common/Constants.h" +#include "common/Settings.h" + +#include +#include +#include +#include +#include +#include + +I18N *I18N::instance() +{ + static I18N m; + return &m; +} + +I18N::I18N(QObject *parent) : QObject{parent} +{ + const QList appTrDirs{ + QDir(QStringLiteral("%1/%2").arg(QCoreApplication::applicationDirPath(), QStringLiteral("translations"))), + QDir(QStringLiteral("%1/../translations").arg(QCoreApplication::applicationDirPath())), + QDir(QStringLiteral("%1/../share/%2/translations").arg(QCoreApplication::applicationDirPath(), kAppId)), + QDir(QStringLiteral("%1/.local/share/%2/translations").arg(QDir::homePath(), kAppId)), + QDir(QStringLiteral("/usr/local/share/%1/translations").arg(kAppId)), + QDir(QStringLiteral("/usr/share/%1/translations").arg(kAppId)) + }; + const QStringList appTrFilter{QStringLiteral("%1*.qm").arg(kAppId)}; + + for (const auto &dir : appTrDirs) { + if (!dir.entryList(appTrFilter, QDir::Files, QDir::Name).isEmpty()) { + m_appTrPath = dir.absolutePath(); + break; + } + } + + if (m_appTrPath.isEmpty()) { + qInfo() << "no app translations found"; + } + + const QList qtTrDirs{ + QDir(QStringLiteral("%1/%2").arg(QCoreApplication::applicationDirPath(), QStringLiteral("translations"))), + QDir(QStringLiteral("%1/../qt-depends/translations").arg(QCoreApplication::applicationDirPath())), + QDir(QStringLiteral("%1/../share/qt/translations").arg(QCoreApplication::applicationDirPath())), + QDir(QStringLiteral("%1/.local/share/%2/translations").arg(QDir::homePath(), QStringLiteral("qt"))), + QDir(QStringLiteral("/usr/local/share/qt/translations")), + QDir(QStringLiteral("/usr/share/qt/translations")) + }; + const QStringList qtTrFilter{QStringLiteral("qt_*.qm")}; + + for (const auto &dir : qtTrDirs) { + if (!dir.entryList(qtTrFilter, QDir::Files, QDir::Name).isEmpty()) { + m_qtTrPath = dir.absolutePath(); + break; + } + } + + if (m_qtTrPath.isEmpty()) { + qInfo() << "no qt translations found"; + } + + detectLanguages(); + + if (Settings::value(Settings::Core::Language).isNull()) { + auto appTranslator = new QTranslator(this); + if (appTranslator->load(QLocale(), kAppId, "_", m_appTrPath)) { + m_currentTranslations.append(appTranslator); + QCoreApplication::installTranslator(appTranslator); + } + + m_currentLang = appTranslator->translate("i18n", "LocalizedName"); + if (m_currentLang.isEmpty()) + m_currentLang = QStringLiteral("English"); + + auto qtTranslator = new QTranslator(this); + if (qtTranslator->load(QLocale(), QStringLiteral("qt"), "_", m_qtTrPath)) { + m_currentTranslations.append(qtTranslator); + QCoreApplication::installTranslator(qtTranslator); + } + } else { + m_currentLang = Settings::value(Settings::Core::Language).toString(); + const auto translations = m_translations.value(m_currentLang); + for (const auto &translation : translations) { + auto translator = new QTranslator(this); + if (translator->load(translation)) { + m_currentTranslations.append(translator); + QCoreApplication::installTranslator(translator); + } + } + } +} + +QStringList I18N::detectedLanguages() +{ + return instance()->m_translations.keys(); +} + +QString I18N::currentLanguage() +{ + return instance()->m_currentLang; +} + +void I18N::setLanguage(const QString &langName) +{ + if (langName == instance()->m_currentLang) { + return; + } + + if (!instance()->m_translations.contains(langName)) { + return; + } + + instance()->m_currentLang = langName; + Settings::setValue(Settings::Core::Language, langName); + + for (const auto &translation : std::as_const(instance()->m_currentTranslations)) + QCoreApplication::removeTranslator(translation); + + qDeleteAll(instance()->m_currentTranslations); + instance()->m_currentTranslations.clear(); + + const auto translations = instance()->m_translations.value(langName); + for (const auto &translation : translations) { + auto translator = new QTranslator(instance()); + if (translator->load(translation)) { + instance()->m_currentTranslations.append(translator); + QCoreApplication::installTranslator(translator); + } + } + + Q_EMIT instance()->languageChanged(langName); +} + +void I18N::reDetectLanguages() +{ + instance()->detectLanguages(); +} + +void I18N::detectLanguages() +{ + const auto oldList = m_translations; + m_translations.clear(); + + QStringList nameFilter = {QStringLiteral("%1_*.qm").arg(kAppId)}; + QMap appTranslations; + QMap shortToNative; + QStringList detectedLangCodes; + QDir dir(m_appTrPath); + QStringList langList = dir.entryList(nameFilter, QDir::Files, QDir::Name); + + for (const QString &translation : std::as_const(langList)) { + QTranslator translator; + std::ignore = translator.load(translation, dir.absolutePath()); + const auto longCode = translator.language(); + //: Replace with your Language name + //: This is a required string + QString nativeLang = translator.translate("i18n", "LocalizedName"); + if (nativeLang.isEmpty()) + nativeLang = QStringLiteral("English"); + + QString shortCode; + if (longCode.startsWith(QStringLiteral("zh")) || longCode.startsWith(QStringLiteral("pt"))) + shortCode = longCode; + else + shortCode = longCode.mid(0, 2); + + appTranslations.insert(shortCode, translator.filePath()); + shortToNative.insert(shortCode, nativeLang); + detectedLangCodes.append(QStringLiteral("qt_%1.qm").arg(shortCode)); + } + + dir.setPath(m_qtTrPath); + const static auto qtTrNameLen = 3; // length of qt_ + langList = dir.entryList(detectedLangCodes, QDir::Files, QDir::Name); + + QMap qtTranslations; + for (const QString &translation : std::as_const(langList)) { + QString lang = translation.mid(qtTrNameLen, translation.lastIndexOf('.') - qtTrNameLen); + qtTranslations.insert(lang, QStringLiteral("%1/%2").arg(m_qtTrPath, translation)); + } + + const QStringList keys = appTranslations.keys(); + for (const QString &lang : keys) + m_translations.insert(shortToNative.value(lang), {appTranslations.value(lang), qtTranslations.value(lang)}); + + if (oldList != m_translations) + Q_EMIT langaugesChanged(m_translations.keys()); +} diff --git a/src/lib/common/I18N.h b/src/lib/common/I18N.h new file mode 100644 index 000000000..7e6f9445f --- /dev/null +++ b/src/lib/common/I18N.h @@ -0,0 +1,70 @@ +/* + * Deskflow -- mouse and keyboard sharing utility + * SPDX-FileCopyrightText: (C) 2025 Chris Rizzitello + * SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception + */ + +#pragma once + +#include +#include + +class QTranslator; +/** + * @brief The I18N singleton class handles detection and loading of translation files + */ +class I18N : public QObject +{ + Q_OBJECT + +public: + static I18N *instance(); + /** + * @brief detectedLanguages + * @return List of detected languages (native names: English, Español etc..) + */ + static QStringList detectedLanguages(); + + /** + * @brief currentLanguage + * @return The current language string (native name: English, Español etc..) + */ + static QString currentLanguage(); + + /** + * @brief setLanguage Sets the current language + * @param langName The language name must be an is 639-1 name + */ + static void setLanguage(const QString &langName); + + /** + * @brief detectLanguages Detect new language files + */ + static void reDetectLanguages(); + +Q_SIGNALS: + /** + * @brief languageChanged Emitted when the current language changes + * @param language The current language (native name, i.e English, Español) + */ + void languageChanged(const QString language); + /** + * @brief langaugesChanged Emitted when the detected languages changes + * @param languages The current list of languages (native names i.e English, Español..) + */ + void langaugesChanged(const QStringList languages); + +private: + explicit I18N(QObject *parent = nullptr); + + I18N *operator=(I18N &other) = delete; + I18N(const I18N &other) = delete; + ~I18N() override = default; + void detectLanguages(); + + QMap m_translations; + QList m_currentTranslations; + QString m_currentLang = QStringLiteral("English"); + QString m_appTrPath; + QString m_qtTrPath; +}; diff --git a/src/lib/common/Settings.h b/src/lib/common/Settings.h index d24644b04..236bdf675 100644 --- a/src/lib/common/Settings.h +++ b/src/lib/common/Settings.h @@ -54,6 +54,7 @@ public: inline static const auto Display = QStringLiteral("core/display"); inline static const auto RestartOnFailure = QStringLiteral("core/restartOnFailure"); inline static const auto UseHooks = QStringLiteral("core/useHooks"); + inline static const auto Language = QStringLiteral("core/language"); }; struct Daemon { @@ -179,6 +180,7 @@ private: , Settings::Core::Display , Settings::Core::RestartOnFailure , Settings::Core::UseHooks + , Settings::Core::Language , Settings::Daemon::Command , Settings::Daemon::Elevate , Settings::Daemon::LogFile diff --git a/translations/CMakeLists.txt b/translations/CMakeLists.txt new file mode 100644 index 000000000..078d69878 --- /dev/null +++ b/translations/CMakeLists.txt @@ -0,0 +1,50 @@ +# SPDX-FileCopyrightText: Chris Rizzitello +# SPDX-License-Identifier: MIT +set_directory_properties(PROPERTIES CLEAN_NO_CUSTOM 1) +option(CLEAN_TRS "Clean obsolete translations from tr files" OFF) +find_package(Qt6 ${REQUIRED_QT_VERSION} REQUIRED NO_MODULE COMPONENTS LinguistTools) + +# To add a new language Add 639 shortname +set (${CMAKE_PROJECT_NAME}_TRS + ${CMAKE_PROJECT_NAME}_es.ts +) + +set(TR_OPTIONS -no-ui-lines -locations none -silent) +if(CLEAN_TRS) + list(APPEND TR_OPTIONS -no-obsolete) +endif() + +# English will have only plurals +qt_create_translation(TRS ${CMAKE_SOURCE_DIR}/src ${CMAKE_PROJECT_NAME}_en.ts OPTIONS -pluralonly ${TR_OPTIONS}) + +# Other languages contain the full set of strings. +qt_create_translation(TRS ${CMAKE_SOURCE_DIR}/src ${${CMAKE_PROJECT_NAME}_TRS} OPTIONS ${TR_OPTIONS}) + +#ensure that the targets are built always +add_custom_target(app_translations ALL DEPENDS ${TRS}) + +if(UNIX AND NOT APPLE) + install(FILES ${TRS} DESTINATION share/${CMAKE_PROJECT_NAME}/translations) +elseif(WIN32) + install(FILES ${TRS} DESTINATION translations) +elseif(APPLE) + #install our translations + set(MAC_LANG_PATH ${CMAKE_PROJECT_PROPER_NAME}.app/Contents/MacOS/translations) + install(FILES ${TRS} DESTINATION ${MAC_LANG_PATH}) + + # find the qt translation files not deployed by macdeployqt + get_target_property(lupdate_executable Qt6::lupdate IMPORTED_LOCATION) + get_filename_component(_qt_bin_dir "${lupdate_executable}" DIRECTORY) + file(REAL_PATH "${_qt_bin_dir}/../" QT_ROOT_DIR) + find_file(_QT_QM_FILE NAMES qtbase_en.qm PATHS ${QT_ROOT_DIR} PATH_SUFFIXES "translations" "share/qt/translations" REQUIRED) + get_filename_component(MAC_QT_LANG_PATH "${_QT_QM_FILE}" DIRECTORY) + + # install each lang file and rename to the same name + # rename does not work with a list so foreach is used instead + install(FILES ${MAC_QT_LANG_PATH}/qtbase_en.qm DESTINATION ${MAC_LANG_PATH} RENAME qt_en.qm) + foreach(LANG ${${CMAKE_PROJECT_NAME}_TRS}) + string(REPLACE "${CMAKE_PROJECT_NAME}_" "" LANG ${LANG}) + string(REPLACE ".ts" "" LANG ${LANG}) + install(FILES ${MAC_QT_LANG_PATH}/qtbase_${LANG}.qm DESTINATION ${MAC_LANG_PATH} RENAME qt_${LANG}.qm) + endforeach() +endif() diff --git a/translations/deskflow_en.ts b/translations/deskflow_en.ts new file mode 100644 index 000000000..e5c6db238 --- /dev/null +++ b/translations/deskflow_en.ts @@ -0,0 +1,15 @@ + + + + + MainWindow + + %1 is connected, with %n client(s): %2 + Shown when in server mode and at least 1 client is connected %1 is replaced by the app name %2 will be a list of at least one client %n will be replaced by the number of clients (n is >=1), it is not requried to be in the translation + + %1 is connected, with a client: %2 + %1 is connected, with %n clients: %2 + + + +