mirror of
https://github.com/JBuildr/nubix.git
synced 2026-10-17 21:00:50 +02:00
348 lines
13 KiB
C++
348 lines
13 KiB
C++
// Nubix — host CLI for live API testing without the UI.
|
|
//
|
|
// xc-cli login device-code login (prints code + URL), then authorize streaming
|
|
// xc-cli devicecode only request + print a device code (live endpoint smoke test)
|
|
// xc-cli token refresh + authorize streaming, print offerings and regions
|
|
// xc-cli refresh refresh MSA token + gs tokens
|
|
// xc-cli status show stored account/token state
|
|
// xc-cli titles [filter] list playable cloud titles (tools/cli_gssv.cpp)
|
|
// xc-cli consoles list xHome consoles (tools/cli_gssv.cpp)
|
|
// xc-cli play <titleId>|--home <serverId> [--no-connect] [--timeout S]
|
|
// gssv session start/poll/connect/keepalive/stop (tools/cli_gssv.cpp)
|
|
// xc-cli session cloud|home ID gssv only: start, poll until Provisioned (or fail), stop
|
|
// xc-cli stream cloud|home ID [seconds] full headless stream via Streamer (no video output)
|
|
// xc-cli offer print a generated SDP offer (local WebRTC gathering only)
|
|
// xc-cli logout
|
|
//
|
|
// Options: --verbose. Data dir: $XC_DATA_DIR or the platform default (shared with the app).
|
|
// Copyright (C) 2026 Nubix contributors
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
#include <SDL.h>
|
|
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <csignal>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
extern "C" {
|
|
#include <libavutil/frame.h>
|
|
}
|
|
|
|
#include "core/auth.hpp"
|
|
#include "core/config.hpp"
|
|
#include "core/gssv.hpp"
|
|
#include "core/http.hpp"
|
|
#include "core/log.hpp"
|
|
#include "cli_gssv.hpp"
|
|
#include "core/protocol.hpp"
|
|
#include "platform/platform.hpp"
|
|
#include "stream/streamer.hpp"
|
|
#include "stream/webrtc.hpp"
|
|
|
|
using namespace xc;
|
|
|
|
namespace {
|
|
|
|
std::atomic<bool> g_stop{false};
|
|
|
|
void onSignal(int) { g_stop = true; }
|
|
|
|
void sleepMs(int ms) { std::this_thread::sleep_for(std::chrono::milliseconds(ms)); }
|
|
|
|
int usage() {
|
|
std::fprintf(stderr,
|
|
"usage: xc-cli [--verbose] <command>\n"
|
|
" login | devicecode | token | refresh | status | logout\n"
|
|
" titles [filter] | consoles\n"
|
|
" play <titleId> | play --home <serverId> [--no-connect] [--timeout S]\n"
|
|
" session <cloud|home> <titleId|serverId>\n"
|
|
" stream <cloud|home> <titleId|serverId> [seconds]\n"
|
|
" offer\n");
|
|
return 2;
|
|
}
|
|
|
|
bool parseKind(const std::string& s, SessionKind& k) {
|
|
if (s == "cloud") { k = SessionKind::Cloud; return true; }
|
|
if (s == "home") { k = SessionKind::Home; return true; }
|
|
return false;
|
|
}
|
|
|
|
int cmdLogin(Config& cfg, Auth& auth) {
|
|
DeviceCode dc;
|
|
std::string err;
|
|
if (!auth.requestDeviceCode(dc, err)) {
|
|
std::fprintf(stderr, "device code request failed: %s\n", err.c_str());
|
|
return 1;
|
|
}
|
|
std::printf("Open %s and enter code: %s\n(QR url: %s)\n", dc.verificationUri.c_str(), dc.userCode.c_str(),
|
|
dc.qrUrl.c_str());
|
|
std::fflush(stdout);
|
|
int interval = dc.interval;
|
|
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(dc.expiresIn);
|
|
while (!g_stop && std::chrono::steady_clock::now() < deadline) {
|
|
for (int i = 0; i < interval * 10 && !g_stop; ++i) sleepMs(100);
|
|
if (g_stop) break;
|
|
PollResult r = auth.pollToken(dc, err);
|
|
if (r == PollResult::Pending) {
|
|
if (err == "slow_down") interval += 5; // RFC 8628
|
|
continue;
|
|
}
|
|
if (r != PollResult::Success) {
|
|
std::fprintf(stderr, "login failed: %s\n", err.c_str());
|
|
return 1;
|
|
}
|
|
if (!auth.authorizeStreaming(err)) {
|
|
std::fprintf(stderr, "signed in, but authorizeStreaming failed: %s\n", err.c_str());
|
|
return 1;
|
|
}
|
|
cfg.save();
|
|
std::printf("Logged in as %s\n", cfg.gamertag().c_str());
|
|
return 0;
|
|
}
|
|
std::fprintf(stderr, "login expired/cancelled\n");
|
|
return 1;
|
|
}
|
|
|
|
// Only request a device code and print it (live smoke test of the MSAL endpoint; no login).
|
|
int cmdDeviceCode(Auth& auth) {
|
|
DeviceCode dc;
|
|
std::string err;
|
|
if (!auth.requestDeviceCode(dc, err)) {
|
|
std::fprintf(stderr, "device code request failed: %s\n", err.c_str());
|
|
return 1;
|
|
}
|
|
std::printf("user code : %s\n", dc.userCode.c_str());
|
|
std::printf("verify url : %s\n", dc.verificationUri.c_str());
|
|
std::printf("qr url : %s\n", dc.qrUrl.c_str());
|
|
std::printf("interval : %d s\n", dc.interval);
|
|
std::printf("expires in : %d s\n", dc.expiresIn);
|
|
if (!dc.message.empty()) std::printf("message : %s\n", dc.message.c_str());
|
|
return 0;
|
|
}
|
|
|
|
void printRegions(const char* name, const std::string& base, const std::vector<Region>& regions) {
|
|
if (regions.empty()) {
|
|
std::printf("%-11s : not available\n", name);
|
|
return;
|
|
}
|
|
std::printf("%-11s : %s\n", name, base.c_str());
|
|
for (const auto& r : regions)
|
|
std::printf(" %c %-22s %s%s\n", r.baseUri == base ? '*' : ' ', r.name.c_str(), r.baseUri.c_str(),
|
|
r.isDefault ? " (default)" : "");
|
|
}
|
|
|
|
// Refresh the MSA token + authorize streaming, then print offerings and regions.
|
|
int cmdToken(Config& cfg, Auth& auth) {
|
|
std::string err;
|
|
if (!auth.isLoggedIn()) {
|
|
std::fprintf(stderr, "not signed in - run: xc-cli login\n");
|
|
return 1;
|
|
}
|
|
if (!auth.refresh(err) || !auth.authorizeStreaming(err)) {
|
|
std::fprintf(stderr, "token: %s\n", err.c_str());
|
|
return 1;
|
|
}
|
|
const Tokens t = cfg.tokens();
|
|
std::printf("gamertag : %s\n", t.gamertag.c_str());
|
|
std::printf("market : %s\n", t.market.c_str());
|
|
std::printf("msa valid : %lld s\n", (long long)(t.msaExpiry - platform::unixTime()));
|
|
std::printf("gs valid : %lld s\n", (long long)(t.gsExpiry - platform::unixTime()));
|
|
std::printf("xbl web : %s\n", t.xblToken.empty() ? "-" : "token");
|
|
printRegions("xhome", t.xhomeBase, t.xhomeRegions);
|
|
printRegions("xgpuweb", t.xcloudBase, t.xcloudRegions);
|
|
printRegions("xgpuwebf2p", t.xcloudF2pBase, t.xcloudF2pRegions);
|
|
return 0;
|
|
}
|
|
|
|
int cmdStatus(const Config& cfg, const Auth& auth) {
|
|
const Tokens t = cfg.tokens();
|
|
std::printf("config : %s\n", cfg.path().c_str());
|
|
std::printf("installId : %s\n", cfg.installId().c_str());
|
|
std::printf("logged in : %s\n", auth.isLoggedIn() ? "yes" : "no");
|
|
std::printf("gamertag : %s\n", t.gamertag.c_str());
|
|
std::printf("msa expiry : %lld (now %lld)\n", (long long)t.msaExpiry, (long long)platform::unixTime());
|
|
std::printf("gs expiry : %lld\n", (long long)t.gsExpiry);
|
|
std::printf("xhome : %s %s\n", t.xhomeGs.empty() ? "-" : "token", t.xhomeBase.c_str());
|
|
std::printf("xcloud : %s %s\n", t.xcloudGs.empty() ? "-" : "token", t.xcloudBase.c_str());
|
|
std::printf("xcloud f2p : %s %s\n", t.xcloudF2pGs.empty() ? "-" : "token", t.xcloudF2pBase.c_str());
|
|
return 0;
|
|
}
|
|
|
|
int cmdSession(Config& cfg, Auth& auth, SessionKind kind, const std::string& id) {
|
|
std::string err;
|
|
if (!auth.ensureFresh(err)) {
|
|
std::fprintf(stderr, "auth: %s\n", err.c_str());
|
|
return 1;
|
|
}
|
|
const Tokens t = cfg.tokens();
|
|
Settings gs = cfg.settings();
|
|
gs.resolution = gssv::deviceTierFor(kind == SessionKind::Home, gs.resolution); // xhome: android only
|
|
GssvClient gssv(kind == SessionKind::Home ? t.xhomeBase : t.xcloudBase,
|
|
kind == SessionKind::Home ? t.xhomeGs : t.xcloudGs, gs);
|
|
gssv.cleanupActive(kind);
|
|
SessionInfo s;
|
|
if (!gssv.play(kind, id, s, err)) {
|
|
std::fprintf(stderr, "play: %s\n", err.c_str());
|
|
return 1;
|
|
}
|
|
std::printf("session %s (%s)\n", s.sessionId.c_str(), s.sessionPath.c_str());
|
|
bool connected = false;
|
|
int rc = 1;
|
|
while (!g_stop) {
|
|
if (!gssv.pollState(s, err)) {
|
|
std::fprintf(stderr, "state: %s\n", err.c_str());
|
|
break;
|
|
}
|
|
std::printf("state: %s\n", s.state.c_str());
|
|
if (s.state == "ReadyToConnect" && !connected) {
|
|
std::string lpt;
|
|
if (!auth.fetchLpt(lpt, err) || !gssv.connect(s, lpt, err)) {
|
|
std::fprintf(stderr, "connect: %s\n", err.c_str());
|
|
break;
|
|
}
|
|
connected = true;
|
|
} else if (s.state == "Provisioned") {
|
|
std::printf("provisioned; keepalive interval %d s\n", gssv.keepAliveSeconds(s));
|
|
rc = 0;
|
|
break;
|
|
} else if (s.state == "Failed") {
|
|
std::fprintf(stderr, "failed: %s %s\n", s.errorCode.c_str(), s.errorMessage.c_str());
|
|
break;
|
|
}
|
|
sleepMs(1000);
|
|
}
|
|
gssv.stop(s);
|
|
return rc;
|
|
}
|
|
|
|
int cmdStream(Config& cfg, Auth& auth, SessionKind kind, const std::string& id, int seconds) {
|
|
if (SDL_Init(SDL_INIT_AUDIO) != 0) std::fprintf(stderr, "SDL audio unavailable: %s\n", SDL_GetError());
|
|
int rc = 1;
|
|
{
|
|
Streamer st(cfg, auth);
|
|
if (!st.start(kind, id)) {
|
|
std::fprintf(stderr, "stream start failed\n");
|
|
SDL_Quit();
|
|
return 1;
|
|
}
|
|
const auto t0 = std::chrono::steady_clock::now();
|
|
StreamState last = StreamState::Idle;
|
|
uint64_t frames = 0;
|
|
while (!g_stop) {
|
|
StreamState s = st.state();
|
|
if (s != last) {
|
|
std::printf("state: %s %s\n", streamStateName(s), st.statusText().c_str());
|
|
last = s;
|
|
}
|
|
if (AVFrame* f = st.currentFrame()) {
|
|
++frames;
|
|
av_frame_free(&f);
|
|
}
|
|
if (s == StreamState::Ended) { rc = 0; break; }
|
|
if (s == StreamState::Failed) break;
|
|
if (s == StreamState::Streaming) {
|
|
rc = 0;
|
|
const double el = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
|
|
static int lastPrint = -1;
|
|
if (static_cast<int>(el) != lastPrint) {
|
|
lastPrint = static_cast<int>(el);
|
|
StreamStats ss = st.stats();
|
|
std::printf("%dx%d %.1f fps %.0f kbps decode %.1f ms loss %.1f%% frames %llu\n", ss.width,
|
|
ss.height, ss.fps, ss.bitrateKbps, ss.decodeMs, ss.lossPercent,
|
|
(unsigned long long)frames);
|
|
}
|
|
if (seconds > 0 && el >= seconds) break;
|
|
}
|
|
sleepMs(5);
|
|
}
|
|
st.stop();
|
|
}
|
|
SDL_Quit();
|
|
return rc;
|
|
}
|
|
|
|
int cmdOffer() {
|
|
WebRtc rtc;
|
|
WebRtc::Callbacks cb;
|
|
if (!rtc.init(cb)) {
|
|
std::fprintf(stderr, "webrtc init failed\n");
|
|
return 1;
|
|
}
|
|
std::string ufrag, pwd, fp;
|
|
std::vector<std::string> cands;
|
|
if (!rtc.gatherLocal(ufrag, pwd, fp, cands, 5000)) {
|
|
std::fprintf(stderr, "gathering failed\n");
|
|
return 1;
|
|
}
|
|
std::printf("%s\n", proto::buildOffer(ufrag, pwd, fp, false, "1080").c_str());
|
|
for (const auto& c : cands) std::printf("%s\n", c.c_str());
|
|
std::printf("ice body: %s\n", proto::icePostBody(cands, ufrag).c_str());
|
|
rtc.close();
|
|
return 0;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main(int argc, char** argv) {
|
|
std::vector<std::string> args;
|
|
bool verbose = false;
|
|
for (int i = 1; i < argc; ++i) {
|
|
if (!std::strcmp(argv[i], "--verbose") || !std::strcmp(argv[i], "-v")) verbose = true;
|
|
else args.emplace_back(argv[i]);
|
|
}
|
|
if (args.empty()) return usage();
|
|
|
|
std::signal(SIGINT, onSignal);
|
|
std::signal(SIGTERM, onSignal);
|
|
|
|
if (!platform::init(argc, argv)) {
|
|
std::fprintf(stderr, "cannot create data dir %s\n", platform::dataDir().c_str());
|
|
return 1;
|
|
}
|
|
logInit("", verbose ? LogLevel::Debug : LogLevel::Info);
|
|
Http::globalInit(platform::assetPath("cacert.pem"));
|
|
|
|
// catalog + gssv commands (titles, consoles, play) live in cli_gssv.cpp; they build their own
|
|
// Config/Auth from the same data dir.
|
|
if (cli_gssv_handles(args[0].c_str())) {
|
|
std::vector<char*> av;
|
|
for (auto& a : args) av.push_back(const_cast<char*>(a.c_str()));
|
|
const int grc = cli_gssv(static_cast<int>(av.size()), av.data());
|
|
Http::globalCleanup();
|
|
logShutdown();
|
|
return grc;
|
|
}
|
|
|
|
Config cfg(platform::dataDir() + "/config.json");
|
|
if (!cfg.load()) XC_LOGW("config %s could not be parsed, using defaults", cfg.path().c_str());
|
|
Auth auth(cfg);
|
|
|
|
const std::string& cmd = args[0];
|
|
int rc = 2;
|
|
SessionKind kind = SessionKind::Cloud;
|
|
if (cmd == "login") rc = cmdLogin(cfg, auth);
|
|
else if (cmd == "devicecode") rc = cmdDeviceCode(auth);
|
|
else if (cmd == "token") rc = cmdToken(cfg, auth);
|
|
else if (cmd == "refresh") {
|
|
std::string err;
|
|
rc = (auth.refresh(err) && auth.authorizeStreaming(err)) ? 0 : 1;
|
|
if (rc) std::fprintf(stderr, "refresh failed: %s\n", err.c_str());
|
|
else cfg.save();
|
|
} else if (cmd == "status") rc = cmdStatus(cfg, auth);
|
|
else if (cmd == "logout") { auth.logout(); rc = 0; }
|
|
else if (cmd == "session" && args.size() >= 3 && parseKind(args[1], kind)) rc = cmdSession(cfg, auth, kind, args[2]);
|
|
else if (cmd == "stream" && args.size() >= 3 && parseKind(args[1], kind))
|
|
rc = cmdStream(cfg, auth, kind, args[2], args.size() > 3 ? std::atoi(args[3].c_str()) : 0);
|
|
else if (cmd == "offer") rc = cmdOffer();
|
|
else rc = usage();
|
|
|
|
Http::globalCleanup();
|
|
logShutdown();
|
|
return rc;
|
|
}
|