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
  <exe>/../share/<app>/res/lang (the cmake --install layout and the
  full-node AppImage) and <exe>/../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 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 06:05:07 -05:00
co-authored by Claude Opus 5.5
parent 04ad1462d5
commit 1f162ec830
3 changed files with 46 additions and 9 deletions
+16 -4
View File
@@ -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=$<BOOL:${DRAGONX_ENABLE_EMBEDDED_DAEMON}>
DRAGONX_ENABLE_LITE_BACKEND=$<BOOL:${DRAGONX_LITE_BACKEND_READY}>
DRAGONX_ENABLE_CHAT=$<BOOL:${DRAGONX_ENABLE_CHAT}>
DRAGONX_EMBED_LANG=$<BOOL:${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=$<BOOL:${DRAGONX_BUILD_LITE}>
DRAGONX_ENABLE_EMBEDDED_DAEMON=$<BOOL:${DRAGONX_ENABLE_EMBEDDED_DAEMON}>
DRAGONX_ENABLE_LITE_BACKEND=$<BOOL:${DRAGONX_LITE_BACKEND_READY}>
DRAGONX_EMBED_LANG=$<BOOL:${DRAGONX_EMBED_LANG}>
DRAGONX_TEST_FIXTURE_DIR="${CMAKE_SOURCE_DIR}/tests/fixtures"
)
+17 -5
View File
@@ -9,9 +9,14 @@
#include <fstream>
#include <cstdio>
#include <cstring>
#include <vector>
#include <nlohmann/json.hpp>
// 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<std::string> 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") {
+13
View File
@@ -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";