From 009682b46287cfeaa1e586de8c95b6343f1c2f5b Mon Sep 17 00:00:00 2001 From: meator Date: Sat, 22 Aug 2026 15:59:15 +0200 Subject: [PATCH 1/5] Move findQmlImportScanner to util --- src/qml.cpp | 23 ----------------------- src/qml.h | 2 -- src/util.cpp | 25 +++++++++++++++++++++++++ src/util.h | 2 ++ 4 files changed, 27 insertions(+), 25 deletions(-) diff --git a/src/qml.cpp b/src/qml.cpp index e81e568..85e7d54 100644 --- a/src/qml.cpp +++ b/src/qml.cpp @@ -25,29 +25,6 @@ using namespace nlohmann; namespace fs = std::filesystem; -fs::path findQmlImportScanner() { - // Calling plain which("qmlimportscanner") is problematic, because it - // is symlinked to qtchooser on some distros. qtchooser's Qt6 support - // is less than ideal, qmlimportscanner used to be in - // /usr/lib/qt5/bin/qmlimportscanner, but it was moved to - // /usr/lib/qt6/libexec/qmlimportscanner in Qt6. qtchooser is capable - // of checking only a single directory for executables at a time, - // and it usually checks the bin/ one, so qmlimportscanner cannot - // be executed on Qt6 (if you are flabbergasted by this, remember that - // current latest release of qtchooser, 66_3, doesn't even include a - // qt6 config lookup file). - // Either way, QT_INSTALL_LIBEXECS/QT_INSTALL_BINS lookup is the more - // robust solution. - auto qmakeVars = queryQmake(findQmake()); - auto path = which(qmakeVars["QT_INSTALL_LIBEXECS"] + "/qmlimportscanner"); - if (path.empty()) - path = which(qmakeVars["QT_INSTALL_BINS"] + "/qmlimportscanner"); - if (path.empty()) - path = which("qmlimportscanner"); - - return path; -} - std::string runQmlImportScanner(const std::vector &sourcesPaths, const std::vector &qmlImportPaths) { auto qmlImportScannerPath = findQmlImportScanner(); diff --git a/src/qml.h b/src/qml.h index aa0fb9a..c5a583f 100644 --- a/src/qml.h +++ b/src/qml.h @@ -22,8 +22,6 @@ struct QmlImportScannerError : public std::runtime_error { // deploys QML files into AppDir void deployQml(linuxdeploy::core::appdir::AppDir &appDir, const std::filesystem::path &installQmlPath); -std::filesystem::path findQmlImportScanner(); - std::string runQmlImportScanner(const std::vector &sourcesPaths, const std::vector& qmlImportPaths); std::filesystem::path getQmlModuleRelativePath(const std::vector& qmlModulesImportPaths, diff --git a/src/util.cpp b/src/util.cpp index 6dd662b..4f5cf35 100644 --- a/src/util.cpp +++ b/src/util.cpp @@ -77,6 +77,31 @@ std::filesystem::path findQmake() { return qmakePath; } +std::filesystem::path findQmlImportScanner() { + using linuxdeploy::util::which; + + // Calling plain which("qmlimportscanner") is problematic, because it + // is symlinked to qtchooser on some distros. qtchooser's Qt6 support + // is less than ideal, qmlimportscanner used to be in + // /usr/lib/qt5/bin/qmlimportscanner, but it was moved to + // /usr/lib/qt6/libexec/qmlimportscanner in Qt6. qtchooser is capable + // of checking only a single directory for executables at a time, + // and it usually checks the bin/ one, so qmlimportscanner cannot + // be executed on Qt6 (if you are flabbergasted by this, remember that + // current latest release of qtchooser, 66_3, doesn't even include a + // qt6 config lookup file). + // Either way, QT_INSTALL_LIBEXECS/QT_INSTALL_BINS lookup is the more + // robust solution. + auto qmakeVars = queryQmake(findQmake()); + auto path = which(qmakeVars["QT_INSTALL_LIBEXECS"] + "/qmlimportscanner"); + if (path.empty()) + path = which(qmakeVars["QT_INSTALL_BINS"] + "/qmlimportscanner"); + if (path.empty()) + path = which("qmlimportscanner"); + + return path; +} + bool pathContainsFile(std::filesystem::path dir, std::filesystem::path file) { // If dir ends with "/" and isn't the root directory, then the final // component returned by iterators will include "." and will interfere diff --git a/src/util.h b/src/util.h index f384662..63f217a 100644 --- a/src/util.h +++ b/src/util.h @@ -39,6 +39,8 @@ std::map queryQmake(const std::filesystem::path& qmake std::filesystem::path findQmake(); +std::filesystem::path findQmlImportScanner(); + bool pathContainsFile(std::filesystem::path dir, std::filesystem::path file); std::string join(const std::vector &list); From 0b707ac6772a5d4b17eda63adaa4fcd274001ecf Mon Sep 17 00:00:00 2001 From: meator Date: Fri, 21 Aug 2026 22:00:48 +0200 Subject: [PATCH 2/5] Minor refactoring of deployTranslations --- src/deployment.h | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/deployment.h b/src/deployment.h index 5bac39a..092f339 100644 --- a/src/deployment.h +++ b/src/deployment.h @@ -124,17 +124,18 @@ deployTranslations(appdir::AppDir &appDir, const fs::path &qtTranslationsPath, c ldLog() << "Qt translations directory:" << qtTranslationsPath << std::endl; - auto checkName = [&appDir, &modules](const fs::path &fileName) { - if (!strEndsWith(fileName.string(), ".qm")) + auto checkName = [&appDir, &modules](const fs::path &fileNamePath) { + std::string fileName = fileNamePath.filename().string(); + if (!strEndsWith(fileName, ".qm")) return false; // always deploy basic Qt translations - if (strStartsWith(fileName.string(), "qt_") && fileName.filename().string().size() >= 5 && - fileName.filename().string().size() <= 6) + if (strStartsWith(fileName, "qt_") && fileName.size() >= 5 && + fileName.size() <= 6) return true; for (const auto &module : modules) { - if (!module.translationFilePrefix.empty() && strStartsWith(fileName.string(), module.translationFilePrefix)) + if (!module.translationFilePrefix.empty() && strStartsWith(fileName, module.translationFilePrefix)) return true; } @@ -145,9 +146,7 @@ deployTranslations(appdir::AppDir &appDir, const fs::path &qtTranslationsPath, c if (!fs::is_regular_file(*i)) continue; - const auto fileName = (*i).path().filename(); - - if (checkName(fileName)) + if (checkName(i->path())) appDir.deployFile(*i, appDir.path() / "usr/translations/"); } From 6d063f2d0336e2b00871216267baead622c10093 Mon Sep 17 00:00:00 2001 From: meator Date: Sat, 22 Aug 2026 22:05:30 +0200 Subject: [PATCH 3/5] Rework translation deploying Removed old broken qt_*.qm handling, added a smarter new one utilizing lconvert when needed and made everything configurable. --- src/CMakeLists.txt | 2 +- src/deployment.h | 49 ------ src/main.cpp | 129 +++++++++++++- src/translation-deploymant.cpp | 309 +++++++++++++++++++++++++++++++++ src/translation-deploymant.h | 27 +++ src/util.cpp | 125 +++++++++++++ src/util.h | 26 +++ tests/CMakeLists.txt | 2 +- tests/test_util.cpp | 15 ++ 9 files changed, 630 insertions(+), 54 deletions(-) create mode 100644 src/translation-deploymant.cpp create mode 100644 src/translation-deploymant.h create mode 100644 tests/test_util.cpp diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 3ff6634..0a0a0ec 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -31,7 +31,7 @@ add_library(linuxdeploy-plugin-qt_util STATIC util.cpp util.h) target_include_directories(linuxdeploy-plugin-qt_util PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(linuxdeploy-plugin-qt_util linuxdeploy_core args Threads::Threads) -add_executable(linuxdeploy-plugin-qt main.cpp qt-modules.h qml.cpp qml.h deployment.h) +add_executable(linuxdeploy-plugin-qt main.cpp qt-modules.h qml.cpp qml.h deployment.h translation-deploymant.cpp translation-deploymant.h) target_link_libraries(linuxdeploy-plugin-qt linuxdeploy_core args nlohmann_json::nlohmann_json linuxdeploy-plugin-qt_util Threads::Threads) set_target_properties(linuxdeploy-plugin-qt PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}/bin") target_compile_definitions(linuxdeploy-plugin-qt diff --git a/src/deployment.h b/src/deployment.h index 092f339..ea34dd1 100644 --- a/src/deployment.h +++ b/src/deployment.h @@ -114,52 +114,3 @@ inline bool createAppRunHook(appdir::AppDir &appDir) { return true; } - -inline bool -deployTranslations(appdir::AppDir &appDir, const fs::path &qtTranslationsPath, const std::vector &modules) { - if (qtTranslationsPath.empty() || !fs::is_directory(qtTranslationsPath)) { - ldLog() << LD_WARNING << "Translation directory does not exist, skipping deployment"; - return true; - } - - ldLog() << "Qt translations directory:" << qtTranslationsPath << std::endl; - - auto checkName = [&appDir, &modules](const fs::path &fileNamePath) { - std::string fileName = fileNamePath.filename().string(); - if (!strEndsWith(fileName, ".qm")) - return false; - - // always deploy basic Qt translations - if (strStartsWith(fileName, "qt_") && fileName.size() >= 5 && - fileName.size() <= 6) - return true; - - for (const auto &module : modules) { - if (!module.translationFilePrefix.empty() && strStartsWith(fileName, module.translationFilePrefix)) - return true; - } - - return false; - }; - - for (fs::directory_iterator i(qtTranslationsPath); i != fs::directory_iterator(); ++i) { - if (!fs::is_regular_file(*i)) - continue; - - if (checkName(i->path())) - appDir.deployFile(*i, appDir.path() / "usr/translations/"); - } - - const auto& appDirTranslationsPath = appDir.path() / "usr/translations"; - for (auto& i: fs::recursive_directory_iterator(appDir.path())) { - if (!fs::is_regular_file(i) || pathContainsFile(appDirTranslationsPath, i)) - continue; - - const auto fileName = i.path().filename(); - - if (strEndsWith(fileName.string(), ".qm")) - appDir.createRelativeSymlink(i, appDir.path() / "usr/translations" / fileName); - } - - return true; -} diff --git a/src/main.cpp b/src/main.cpp index db89e05..694a2b0 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -16,6 +16,7 @@ #include "qt-modules.h" #include "util.h" #include "deployment.h" +#include "translation-deploymant.h" #include "deployers/PluginsDeployerFactory.h" namespace fs = std::filesystem; @@ -26,6 +27,67 @@ using namespace linuxdeploy::log; using namespace linuxdeploy::plugin::qt; +// These classes are a hack to be able to get --feature/--no-feature flags +// where latter flags override former ones. +class TrueToggleFlag : public args::Flag { + private: + bool &value; + + public: + TrueToggleFlag(args::Group &group, const std::string &name, + const std::string &help, args::Matcher &&matcher, + bool &value) + : args::Flag(group, name, help, std::move(matcher)), + value(value) {} + + virtual void ParseValue(const std::vector &v) override { + args::Flag::ParseValue(v); // keeps Matched()/Get() bookkeeping intact + value = true; + } +}; + +class FalseToggleFlag : public args::Flag { + private: + bool &value; + + public: + FalseToggleFlag(args::Group &group, const std::string &name, + const std::string &help, args::Matcher &&matcher, + bool &value) + : args::Flag(group, name, help, std::move(matcher)), + value(value) {} + + virtual void ParseValue(const std::vector &v) override { + args::Flag::ParseValue(v); // keeps Matched()/Get() bookkeeping intact + value = false; + } +}; + + +class CustomArgumentParseError : public std::runtime_error { + using std::runtime_error::runtime_error; +}; + + +static bool yesNoArg(const char *envVar, std::string_view value) { + std::string lowercase; + lowercase.reserve(value.size()); + + std::transform(value.begin(), value.end(), std::back_inserter(lowercase), + [](unsigned char c){ return std::tolower(c); } + ); + + if (lowercase == "yes" || lowercase == "y" || lowercase == "on" || + lowercase == "1" || lowercase == "true") + return true; + if (lowercase == "no" || lowercase == "n" || lowercase == "off" || + lowercase == "0" || lowercase == "false") + return false; + + throw CustomArgumentParseError("Unknown value for env variable \"" + + std::string(value) + "!"); +} + int main(const int argc, const char *const *const argv) { // set up verbose logging if $DEBUG is set if (getenv("DEBUG")) @@ -44,6 +106,23 @@ int main(const int argc, const char *const *const argv) { "Extra Qt module to deploy (specified by name, filename or path)", {'m', "extra-module"}); + bool individualTranslations = true; + bool appTranslations = true; + bool mergedTranslations = false; + + TrueToggleFlag yesIndividualTranslations(parser, "", "Enable individual translations", + {"individual-translations"}, individualTranslations); + FalseToggleFlag noIndividualTranslations(parser, "", "Disable individual translations", + {"no-individual-translations"}, individualTranslations); + TrueToggleFlag yesAppTranslations(parser, "", "Enable symlinking app translations to standard directory", + {"app-symlink-translations"}, appTranslations); + FalseToggleFlag noAppTranslations(parser, "", "Disable symlinking app translations to standard directory", + {"no-app-symlink-translations"}, appTranslations); + TrueToggleFlag yesMergedTranslations(parser, "", "Enable producing of merged qt_*.qm translation files", + {"merged-translations"}, mergedTranslations); + FalseToggleFlag noMergedTranslations(parser, "", "Disable producing of merged qt_*.qm translation files", + {"no-merged-translations"}, mergedTranslations); + args::Flag pluginType(parser, "", "Print plugin type and exit", {"plugin-type"}); args::Flag pluginApiVersion(parser, "", "Print plugin API version and exit", {"plugin-api-version"}); @@ -290,12 +369,56 @@ int main(const int argc, const char *const *const argv) { return 1; } - ldLog() << std::endl << "-- Deploying translations --" << std::endl; - if (!deployTranslations(appDir, qtTranslationsPath, qtModulesToDeploy)) { - ldLog() << LD_ERROR << "Failed to deploy translations" << std::endl; + // deployTranslations() might need a temporary directory. It is placed here + // to make sure it lives long enough, because files from it will be deployed. + TempDir lconvertTemporaryDirectory; + + try { + if (!yesIndividualTranslations && !noIndividualTranslations) { + const char *individualTranslationsEnv = getenv("TRANSLATIONS_INDIVIDUAL"); + if (individualTranslationsEnv != nullptr) { + individualTranslations = yesNoArg("TRANSLATIONS_INDIVIDUAL", + individualTranslationsEnv); + } + } + if (!yesMergedTranslations && !noMergedTranslations) { + const char *mergedTranslationsEnv = getenv("TRANSLATIONS_MERGED"); + if (mergedTranslationsEnv != nullptr) { + mergedTranslations = yesNoArg("TRANSLATIONS_MERGED", mergedTranslationsEnv); + } + } + if (!yesAppTranslations && !noAppTranslations) { + const char *appTranslationsEnv = getenv("TRANSLATIONS_SYMLINK_APP"); + if (appTranslationsEnv != nullptr) { + mergedTranslations = yesNoArg("TRANSLATIONS_SYMLINK_APP", appTranslationsEnv); + } + } + } catch (const CustomArgumentParseError & exc) { + std::cerr << exc.what() << std::endl; return 1; } + TranslationDeploymentType translationDeploymentType = 0; + if (individualTranslations) + translationDeploymentType |= TranslationDeployment::individual; + if (appTranslations) + translationDeploymentType |= TranslationDeployment::user_symlink; + if (mergedTranslations) + translationDeploymentType |= TranslationDeployment::merged; + + if (translationDeploymentType == 0) { + ldLog() << std::endl << "-- Skipping translation deployment on user request --" << std::endl; + } else { + ldLog() << std::endl << "-- Deploying translations --" << std::endl; + if (!deployTranslations(appDir, qtTranslationsPath, qtModulesToDeploy, + translationDeploymentType, languages, + lconvertTemporaryDirectory)) + { + ldLog() << LD_ERROR << "Failed to deploy translations" << std::endl; + return 1; + } + } + ldLog() << std::endl << "-- Executing deferred operations --" << std::endl; if (!appDir.executeDeferredOperations()) { ldLog() << LD_ERROR << "Failed to execute deferred operations" << std::endl; diff --git a/src/translation-deploymant.cpp b/src/translation-deploymant.cpp new file mode 100644 index 0000000..00bccb9 --- /dev/null +++ b/src/translation-deploymant.cpp @@ -0,0 +1,309 @@ +// system headers +#include +#include + +// library includes +#include +#include +#include + +// local includes +#include "translation-deploymant.h" +#include "util.h" + +struct TranslationInfo { + linuxdeploy::core::appdir::AppDir *appDir; + // Something like /usr/share/qt6/translations + std::filesystem::path qtTranslationsPath; + // Dest path in appDir + std::filesystem::path appDirTranslationsPath; + TranslationDeploymentType deploymentType; + // Something like qtbase, qtmultimedia, ... + std::vector knownQmPrefixes; + TempDir * tempDir; + + static std::vector + getKnownQmPrefixes(const std::vector & modules) { + std::unordered_set knownPrefixes; + + for (const QtModule &module : modules) { + if (module.translationFilePrefix.empty()) + continue; + knownPrefixes.insert(module.translationFilePrefix); + } + + return std::vector(knownPrefixes.begin(), knownPrefixes.end()); + } +}; + +struct QmFileInfo { + std::string libName; + std::string language; + + bool isValid() const { + return !libName.empty() && !language.empty(); + } +}; + +struct TranslationData { + // All library translations encountered so far (like qtbase, qtmultimedia...). + // Needed when merging .qm into qt_??.qm. + std::unordered_set usedTranslatedLibs; + // Needed when merging .qm into qt_??.qm. + std::unordered_map< + std::string, /* language (like cs, pt_BR...) */ + std::vector /* qm file paths (like qtbase_cs.qm, qtmultimedia_cs.qm) */ + > lang2TranslationMapping; +}; + +struct TranslationError : public std::exception { + using std::exception::exception; +}; + +static bool +isValidTranslationFile(const std::string &fileName) { + if (fileName.empty()) + return false; + if (!strEndsWith(fileName, ".qm")) + return false; + return true; +} + +static bool +isValidTranslationFile(const std::string &fileName, const std::string &prefix) { + if (!isValidTranslationFile(fileName)) + return false; + + if (!strStartsWith(fileName, prefix)) + return false; + // qtbase _ cs .qm + // |^^^^^ | |^ |^^ + // prefix.size() 1 | 3 + // min 2 (with extra specifier, like pt_BR 5) + if (fileName.size() < (prefix.size() + 1 + 2 + 3)) + return false; + if (fileName[prefix.size()] != '_') + return false; + return true; +} + +static QmFileInfo +getModuleTranslation(const std::string & fileName, const std::vector &knownQmPrefixes) { + QmFileInfo result; + for (const std::string &translationFilePrefix : knownQmPrefixes) { + if (translationFilePrefix.empty() || !strStartsWith(fileName, translationFilePrefix)) + continue; + result.libName = translationFilePrefix; + + // We assume filename is reasonable, since it was checked by + // isValidTranslationFile(). + + auto prefixUnderscoreLen = translationFilePrefix.size() + 1; + + // 3 = .qm + result.language = fileName.substr(prefixUnderscoreLen, + fileName.size() - prefixUnderscoreLen - 3); + + return result; + } + return result; +} + +static void +deployTranslationsQtWalkTrDir(const TranslationInfo &ti, TranslationData &translationData) { + namespace fs = std::filesystem; + for (fs::directory_iterator i(ti.qtTranslationsPath); i != fs::directory_iterator(); ++i) { + if (!fs::is_regular_file(*i)) + continue; + + std::string fileName = i->path().filename().string(); + + if (!isValidTranslationFile(fileName)) + continue; + + QmFileInfo moduleTranslation = + getModuleTranslation(fileName, ti.knownQmPrefixes); + + if (moduleTranslation.isValid()) { + translationData.usedTranslatedLibs.insert(moduleTranslation.libName); + + auto & lang2TranslationMapping = translationData.lang2TranslationMapping; + + auto langMapping = lang2TranslationMapping.find(moduleTranslation.language); + if (langMapping == lang2TranslationMapping.end()) { + lang2TranslationMapping.try_emplace( + moduleTranslation.language, + std::vector{i->path()} + ); + } else { + langMapping->second.push_back(i->path()); + } + if (ti.deploymentType & TranslationDeployment::individual) + ti.appDir->deployFile(*i, ti.appDirTranslationsPath); + } + } +} + +static void +handleLconvertError(int exitStatus, const std::string & errMsg, + const std::filesystem::path & lconvertExe, const std::vector & toMerge) +{ + using namespace linuxdeploy::log; + + std::vector files; + files.reserve(toMerge.size()); + + std::transform( + toMerge.cbegin(), toMerge.cend(), std::back_inserter(files), + [](const std::filesystem::path & path){ return path.string(); } + ); + + ldLog() << LD_ERROR << "Executing '" << lconvertExe << "' to merge translation files " + << join(files) << " has failed with exit status " << exitStatus << ": " << errMsg; +} + +static void +deployTranslationsQtRunLconvert(const TranslationInfo &ti, const TranslationData &translationData) { + using namespace linuxdeploy::log; + using namespace linuxdeploy::subprocess; + namespace fs = std::filesystem; + + fs::path lconvert = findLconvert(); + + if (lconvert.empty()) { + ldLog() << LD_ERROR << "Could not find 'lconvert' exe to compile qt_??.qm translations!"; + throw TranslationError(); + } + + ti.tempDir->create("linuxdeploy-plugin-qt-lconvert-merged-qm"); + + std::vector cmdline; + + for (const auto &[language, files] : translationData.lang2TranslationMapping) { + cmdline.clear(); + cmdline.push_back(lconvert.string()); + cmdline.push_back("-input-format"); + cmdline.push_back("qm"); + + for (const std::string &qmFile : files) { + cmdline.push_back("-input-file"); + cmdline.push_back(qmFile); + } + cmdline.push_back("-output-file"); + std::string outputFilename = "qt_" + language + ".qm"; + fs::path outputPath = ti.tempDir->path() / outputFilename; + cmdline.push_back(outputPath); + + ldLog() << LD_INFO << "Running lconvert:" << shellJoin(cmdline) << std::endl; + + auto result = subprocess(cmdline).run(); + + if (result.exit_code() != 0) { + handleLconvertError(result.exit_code(), result.stderr_string(), lconvert, files); + throw TranslationError(); + } + ti.appDir->deployFile(outputPath.string(), ti.appDirTranslationsPath); + } +} + +static void +deployTranslationsQt(const TranslationInfo &ti) { + using namespace linuxdeploy::log; + + TranslationData translationData; + + deployTranslationsQtWalkTrDir(ti, translationData); + + if (translationData.usedTranslatedLibs.size() == 0) { + ldLog() << LD_WARNING << "No translations found in " << ti.qtTranslationsPath + << ", skipping deployment"; + return; + } + + // TranslationDeployment::individual was already fulfilled above, if we do not + // need merged qt_??.qm files, we can end here. + if ((ti.deploymentType & TranslationDeployment::merged) == 0) + return; + + if (translationData.usedTranslatedLibs.size() == 1) { + // No need to merge .qm files when there's just one per language. + for (const auto &[language, translationFiles] : translationData.lang2TranslationMapping) { + assert(translationFiles.size() == 1); + std::string destFileName = "qt_" + language + ".qm"; + auto destPath = ti.appDirTranslationsPath / destFileName; + ti.appDir->deployFile(translationFiles.front().string(), destPath); + } + + return; + } + + // We need to lconvert multiple .qm files into single qt_??.qm file. + deployTranslationsQtRunLconvert(ti, translationData); +} + +static void +deployTranslationsApp(const TranslationInfo &ti) { + namespace fs = std::filesystem; + + bool checkTranslationsDirExistance = false; + + for (auto& i : fs::recursive_directory_iterator(ti.appDir->path())) { + if (!fs::is_regular_file(i) || pathContainsFile(ti.appDirTranslationsPath, i)) + continue; + + const auto fileName = i.path().filename(); + + if (strEndsWith(fileName.string(), ".qm")) { + if (!checkTranslationsDirExistance) { + if (!fs::is_directory(ti.appDirTranslationsPath)) { + // Symlink below fails if directory doesn't exist, which can very + // well happen, since the .qm file deployments are deferred. + fs::create_directories(ti.appDirTranslationsPath); + } + checkTranslationsDirExistance = true; + } + ti.appDir->createRelativeSymlink(i, ti.appDirTranslationsPath / fileName); + } + } +} + +bool +deployTranslations(linuxdeploy::core::appdir::AppDir &appDir, const std::filesystem::path &qtTranslationsPath, + const std::vector &modules, TranslationDeploymentType deploymentType, TempDir & tmpDir) +{ + using namespace linuxdeploy::log; + using namespace linuxdeploy::util::misc; + namespace fs = std::filesystem; + + if (qtTranslationsPath.empty() || !fs::is_directory(qtTranslationsPath)) { + ldLog() << LD_WARNING << "Translation directory does not exist, skipping deployment"; + return true; + } + + ldLog() << "Qt translations directory:" << qtTranslationsPath << std::endl; + + assert(deploymentType != 0); + + TranslationInfo translationInfo; + translationInfo.appDir = &appDir; + translationInfo.appDirTranslationsPath = appDir.path() / "usr/translations/"; + translationInfo.qtTranslationsPath = qtTranslationsPath; + translationInfo.deploymentType = deploymentType; + translationInfo.knownQmPrefixes = TranslationInfo::getKnownQmPrefixes(modules); + translationInfo.tempDir = &tmpDir; + + if (deploymentType & (TranslationDeployment::individual | TranslationDeployment::merged)) { + try { + deployTranslationsQt(translationInfo); + } + catch (const TranslationError &) { + return false; + } + } + + if (deploymentType & TranslationDeployment::user_symlink) { + deployTranslationsApp(translationInfo); + } + + return true; +} diff --git a/src/translation-deploymant.h b/src/translation-deploymant.h new file mode 100644 index 0000000..1892c83 --- /dev/null +++ b/src/translation-deploymant.h @@ -0,0 +1,27 @@ +#pragma once + +// library includes +#include + +// local includes +#include "qt-modules.h" +#include "util.h" + +namespace TranslationDeployment +{ + enum TranslationDeploymentEnum + { + // Copy over individual Qt library .qm files. + individual = 1 << 0, + // Copy/Concatenate a full Qt qt_??.qm file. + merged = 1 << 1, + // Add a symlink to user .qm files to Qt's translation dir. + user_symlink = 1 << 2, + }; +} +using TranslationDeploymentType = std::underlying_type_t; + +bool +deployTranslations(linuxdeploy::core::appdir::AppDir &appDir, const std::filesystem::path &qtTranslationsPath, + const std::vector &modules, TranslationDeploymentType deploymentType, TempDir & tmpDir +); diff --git a/src/util.cpp b/src/util.cpp index 4f5cf35..b7225d2 100644 --- a/src/util.cpp +++ b/src/util.cpp @@ -1,5 +1,6 @@ // system headers #include +#include // library headers #include @@ -11,6 +12,82 @@ using namespace linuxdeploy::subprocess; +constexpr auto tempDirCreationAttempts = 50; +constexpr std::string_view tempDirAlphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; +constexpr auto randomPartLength = 10; + +TempDir::TempDir(std::string_view name) { + create(name); +} + +void TempDir::create(std::string_view name) { + std::random_device rd; + std::mt19937 gen(rd()); + std::uniform_int_distribution<> distrib(0, tempDirAlphabet.size()); + + std::array randomPart; + + if (!tmpDirPath.empty()) { + throw std::runtime_error("Temporary directory exists already!"); + } + + namespace fs = std::filesystem; + for (unsigned int i = 0; i < tempDirCreationAttempts; ++i) { + for (int i = 0; i < randomPart.size(); ++i) + randomPart[i] = tempDirAlphabet[distrib(gen)]; + + std::string filename = std::string(name) + '-'; + filename.append(randomPart.data(), randomPart.size()); + + fs::path newTmpDirPath = fs::temp_directory_path() / filename; + + bool success = fs::create_directory(newTmpDirPath); + if (success) { + tmpDirPath = newTmpDirPath; + return; + } + } + + throw std::runtime_error("Couldn't create temporary directory for " + std::string(name) + '!'); +} + +TempDir::TempDir(TempDir && other) noexcept : tmpDirPath(other.tmpDirPath) { + other.tmpDirPath.clear(); +} + +TempDir &TempDir::operator=(TempDir && other) noexcept { + if (this == &other) + return *this; + tmpDirPath = std::move(other.tmpDirPath); + + other.tmpDirPath.clear(); + return *this; +} + +TempDir::~TempDir() { + using namespace linuxdeploy::log; + namespace fs = std::filesystem; + + if (tmpDirPath.empty()) + return; + try { + if (fs::remove_all(tmpDirPath) == 0) { + throw fs::filesystem_error( + "Tried to remove directory, but nothing was deleted (was the directory " + "deleted already?).", {} + ); + } + } + catch (const fs::filesystem_error & exc) { + ldLog() << LD_WARNING << "Couldn't delete temporary directory " << tmpDirPath + << ": " << exc.what() << std::endl; + } +} + +std::filesystem::path TempDir::path() const { + return tmpDirPath; +} + std::map queryQmake(const std::filesystem::path& qmakePath) { auto qmakeCall = subprocess({qmakePath.string(), "-query"}).run(); @@ -102,6 +179,18 @@ std::filesystem::path findQmlImportScanner() { return path; } +std::filesystem::path findLconvert() { + using linuxdeploy::util::which; + + // lconvert remained in bin/ dir even in Qt6 (it is not in libexec). + auto qmakeVars = queryQmake(findQmake()); + auto path = which(qmakeVars["QT_INSTALL_BINS"] + "/lconvert"); + if (path.empty()) + path = which("lconvert"); + + return path; +} + bool pathContainsFile(std::filesystem::path dir, std::filesystem::path file) { // If dir ends with "/" and isn't the root directory, then the final // component returned by iterators will include "." and will interfere @@ -132,6 +221,42 @@ std::string join(const std::set &list) { return join(list.begin(), list.end()); } +std::string shellJoin(const std::vector &arguments) { + const auto &npos = std::string::npos; + + auto containsUnsafeCharacters = [](const std::string & arg){ + return arg.find_first_of(" \t$`\"'\\\n!") != npos; + }; + + std::string result; + + bool first = true; + + for (const std::string & arg : arguments) { + if (!first) + result += ' '; + first = false; + + if (!containsUnsafeCharacters(arg)) { + result.append(arg); + continue; + } + + result += "'"; + + std::string::size_type start = 0, next; + while ((next = arg.find('\'', start)) != npos) { + result += arg.substr(start, next - start); + result += R"--('"'"')--"; + start = next + 1; + } + result += arg.substr(start); + result += "'"; + } + + return result; +} + bool strStartsWith(const std::string &str, const std::string &prefix) { if (str.size() < prefix.size()) return false; diff --git a/src/util.h b/src/util.h index 63f217a..5331684 100644 --- a/src/util.h +++ b/src/util.h @@ -1,6 +1,7 @@ #pragma once // system includes +#include #include #include #include @@ -18,6 +19,26 @@ typedef struct { std::string stderrOutput; } procOutput; +class TempDir { + public: + TempDir() = default; + TempDir(std::string_view name); + + void create(std::string_view name); + + TempDir(const TempDir &) = delete; + TempDir &operator=(const TempDir &) = delete; + + TempDir(TempDir &&) noexcept; + TempDir &operator=(TempDir &&) noexcept; + + ~TempDir(); + + std::filesystem::path path() const; + private: + std::filesystem::path tmpDirPath; +}; + procOutput check_command(const std::vector &args); template @@ -41,12 +62,17 @@ std::filesystem::path findQmake(); std::filesystem::path findQmlImportScanner(); +std::filesystem::path findLconvert(); + bool pathContainsFile(std::filesystem::path dir, std::filesystem::path file); std::string join(const std::vector &list); std::string join(const std::set &list); +// Join a command line, single quote arguments which might need it. +std::string shellJoin(const std::vector &arguments); + bool strStartsWith(const std::string &str, const std::string &prefix); bool strEndsWith(const std::string &str, const std::string &suffix); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8c02755..4a37735 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -16,7 +16,7 @@ if(NOT COMMAND ld_add_test) endfunction() endif() -add_executable(linuxdeploy-plugin-qt-tests test_main.cpp test_deploy_qml.cpp ../src/qml.cpp) +add_executable(linuxdeploy-plugin-qt-tests test_main.cpp test_deploy_qml.cpp test_util.cpp ../src/qml.cpp) target_link_libraries(linuxdeploy-plugin-qt-tests linuxdeploy_core args nlohmann_json::nlohmann_json gtest linuxdeploy-plugin-qt_util) target_compile_definitions(linuxdeploy-plugin-qt-tests PRIVATE TESTS_DATA_DIR="${CMAKE_CURRENT_SOURCE_DIR}/data") diff --git a/tests/test_util.cpp b/tests/test_util.cpp new file mode 100644 index 0000000..afbfaa3 --- /dev/null +++ b/tests/test_util.cpp @@ -0,0 +1,15 @@ +// library includes +#include + +// local includes +#include "../src/util.h" + +TEST(Util, ShellJoin) { + ASSERT_EQ(shellJoin({"test"}), "test"); + ASSERT_EQ(shellJoin({"test", "arg"}), "test arg"); + ASSERT_EQ(shellJoin({"test", "$arg"}), "test '$arg'"); + ASSERT_EQ(shellJoin({"test", "ar`"}), "test 'ar`'"); + ASSERT_EQ(shellJoin({"!"}), "'!'"); + + ASSERT_EQ(shellJoin({"test", "abc'def"}), R"--(test 'abc'"'"'def')--"); +} From 631c687455c60413c646b5fd8abb9b2301c1f0bb Mon Sep 17 00:00:00 2001 From: meator Date: Sat, 22 Aug 2026 22:06:53 +0200 Subject: [PATCH 4/5] Allow installing selected languages only --- src/main.cpp | 14 ++++++++++++++ src/translation-deploymant.cpp | 9 ++++++++- src/translation-deploymant.h | 3 ++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 694a2b0..e0af90a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -106,6 +106,9 @@ int main(const int argc, const char *const *const argv) { "Extra Qt module to deploy (specified by name, filename or path)", {'m', "extra-module"}); + args::ValueFlag qtLanguages(parser, "language list", + "Comma separated list of Qt languages to install (does not apply to " + ".qm files provided by program)", {"qt-languages"}); bool individualTranslations = true; bool appTranslations = true; bool mergedTranslations = false; @@ -406,6 +409,17 @@ int main(const int argc, const char *const *const argv) { if (mergedTranslations) translationDeploymentType |= TranslationDeployment::merged; + std::vector languages = split(qtLanguages.Get(), ','); + + if (qtLanguages) { + languages = split(qtLanguages.Get(), ','); + } else { + const char *languagesEnv = getenv("TRANSLATION_LANGUAGES"); + if (languagesEnv != nullptr) { + languages = split(languagesEnv, ','); + } + } + if (translationDeploymentType == 0) { ldLog() << std::endl << "-- Skipping translation deployment on user request --" << std::endl; } else { diff --git a/src/translation-deploymant.cpp b/src/translation-deploymant.cpp index 00bccb9..59bbf09 100644 --- a/src/translation-deploymant.cpp +++ b/src/translation-deploymant.cpp @@ -20,6 +20,8 @@ struct TranslationInfo { TranslationDeploymentType deploymentType; // Something like qtbase, qtmultimedia, ... std::vector knownQmPrefixes; + // Qt languages to install; install everything if empty + std::unordered_set languages; TempDir * tempDir; static std::vector @@ -125,6 +127,9 @@ deployTranslationsQtWalkTrDir(const TranslationInfo &ti, TranslationData &transl getModuleTranslation(fileName, ti.knownQmPrefixes); if (moduleTranslation.isValid()) { + if (!ti.languages.empty() && ti.languages.count(moduleTranslation.language)) + continue; + translationData.usedTranslatedLibs.insert(moduleTranslation.libName); auto & lang2TranslationMapping = translationData.lang2TranslationMapping; @@ -269,7 +274,8 @@ deployTranslationsApp(const TranslationInfo &ti) { bool deployTranslations(linuxdeploy::core::appdir::AppDir &appDir, const std::filesystem::path &qtTranslationsPath, - const std::vector &modules, TranslationDeploymentType deploymentType, TempDir & tmpDir) + const std::vector &modules, TranslationDeploymentType deploymentType, + const std::vector &languages, TempDir & tmpDir) { using namespace linuxdeploy::log; using namespace linuxdeploy::util::misc; @@ -290,6 +296,7 @@ deployTranslations(linuxdeploy::core::appdir::AppDir &appDir, const std::filesys translationInfo.qtTranslationsPath = qtTranslationsPath; translationInfo.deploymentType = deploymentType; translationInfo.knownQmPrefixes = TranslationInfo::getKnownQmPrefixes(modules); + translationInfo.languages.insert(languages.begin(), languages.end()); translationInfo.tempDir = &tmpDir; if (deploymentType & (TranslationDeployment::individual | TranslationDeployment::merged)) { diff --git a/src/translation-deploymant.h b/src/translation-deploymant.h index 1892c83..ed04e26 100644 --- a/src/translation-deploymant.h +++ b/src/translation-deploymant.h @@ -23,5 +23,6 @@ using TranslationDeploymentType = std::underlying_type_t &modules, TranslationDeploymentType deploymentType, TempDir & tmpDir + const std::vector &modules, TranslationDeploymentType deploymentType, + const std::vector &languages, TempDir & tmpDir ); From c824300fb1e1da6cdfea36e229c4c8a6c51b9329 Mon Sep 17 00:00:00 2001 From: meator Date: Sun, 23 Aug 2026 18:52:36 +0200 Subject: [PATCH 5/5] Document translation process --- README.md | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/README.md b/README.md index 7d1a67c..63b4423 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,94 @@ To use linuxdeploy-plugin-standalone, download the official AppImage, make it ex linuxdeploy-plugin-qt will look for Qt libraries in the library directory `usr/lib/` and deploy the Qt plugins and other resources for these. This means that if linuxdeploy or another tool haven't been run on the AppDir yet, i.e., no Qt libraries have been deployed yet, linuxdeploy-plugin-qt won't be able to recognize which plugins and resources have to be deployed, and will return an error. +### Translations +#### Qt Translations +Translation of Qt libraries (usually accessible at `/usr/share/qt{5,6}/translations/`) is split into the following categories: + +| Category | CLI enable | CLI disable | Env variable | +| ------------------------------- | --------------------------- | ------------------------------ | -------------------------------- | +| Individual library translations | `--individual-translations` | `--no-individual-translations` | `TRANSLATIONS_INDIVIDUAL=YES/NO` | +| Merged library translations | `--merged-translations` | `--no-merged-translations` | `TRANSLATIONS_MERGED=YES/NO` | + +Individual library translations copy over individual `.qm` files into standard translation directory (`/share/translations`, retrievable by calling `QLibraryInfo::path(QLibraryInfo::TranslationsPath)` from within program). For example, if the program is using Core and Multimedia modules, `qtbase_cs.qm`, `qtmultimedia_cs.qm`, `qtbase_de.qm`, `qtmultimedia_de.qm`... will get copied over. + +Merged library translations will produce a `qt_.qm` file into standard translation directory. This is consistent with for example how `windeployqt.exe` Qt official deployer deploys translations. + +By default, all available translations matching the Qt libraries used are deployed. The list of deployed languages can be restricted by supplying a comma separated list of language codes (codes matching filenames in `/usr/share/qt{5,6}/translations/`) with `--qt-languages` or `$TRANSLATION_LANGUAGES`. + +Note that `--qt-languages` and `$TRANSLATION_LANGUAGES` only affect Qt's own translations. Program provided translations are not affected. + +#### App Translations +App translation handling is program specific. For best results, make sure to configure the build system of the program to be deployed [as described in AppImage documentation](https://docs.appimage.org/packaging-guide/from-source/native-binaries.html#using-the-build-system-to-build-the-basic-appdir) when deploying from source. + +It is best to test translations before distributing the AppImage. This can be done by + +1. Making sure the locale to be tested is loaded on glibc Linux + + Here are some resources on the topic: [Arch Linux (Arch Wiki)](https://wiki.archlinux.org/title/Locale), [Debian](https://wiki.debian.org/Locale), [Alpine](https://wiki.alpinelinux.org/wiki/Locale), [Void Linux](https://docs.voidlinux.org/config/locales.html), [Gentoo](https://wiki.gentoo.org/wiki/Localization/Guide). +2. Override the `LC_MESSAGES` or `LANG` variable while executing the appimage from a terminal by either prepending `= ./myappimage.AppImage`: + + ``` + LC_MESSAGES=cs_CZ.UTF-8 ./myappimage-x86_64.AppImage + ``` + + or by issuing `export` before running the AppImage: + + ``` + export LC_MESSAGES=cs_CZ.UTF-8 + ./myappimage-x86_64.AppImage + ``` + +Make sure that the tested program does indeed provide translations for the overridden locale. + +linuxdeploy-plugin-qt provides a flag to add a symlink to program translations to `TranslationsPath` (to `/usr/translations`): + +| Category | CLI enable | CLI disable | Env variable | +| ------------------------------- | ---------------------------- | ------------------------------- | --------------------------------- | +| Symlink app translations | `--app-symlink-translations` | `--no-app-symlink-translations` | `TRANSLATIONS_SYMLINK_APP=YES/NO` | + +#### Recommendations +linuxdeploy-plugin-qt enables individual library translations and symlink app translations and disabled merged library translations by default for backwards compatibility. + +If the program was written with for example with `windeployqt.exe` in mind, merged library translations and symlink app translations should do the job. + +You can try enabling and disabling these flags to see which are required for the program being packaged to load translations. + +#### Recommendations to application developers +Load Qt translations with + +```cpp +translator.load("qt_" + language, QLibraryInfo::path(QLibraryInfo::TranslationsPath)); +``` + +or + +```cpp +translator.load(QLocale::system(), "qt", "_", QLibraryInfo::path(QLibraryInfo::TranslationsPath)); +``` + +This should work with linux distro packages, `windeployqt` deployed `.exe` files and with linuxdeploy-plugin-qt. + +For program translations, the easiest way of distributing translations in regard to deploying it (with linuxdeploy-plugin-qt or other tools) is to bundle them into the executable as a [Qt resource](https://doc.qt.io/qt-6/resources.html). The rest of this section concerns the more complicated solution, which is installing compiled translations alongside the executable. + +For program translations, you have the freedom of choosing translation directory, but be aware that the [recommended building process](https://docs.appimage.org/packaging-guide/from-source/native-binaries.html#using-the-build-system-to-build-the-basic-appdir) uses prefix of `/usr` and `DESTDIR` to install program files into AppDir. + +If you try to load translations from the directory your build system thinks it installs them into at configure time, it will try to load translations from host, not from the appimage. + +One solution is to load directories relative to `QLibraryInfo::path(QLibraryInfo::PrefixPath)` instead of `/usr` or build system prefix. See [standard paths](#standard-paths) for a list of standard paths recognized by Qt. + +Another solution is to load from path relative to `QCoreApplication::applicationDirPath()`. + +It is wise to try several directories for loading app translations. Some reasonable picks include: + +```cpp +// Good for windeployqt and macdeployqt +QLibraryInfo::path(QLibraryInfo::TranslationsPath) +// windeployqt-esque +QCoreApplication::applicationDirPath() + "/translations" +// Not Windows friendly, good in combination with some other dirs +QLibraryInfo::path(QLibraryInfo::PrefixPath) + "/share/" + QCoreApplication::applicationName() + "/translations" +``` ### Environment variables @@ -62,6 +150,34 @@ Just like all linuxdeploy plugins, the Qt plugin's behavior can be configured so - `$EXTRA_PLATFORM_PLUGINS=platformA;platformB`: Platforms to deploy in addition to `libqxcb.so`. Platform must be available from `QT_INSTALL_PLUGINS/platforms`. - To support Wayland, add `libqwayland-egl.so;libqwayland-generic.so` +**Translations:** +- `$TRANSLATIONS_INDIVIDUAL=YES/NO` +- `$TRANSLATIONS_MERGED=YES/NO` +- `$TRANSLATIONS_SYMLINK_APP=YES/NO` +- `$TRANSLATION_LANGUAGES=comma separated language list` + +See [translations](#translations) for an explanation of the env variables. + QML related: - `$QML_SOURCES_PATHS`: directory containing the application's QML files — useful/needed if QML files are "baked" into the binaries. linuxdeploy-plugin-qt will look for all imported QML modules and include them. `$QT_INSTALL_QML` is prepended to this list internally. - `$QML_MODULES_PATHS`: extra directories containing imported QML files (normally doesn't need to be specified). + +## Developer details +### Standard paths +Here are standard Qt lookup paths of appimage contents (same in Qt5 and Qt6): + +| Path type | Path | +| -----------------------: | ------------------------------------------ | +| `PrefixPath` | `/tmp/.mount_/usr` | +| `DocumentationPath` | `/tmp/.mount_/usr/doc` | +| `HeadersPath` | `/tmp/.mount_/usr/include` | +| `LibraryExecutablesPath` | `/tmp/.mount_/usr/libexec` | +| `BinariesPath` | `/tmp/.mount_/usr/bin` | +| `PluginsPath` | `/tmp/.mount_/usr/plugins` | +| `QmlImportsPath` | `/tmp/.mount_/usr/qml` | +| `ArchDataPath` | `/tmp/.mount_/usr` | +| `DataPath` | `/tmp/.mount_/usr` | +| `TranslationsPath` | `/tmp/.mount_/usr/translations` | +| `ExamplesPath` | `/tmp/.mount_/usr/examples` | +| `TestsPath` | `/tmp/.mount_/usr/tests` | +| `SettingsPath` | `/tmp/.mount_/usr` |