Discovery is per user, so every project's launcher sees every debug app running, and with two of them up every command refused to guess and asked for -i, though the agent in one project meant its own app. Two copies of the example, both named basic, could only be told apart by id. The launcher now exports CRAFTER_DEBUG_PROJECT, its project's directory (flatpak-spawn gets it spelled out), and the client counts an app as the project's when its executable lies under it, as `-r` runs it from <project>/bin/<variant>/. With other projects' apps running, commands pick this project's; two copies of it still need -i. Another project's app is never picked by default: with this project's app not up yet, or crashed, the agent would otherwise drive, or quit, someone else's. It is listed instead, for -i. `ls` lists this project's apps first, marked, `--json` says which are, and `--wait` waits for one of them rather than any app; the exit code says whether one runs. A client started without the launcher has no project and picks the one app running, as before, and the command the protocol check prints for an older app's own client now names it with -i. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
202 lines
10 KiB
C++
202 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`.
|
|
//
|
|
// It tells the client which project it belongs to: with apps of other
|
|
// projects running too, the client picks this project's and leaves the
|
|
// others to -i. flatpak run passes the environment on; flatpak-spawn
|
|
// needs it spelled out.
|
|
std::string LauncherText(const fs::path& client, const Sandbox& sandbox, std::string_view flag, const fs::path& project) {
|
|
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"
|
|
"CRAFTER_DEBUG_PROJECT={2}\n"
|
|
"export CRAFTER_DEBUG_PROJECT\n"
|
|
"if [ ! -x \"$client\" ]; then\n"
|
|
" echo \"crafter-debug: the client is not built yet; let crafter-build {0} finish first, which builds it with the app\" >&2\n"
|
|
" exit 2\n"
|
|
"fi\n",
|
|
flag, ShellQuote(client.string()), ShellQuote(project.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 --env=CRAFTER_DEBUG_PROJECT=\"$CRAFTER_DEBUG_PROJECT\" {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 {0}, then start the app in the background with: crafter-build {0} -r\" >&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, launcher.parent_path().parent_path()));
|
|
return true;
|
|
}
|
|
}
|