// 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; // ... // Debug::Use(cfg, { .enabled = opts.Has("--debug-api"), .flag = "--debug-api", // .define = "MYAPP_DEBUG_API", .args = depArgs }); // // 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 // /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(in)), std::istreambuf_iterator()); 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(in)), std::istreambuf_iterator()) == 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 args; }; // Adds Crafter.Debug to `cfg` when options.enabled, and writes the // launcher; see the comment at the top. `at` defaults to the call site, // which is how Use finds the consumer's directory. void 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; } 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; 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)); } }