From 1f162ec83059a8a634af8f4a4ba5d5228571dabe Mon Sep 17 00:00:00 2001 From: DanS Date: Sun, 11 Oct 2026 06:05:07 -0500 Subject: [PATCH] build(i18n): embed language JSON only on Windows, add lookup fallbacks CMake turned every res/lang/*.json into an xxd header that i18n.cpp compiled in as a fallback, on every platform. Only the Windows single-file exe needs it: the Linux zip and AppImage and the macOS .app always ship res/lang, and loadLanguage() reads the loose file first. On Linux the embedded copy added about 1.5 MB to the binary, and every translation edit recompiled i18n.cpp. A new option, DRAGONX_EMBED_LANG, defaults to ON for WIN32 and OFF elsewhere: - ON without xxd is now a configure error (it used to warn, then fail to compile i18n.cpp on the missing headers); - i18n.cpp compiles the embedded includes and the fallback only when ON; - outside Windows, loadLanguage() also tries /../share//res/lang (the cmake --install layout and the full-node AppImage) and /../Resources/res/lang (the macOS .app binary run directly), so those layouts find the loose files whatever the working directory; before, only the embedded copy covered that. Measured: - full-node Linux binary: .data 1,699,696 -> 168,592 bytes; stripped 19,983,136 -> 18,456,128 bytes (-1.53 MB); - with the ImGui demo stubs, the Linux zips and AppImages are 0.50-0.55 MB smaller, with identical file lists including all 8 language files; - a Lite build started from an unrelated working directory loads the ja.json next to the binary (2422 strings), and the packaged Lite zip and AppImage load ja.json with no new not-found or failed lines; - Windows keeps the embedded copy: the 8 language headers are generated and the ja.json bytes are in the exe. A new test loads "de" from the loose files and checks a translated string; with bin/res/lang moved away exactly its two assertions fail. Behaviour changes on Linux and macOS: a missing or corrupt res/lang now falls back to English instead of the embedded copy, and a translation edit needs only the copy_langs step instead of a recompile. Not verifiable here: the macOS .app in a non-English language, and the Windows single-file exe at runtime. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 20 ++++++++++++++++---- src/util/i18n.cpp | 22 +++++++++++++++++----- tests/test_phase4.cpp | 13 +++++++++++++ 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index fac7884..666dab4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -46,6 +46,14 @@ option(DRAGONX_BUILD_LITE "Build ObsidianDragonLite variant without full-node fe option(DRAGONX_ENABLE_LITE_BACKEND "Enable real lite wallet backend integration" OFF) option(DRAGONX_ENABLE_CHAT "Enable the HushChat protocol/UI integration" ON) option(DRAGONX_BUILD_FAKE_DAEMON "Build the fake slow dragonxd test stub (tools/fake_dragonxd); never shipped" OFF) +# Embedded fallback copy of res/lang/*.json (xxd -i -> lang_*.h). Needed only by the Windows single-file exe (no res/ beside it); +# Linux zip/AppImage and the macOS .app always ship res/lang, which loadLanguage() reads first. +if(WIN32) + set(_DRAGONX_EMBED_LANG_DEFAULT ON) +else() + set(_DRAGONX_EMBED_LANG_DEFAULT OFF) +endif() +option(DRAGONX_EMBED_LANG "Embed res/lang/*.json in the binary as a fallback" ${_DRAGONX_EMBED_LANG_DEFAULT}) set(DRAGONX_LITE_BACKEND_LIBRARY "" CACHE FILEPATH "Path to a prebuilt SDXL-compatible lite backend library") set(DRAGONX_LITE_BACKEND_INCLUDE_DIR "" CACHE PATH "Optional include directory for SDXL-compatible lite backend headers") set(DRAGONX_LITE_BACKEND_EXTRA_LIBS "" CACHE STRING "Additional libraries needed by the SDXL-compatible lite backend") @@ -929,6 +937,7 @@ target_compile_definitions(ObsidianDragon PRIVATE DRAGONX_ENABLE_EMBEDDED_DAEMON=$ DRAGONX_ENABLE_LITE_BACKEND=$ DRAGONX_ENABLE_CHAT=$ + DRAGONX_EMBED_LANG=$ ) if(WIN32) target_compile_definitions(ObsidianDragon PRIVATE DRAGONX_USE_DX11) @@ -990,9 +999,11 @@ endif() # so edits to res/lang/*.json are picked up by 'make' without re-running cmake. file(GLOB LANG_FILES ${CMAKE_SOURCE_DIR}/res/lang/*.json) if(LANG_FILES) - find_program(XXD_EXECUTABLE NAMES xxd) - if(NOT XXD_EXECUTABLE) - message(WARNING "xxd not found; runtime language JSON files will be copied, but embedded build/generated/embedded/lang_*.h files will not be regenerated") + if(DRAGONX_EMBED_LANG) + find_program(XXD_EXECUTABLE NAMES xxd) + endif() + if(DRAGONX_EMBED_LANG AND NOT XXD_EXECUTABLE) + message(FATAL_ERROR "DRAGONX_EMBED_LANG is ON but xxd was not found; it generates build/generated/embedded/lang_*.h. Install xxd or configure with -DDRAGONX_EMBED_LANG=OFF") endif() file(MAKE_DIRECTORY ${CMAKE_BINARY_DIR}/generated/embedded) @@ -1009,7 +1020,7 @@ if(LANG_FILES) list(APPEND LANG_OUTPUTS ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/res/lang/${LANG_FILENAME}) # Also regenerate the embedded header so the binary always has fresh translations - if(XXD_EXECUTABLE) + if(DRAGONX_EMBED_LANG AND XXD_EXECUTABLE) get_filename_component(LANG_CODE ${LANG_FILENAME} NAME_WE) set(LANG_HEADER ${CMAKE_BINARY_DIR}/generated/embedded/lang_${LANG_CODE}.h) add_custom_command( @@ -1286,6 +1297,7 @@ if(BUILD_TESTING) DRAGONX_LITE_BUILD=$ DRAGONX_ENABLE_EMBEDDED_DAEMON=$ DRAGONX_ENABLE_LITE_BACKEND=$ + DRAGONX_EMBED_LANG=$ DRAGONX_TEST_FIXTURE_DIR="${CMAKE_SOURCE_DIR}/tests/fixtures" ) diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index b88d0b6..d55871f 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -9,9 +9,14 @@ #include #include #include +#include #include -// Embedded language files +#ifndef DRAGONX_EMBED_LANG +#define DRAGONX_EMBED_LANG 1 // legacy build paths keep the old behaviour +#endif +#if DRAGONX_EMBED_LANG +// Embedded language files (Windows single-file exe has no res/lang) #include "embedded/lang_es.h" #include "embedded/lang_zh.h" #include "embedded/lang_ru.h" @@ -20,6 +25,7 @@ #include "embedded/lang_pt.h" #include "embedded/lang_ja.h" #include "embedded/lang_ko.h" +#endif #include "../util/logger.h" namespace dragonx { @@ -109,10 +115,14 @@ bool I18n::loadLanguage(const std::string& locale) { // Try to load from file: CWD-relative first, then exe-relative std::string rel_path = "res/lang/" + locale + ".json"; - std::string exe_path = Platform::getExecutableDirectory() + "/" + rel_path; - - // Try both paths; prefer whichever opens successfully - for (const auto& lang_file : { rel_path, exe_path }) { + const std::string exe_dir = Platform::getExecutableDirectory(); + std::vector candidates = { rel_path, exe_dir + "/" + rel_path }; +#ifndef _WIN32 + // Layouts where res/ is not beside the binary, independent of the launcher's cd: + candidates.push_back(exe_dir + "/../share/" DRAGONX_APP_NAME "/" + rel_path); // cmake --install; full AppImage + candidates.push_back(exe_dir + "/../Resources/" + rel_path); // macOS .app (.bin run directly) +#endif + for (const auto& lang_file : candidates) { std::ifstream file(lang_file); if (!file.is_open()) continue; @@ -137,6 +147,7 @@ bool I18n::loadLanguage(const std::string& locale) } } +#if DRAGONX_EMBED_LANG // Try embedded language data const unsigned char* embedded_data = nullptr; unsigned int embedded_size = 0; @@ -188,6 +199,7 @@ bool I18n::loadLanguage(const std::string& locale) DEBUG_LOGF("Error parsing embedded language %s: %s\n", locale.c_str(), e.what()); } } +#endif // If English, use built-in strings if (locale == "en") { diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 763b32f..4db2a68 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -15320,6 +15320,18 @@ void testTextFitBanner() EXPECT_EQ(calls, 6); } } + +// Linux/macOS builds no longer embed res/lang (DRAGONX_EMBED_LANG=OFF), so a translation must load from the +// loose copy beside the binary (copy_langs puts res/lang in bin/, next to this test). Windows (ON) passes +// through either the loose or the embedded copy. +void testLoadLanguageFromLooseFiles() +{ + auto& i18n = dragonx::util::I18n::instance(); + EXPECT_TRUE(i18n.loadLanguage("de")); + EXPECT_TRUE(std::string(i18n.translate("overview")) != "Overview"); // de.json: "Übersicht" + EXPECT_TRUE(i18n.loadLanguage("en")); // restore the built-in English table + EXPECT_EQ(std::string(i18n.translate("overview")), std::string("Overview")); +} } // namespace int main() @@ -15569,6 +15581,7 @@ int main() testChatThreadLayout(); testTextFit(); testTextFitBanner(); + testLoadLanguageFromLooseFiles(); if (g_failures != 0) { std::cerr << g_failures << " test assertion(s) failed\n";