fix(assets): runtime asset lookup and document install/package layout

This commit is contained in:
Lemmy
2026-04-19 14:53:22 -04:00
parent f7ef195a72
commit ce288a3935
9 changed files with 195 additions and 14 deletions
+38
View File
@@ -116,6 +116,44 @@ meson compile -C build-debug
```
</details>
## Installation / Packaging
`meson install` now installs the binary and shipped assets separately using the normal prefix layout:
```text
/usr/local/bin/noctalia
/usr/local/share/noctalia/assets/...
```
With a different Meson `prefix`/`datadir`, the same structure is preserved under that prefix.
For packagers, the important point is that Noctalia needs the `assets/` tree at runtime. Copying only the bare `noctalia` binary is not enough.
Portable bundle layouts are also supported:
```text
bundle/
noctalia
assets/
```
```text
bundle/
bin/noctalia
share/noctalia/assets/
```
Runtime asset lookup order:
1. `NOCTALIA_ASSETS_DIR`
2. `assets/` next to the executable
3. `assets/` one level above the executable
4. install-style `../share/noctalia/assets` relative to the executable
5. the compiled install path from Meson (`<prefix>/<datadir>/noctalia/assets`)
6. the source-tree `assets/` directory as a development fallback
An asset root is only accepted if it contains the expected shipped files such as `emoji.json`, `fonts/tabler.ttf`, `templates/builtin.toml`, and `translations/en.json`.
## Code Style
This project uses [clang-format](https://clang.llvm.org/docs/ClangFormat.html) for formatting. Run `just format` before committing.
+9 -3
View File
@@ -282,6 +282,7 @@ _noctalia_sources = files(
'src/core/deferred_call.cpp',
'src/core/log.cpp',
'src/core/process.cpp',
'src/core/resource_paths.cpp',
'src/core/timer_manager.cpp',
'src/core/ui_phase.cpp',
'src/dbus/bluetooth/bluetooth_agent.cpp',
@@ -530,9 +531,14 @@ executable('noctalia',
include_directories: _noctalia_inc,
cpp_args: [
'-Wall', '-Wextra', '-Wpedantic', '-Wconversion', '-Wshadow',
'-DNOCTALIA_ASSETS_DIR="' + meson.project_source_root() / 'assets' + '"',
'-DNOCTALIA_I18N_DIR="' + meson.project_source_root() / 'assets' / 'translations' + '"',
'-DNOCTALIA_SOURCE_ASSETS_DIR="' + meson.project_source_root() / 'assets' + '"',
'-DNOCTALIA_INSTALL_PREFIX="' + get_option('prefix') + '"',
'-DNOCTALIA_INSTALL_DATADIR="' + get_option('datadir') + '"',
'-DNOCTALIA_VERSION="' + meson.project_version() + '"',
],
install: false,
install: true,
)
install_subdir('assets',
install_dir: get_option('datadir') / 'noctalia',
)
+115
View File
@@ -0,0 +1,115 @@
#include "core/resource_paths.h"
#include "core/log.h"
#include <algorithm>
#include <array>
#include <cstdlib>
#include <optional>
#include <string>
#include <system_error>
#include <unistd.h>
#include <vector>
namespace paths {
namespace {
constexpr Logger kLog("paths");
std::filesystem::path installedAssetsRoot() {
const std::filesystem::path datadir(NOCTALIA_INSTALL_DATADIR);
if (datadir.is_absolute()) {
return datadir / "noctalia" / "assets";
}
return std::filesystem::path(NOCTALIA_INSTALL_PREFIX) / datadir / "noctalia" / "assets";
}
std::filesystem::path sourceAssetsRoot() { return std::filesystem::path(NOCTALIA_SOURCE_ASSETS_DIR); }
bool isAssetRoot(const std::filesystem::path& root) {
if (root.empty()) {
return false;
}
std::error_code ec;
return std::filesystem::exists(root / "emoji.json", ec) &&
std::filesystem::exists(root / "fonts" / "tabler.ttf", ec) &&
std::filesystem::exists(root / "templates" / "builtin.toml", ec) &&
std::filesystem::exists(root / "translations" / "en.json", ec);
}
std::optional<std::filesystem::path> executablePath() {
std::array<char, 4096> buffer{};
const ssize_t count = ::readlink("/proc/self/exe", buffer.data(), buffer.size() - 1);
if (count <= 0 || static_cast<std::size_t>(count) >= buffer.size() - 1) {
return std::nullopt;
}
buffer[static_cast<std::size_t>(count)] = '\0';
return std::filesystem::path(buffer.data());
}
void appendUnique(std::vector<std::filesystem::path>& candidates, const std::filesystem::path& candidate) {
if (candidate.empty()) {
return;
}
if (std::find(candidates.begin(), candidates.end(), candidate) == candidates.end()) {
candidates.push_back(candidate);
}
}
std::vector<std::filesystem::path> assetCandidates() {
std::vector<std::filesystem::path> candidates;
if (const char* env = std::getenv("NOCTALIA_ASSETS_DIR"); env != nullptr && env[0] != '\0') {
const std::filesystem::path overridePath(env);
if (isAssetRoot(overridePath)) {
candidates.push_back(overridePath);
return candidates;
}
kLog.warn("NOCTALIA_ASSETS_DIR is not a valid asset bundle: {}", overridePath.string());
}
if (auto exe = executablePath()) {
const std::filesystem::path exeDir = exe->parent_path();
appendUnique(candidates, exeDir / "assets");
appendUnique(candidates, exeDir.parent_path() / "assets");
const std::filesystem::path datadir(NOCTALIA_INSTALL_DATADIR);
if (!datadir.empty() && !datadir.is_absolute()) {
appendUnique(candidates, exeDir.parent_path() / datadir / "noctalia" / "assets");
}
appendUnique(candidates, exeDir.parent_path() / "share" / "noctalia" / "assets");
}
appendUnique(candidates, installedAssetsRoot());
appendUnique(candidates, sourceAssetsRoot());
return candidates;
}
std::filesystem::path resolveAssetsRoot() {
for (const auto& candidate : assetCandidates()) {
if (isAssetRoot(candidate)) {
kLog.debug("using assets from {}", candidate.string());
return candidate;
}
}
const std::filesystem::path fallback = installedAssetsRoot();
kLog.warn("could not locate a valid asset bundle; defaulting to {}", fallback.string());
return fallback;
}
} // namespace
const std::filesystem::path& assetsRoot() {
static const std::filesystem::path s_root = resolveAssetsRoot();
return s_root;
}
std::filesystem::path assetPath(std::string_view relativePath) {
return assetsRoot() / std::filesystem::path(std::string(relativePath));
}
} // namespace paths
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include <filesystem>
#include <string_view>
namespace paths {
[[nodiscard]] const std::filesystem::path& assetsRoot();
[[nodiscard]] std::filesystem::path assetPath(std::string_view relativePath);
} // namespace paths
+5 -3
View File
@@ -1,8 +1,10 @@
#include "i18n/i18n_service.h"
#include "core/log.h"
#include "core/resource_paths.h"
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <json.hpp>
#include <string>
@@ -68,7 +70,7 @@ namespace i18n {
}
bool Service::loadCatalog(std::string_view lang, Catalog& out) const {
std::string path = std::string(NOCTALIA_I18N_DIR) + "/" + std::string(lang) + ".json";
const std::filesystem::path path = paths::assetPath("translations/" + std::string(lang) + ".json");
std::ifstream file(path);
if (!file.is_open()) {
return false;
@@ -76,7 +78,7 @@ namespace i18n {
try {
auto json = nlohmann::json::parse(file);
if (!json.is_object()) {
kLog.warn("catalog {} is not a JSON object", path);
kLog.warn("catalog {} is not a JSON object", path.string());
return false;
}
Catalog fresh;
@@ -84,7 +86,7 @@ namespace i18n {
out = std::move(fresh);
return true;
} catch (const std::exception& e) {
kLog.error("failed to parse {}: {}", path, e.what());
kLog.error("failed to parse {}: {}", path.string(), e.what());
return false;
}
}
+3 -1
View File
@@ -1,8 +1,10 @@
#include "launcher/emoji_provider.h"
#include "core/resource_paths.h"
#include "wayland/clipboard_service.h"
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <json.hpp>
#include <sstream>
@@ -20,7 +22,7 @@ namespace {
} // namespace
void EmojiProvider::initialize() {
std::string path = std::string(NOCTALIA_ASSETS_DIR) + "/emoji.json";
const std::filesystem::path path = paths::assetPath("emoji.json");
std::ifstream file(path);
if (!file.is_open()) {
return;
+2 -1
View File
@@ -1,6 +1,7 @@
#include "render/render_context.h"
#include "core/log.h"
#include "core/resource_paths.h"
#include "core/ui_phase.h"
#include "render/gl_shared_context.h"
#include "render/render_target.h"
@@ -73,7 +74,7 @@ void RenderContext::initialize(GlSharedContext& shared) {
// Pango handles font fallback via Fontconfig automatically — no explicit chain.
ensureGlPrograms();
m_textRenderer.initialize(&m_glyphProgram);
m_glyphRenderer.initialize(NOCTALIA_ASSETS_DIR "/fonts/tabler.ttf", &m_glyphProgram);
m_glyphRenderer.initialize(paths::assetPath("fonts/tabler.ttf").string(), &m_glyphProgram);
}
void RenderContext::ensureGlPrograms() {
+6 -3
View File
@@ -1,5 +1,6 @@
#include "theme/cli.h"
#include "core/resource_paths.h"
#include "core/toml.h"
#include "theme/color.h"
#include "theme/fixed_palette.h"
@@ -53,7 +54,7 @@ namespace noctalia::theme {
" --list-builtins List built-in templates from the shipped catalog\n"
" --default-mode Template default mode: dark or light";
constexpr const char* kBuiltinTemplateConfig = NOCTALIA_ASSETS_DIR "/templates/builtin.toml";
std::filesystem::path builtinTemplateConfigPath() { return paths::assetPath("templates/builtin.toml"); }
struct BuiltinTemplateInfo {
std::string id;
@@ -77,7 +78,7 @@ namespace noctalia::theme {
std::vector<BuiltinTemplateInfo> loadBuiltinTemplateInfo(std::string& err) {
toml::table root;
try {
root = toml::parse_file(kBuiltinTemplateConfig);
root = toml::parse_file(builtinTemplateConfigPath().string());
} catch (const toml::parse_error& e) {
err = e.description();
return {};
@@ -272,6 +273,7 @@ namespace noctalia::theme {
Variant variant = Variant::Dark;
const char* outPath = nullptr;
const char* configPath = nullptr;
std::string builtinConfigPathStorage;
bool builtinConfig = false;
bool listBuiltins = false;
std::string defaultMode = "dark";
@@ -356,7 +358,8 @@ namespace noctalia::theme {
std::fputs("error: --builtin-config cannot be combined with --config\n", stderr);
return 1;
}
configPath = kBuiltinTemplateConfig;
builtinConfigPathStorage = builtinTemplateConfigPath().string();
configPath = builtinConfigPathStorage.c_str();
}
if (!imagePath && !themeJsonPath) {
+6 -3
View File
@@ -2,6 +2,7 @@
#include "config/config_service.h"
#include "core/log.h"
#include "core/resource_paths.h"
#include "theme/template_engine.h"
#include <cstdlib>
@@ -13,7 +14,8 @@ namespace noctalia::theme {
namespace {
constexpr Logger kLog("theme_templates");
constexpr const char* kBuiltinTemplateConfig = NOCTALIA_ASSETS_DIR "/templates/builtin.toml";
std::filesystem::path builtinTemplateConfigPath() { return paths::assetPath("templates/builtin.toml"); }
std::filesystem::path expandUserPath(const std::string& path) {
if (path.empty() || path[0] != '~')
@@ -53,8 +55,9 @@ namespace noctalia::theme {
TemplateEngine::Options builtinOptions = options;
builtinOptions.enabledTemplates.insert(templateCfg.builtinIds.begin(), templateCfg.builtinIds.end());
TemplateEngine builtinEngine(TemplateEngine::makeThemeData(palette), std::move(builtinOptions));
if (!builtinEngine.processConfigFile(kBuiltinTemplateConfig)) {
kLog.warn("failed to apply built-in templates from {}", kBuiltinTemplateConfig);
const std::filesystem::path builtinConfig = builtinTemplateConfigPath();
if (!builtinEngine.processConfigFile(builtinConfig)) {
kLog.warn("failed to apply built-in templates from {}", builtinConfig.string());
}
}