A consumer adds its debug-only sources, the ones that import Crafter.Debug, under the same condition Use adds the library. Testing its own flag is not that condition: a build with the flag for a target other than native Linux gets neither the library nor the define, and its debug-only sources would then fail to import Crafter.Debug. Use now returns whether it added them, and the README shows the pattern. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
195 lines
10 KiB
C++
195 lines
10 KiB
C++
// SPDX-License-Identifier: LGPL-3.0-only
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
// Wires Crafter.Debug into a consumer's build. In the consumer's project.cpp:
|
|
//
|
|
// #pragma crafter dependency "https://forgejo.catcrafts.net/Catcrafts/Crafter.Debug.git" project "tooling/project.cpp"
|
|
// import Crafter.Debug.Build;
|
|
// ...
|
|
// const bool debugApi = Debug::Use(cfg, { .enabled = opts.Has("--debug-api"), .flag = "--debug-api",
|
|
// .define = "MYAPP_DEBUG_API", .args = depArgs });
|
|
// if (debugApi) { /* add the debug-only sources */ }
|
|
//
|
|
// crafter-build builds this module for the host and links it into the
|
|
// consumer's project.so, the way Crafter.LintRules gets in, so Use runs each
|
|
// time crafter-build loads the consumer: for every verb, before anything is
|
|
// built. With the consumer's flag on, Use adds the Crafter.Debug library from
|
|
// the checkout this module was built from, and writes build/crafter-debug, the
|
|
// launcher for the client that library builds. The client is one of the
|
|
// library's dependencies, so the build that follows compiles it in the same
|
|
// pass as the app, from the same sources.
|
|
//
|
|
// The launcher is a POSIX sh script, because what it starts has to run where
|
|
// it was built. A client built inside the CrafterBuild Flatpak links the libc++
|
|
// in that Flatpak's /app/lib against the runtime's glibc, so on the host the
|
|
// launcher starts it through `flatpak run`: nothing the Flatpak compiled runs
|
|
// on the host itself. The script keeps to what every /bin/sh on Linux shares
|
|
// (test, command -v, grep -q, exec "$@"), and every path in it is quoted.
|
|
export module Crafter.Debug.Build;
|
|
import std;
|
|
import Crafter.Build;
|
|
namespace fs = std::filesystem;
|
|
|
|
namespace Crafter::Debug {
|
|
// The checkout this module was built from. crafter-build compiles module
|
|
// sources from absolute paths, and this file is
|
|
// <checkout>/tooling/interfaces/Crafter.Debug.Build.cppm.
|
|
fs::path Checkout() {
|
|
return fs::path(std::source_location::current().file_name()).parent_path().parent_path().parent_path();
|
|
}
|
|
|
|
// The value of `key` in `section` of an INI-style file such as
|
|
// /.flatpak-info, or "" when either is missing.
|
|
std::string IniValue(std::string_view text, std::string_view section, std::string_view key) {
|
|
bool inSection = false;
|
|
for (const auto part : std::views::split(text, '\n')) {
|
|
const std::string_view line(std::ranges::data(part), std::ranges::size(part));
|
|
if (line.starts_with('[')) {
|
|
inSection = line.size() == section.size() + 2 && line.substr(1, section.size()) == section && line.back() == ']';
|
|
continue;
|
|
}
|
|
if (inSection && line.size() > key.size() && line.starts_with(key) && line[key.size()] == '=') {
|
|
return std::string(line.substr(key.size() + 1));
|
|
}
|
|
}
|
|
return {};
|
|
}
|
|
|
|
// The Flatpak crafter-build runs in, as its /.flatpak-info describes it:
|
|
// the app id and the branch. Both empty when crafter-build runs natively.
|
|
struct Sandbox {
|
|
std::string app;
|
|
std::string branch;
|
|
};
|
|
|
|
Sandbox CurrentSandbox() {
|
|
std::ifstream in("/.flatpak-info");
|
|
if (!in) return {};
|
|
const std::string text((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
|
return { IniValue(text, "Application", "name"), IniValue(text, "Instance", "branch") };
|
|
}
|
|
|
|
// A target the debug server can run on: Linux, not the browser.
|
|
bool NativeLinux(std::string_view target) {
|
|
return target.find("-linux-") != std::string_view::npos && !target.starts_with("wasm");
|
|
}
|
|
|
|
// The launcher for `client`. Inside the Flatpak that built the client it
|
|
// runs it directly; on the host it starts it in that Flatpak, on the
|
|
// branch that built it, so the client gets the runtime it was linked
|
|
// against; from another app's sandbox it goes through flatpak-spawn.
|
|
// `--die-with-parent` ends the client when whatever started the launcher
|
|
// is killed, a timed-out `wait` or a Monitor-stopped `log --follow`.
|
|
std::string LauncherText(const fs::path& client, const Sandbox& sandbox, std::string_view flag) {
|
|
std::string text = std::format(
|
|
"#!/bin/sh\n"
|
|
"# Written by Crafter.Debug (Debug::Use) each time crafter-build loads this\n"
|
|
"# project with {0}: runs the crafter-debug client that build compiled.\n"
|
|
"client={1}\n"
|
|
"if [ ! -x \"$client\" ]; then\n"
|
|
" echo \"crafter-debug: the client is not built yet; build with: crafter-build {0}\" >&2\n"
|
|
" exit 2\n"
|
|
"fi\n",
|
|
flag, ShellQuote(client.string()));
|
|
if (sandbox.app.empty()) {
|
|
text += "exec \"$client\" \"$@\"\n";
|
|
return text;
|
|
}
|
|
const std::string run = std::format("flatpak run --die-with-parent --branch={} --command=\"$client\" {} \"$@\"",
|
|
ShellQuote(sandbox.branch), ShellQuote(sandbox.app));
|
|
text += std::format(
|
|
"if grep -qsx {0} /.flatpak-info; then exec \"$client\" \"$@\"; fi\n"
|
|
"if command -v flatpak >/dev/null 2>&1; then exec {1}; fi\n"
|
|
"if command -v flatpak-spawn >/dev/null 2>&1; then exec flatpak-spawn --host {1}; fi\n"
|
|
"echo \"crafter-debug: needs flatpak to start $client\" >&2\n"
|
|
"exit 1\n",
|
|
ShellQuote(std::format("name={}", sandbox.app)), run);
|
|
return text;
|
|
}
|
|
|
|
// What build/crafter-debug says before the project has been built with
|
|
// its debug flag.
|
|
std::string StubText(std::string_view flag) {
|
|
return std::format(
|
|
"#!/bin/sh\n"
|
|
"# Written by Crafter.Debug (Debug::Use): this project has no debug build yet.\n"
|
|
"echo \"crafter-debug: no debug build yet; build with: crafter-build {}\" >&2\n"
|
|
"exit 2\n",
|
|
flag);
|
|
}
|
|
|
|
// Replaces `file` with `text`, executable, unless it already holds it.
|
|
// Through a temporary name and a rename, so a launcher that is running
|
|
// never reads half a file. A failure is a warning: the build itself does
|
|
// not depend on the launcher.
|
|
void WriteIfChanged(const fs::path& file, const std::string& text) {
|
|
{
|
|
std::ifstream in(file, std::ios::binary);
|
|
if (in && std::string((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>()) == text) return;
|
|
}
|
|
std::error_code ec;
|
|
fs::create_directories(file.parent_path(), ec);
|
|
std::random_device random;
|
|
fs::path temp = file;
|
|
temp += std::format(".{:08x}.tmp", random());
|
|
{
|
|
std::ofstream out(temp, std::ios::binary | std::ios::trunc);
|
|
out << text;
|
|
if (!out) {
|
|
std::println(std::cerr, "Crafter.Debug: could not write {}", temp.string());
|
|
return;
|
|
}
|
|
}
|
|
fs::permissions(temp, fs::perms::owner_all | fs::perms::group_read | fs::perms::group_exec | fs::perms::others_read | fs::perms::others_exec, ec);
|
|
fs::rename(temp, file, ec);
|
|
if (ec) {
|
|
std::println(std::cerr, "Crafter.Debug: could not write {}: {}", file.string(), ec.message());
|
|
fs::remove(temp, ec);
|
|
}
|
|
}
|
|
}
|
|
|
|
export namespace Crafter::Debug {
|
|
struct UseOptions {
|
|
// Whether this build includes Crafter.Debug: the consumer's own flag,
|
|
// such as `opts.Has("--debug-api")`. Crafter.Debug never sees the flag.
|
|
bool enabled = false;
|
|
// That flag as the user types it, for the launcher's messages.
|
|
std::string flag = "--debug-api";
|
|
// The define the consumer's own sources test, such as "MYAPP_DEBUG_API".
|
|
std::string define;
|
|
// The args the consumer hands its other dependencies. A dependency
|
|
// two paths share must get the same list on both, or crafter-build
|
|
// refuses the graph ("module ... built twice").
|
|
std::vector<std::string> args;
|
|
};
|
|
|
|
// Adds Crafter.Debug to `cfg` when options.enabled, and writes the
|
|
// launcher; see the comment at the top. Returns whether it added the
|
|
// library and the define, which is when the consumer adds its debug-only
|
|
// sources: not for every build with the flag, since a target other than
|
|
// native Linux gets neither. `at` defaults to the call site, which is how
|
|
// Use finds the consumer's directory.
|
|
bool Use(Configuration& cfg, const UseOptions& options, std::source_location at = std::source_location::current()) {
|
|
const fs::path launcher = fs::path(at.file_name()).parent_path() / "build" / "crafter-debug";
|
|
// Only for the project crafter-build was pointed at: a consumer loaded
|
|
// as someone else's dependency has no agent driving it from its own
|
|
// directory. Asked before LocalProject, which loads another project.
|
|
const bool topLevel = IsTopLevelProject();
|
|
if (!options.enabled || !NativeLinux(cfg.target)) {
|
|
if (topLevel && !fs::exists(launcher)) WriteIfChanged(launcher, StubText(options.flag));
|
|
return false;
|
|
}
|
|
Configuration* library = LocalProject({ .projectFile = Checkout() / "project.cpp", .args = options.args });
|
|
cfg.dependencies.push_back(library);
|
|
if (!options.define.empty()) cfg.defines.push_back({ options.define, "" });
|
|
if (!topLevel) return true;
|
|
const auto client = std::ranges::find(library->dependencies, std::string_view("crafter.debug-cli"),
|
|
[](const Configuration* dependency) { return std::string_view(dependency->name); });
|
|
if (client == library->dependencies.end()) {
|
|
throw std::runtime_error(std::format("Crafter.Debug: {} declares no crafter.debug-cli client", (Checkout() / "project.cpp").string()));
|
|
}
|
|
WriteIfChanged(launcher, LauncherText((*client)->BinDir() / (*client)->outputName, CurrentSandbox(), options.flag));
|
|
return true;
|
|
}
|
|
}
|