fix(api): return proper json objects (#3544)

This commit is contained in:
ReenigneArcher
2025-01-31 23:38:22 -05:00
committed by GitHub
parent 5b36357133
commit 257a102127
19 changed files with 364 additions and 350 deletions

View File

@@ -887,7 +887,6 @@ jobs:
mingw-w64-ucrt-x86_64-graphviz mingw-w64-ucrt-x86_64-graphviz
mingw-w64-ucrt-x86_64-MinHook mingw-w64-ucrt-x86_64-MinHook
mingw-w64-ucrt-x86_64-miniupnpc mingw-w64-ucrt-x86_64-miniupnpc
mingw-w64-ucrt-x86_64-nlohmann-json
mingw-w64-ucrt-x86_64-nodejs mingw-w64-ucrt-x86_64-nodejs
mingw-w64-ucrt-x86_64-nsis mingw-w64-ucrt-x86_64-nsis
mingw-w64-ucrt-x86_64-onevpl mingw-w64-ucrt-x86_64-onevpl

View File

@@ -149,6 +149,7 @@ list(APPEND SUNSHINE_EXTERNAL_LIBRARIES
${CMAKE_THREAD_LIBS_INIT} ${CMAKE_THREAD_LIBS_INIT}
enet enet
libdisplaydevice::display_device libdisplaydevice::display_device
nlohmann_json::nlohmann_json
opus opus
${FFMPEG_LIBRARIES} ${FFMPEG_LIBRARIES}
${Boost_LIBRARIES} ${Boost_LIBRARIES}

View File

@@ -76,7 +76,6 @@ list(PREPEND PLATFORM_LIBRARIES
libstdc++.a libstdc++.a
libwinpthread.a libwinpthread.a
minhook::minhook minhook::minhook
nlohmann_json::nlohmann_json
ntdll ntdll
setupapi setupapi
shlwapi shlwapi

View File

@@ -16,6 +16,7 @@ add_subdirectory("${CMAKE_SOURCE_DIR}/third-party/Simple-Web-Server")
add_subdirectory("${CMAKE_SOURCE_DIR}/third-party/libdisplaydevice") add_subdirectory("${CMAKE_SOURCE_DIR}/third-party/libdisplaydevice")
# common dependencies # common dependencies
include("${CMAKE_MODULE_PATH}/dependencies/nlohmann_json.cmake")
find_package(OpenSSL REQUIRED) find_package(OpenSSL REQUIRED)
find_package(PkgConfig REQUIRED) find_package(PkgConfig REQUIRED)
find_package(Threads REQUIRED) find_package(Threads REQUIRED)

View File

@@ -0,0 +1,18 @@
#
# Loads the nlohmann_json library giving the priority to the system package first, with a fallback to FetchContent.
#
include_guard(GLOBAL)
find_package(nlohmann_json 3.11 QUIET GLOBAL)
if(NOT nlohmann_json_FOUND)
message(STATUS "nlohmann_json v3.11.x package not found in the system. Falling back to FetchContent.")
include(FetchContent)
FetchContent_Declare(
json
URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz
URL_HASH MD5=c23a33f04786d85c29fda8d16b5f0efd
DOWNLOAD_EXTRACT_TIMESTAMP
)
FetchContent_MakeAvailable(json)
endif()

View File

@@ -1,8 +1,5 @@
# windows specific dependencies # windows specific dependencies
# nlohmann_json
find_package(nlohmann_json CONFIG 3.11 REQUIRED)
# Make sure MinHook is installed # Make sure MinHook is installed
find_library(MINHOOK_LIBRARY libMinHook.a REQUIRED) find_library(MINHOOK_LIBRARY libMinHook.a REQUIRED)
find_path(MINHOOK_INCLUDE_DIR MinHook.h PATH_SUFFIXES include REQUIRED) find_path(MINHOOK_INCLUDE_DIR MinHook.h PATH_SUFFIXES include REQUIRED)

View File

@@ -12,17 +12,23 @@ basic authentication with the admin username and password.
## GET /api/apps ## GET /api/apps
@copydoc confighttp::getApps() @copydoc confighttp::getApps()
## GET /api/logs
@copydoc confighttp::getLogs()
## POST /api/apps ## POST /api/apps
@copydoc confighttp::saveApp() @copydoc confighttp::saveApp()
## POST /api/apps/close
@copydoc confighttp::closeApp()
## DELETE /api/apps/{index} ## DELETE /api/apps/{index}
@copydoc confighttp::deleteApp() @copydoc confighttp::deleteApp()
## POST /api/covers/upload ## GET /api/clients/list
@copydoc confighttp::uploadCover() @copydoc confighttp::getClients()
## POST /api/clients/unpair
@copydoc confighttp::unpair()
## POST /api/clients/unpair-all
@copydoc confighttp::unpairAll()
## GET /api/config ## GET /api/config
@copydoc confighttp::getConfig() @copydoc confighttp::getConfig()
@@ -33,11 +39,11 @@ basic authentication with the admin username and password.
## POST /api/config ## POST /api/config
@copydoc confighttp::saveConfig() @copydoc confighttp::saveConfig()
## POST /api/restart ## POST /api/covers/upload
@copydoc confighttp::restart() @copydoc confighttp::uploadCover()
## POST /api/reset-display-device-persistence ## GET /api/logs
@copydoc confighttp::resetDisplayDevicePersistence() @copydoc confighttp::getLogs()
## POST /api/password ## POST /api/password
@copydoc confighttp::savePassword() @copydoc confighttp::savePassword()
@@ -45,17 +51,11 @@ basic authentication with the admin username and password.
## POST /api/pin ## POST /api/pin
@copydoc confighttp::savePin() @copydoc confighttp::savePin()
## POST /api/clients/unpair-all ## POST /api/reset-display-device-persistence
@copydoc confighttp::unpairAll() @copydoc confighttp::resetDisplayDevicePersistence()
## POST /api/clients/unpair ## POST /api/restart
@copydoc confighttp::unpair() @copydoc confighttp::restart()
## GET /api/clients/list
@copydoc confighttp::listClients()
## POST /api/apps/close
@copydoc confighttp::closeApp()
<div class="section_buttons"> <div class="section_buttons">

View File

@@ -301,22 +301,19 @@ administrative privileges. Simply enable the elevated option in the WEB UI, or a
This is an option for both prep-cmd and regular commands and will launch the process with the current user without a This is an option for both prep-cmd and regular commands and will launch the process with the current user without a
UAC prompt. UAC prompt.
@note{It is important to write the values "true" and "false" as string values, not as the typical true/false
values in most JSON.}
**Example** **Example**
```json ```json
{ {
"name": "Game With AntiCheat that Requires Admin", "name": "Game With AntiCheat that Requires Admin",
"output": "", "output": "",
"cmd": "ping 127.0.0.1", "cmd": "ping 127.0.0.1",
"exclude-global-prep-cmd": "false", "exclude-global-prep-cmd": false,
"elevated": "true", "elevated": true,
"prep-cmd": [ "prep-cmd": [
{ {
"do": "powershell.exe -command \"Start-Streaming\"", "do": "powershell.exe -command \"Start-Streaming\"",
"undo": "powershell.exe -command \"Stop-Streaming\"", "undo": "powershell.exe -command \"Stop-Streaming\"",
"elevated": "false" "elevated": false
} }
], ],
"image-path": "" "image-path": ""

View File

@@ -92,7 +92,6 @@ dependencies=(
"mingw-w64-ucrt-x86_64-graphviz" # Optional, for docs "mingw-w64-ucrt-x86_64-graphviz" # Optional, for docs
"mingw-w64-ucrt-x86_64-MinHook" "mingw-w64-ucrt-x86_64-MinHook"
"mingw-w64-ucrt-x86_64-miniupnpc" "mingw-w64-ucrt-x86_64-miniupnpc"
"mingw-w64-ucrt-x86_64-nlohmann-json"
"mingw-w64-ucrt-x86_64-nodejs" "mingw-w64-ucrt-x86_64-nodejs"
"mingw-w64-ucrt-x86_64-nsis" "mingw-w64-ucrt-x86_64-nsis"
"mingw-w64-ucrt-x86_64-onevpl" "mingw-w64-ucrt-x86_64-onevpl"

View File

@@ -225,7 +225,7 @@ editing the `conf` file in a text editor. Use the examples as reference.
<tr> <tr>
<td>Example</td> <td>Example</td>
<td colspan="2">@code{} <td colspan="2">@code{}
global_prep_cmd = [{"do":"nircmd.exe setdisplay 1280 720 32 144","undo":"nircmd.exe setdisplay 2560 1440 32 144"}] global_prep_cmd = [{"do":"nircmd.exe setdisplay 1280 720 32 144","elevated":true,"undo":"nircmd.exe setdisplay 2560 1440 32 144"}]
@endcode</td> @endcode</td>
</tr> </tr>
</table> </table>

View File

@@ -8,16 +8,14 @@
// standard includes // standard includes
#include <filesystem> #include <filesystem>
#include <fstream>
#include <set> #include <set>
// lib includes // lib includes
#include <boost/algorithm/string.hpp> #include <boost/algorithm/string.hpp>
#include <boost/asio/ssl/context.hpp> #include <boost/asio/ssl/context.hpp>
#include <boost/asio/ssl/context_base.hpp>
#include <boost/filesystem.hpp> #include <boost/filesystem.hpp>
#include <boost/property_tree/json_parser.hpp> #include <nlohmann/json.hpp>
#include <boost/property_tree/ptree.hpp>
#include <boost/property_tree/xml_parser.hpp>
#include <Simple-Web-Server/crypto.hpp> #include <Simple-Web-Server/crypto.hpp>
#include <Simple-Web-Server/server_https.hpp> #include <Simple-Web-Server/server_https.hpp>
@@ -34,7 +32,6 @@
#include "nvhttp.h" #include "nvhttp.h"
#include "platform/common.h" #include "platform/common.h"
#include "process.h" #include "process.h"
#include "rtsp.h"
#include "utility.h" #include "utility.h"
#include "uuid.h" #include "uuid.h"
#include "version.h" #include "version.h"
@@ -43,7 +40,6 @@ using namespace std::literals;
namespace confighttp { namespace confighttp {
namespace fs = std::filesystem; namespace fs = std::filesystem;
namespace pt = boost::property_tree;
using https_server_t = SimpleWeb::Server<SimpleWeb::HTTPS>; using https_server_t = SimpleWeb::Server<SimpleWeb::HTTPS>;
@@ -82,10 +78,11 @@ namespace confighttp {
* @param response The HTTP response object. * @param response The HTTP response object.
* @param output_tree The JSON tree to send. * @param output_tree The JSON tree to send.
*/ */
void send_response(resp_https_t response, const pt::ptree &output_tree) { void send_response(resp_https_t response, const nlohmann::json &output_tree) {
std::ostringstream data; SimpleWeb::CaseInsensitiveMultimap headers;
pt::write_json(data, output_tree); headers.emplace("Content-Type", "application/json");
response->write(data.str());
response->write(output_tree.dump(), headers);
} }
/** /**
@@ -96,10 +93,20 @@ namespace confighttp {
void send_unauthorized(resp_https_t response, req_https_t request) { void send_unauthorized(resp_https_t response, req_https_t request) {
auto address = net::addr_to_normalized_string(request->remote_endpoint().address()); auto address = net::addr_to_normalized_string(request->remote_endpoint().address());
BOOST_LOG(info) << "Web UI: ["sv << address << "] -- not authorized"sv; BOOST_LOG(info) << "Web UI: ["sv << address << "] -- not authorized"sv;
constexpr SimpleWeb::StatusCode code = SimpleWeb::StatusCode::client_error_unauthorized;
nlohmann::json tree;
tree["status_code"] = code;
tree["status"] = false;
tree["error"] = "Unauthorized";
const SimpleWeb::CaseInsensitiveMultimap headers { const SimpleWeb::CaseInsensitiveMultimap headers {
{"Content-Type", "application/json"},
{"WWW-Authenticate", R"(Basic realm="Sunshine Gamestream Host", charset="UTF-8")"} {"WWW-Authenticate", R"(Basic realm="Sunshine Gamestream Host", charset="UTF-8")"}
}; };
response->write(SimpleWeb::StatusCode::client_error_unauthorized, headers);
response->write(code, tree.dump(), headers);
} }
/** /**
@@ -176,17 +183,14 @@ namespace confighttp {
void not_found(resp_https_t response, [[maybe_unused]] req_https_t request) { void not_found(resp_https_t response, [[maybe_unused]] req_https_t request) {
constexpr SimpleWeb::StatusCode code = SimpleWeb::StatusCode::client_error_not_found; constexpr SimpleWeb::StatusCode code = SimpleWeb::StatusCode::client_error_not_found;
pt::ptree tree; nlohmann::json tree;
tree.put("status_code", static_cast<int>(code)); tree["status_code"] = code;
tree.put("error", "Not Found"); tree["error"] = "Not Found";
std::ostringstream data;
pt::write_json(data, tree);
SimpleWeb::CaseInsensitiveMultimap headers; SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Content-Type", "application/json"); headers.emplace("Content-Type", "application/json");
response->write(code, data.str(), headers); response->write(code, tree.dump(), headers);
} }
/** /**
@@ -198,18 +202,15 @@ namespace confighttp {
void bad_request(resp_https_t response, [[maybe_unused]] req_https_t request, const std::string &error_message = "Bad Request") { void bad_request(resp_https_t response, [[maybe_unused]] req_https_t request, const std::string &error_message = "Bad Request") {
constexpr SimpleWeb::StatusCode code = SimpleWeb::StatusCode::client_error_bad_request; constexpr SimpleWeb::StatusCode code = SimpleWeb::StatusCode::client_error_bad_request;
pt::ptree tree; nlohmann::json tree;
tree.put("status_code", static_cast<int>(code)); tree["status_code"] = code;
tree.put("status", false); tree["status"] = false;
tree.put("error", error_message); tree["error"] = error_message;
std::ostringstream data;
pt::write_json(data, tree);
SimpleWeb::CaseInsensitiveMultimap headers; SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Content-Type", "application/json"); headers.emplace("Content-Type", "application/json");
response->write(code, data.str(), headers); response->write(code, tree.dump(), headers);
} }
/** /**
@@ -455,30 +456,50 @@ namespace confighttp {
print_req(request); print_req(request);
std::string content = file_handler::read_file(config::stream.file_apps.c_str()); try {
SimpleWeb::CaseInsensitiveMultimap headers; std::string content = file_handler::read_file(config::stream.file_apps.c_str());
headers.emplace("Content-Type", "application/json"); nlohmann::json file_tree = nlohmann::json::parse(content);
response->write(content, headers);
}
/** // Legacy versions of Sunshine used strings for boolean and integers, let's convert them
* @brief Get the logs from the log file. // List of keys to convert to boolean
* @param response The HTTP response object. std::vector<std::string> boolean_keys = {
* @param request The HTTP request object. "exclude-global-prep-cmd",
* "elevated",
* @api_examples{/api/logs| GET| null} "auto-detach",
*/ "wait-all"
void getLogs(resp_https_t response, req_https_t request) { };
if (!authenticate(response, request)) {
return; // List of keys to convert to integers
std::vector<std::string> integer_keys = {
"exit-timeout"
};
// Walk fileTree and convert true/false strings to boolean or integer values
for (auto &app : file_tree["apps"]) {
for (const auto &key : boolean_keys) {
if (app.contains(key) && app[key].is_string()) {
app[key] = app[key] == "true";
}
}
for (const auto &key : integer_keys) {
if (app.contains(key) && app[key].is_string()) {
app[key] = std::stoi(app[key].get<std::string>());
}
}
if (app.contains("prep-cmd")) {
for (auto &prep : app["prep-cmd"]) {
if (prep.contains("elevated") && prep["elevated"].is_string()) {
prep["elevated"] = prep["elevated"] == "true";
}
}
}
}
send_response(response, file_tree);
} catch (std::exception &e) {
BOOST_LOG(warning) << "GetApps: "sv << e.what();
bad_request(response, request, e.what());
} }
print_req(request);
std::string content = file_handler::read_file(config::sunshine.log_file.c_str());
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Content-Type", "text/plain");
response->write(SimpleWeb::StatusCode::success_ok, content, headers);
} }
/** /**
@@ -522,74 +543,78 @@ namespace confighttp {
std::stringstream ss; std::stringstream ss;
ss << request->content.rdbuf(); ss << request->content.rdbuf();
BOOST_LOG(info) << config::stream.file_apps;
try { try {
// TODO: Input Validation // TODO: Input Validation
pt::ptree fileTree; nlohmann::json output_tree;
pt::ptree inputTree; nlohmann::json input_tree = nlohmann::json::parse(ss);
pt::ptree outputTree; std::string file = file_handler::read_file(config::stream.file_apps.c_str());
pt::read_json(ss, inputTree); BOOST_LOG(info) << file;
pt::read_json(config::stream.file_apps, fileTree); nlohmann::json file_tree = nlohmann::json::parse(file);
if (inputTree.get_child("prep-cmd").empty()) { if (input_tree["prep-cmd"].empty()) {
inputTree.erase("prep-cmd"); input_tree.erase("prep-cmd");
} }
if (inputTree.get_child("detached").empty()) { if (input_tree["detached"].empty()) {
inputTree.erase("detached"); input_tree.erase("detached");
} }
auto &apps_node = fileTree.get_child("apps"s); auto &apps_node = file_tree["apps"];
int index = inputTree.get<int>("index"); int index = input_tree["index"].get<int>(); // this will intentionally cause exception if the provided value is the wrong type
inputTree.erase("index"); input_tree.erase("index");
if (index == -1) { if (index == -1) {
apps_node.push_back(std::make_pair("", inputTree)); apps_node.push_back(input_tree);
} else { } else {
// Unfortunately Boost PT does not allow to directly edit the array, copy should do the trick nlohmann::json newApps = nlohmann::json::array();
pt::ptree newApps; for (size_t i = 0; i < apps_node.size(); ++i) {
int i = 0;
for (const auto &[k, v] : apps_node) {
if (i == index) { if (i == index) {
newApps.push_back(std::make_pair("", inputTree)); newApps.push_back(input_tree);
} else { } else {
newApps.push_back(std::make_pair("", v)); newApps.push_back(apps_node[i]);
} }
i++;
} }
fileTree.erase("apps"); file_tree["apps"] = newApps;
fileTree.push_back(std::make_pair("apps", newApps));
} }
// Sort the apps array by name // Sort the apps array by name
std::vector<pt::ptree> apps_vector; std::sort(apps_node.begin(), apps_node.end(), [](const nlohmann::json &a, const nlohmann::json &b) {
for (const auto &[k, v] : fileTree.get_child("apps")) { return a["name"].get<std::string>() < b["name"].get<std::string>();
apps_vector.push_back(v);
}
std::ranges::sort(apps_vector, [](const pt::ptree &a, const pt::ptree &b) {
return a.get<std::string>("name") < b.get<std::string>("name");
}); });
pt::ptree sorted_apps; file_handler::write_file(config::stream.file_apps.c_str(), file_tree.dump(4));
for (const auto &app : apps_vector) {
sorted_apps.push_back(std::make_pair("", app));
}
fileTree.erase("apps");
fileTree.add_child("apps", sorted_apps);
pt::write_json(config::stream.file_apps, fileTree);
proc::refresh(config::stream.file_apps); proc::refresh(config::stream.file_apps);
outputTree.put("status", true); output_tree["status"] = true;
send_response(response, outputTree); send_response(response, output_tree);
} catch (std::exception &e) { } catch (std::exception &e) {
BOOST_LOG(warning) << "SaveApp: "sv << e.what(); BOOST_LOG(warning) << "SaveApp: "sv << e.what();
bad_request(response, request, e.what()); bad_request(response, request, e.what());
} }
} }
/**
* @brief Close the currently running application.
* @param response The HTTP response object.
* @param request The HTTP request object.
*
* @api_examples{/api/apps/close| POST| null}
*/
void closeApp(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) {
return;
}
print_req(request);
proc::proc.terminate();
nlohmann::json output_tree;
output_tree["status"] = true;
send_response(response, output_tree);
}
/** /**
* @brief Delete an application. * @brief Delete an application.
* @param response The HTTP response object. * @param response The HTTP response object.
@@ -604,13 +629,13 @@ namespace confighttp {
print_req(request); print_req(request);
pt::ptree outputTree;
try { try {
pt::ptree fileTree; nlohmann::json output_tree;
pt::ptree newApps; nlohmann::json new_apps = nlohmann::json::array();
pt::read_json(config::stream.file_apps, fileTree); std::string file = file_handler::read_file(config::stream.file_apps.c_str());
auto &apps_node = fileTree.get_child("apps"s); nlohmann::json file_tree = nlohmann::json::parse(file);
int index = stoi(request->path_match[1]); auto &apps_node = file_tree["apps"];
const int index = std::stoi(request->path_match[1]);
if (index < 0 || index >= static_cast<int>(apps_node.size())) { if (index < 0 || index >= static_cast<int>(apps_node.size())) {
std::string error; std::string error;
@@ -623,22 +648,19 @@ namespace confighttp {
return; return;
} }
// Unfortunately Boost PT does not allow to directly edit the array, copy should do the trick for (size_t i = 0; i < apps_node.size(); ++i) {
int i = 0; if (i != index) {
for (const auto &[k, v] : apps_node) { new_apps.push_back(apps_node[i]);
if (i++ != index) {
newApps.push_back(std::make_pair("", v));
} }
} }
fileTree.erase("apps"); file_tree["apps"] = new_apps;
fileTree.push_back(std::make_pair("apps", newApps));
pt::write_json(config::stream.file_apps, fileTree); file_handler::write_file(config::stream.file_apps.c_str(), file_tree.dump(4));
proc::refresh(config::stream.file_apps); proc::refresh(config::stream.file_apps);
outputTree.put("status", true); output_tree["status"] = true;
outputTree.put("result", "application "s + std::to_string(index) + " deleted"); output_tree["result"] = "application " + std::to_string(index) + " deleted";
send_response(response, outputTree); send_response(response, output_tree);
} catch (std::exception &e) { } catch (std::exception &e) {
BOOST_LOG(warning) << "DeleteApp: "sv << e.what(); BOOST_LOG(warning) << "DeleteApp: "sv << e.what();
bad_request(response, request, e.what()); bad_request(response, request, e.what());
@@ -646,66 +668,83 @@ namespace confighttp {
} }
/** /**
* @brief Upload a cover image. * @brief Get the list of paired clients.
* @param response The HTTP response object.
* @param request The HTTP request object.
*
* @api_examples{/api/clients/list| GET| null}
*/
void getClients(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) {
return;
}
print_req(request);
const nlohmann::json named_certs = nvhttp::get_all_clients();
nlohmann::json output_tree;
output_tree["named_certs"] = named_certs;
output_tree["status"] = true;
send_response(response, output_tree);
}
/**
* @brief Unpair a client.
* @param response The HTTP response object. * @param response The HTTP response object.
* @param request The HTTP request object. * @param request The HTTP request object.
* The body for the post request should be JSON serialized in the following format: * The body for the post request should be JSON serialized in the following format:
* @code{.json} * @code{.json}
* { * {
* "key": "igdb_<game_id>", * "uuid": "<uuid>"
* "url": "https://images.igdb.com/igdb/image/upload/t_cover_big_2x/<slug>.png"
* } * }
* @endcode * @endcode
* *
* @api_examples{/api/covers/upload| POST| {"key":"igdb_1234","url":"https://images.igdb.com/igdb/image/upload/t_cover_big_2x/abc123.png"}} * @api_examples{/api/unpair| POST| {"uuid":"1234"}}
*/ */
void uploadCover(resp_https_t response, req_https_t request) { void unpair(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) { if (!authenticate(response, request)) {
return; return;
} }
print_req(request);
std::stringstream ss; std::stringstream ss;
std::stringstream configStream;
ss << request->content.rdbuf(); ss << request->content.rdbuf();
pt::ptree outputTree;
pt::ptree inputTree;
try { try {
pt::read_json(ss, inputTree); // TODO: Input Validation
nlohmann::json output_tree;
const nlohmann::json input_tree = nlohmann::json::parse(ss);
const std::string uuid = input_tree.value("uuid", "");
output_tree["status"] = nvhttp::unpair_client(uuid);
send_response(response, output_tree);
} catch (std::exception &e) { } catch (std::exception &e) {
BOOST_LOG(warning) << "UploadCover: "sv << e.what(); BOOST_LOG(warning) << "Unpair: "sv << e.what();
bad_request(response, request, e.what()); bad_request(response, request, e.what());
}
}
/**
* @brief Unpair all clients.
* @param response The HTTP response object.
* @param request The HTTP request object.
*
* @api_examples{/api/clients/unpair-all| POST| null}
*/
void unpairAll(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) {
return; return;
} }
auto key = inputTree.get("key", ""); print_req(request);
if (key.empty()) {
bad_request(response, request, "Cover key is required");
return;
}
auto url = inputTree.get("url", "");
const std::string coverdir = platf::appdata().string() + "/covers/"; nvhttp::erase_all_clients();
file_handler::make_directory(coverdir); proc::proc.terminate();
std::basic_string path = coverdir + http::url_escape(key) + ".png"; nlohmann::json output_tree;
if (!url.empty()) { output_tree["status"] = true;
if (http::url_get_host(url) != "images.igdb.com") { send_response(response, output_tree);
bad_request(response, request, "Only images.igdb.com is allowed");
return;
}
if (!http::download_file(url, path)) {
bad_request(response, request, "Failed to download cover");
return;
}
} else {
auto data = SimpleWeb::Crypto::Base64::decode(inputTree.get<std::string>("data"));
std::ofstream imgfile(path);
imgfile.write(data.data(), (int) data.size());
}
outputTree.put("status", true);
outputTree.put("path", path);
send_response(response, outputTree);
} }
/** /**
@@ -722,18 +761,18 @@ namespace confighttp {
print_req(request); print_req(request);
pt::ptree outputTree; nlohmann::json output_tree;
outputTree.put("status", true); output_tree["status"] = true;
outputTree.put("platform", SUNSHINE_PLATFORM); output_tree["platform"] = SUNSHINE_PLATFORM;
outputTree.put("version", PROJECT_VER); output_tree["version"] = PROJECT_VER;
auto vars = config::parse_config(file_handler::read_file(config::sunshine.config_file.c_str())); auto vars = config::parse_config(file_handler::read_file(config::sunshine.config_file.c_str()));
for (auto &[name, value] : vars) { for (auto &[name, value] : vars) {
outputTree.put(std::move(name), std::move(value)); output_tree[name] = std::move(value);
} }
send_response(response, outputTree); send_response(response, output_tree);
} }
/** /**
@@ -748,10 +787,10 @@ namespace confighttp {
print_req(request); print_req(request);
pt::ptree outputTree; nlohmann::json output_tree;
outputTree.put("status", true); output_tree["status"] = true;
outputTree.put("locale", config::sunshine.locale); output_tree["locale"] = config::sunshine.locale;
send_response(response, outputTree); send_response(response, output_tree);
} }
/** /**
@@ -777,24 +816,24 @@ namespace confighttp {
print_req(request); print_req(request);
std::stringstream ss; std::stringstream ss;
std::stringstream configStream;
ss << request->content.rdbuf(); ss << request->content.rdbuf();
try { try {
// TODO: Input Validation // TODO: Input Validation
pt::ptree inputTree; std::stringstream config_stream;
pt::ptree outputTree; nlohmann::json output_tree;
pt::read_json(ss, inputTree); nlohmann::json input_tree = nlohmann::json::parse(ss);
for (const auto &[k, v] : inputTree) { for (const auto &[k, v] : input_tree.items()) {
std::string value = inputTree.get<std::string>(k); if (v.is_null() || (v.is_string() && v.get<std::string>().empty())) {
if (value.length() == 0 || value.compare("null") == 0) {
continue; continue;
} }
configStream << k << " = " << value << std::endl; // v.dump() will dump valid json, which we do not want for strings in the config right now
// we should migrate the config file to straight json and get rid of all this nonsense
config_stream << k << " = " << (v.is_string() ? v.get<std::string>() : v.dump()) << std::endl;
} }
file_handler::write_file(config::sunshine.config_file.c_str(), configStream.str()); file_handler::write_file(config::sunshine.config_file.c_str(), config_stream.str());
outputTree.put("status", true); output_tree["status"] = true;
send_response(response, outputTree); send_response(response, output_tree);
} catch (std::exception &e) { } catch (std::exception &e) {
BOOST_LOG(warning) << "SaveConfig: "sv << e.what(); BOOST_LOG(warning) << "SaveConfig: "sv << e.what();
bad_request(response, request, e.what()); bad_request(response, request, e.what());
@@ -802,40 +841,83 @@ namespace confighttp {
} }
/** /**
* @brief Restart Sunshine. * @brief Upload a cover image.
* @param response The HTTP response object. * @param response The HTTP response object.
* @param request The HTTP request object. * @param request The HTTP request object.
* The body for the post request should be JSON serialized in the following format:
* @code{.json}
* {
* "key": "igdb_<game_id>",
* "url": "https://images.igdb.com/igdb/image/upload/t_cover_big_2x/<slug>.png"
* }
* @endcode
* *
* @api_examples{/api/restart| POST| null} * @api_examples{/api/covers/upload| POST| {"key":"igdb_1234","url":"https://images.igdb.com/igdb/image/upload/t_cover_big_2x/abc123.png"}}
*/ */
void restart(resp_https_t response, req_https_t request) { void uploadCover(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) { if (!authenticate(response, request)) {
return; return;
} }
print_req(request); std::stringstream ss;
ss << request->content.rdbuf();
try {
nlohmann::json output_tree;
nlohmann::json input_tree = nlohmann::json::parse(ss);
// We may not return from this call std::string key = input_tree.value("key", "");
platf::restart(); if (key.empty()) {
bad_request(response, request, "Cover key is required");
return;
}
std::string url = input_tree.value("url", "");
const std::string coverdir = platf::appdata().string() + "/covers/";
file_handler::make_directory(coverdir);
std::basic_string path = coverdir + http::url_escape(key) + ".png";
if (!url.empty()) {
if (http::url_get_host(url) != "images.igdb.com") {
bad_request(response, request, "Only images.igdb.com is allowed");
return;
}
if (!http::download_file(url, path)) {
bad_request(response, request, "Failed to download cover");
return;
}
} else {
auto data = SimpleWeb::Crypto::Base64::decode(input_tree.value("data", ""));
std::ofstream imgfile(path);
imgfile.write(data.data(), static_cast<int>(data.size()));
}
output_tree["status"] = true;
output_tree["path"] = path;
send_response(response, output_tree);
} catch (std::exception &e) {
BOOST_LOG(warning) << "UploadCover: "sv << e.what();
bad_request(response, request, e.what());
}
} }
/** /**
* @brief Reset the display device persistence. * @brief Get the logs from the log file.
* @param response The HTTP response object. * @param response The HTTP response object.
* @param request The HTTP request object. * @param request The HTTP request object.
* *
* @api_examples{/api/reset-display-device-persistence| POST| null} * @api_examples{/api/logs| GET| null}
*/ */
void resetDisplayDevicePersistence(resp_https_t response, req_https_t request) { void getLogs(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) { if (!authenticate(response, request)) {
return; return;
} }
print_req(request); print_req(request);
pt::ptree outputTree; std::string content = file_handler::read_file(config::sunshine.log_file.c_str());
outputTree.put("status", display_device::reset_persistence()); SimpleWeb::CaseInsensitiveMultimap headers;
send_response(response, outputTree); headers.emplace("Content-Type", "text/plain");
response->write(SimpleWeb::StatusCode::success_ok, content, headers);
} }
/** /**
@@ -864,23 +946,21 @@ namespace confighttp {
std::vector<std::string> errors = {}; std::vector<std::string> errors = {};
std::stringstream ss; std::stringstream ss;
std::stringstream configStream; std::stringstream config_stream;
ss << request->content.rdbuf(); ss << request->content.rdbuf();
try { try {
// TODO: Input Validation // TODO: Input Validation
pt::ptree inputTree; nlohmann::json output_tree;
pt::ptree outputTree; nlohmann::json input_tree = nlohmann::json::parse(ss);
pt::read_json(ss, inputTree); std::string username = input_tree.value("currentUsername", "");
auto username = inputTree.count("currentUsername") > 0 ? inputTree.get<std::string>("currentUsername") : ""; std::string newUsername = input_tree.value("newUsername", "");
auto newUsername = inputTree.get<std::string>("newUsername"); std::string password = input_tree.value("currentPassword", "");
auto password = inputTree.count("currentPassword") > 0 ? inputTree.get<std::string>("currentPassword") : ""; std::string newPassword = input_tree.value("newPassword", "");
auto newPassword = inputTree.count("newPassword") > 0 ? inputTree.get<std::string>("newPassword") : ""; std::string confirmPassword = input_tree.value("confirmNewPassword", "");
auto confirmPassword = inputTree.count("confirmNewPassword") > 0 ? inputTree.get<std::string>("confirmNewPassword") : ""; if (newUsername.empty()) {
if (newUsername.length() == 0) {
newUsername = username; newUsername = username;
} }
if (newUsername.length() == 0) { if (newUsername.empty()) {
errors.emplace_back("Invalid Username"); errors.emplace_back("Invalid Username");
} else { } else {
auto hash = util::hex(crypto::hash(password + config::sunshine.salt)).to_string(); auto hash = util::hex(crypto::hash(password + config::sunshine.salt)).to_string();
@@ -890,7 +970,7 @@ namespace confighttp {
} else { } else {
http::save_user_creds(config::sunshine.credentials_file, newUsername, newPassword); http::save_user_creds(config::sunshine.credentials_file, newUsername, newPassword);
http::reload_user_creds(config::sunshine.credentials_file); http::reload_user_creds(config::sunshine.credentials_file);
outputTree.put("status", true); output_tree["status"] = true;
} }
} else { } else {
errors.emplace_back("Invalid Current Credentials"); errors.emplace_back("Invalid Current Credentials");
@@ -906,7 +986,7 @@ namespace confighttp {
return; return;
} }
send_response(response, outputTree); send_response(response, output_tree);
} catch (std::exception &e) { } catch (std::exception &e) {
BOOST_LOG(warning) << "SavePassword: "sv << e.what(); BOOST_LOG(warning) << "SavePassword: "sv << e.what();
bad_request(response, request, e.what()); bad_request(response, request, e.what());
@@ -936,16 +1016,20 @@ namespace confighttp {
std::stringstream ss; std::stringstream ss;
ss << request->content.rdbuf(); ss << request->content.rdbuf();
try { try {
// TODO: Input Validation nlohmann::json output_tree;
pt::ptree inputTree; nlohmann::json input_tree = nlohmann::json::parse(ss);
pt::ptree outputTree; const std::string name = input_tree.value("name", "");
pt::read_json(ss, inputTree); const std::string pin = input_tree.value("pin", "");
std::string pin = inputTree.get<std::string>("pin");
std::string name = inputTree.get<std::string>("name"); int _pin = 0;
outputTree.put("status", nvhttp::pin(pin, name)); _pin = std::stoi(pin);
send_response(response, outputTree); if (_pin < 0 || _pin > 9999) {
bad_request(response, request, "PIN must be between 0000 and 9999");
}
output_tree["status"] = nvhttp::pin(pin, name);
send_response(response, output_tree);
} catch (std::exception &e) { } catch (std::exception &e) {
BOOST_LOG(warning) << "SavePin: "sv << e.what(); BOOST_LOG(warning) << "SavePin: "sv << e.what();
bad_request(response, request, e.what()); bad_request(response, request, e.what());
@@ -953,106 +1037,40 @@ namespace confighttp {
} }
/** /**
* @brief Unpair all clients. * @brief Reset the display device persistence.
* @param response The HTTP response object. * @param response The HTTP response object.
* @param request The HTTP request object. * @param request The HTTP request object.
* *
* @api_examples{/api/clients/unpair-all| POST| null} * @api_examples{/api/reset-display-device-persistence| POST| null}
*/ */
void unpairAll(resp_https_t response, req_https_t request) { void resetDisplayDevicePersistence(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) { if (!authenticate(response, request)) {
return; return;
} }
print_req(request); print_req(request);
nvhttp::erase_all_clients(); nlohmann::json output_tree;
proc::proc.terminate(); output_tree["status"] = display_device::reset_persistence();
send_response(response, output_tree);
pt::ptree outputTree;
outputTree.put("status", true);
send_response(response, outputTree);
} }
/** /**
* @brief Unpair a client. * @brief Restart Sunshine.
* @param response The HTTP response object. * @param response The HTTP response object.
* @param request The HTTP request object. * @param request The HTTP request object.
* The body for the post request should be JSON serialized in the following format:
* @code{.json}
* {
* "uuid": "<uuid>"
* }
* @endcode
* *
* @api_examples{/api/unpair| POST| {"uuid":"1234"}} * @api_examples{/api/restart| POST| null}
*/ */
void unpair(resp_https_t response, req_https_t request) { void restart(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) { if (!authenticate(response, request)) {
return; return;
} }
print_req(request); print_req(request);
std::stringstream ss; // We may not return from this call
ss << request->content.rdbuf(); platf::restart();
try {
// TODO: Input Validation
pt::ptree inputTree;
pt::ptree outputTree;
pt::read_json(ss, inputTree);
std::string uuid = inputTree.get<std::string>("uuid");
outputTree.put("status", nvhttp::unpair_client(uuid));
send_response(response, outputTree);
} catch (std::exception &e) {
BOOST_LOG(warning) << "Unpair: "sv << e.what();
bad_request(response, request, e.what());
}
}
/**
* @brief Get the list of paired clients.
* @param response The HTTP response object.
* @param request The HTTP request object.
*
* @api_examples{/api/clients/list| GET| null}
*/
void listClients(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) {
return;
}
print_req(request);
const pt::ptree named_certs = nvhttp::get_all_clients();
pt::ptree outputTree;
outputTree.put("status", false);
outputTree.add_child("named_certs", named_certs);
outputTree.put("status", true);
send_response(response, outputTree);
}
/**
* @brief Close the currently running application.
* @param response The HTTP response object.
* @param request The HTTP request object.
*
* @api_examples{/api/apps/close| POST| null}
*/
void closeApp(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) {
return;
}
print_req(request);
proc::proc.terminate();
pt::ptree outputTree;
outputTree.put("status", true);
send_response(response, outputTree);
} }
void start() { void start() {
@@ -1095,7 +1113,7 @@ namespace confighttp {
server.resource["^/api/password$"]["POST"] = savePassword; server.resource["^/api/password$"]["POST"] = savePassword;
server.resource["^/api/apps/([0-9]+)$"]["DELETE"] = deleteApp; server.resource["^/api/apps/([0-9]+)$"]["DELETE"] = deleteApp;
server.resource["^/api/clients/unpair-all$"]["POST"] = unpairAll; server.resource["^/api/clients/unpair-all$"]["POST"] = unpairAll;
server.resource["^/api/clients/list$"]["GET"] = listClients; server.resource["^/api/clients/list$"]["GET"] = getClients;
server.resource["^/api/clients/unpair$"]["POST"] = unpair; server.resource["^/api/clients/unpair$"]["POST"] = unpair;
server.resource["^/api/apps/close$"]["POST"] = closeApp; server.resource["^/api/apps/close$"]["POST"] = closeApp;
server.resource["^/api/covers/upload$"]["POST"] = uploadCover; server.resource["^/api/covers/upload$"]["POST"] = uploadCover;

View File

@@ -5,7 +5,6 @@
#pragma once #pragma once
// standard includes // standard includes
#include <functional>
#include <string> #include <string>
// local includes // local includes

View File

@@ -765,14 +765,14 @@ namespace nvhttp {
response->close_connection_after_response = true; response->close_connection_after_response = true;
} }
pt::ptree get_all_clients() { nlohmann::json get_all_clients() {
pt::ptree named_cert_nodes; nlohmann::json named_cert_nodes = nlohmann::json::array();
client_t &client = client_root; client_t &client = client_root;
for (auto &named_cert : client.named_devices) { for (auto &named_cert : client.named_devices) {
pt::ptree named_cert_node; nlohmann::json named_cert_node;
named_cert_node.put("name"s, named_cert.name); named_cert_node["name"] = named_cert.name;
named_cert_node.put("uuid"s, named_cert.uuid); named_cert_node["uuid"] = named_cert.uuid;
named_cert_nodes.push_back(std::make_pair(""s, named_cert_node)); named_cert_nodes.push_back(named_cert_node);
} }
return named_cert_nodes; return named_cert_nodes;
@@ -1177,13 +1177,13 @@ namespace nvhttp {
save_state(); save_state();
} }
int unpair_client(std::string uuid) { bool unpair_client(const std::string_view uuid) {
int removed = 0; bool removed = false;
client_t &client = client_root; client_t &client = client_root;
for (auto it = client.named_devices.begin(); it != client.named_devices.end();) { for (auto it = client.named_devices.begin(); it != client.named_devices.end();) {
if ((*it).uuid == uuid) { if ((*it).uuid == uuid) {
it = client.named_devices.erase(it); it = client.named_devices.erase(it);
removed++; removed = true;
} else { } else {
++it; ++it;
} }

View File

@@ -10,6 +10,7 @@
// lib includes // lib includes
#include <boost/property_tree/ptree.hpp> #include <boost/property_tree/ptree.hpp>
#include <nlohmann/json.hpp>
#include <Simple-Web-Server/server_https.hpp> #include <Simple-Web-Server/server_https.hpp>
// local includes // local includes
@@ -176,20 +177,21 @@ namespace nvhttp {
/** /**
* @brief Remove single client. * @brief Remove single client.
* @param uuid The UUID of the client to remove.
* @examples * @examples
* nvhttp::unpair_client("4D7BB2DD-5704-A405-B41C-891A022932E1"); * nvhttp::unpair_client("4D7BB2DD-5704-A405-B41C-891A022932E1");
* @examples_end * @examples_end
*/ */
int unpair_client(std::string uniqueid); bool unpair_client(std::string_view uuid);
/** /**
* @brief Get all paired clients. * @brief Get all paired clients.
* @return The list of all paired clients. * @return The list of all paired clients.
* @examples * @examples
* boost::property_tree::ptree clients = nvhttp::get_all_clients(); * nlohmann::json clients = nvhttp::get_all_clients();
* @examples_end * @examples_end
*/ */
boost::property_tree::ptree get_all_clients(); nlohmann::json get_all_clients();
/** /**
* @brief Remove all paired clients. * @brief Remove all paired clients.

View File

@@ -234,8 +234,8 @@
<!-- exit timeout --> <!-- exit timeout -->
<div class="mb-3"> <div class="mb-3">
<label for="exitTimeout" class="form-label">{{ $t('apps.exit_timeout') }}</label> <label for="exitTimeout" class="form-label">{{ $t('apps.exit_timeout') }}</label>
<input type="text" class="form-control monospace" id="exitTimeout" aria-describedby="exitTimeoutHelp" <input type="number" class="form-control monospace" id="exitTimeout" aria-describedby="exitTimeoutHelp"
v-model="editForm['exit-timeout']" /> v-model="editForm['exit-timeout']" min="0" placeholder="5" />
<div id="exitTimeoutHelp" class="form-text">{{ $t('apps.exit_timeout_desc') }}</div> <div id="exitTimeoutHelp" class="form-text">{{ $t('apps.exit_timeout_desc') }}</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">

View File

@@ -343,10 +343,7 @@
this.$forceUpdate() this.$forceUpdate()
}, },
serialize() { serialize() {
let config = JSON.parse(JSON.stringify(this.config)); return JSON.parse(JSON.stringify(this.config));
config.global_prep_cmd = JSON.stringify(config.global_prep_cmd);
config.dd_mode_remapping = JSON.stringify(config.dd_mode_remapping);
return config;
}, },
save() { save() {
this.saved = false; this.saved = false;
@@ -360,17 +357,8 @@
Object.keys(tab.options).forEach(optionKey => { Object.keys(tab.options).forEach(optionKey => {
let delete_value = false let delete_value = false
if (["global_prep_cmd", "dd_mode_remapping"].includes(optionKey)) {
const config_value = config[optionKey]
const default_value = JSON.stringify(tab.options[optionKey])
if (config_value === default_value) {
delete_value = true
}
}
// todo: add proper type checking // todo: add proper type checking
if (String(config[optionKey]) === String(tab.options[optionKey])) { if (JSON.stringify(config[optionKey]) === JSON.stringify(tab.options[optionKey])) {
delete_value = true delete_value = true
} }

View File

@@ -39,14 +39,12 @@ const config = ref(props.config)
</div> </div>
<!-- Spatial AQ --> <!-- Spatial AQ -->
<div class="mb-3"> <Checkbox class="mb-3"
<label for="nvenc_spatial_aq" class="form-label">{{ $t('config.nvenc_spatial_aq') }}</label> id="nvenc_spatial_aq"
<select id="nvenc_spatial_aq" class="form-select" v-model="config.nvenc_spatial_aq"> locale-prefix="config"
<option value="disabled">{{ $t('config.nvenc_spatial_aq_disabled') }}</option> v-model="config.nvenc_spatial_aq"
<option value="enabled">{{ $t('config.nvenc_spatial_aq_enabled') }}</option> default="false"
</select> ></Checkbox>
<div class="form-text">{{ $t('config.nvenc_spatial_aq_desc') }}</div>
</div>
<!-- Single-frame VBV/HRD percentage increase --> <!-- Single-frame VBV/HRD percentage increase -->
<div class="mb-3"> <div class="mb-3">

View File

@@ -65,7 +65,7 @@
"env_vars_desc": "All commands get these environment variables by default:", "env_vars_desc": "All commands get these environment variables by default:",
"env_xrandr_example": "Example - Xrandr for Resolution Automation:", "env_xrandr_example": "Example - Xrandr for Resolution Automation:",
"exit_timeout": "Exit Timeout", "exit_timeout": "Exit Timeout",
"exit_timeout_desc": "Number of seconds to wait for all app processes to gracefully exit when requested to quit. If unset, the default is to wait up to 5 seconds. If set to zero or a negative value, the app will be immediately terminated.", "exit_timeout_desc": "Number of seconds to wait for all app processes to gracefully exit when requested to quit. If unset, the default is to wait up to 5 seconds. If set to 0, the app will be immediately terminated.",
"find_cover": "Find Cover", "find_cover": "Find Cover",
"global_prep_desc": "Enable/Disable the execution of Global Prep Commands for this application.", "global_prep_desc": "Enable/Disable the execution of Global Prep Commands for this application.",
"global_prep_name": "Global Prep Commands", "global_prep_name": "Global Prep Commands",
@@ -279,8 +279,6 @@
"nvenc_realtime_hags_desc": "Currently NVIDIA drivers may freeze in encoder when HAGS is enabled, realtime priority is used and VRAM utilization is close to maximum. Disabling this option lowers the priority to high, sidestepping the freeze at the cost of reduced capture performance when the GPU is heavily loaded.", "nvenc_realtime_hags_desc": "Currently NVIDIA drivers may freeze in encoder when HAGS is enabled, realtime priority is used and VRAM utilization is close to maximum. Disabling this option lowers the priority to high, sidestepping the freeze at the cost of reduced capture performance when the GPU is heavily loaded.",
"nvenc_spatial_aq": "Spatial AQ", "nvenc_spatial_aq": "Spatial AQ",
"nvenc_spatial_aq_desc": "Assign higher QP values to flat regions of the video. Recommended to enable when streaming at lower bitrates.", "nvenc_spatial_aq_desc": "Assign higher QP values to flat regions of the video. Recommended to enable when streaming at lower bitrates.",
"nvenc_spatial_aq_disabled": "Disabled (faster, default)",
"nvenc_spatial_aq_enabled": "Enabled (slower)",
"nvenc_twopass": "Two-pass mode", "nvenc_twopass": "Two-pass mode",
"nvenc_twopass_desc": "Adds preliminary encoding pass. This allows to detect more motion vectors, better distribute bitrate across the frame and more strictly adhere to bitrate limits. Disabling it is not recommended since this can lead to occasional bitrate overshoot and subsequent packet loss.", "nvenc_twopass_desc": "Adds preliminary encoding pass. This allows to detect more motion vectors, better distribute bitrate across the frame and more strictly adhere to bitrate limits. Disabling it is not recommended since this can lead to occasional bitrate overshoot and subsequent packet loss.",
"nvenc_twopass_disabled": "Disabled (fastest, not recommended)", "nvenc_twopass_disabled": "Disabled (fastest, not recommended)",

View File

@@ -8,8 +8,8 @@
{ {
"name": "Steam Big Picture", "name": "Steam Big Picture",
"cmd": "steam://open/bigpicture", "cmd": "steam://open/bigpicture",
"auto-detach": "true", "auto-detach": true,
"wait-all": "true", "wait-all": true,
"image-path": "steam.png" "image-path": "steam.png"
} }
] ]