The scriptable client, built now rather than last: it is how the daemon gets exercised before the GUI is pointed at it (AGENT-DAEMON.md). - src/client — synchronous blocking RPC over the Unix socket: resolve $XDG_RUNTIME_DIR/velox/velox.sock, connect, session.hello, one framed request/reply per call. Distinguishes transport failure (exit 3) from a daemon-returned error (exit 1). - src/main — subcommands add/ls/pause/resume/rm; --json prints the raw JSON-RPC result or error; --dir/--out/--segments on add; --delete-file on rm. Usage errors exit 2. ls works end to end against veloxd today (empty table). add and the bulk verbs reach the daemon and surface its "not implemented" (-32603) cleanly until the store lands — the plumbing is done, the commands light up as handlers do. Test velox.client: the real Client against an in-process UdsServer — no-daemon path, session.hello, download.list, and a not-implemented method surfacing as an RPC error rather than a transport error. ASan+UBSan clean; full tree (21 tests, incl. conformance) green. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Upd9WhG9oppieig5nRDLig
131 lines
4.4 KiB
C++
131 lines
4.4 KiB
C++
#include "client.hpp"
|
|
|
|
#include <sys/socket.h>
|
|
#include <sys/un.h>
|
|
#include <unistd.h>
|
|
|
|
#include <cerrno>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
|
|
#include "velox_proto.hpp"
|
|
|
|
namespace velox::cli {
|
|
|
|
namespace {
|
|
|
|
std::string read_error_message(int e) { return std::strerror(e); }
|
|
|
|
} // namespace
|
|
|
|
std::string default_socket_path() {
|
|
std::string base;
|
|
if (const char* xdg = ::getenv("XDG_RUNTIME_DIR"); xdg != nullptr && xdg[0] != '\0') {
|
|
base = xdg;
|
|
} else {
|
|
base = "/run/user/" + std::to_string(::geteuid());
|
|
}
|
|
if (!base.empty() && base.back() == '/') base.pop_back();
|
|
return base + "/velox/velox.sock";
|
|
}
|
|
|
|
Client::~Client() {
|
|
if (fd_ >= 0) ::close(fd_);
|
|
}
|
|
|
|
std::optional<CallError> Client::connect() {
|
|
socket_path_ = default_socket_path();
|
|
if (socket_path_.size() + 1 > sizeof(sockaddr_un::sun_path)) {
|
|
return CallError{CallError::kConnect, "socket path too long: " + socket_path_, {}};
|
|
}
|
|
|
|
fd_ = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
|
|
if (fd_ < 0) return CallError{CallError::kConnect, read_error_message(errno), {}};
|
|
|
|
sockaddr_un addr{};
|
|
addr.sun_family = AF_UNIX;
|
|
std::memcpy(addr.sun_path, socket_path_.c_str(), socket_path_.size());
|
|
if (::connect(fd_, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0) {
|
|
const int e = errno;
|
|
::close(fd_);
|
|
fd_ = -1;
|
|
return CallError{CallError::kConnect,
|
|
"cannot reach veloxd at " + socket_path_ + ": " + read_error_message(e),
|
|
{}};
|
|
}
|
|
|
|
nlohmann::json hello = {
|
|
{"clientType", "cli"},
|
|
{"clientName", std::string("velox ") + std::string(velox::proto::kProtocolVersion)},
|
|
{"protocolVersion", std::string(velox::proto::kProtocolVersion)},
|
|
};
|
|
auto r = call("session.hello", hello);
|
|
if (!r) return r.error();
|
|
hello_result_ = *r;
|
|
return std::nullopt;
|
|
}
|
|
|
|
std::expected<nlohmann::json, CallError> Client::call(const std::string& method,
|
|
const nlohmann::json& params) {
|
|
const nlohmann::json request = {
|
|
{"jsonrpc", "2.0"},
|
|
{"id", next_id_++},
|
|
{"method", method},
|
|
{"params", params},
|
|
};
|
|
return round_trip(request);
|
|
}
|
|
|
|
std::expected<nlohmann::json, CallError> Client::round_trip(const nlohmann::json& request) {
|
|
if (fd_ < 0) return std::unexpected(CallError{CallError::kConnect, "not connected", {}});
|
|
|
|
std::string out = request.dump();
|
|
out.push_back('\n');
|
|
std::size_t off = 0;
|
|
while (off < out.size()) {
|
|
const ssize_t n = ::write(fd_, out.data() + off, out.size() - off);
|
|
if (n > 0) {
|
|
off += static_cast<std::size_t>(n);
|
|
continue;
|
|
}
|
|
if (n < 0 && errno == EINTR) continue;
|
|
return std::unexpected(CallError{CallError::kConnect,
|
|
"write to daemon failed: " + read_error_message(errno), {}});
|
|
}
|
|
|
|
// Read until a newline completes a frame.
|
|
for (;;) {
|
|
if (const auto nl = inbuf_.find('\n'); nl != std::string::npos) {
|
|
const std::string line = inbuf_.substr(0, nl);
|
|
inbuf_.erase(0, nl + 1);
|
|
nlohmann::json reply = nlohmann::json::parse(line, nullptr, false);
|
|
if (reply.is_discarded()) {
|
|
return std::unexpected(
|
|
CallError{CallError::kProtocol, "daemon sent a malformed reply", {}});
|
|
}
|
|
if (reply.contains("error")) {
|
|
const auto& e = reply.at("error");
|
|
return std::unexpected(CallError{e.value("code", 0), e.value("message", ""),
|
|
e.contains("data") ? e.at("data") : nlohmann::json()});
|
|
}
|
|
return reply.contains("result") ? reply.at("result") : nlohmann::json(nullptr);
|
|
}
|
|
|
|
char chunk[8192];
|
|
const ssize_t n = ::read(fd_, chunk, sizeof(chunk));
|
|
if (n > 0) {
|
|
inbuf_.append(chunk, static_cast<std::size_t>(n));
|
|
continue;
|
|
}
|
|
if (n == 0) {
|
|
return std::unexpected(CallError{CallError::kConnect,
|
|
"daemon closed the connection", {}});
|
|
}
|
|
if (errno == EINTR) continue;
|
|
return std::unexpected(CallError{CallError::kConnect,
|
|
"read from daemon failed: " + read_error_message(errno), {}});
|
|
}
|
|
}
|
|
|
|
} // namespace velox::cli
|