diff --git a/docs/modules/dev/pages/api.adoc b/docs/modules/dev/pages/api.adoc index f32bdfbf..9e5228e9 100644 --- a/docs/modules/dev/pages/api.adoc +++ b/docs/modules/dev/pages/api.adoc @@ -2,7 +2,8 @@ = Wolf API Wolf exposes a REST API that allows you to interact with the platform programmatically. + -The API can be accessed only via UNIX sockets, you can control the exact path by setting the `WOLF_SOCKET_PATH` environment variable. If you want to access the socket from outside the container, you should mount the socket to the host machine, ex: `-e WOLF_SOCKET_PATH=/var/run/wolf/wolf.sock` and `-v /var/run/wolf:/var/run/wolf` will allow you to access the socket from the host machine at `/var/run/wolf/wolf.sock`. +The API can be accessed only via UNIX sockets, you can control the exact path by setting the `WOLF_SOCKET_PATH` environment variable. +If you want to access the socket from outside the container, you should mount the socket to the host machine, ex: `-e WOLF_SOCKET_PATH=/var/run/wolf/wolf.sock` and `-v /var/run/wolf:/var/run/wolf` will allow you to access the socket from the host machine at `/var/run/wolf/wolf.sock`. You can test out the API using the `curl` command, for example, to get the OpenAPI specification you can run: @@ -66,5 +67,16 @@ curl localhost:8080/api/v1/openapi-schema type="application/json"> include::{includedir}/spec.json[] + + ++++ \ No newline at end of file diff --git a/docs/modules/dev/partials/spec.json b/docs/modules/dev/partials/spec.json index 68c16c63..8b0347ec 100644 --- a/docs/modules/dev/partials/spec.json +++ b/docs/modules/dev/partials/spec.json @@ -176,6 +176,65 @@ } } }, + "/api/v1/clients/settings": { + "post": { + "summary": "Update client settings", + "description": "Update a client's settings including app state folder and client-specific settings", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/wolf__api__UpdateClientSettingsRequest" + } + } + }, + "description": "", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/wolf__api__GenericSuccessResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/wolf__api__GenericErrorResponse" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/wolf__api__GenericErrorResponse" + } + } + }, + "description": "" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/wolf__api__GenericErrorResponse" + } + } + }, + "description": "" + } + } + } + }, "/api/v1/pair/client": { "post": { "summary": "Pair a client", @@ -448,6 +507,45 @@ } } } + }, + "/api/v1/unpair/client": { + "post": { + "summary": "Unpair a client", + "description": "", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/wolf__api__UnpairClientRequest" + } + } + }, + "description": "", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/wolf__api__GenericSuccessResponse" + } + } + }, + "description": "" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/wolf__api__GenericErrorResponse" + } + } + }, + "description": "" + } + } + } } }, "components": { @@ -640,12 +738,16 @@ "type": "string" }, "client_id": { - "type": "integer" + "type": "string" + }, + "settings": { + "$ref": "#/components/schemas/wolf__config__ClientSettings" } }, "required": [ "app_state_folder", - "client_id" + "client_id", + "settings" ] }, "wolf__api__PairedClientsResponse": { @@ -666,13 +768,104 @@ "success" ] }, + "wolf__api__PartialClientSettings": { + "type": "object", + "properties": { + "controllers_override": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "XBOX", + "PS", + "NINTENDO", + "AUTO" + ] + } + }, + { + "type": "null" + } + ] + }, + "h_scroll_acceleration": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "mouse_acceleration": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "run_gid": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "run_uid": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "v_scroll_acceleration": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": [] + }, + "wolf__api__PendingPairClient": { + "type": "object", + "properties": { + "client_ip": { + "type": "string", + "description": "The IP of the remote Moonlight client" + }, + "pair_secret": { + "type": "string" + } + }, + "required": [ + "client_ip", + "pair_secret" + ] + }, "wolf__api__PendingPairRequestsResponse": { "type": "object", "properties": { "requests": { "type": "array", "items": { - "$ref": "#/components/schemas/wolf__api__PairRequest" + "$ref": "#/components/schemas/wolf__api__PendingPairClient" } }, "success": { @@ -803,6 +996,54 @@ "session_id" ] }, + "wolf__api__UnpairClientRequest": { + "type": "object", + "properties": { + "client_id": { + "type": "string", + "description": "The client ID to unpair" + } + }, + "required": [ + "client_id" + ] + }, + "wolf__api__UpdateClientSettingsRequest": { + "type": "object", + "properties": { + "app_state_folder": { + "description": "New app state folder path (optional)", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "client_id": { + "type": "string", + "description": "The client ID to identify the client (derived from certificate)" + }, + "settings": { + "description": "Client settings to update (only specified fields will be updated)", + "anyOf": [ + { + "$ref": "#/components/schemas/wolf__api__PartialClientSettings" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "app_state_folder", + "client_id", + "settings" + ] + }, "wolf__config__AppCMD__tagged": { "type": "object", "properties": { @@ -1128,4 +1369,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/moonlight-server/api/api.hpp b/src/moonlight-server/api/api.hpp index 1270e3a6..7aef1672 100644 --- a/src/moonlight-server/api/api.hpp +++ b/src/moonlight-server/api/api.hpp @@ -12,11 +12,20 @@ using namespace wolf::core; void start_server(immer::box app_state); +struct PendingPairClient { + std::string pair_secret; + rfl::Description<"The IP of the remote Moonlight client", std::string> client_ip; +}; + struct PairRequest { std::string pair_secret; rfl::Description<"The PIN created by the remote Moonlight client", std::string> pin; }; +struct UnpairClientRequest { + rfl::Description<"The client ID to unpair", std::string> client_id; +}; + struct GenericSuccessResponse { bool success = true; }; @@ -28,12 +37,13 @@ struct GenericErrorResponse { struct PendingPairRequestsResponse { bool success = true; - std::vector requests; + std::vector requests; }; struct PairedClient { - std::size_t client_id; + std::string client_id; std::string app_state_folder; + config::ClientSettings settings = {}; }; struct PairedClientsResponse { @@ -41,6 +51,22 @@ struct PairedClientsResponse { std::vector clients; }; +struct PartialClientSettings { + std::optional run_uid; + std::optional run_gid; + std::optional> controllers_override; + std::optional mouse_acceleration; + std::optional v_scroll_acceleration; + std::optional h_scroll_acceleration; +}; + +struct UpdateClientSettingsRequest { + rfl::Description<"The client ID to identify the client (derived from certificate)", std::string> client_id; + rfl::Description<"New app state folder path (optional)", std::optional> app_state_folder; + rfl::Description<"Client settings to update (only specified fields will be updated)", std::optional> + settings; +}; + struct AppListResponse { bool success = true; std::vector::ReflType> apps; @@ -114,7 +140,7 @@ class UnixSocketServer { void endpoint_Apps(const HTTPRequest &req, std::shared_ptr socket); void endpoint_AddApp(const HTTPRequest &req, std::shared_ptr socket); void endpoint_RemoveApp(const HTTPRequest &req, std::shared_ptr socket); - + void endpoint_UnpairClient(const HTTPRequest &req, std::shared_ptr socket); void endpoint_StreamSessions(const HTTPRequest &req, std::shared_ptr socket); void endpoint_StreamSessionAdd(const HTTPRequest &req, std::shared_ptr socket); void endpoint_StreamSessionStart(const HTTPRequest &req, std::shared_ptr socket); @@ -124,6 +150,8 @@ class UnixSocketServer { void endpoint_RunnerStart(const HTTPRequest &req, std::shared_ptr socket); + void endpoint_UpdateClientSettings(const HTTPRequest &req, std::shared_ptr socket); + void sse_broadcast(const std::string &payload); void sse_keepalive(const boost::system::error_code &e); diff --git a/src/moonlight-server/api/endpoints.cpp b/src/moonlight-server/api/endpoints.cpp index d9cb6b75..72a2363f 100644 --- a/src/moonlight-server/api/endpoints.cpp +++ b/src/moonlight-server/api/endpoints.cpp @@ -15,9 +15,9 @@ void UnixSocketServer::endpoint_Events(const HTTPRequest &req, std::shared_ptr socket) { - auto requests = std::vector(); + auto requests = std::vector(); for (auto [secret, pair_request] : *(state_->app_state)->pairing_atom->load()) { - requests.push_back({.pair_secret = secret, .pin = pair_request->client_ip}); + requests.push_back({.pair_secret = secret, .client_ip = pair_request->client_ip}); } send_http(socket, 200, rfl::json::write(PendingPairRequestsResponse{.requests = requests})); } @@ -44,13 +44,41 @@ void UnixSocketServer::endpoint_Pair(const HTTPRequest &req, std::shared_ptr socket) { auto res = PairedClientsResponse{.success = true}; auto clients = state_->app_state->config->paired_clients->load(); - for (const auto &client : clients.get()) { - res.clients.push_back( - PairedClient{.client_id = state::get_client_id(client), .app_state_folder = client->app_state_folder}); + for (const config::PairedClient &client : clients.get()) { + res.clients.push_back(PairedClient{.client_id = std::to_string(state::get_client_id(client)), + .app_state_folder = client.app_state_folder, + .settings = client.settings}); } send_http(socket, 200, rfl::json::write(res)); } +void UnixSocketServer::endpoint_UnpairClient(const HTTPRequest &req, std::shared_ptr socket) { + try { + auto payload_result = rfl::json::read(req.body); + if (!payload_result) { + auto res = GenericErrorResponse{.error = "Invalid request format"}; + send_http(socket, 400, rfl::json::write(res)); + return; + } + + const auto &payload = payload_result.value(); // Unwrap the Result + auto client = state::get_client_by_id(this->state_->app_state->config, payload.client_id.value()); + if (!client) { + auto res = GenericErrorResponse{.error = "Client not found"}; + send_http(socket, 404, rfl::json::write(res)); + return; + } + + state::unpair(this->state_->app_state->config, *client); + + auto res = GenericSuccessResponse{.success = true}; + send_http(socket, 200, rfl::json::write(res)); + } catch (const std::exception &e) { + auto res = GenericErrorResponse{.error = e.what()}; + send_http(socket, 500, rfl::json::write(res)); + } +} + void UnixSocketServer::endpoint_Apps(const HTTPRequest &req, std::shared_ptr socket) { auto res = AppListResponse{.success = true}; auto apps = state_->app_state->config->apps->load(); @@ -125,7 +153,7 @@ void UnixSocketServer::endpoint_StreamSessionAdd(const HTTPRequest &req, std::sh return; } - auto client = state::get_client_by_id(this->state_->app_state->config, std::stoul(ss.client_id)); + auto client = state::get_client_by_id(this->state_->app_state->config, ss.client_id); if (!client) { logs::log(logs::warning, "[API] Invalid client_id: {}", ss.client_id); auto res = GenericErrorResponse{.error = "Invalid client_id"}; @@ -279,4 +307,41 @@ void UnixSocketServer::endpoint_RunnerStart(const wolf::api::HTTPRequest &req, s } } +void UnixSocketServer::endpoint_UpdateClientSettings(const HTTPRequest &req, std::shared_ptr socket) { + auto payload_result = rfl::json::read(req.body); + if (!payload_result) { + auto res = GenericErrorResponse{.error = "Invalid request format"}; + send_http(socket, 400, rfl::json::write(res)); + return; + } + + const auto &payload = payload_result.value(); + auto current_client = state::get_client_by_id(this->state_->app_state->config, payload.client_id.value()); + if (!current_client) { + auto res = GenericErrorResponse{.error = "Client not found"}; + send_http(socket, 404, rfl::json::write(res)); + return; + } + + // Edit only the settings that are being passed in the payload + auto current_settings = current_client->settings; + auto new_settings = payload.settings.get().value_or(PartialClientSettings{}); + auto merged_client = config::PairedClient{ + .client_cert = current_client->client_cert, // Immutable, changing this would mean a new client + .app_state_folder = payload.app_state_folder.get().value_or(current_client->app_state_folder), + .settings = config::ClientSettings{ + .run_uid = new_settings.run_gid.value_or(current_settings.run_uid), + .run_gid = new_settings.run_gid.value_or(current_settings.run_gid), + .controllers_override = new_settings.controllers_override.value_or(current_settings.controllers_override), + .mouse_acceleration = new_settings.mouse_acceleration.value_or(current_settings.mouse_acceleration), + .v_scroll_acceleration = new_settings.v_scroll_acceleration.value_or(current_settings.v_scroll_acceleration), + .h_scroll_acceleration = new_settings.h_scroll_acceleration.value_or(current_settings.h_scroll_acceleration), + }}; + + update_client_settings(this->state_->app_state->config, std::stoull(payload.client_id.value()), merged_client); + + auto res = GenericSuccessResponse{.success = true}; + send_http(socket, 200, rfl::json::write(res)); +} + } // namespace wolf::api \ No newline at end of file diff --git a/src/moonlight-server/api/unix_socket_server.cpp b/src/moonlight-server/api/unix_socket_server.cpp index f2e0c95e..ad0a48ab 100644 --- a/src/moonlight-server/api/unix_socket_server.cpp +++ b/src/moonlight-server/api/unix_socket_server.cpp @@ -56,6 +56,16 @@ UnixSocketServer::UnixSocketServer(boost::asio::io_context &io_context, .handler = [this](auto req, auto socket) { endpoint_Pair(req, socket); }, }); + state_->http.add(HTTPMethod::POST, + "/api/v1/unpair/client", + { + .summary = "Unpair a client", + .request_description = APIDescription{.json_schema = rfl::json::to_schema()}, + .response_description = {{200, {.json_schema = rfl::json::to_schema()}}, + {500, {.json_schema = rfl::json::to_schema()}}}, + .handler = [this](auto req, auto socket) { endpoint_UnpairClient(req, socket); }, + }); + state_->http.add(HTTPMethod::GET, "/api/v1/clients", { @@ -65,6 +75,21 @@ UnixSocketServer::UnixSocketServer(boost::asio::io_context &io_context, .handler = [this](auto req, auto socket) { endpoint_PairedClients(req, socket); }, }); + state_->http.add(HTTPMethod::POST, + "/api/v1/clients/settings", + { + .summary = "Update client settings", + .description = "Update a client's settings including app state folder and client-specific settings", + .request_description = APIDescription{.json_schema = rfl::json::to_schema()}, + .response_description = { + {200, {.json_schema = rfl::json::to_schema()}}, + {400, {.json_schema = rfl::json::to_schema()}}, + {404, {.json_schema = rfl::json::to_schema()}}, + {500, {.json_schema = rfl::json::to_schema()}} + }, + .handler = [this](auto req, auto socket) { endpoint_UpdateClientSettings(req, socket); }, + }); + /** * Apps API */ diff --git a/src/moonlight-server/rest/servers.cpp b/src/moonlight-server/rest/servers.cpp index 8562262e..8ebe44e1 100644 --- a/src/moonlight-server/rest/servers.cpp +++ b/src/moonlight-server/rest/servers.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include namespace HTTPServers { @@ -10,6 +11,7 @@ namespace HTTPServers { */ constexpr char const *pin_html = #include "html/pin.include.html" + ; namespace bt = boost::property_tree; @@ -70,10 +72,19 @@ void startServer(HttpServer *server, const immer::box state, in auto pair_handler = state->event_bus->register_handler>( [pairing_atom](const immer::box pair_sig) { - pairing_atom->update([&pair_sig](auto m) { + pairing_atom->update([&pair_sig](const immer::map> &m) { auto secret = crypto::str_to_hex(crypto::random(8)); logs::log(logs::info, "Insert pin at http://{}:47989/pin/#{}", pair_sig->host_ip, secret); - return m.set(secret, pair_sig); + // filter out any other (dangling) pair request from the same client + auto t_map = m.transient(); + for (auto [key, value] : m) { + if (value->client_ip == pair_sig->client_ip) { + t_map.erase(key); + } + } + // insert the new pair request + t_map.set(secret, pair_sig); + return t_map.persistent(); }); }); diff --git a/src/moonlight-server/state/config.hpp b/src/moonlight-server/state/config.hpp index 97d304bb..e1c31145 100644 --- a/src/moonlight-server/state/config.hpp +++ b/src/moonlight-server/state/config.hpp @@ -40,9 +40,11 @@ void unpair(const Config &cfg, const PairedClient &client); */ inline std::optional get_client_via_ssl(const Config &cfg, x509::x509_ptr client_cert) { auto paired_clients = cfg.paired_clients->load(); - auto search_result = - std::find_if(paired_clients->begin(), paired_clients->end(), [&client_cert](const PairedClient &pair_client) { - auto paired_cert = x509::cert_from_string(pair_client.client_cert); + auto search_result = std::find_if( + paired_clients->begin(), + paired_clients->end(), + [&client_cert](const immer::box &pair_client) { + auto paired_cert = x509::cert_from_string(pair_client->client_cert); auto verification_error = x509::verification_error(paired_cert, client_cert); if (verification_error) { logs::log(logs::trace, "X509 certificate verification error: {}", verification_error.value()); @@ -52,7 +54,7 @@ inline std::optional get_client_via_ssl(const Config &cfg, x509::x } }); if (search_result != paired_clients->end()) { - return *search_result; + return **search_result; } else { return std::nullopt; } @@ -65,21 +67,31 @@ inline std::optional get_client_via_ssl(const Config &cfg, const s return get_client_via_ssl(cfg, x509::cert_from_string(client_cert)); } +/** + * Returns the client ID for a given client + */ inline std::size_t get_client_id(const PairedClient ¤t_client) { return std::hash{}(current_client.client_cert); } -inline std::optional get_client_by_id(const Config &cfg, std::size_t client_id) { +/** + * Returns the first PairedClient with the given client_id + */ +inline std::optional get_client_by_id(const Config &cfg, const std::string &client_id) { auto paired_clients = cfg.paired_clients->load(); - auto search_result = - std::find_if(paired_clients->begin(), paired_clients->end(), [client_id](const PairedClient &pair_client) { - return get_client_id(pair_client) == client_id; - }); + auto client_id_num = std::stoull(client_id); + + auto search_result = std::find_if(paired_clients->begin(), + paired_clients->end(), + [client_id_num](const immer::box &pair_client) -> bool { + auto id = get_client_id(*pair_client); + return id == client_id_num; + }); + if (search_result != paired_clients->end()) { - return *search_result; - } else { - return std::nullopt; + return **search_result; } + return std::nullopt; } /** @@ -138,4 +150,12 @@ static moonlight::control::pkts::CONTROLLER_TYPE get_controller_type(const Contr } return moonlight::control::pkts::CONTROLLER_TYPE::AUTO; } + +std::optional get_client_by_id(const Config &cfg, const std::string &client_id); + +/** + * Replaces the specified client_id with the updated_client + * Side effects: will save back the configuration to disk + */ +void update_client_settings(const Config &cfg, std::size_t client_id, const PairedClient &updated_client); } // namespace state \ No newline at end of file diff --git a/src/moonlight-server/state/configTOML.cpp b/src/moonlight-server/state/configTOML.cpp index fa9335e8..34921e99 100644 --- a/src/moonlight-server/state/configTOML.cpp +++ b/src/moonlight-server/state/configTOML.cpp @@ -305,4 +305,30 @@ void unpair(const Config &cfg, const PairedClient &client) { rfl::toml::save(cfg.config_source, tml); } +void update_client_settings(const Config &cfg, std::size_t client_id, const PairedClient &updated_client) { + + auto update_client_fn = [&](immer::box client) -> immer::box { + if (get_client_id(client) == client_id) { + return immer::box(updated_client); + } + return client; + }; + + cfg.paired_clients->update([&](const PairedClientList &paired_clients) { + return paired_clients | // + ranges::views::transform(update_client_fn) | // + ranges::to(); + }); + + // Update the TOML file + auto tml = rfl::toml::load(cfg.config_source).value(); + + tml.paired_clients = tml.paired_clients | // + ranges::views::transform(update_client_fn) | // + ranges::to>(); + + // Save back to file + rfl::toml::save(cfg.config_source, tml); +} + } // namespace state \ No newline at end of file diff --git a/src/moonlight-server/state/data-structures.hpp b/src/moonlight-server/state/data-structures.hpp index e7bf7014..ed4e08b4 100644 --- a/src/moonlight-server/state/data-structures.hpp +++ b/src/moonlight-server/state/data-structures.hpp @@ -225,5 +225,4 @@ const static immer::array DISPLAY_CONFIGURATIONS = {{ {.width = 7680, .height = 4320, .refreshRate = 90}, {.width = 7680, .height = 4320, .refreshRate = 60}, }}; - } // namespace state \ No newline at end of file diff --git a/tests/testWolfAPI.cpp b/tests/testWolfAPI.cpp index aacdd595..e7876eb5 100644 --- a/tests/testWolfAPI.cpp +++ b/tests/testWolfAPI.cpp @@ -3,10 +3,11 @@ #include #include #include +#include #include -using Catch::Matchers::Equals; using Catch::Matchers::ContainsSubstring; +using Catch::Matchers::Equals; using namespace wolf::api; using curl_ptr = std::unique_ptr; @@ -84,9 +85,14 @@ req(CURL *handle, TEST_CASE("Pair APIs", "[API]") { auto event_bus = std::make_shared(); auto running_sessions = std::make_shared>>(); - auto config = immer::box(state::load_or_default("config.test.toml", event_bus, running_sessions)); + auto config = state::load_or_default("config.test.toml", event_bus, running_sessions); + { // Avoid overriding the test config file (shared across multiple tests) + config.config_source = "config.test.EDITED.toml"; + auto tml = rfl::toml::load("config.test.toml").value(); + rfl::toml::save(config.config_source, tml); + } auto app_state = immer::box(state::AppState{ - .config = config, + .config = immer::box(config), .pairing_cache = std::make_shared>>(), .pairing_atom = std::make_shared>>>(), .event_bus = event_bus, @@ -110,8 +116,16 @@ TEST_CASE("Pair APIs", "[API]") { response = req(curl.get(), HTTPMethod::GET, "http://localhost/api/v1/clients"); REQUIRE(response); REQUIRE_THAT(response->second, - Equals("{\"success\":true,\"clients\":[{\"client_id\":10594003729173467913,\"app_state_folder\":\"some/" - "folder\"}]}")); + Equals("{\"success\":true,\"clients\":[" + "{\"client_id\":\"10594003729173467913\"," + "\"app_state_folder\":\"some/folder\"," + "\"settings\":{" + "\"run_uid\":1234," + "\"run_gid\":5678," + "\"controllers_override\":[\"PS\"]," + "\"mouse_acceleration\":2.5," + "\"v_scroll_acceleration\":1.5," + "\"h_scroll_acceleration\":10.199999809265137}}]}")); auto pair_promise = std::make_shared>(); @@ -125,7 +139,7 @@ TEST_CASE("Pair APIs", "[API]") { response = req(curl.get(), HTTPMethod::GET, "http://localhost/api/v1/pair/pending"); REQUIRE(response); REQUIRE_THAT(response->second, - Equals("{\"success\":true,\"requests\":[{\"pair_secret\":\"secret\",\"pin\":\"1234\"}]}")); + Equals("{\"success\":true,\"requests\":[{\"pair_secret\":\"secret\",\"client_ip\":\"1234\"}]}")); // Let's complete the pairing process response = req(curl.get(), @@ -135,6 +149,34 @@ TEST_CASE("Pair APIs", "[API]") { REQUIRE(response); REQUIRE_THAT(response->second, Equals("{\"success\":true}")); REQUIRE(pair_promise->get_future().get() == "1234"); + + { // Test out changing client settings + REQUIRE_THAT(app_state->config.get().paired_clients->load().get()[0]->app_state_folder, Equals("some/folder")); + response = req(curl.get(), + HTTPMethod::POST, + "http://localhost/api/v1/clients/settings", + "{\"client_id\":\"10594003729173467913\",\"app_state_folder\":\"OVERRIDDEN\", \"settings\":{}}"); + REQUIRE(response); + REQUIRE_THAT(response->second, Equals("{\"success\":true}")); + REQUIRE_THAT(app_state->config.get().paired_clients->load().get()[0]->app_state_folder, Equals("OVERRIDDEN")); + + // Check back that we've correctly updated the config file + auto tml = rfl::toml::load(config.config_source).value(); + REQUIRE(tml.paired_clients[0].app_state_folder == "OVERRIDDEN"); + } + + { // Test out unpairing + response = req(curl.get(), + HTTPMethod::POST, + "http://localhost/api/v1/unpair/client", + "{\"client_id\":\"10594003729173467913\"}"); + REQUIRE(response); + REQUIRE_THAT(response->second, Equals("{\"success\":true}")); + + // Check back that we've correctly updated the config file + auto tml = rfl::toml::load(config.config_source).value(); + REQUIRE(tml.paired_clients.empty()); + } } TEST_CASE("APPs APIs", "[API]") {