diff --git a/.github/workflows/msbuild.yml b/.github/workflows/msbuild.yml index 5d26f88..f1e5975 100644 --- a/.github/workflows/msbuild.yml +++ b/.github/workflows/msbuild.yml @@ -50,7 +50,7 @@ jobs: run: cmake --install build --config Release --prefix install - name: Upload a Build Artifact - uses: actions/upload-artifact@v3.1.2 + uses: actions/upload-artifact@v4 with: name: "spotify-volume-controller" path: | diff --git a/CMakeLists.txt b/CMakeLists.txt index 62d1716..5708c37 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,8 +13,11 @@ project( include(cmake/project-is-top-level.cmake) include(cmake/variables.cmake) -find_package(cpprestsdk REQUIRED) find_package(argparse CONFIG REQUIRED) +find_package(nlohmann_json REQUIRED) +find_package(fmt REQUIRED) +find_package(cpr REQUIRED) +find_package(httplib REQUIRED) # ---- Declare library ---- @@ -22,10 +25,7 @@ add_library( spotify_volume_controller_lib OBJECT source/Client.cpp source/Config.cpp - source/http_utils.cpp source/oauth.cpp - source/SpotifyListener.cpp - source/updater.cpp source/VolumeController.cpp source/windows_helpers.cpp ) @@ -38,7 +38,9 @@ target_include_directories( target_compile_features(spotify_volume_controller_lib PUBLIC cxx_std_20) target_compile_definitions(spotify_volume_controller_lib PRIVATE _SILENCE_ALL_MS_EXT_DEPRECATION_WARNINGS=1) -target_link_libraries(spotify_volume_controller_lib PRIVATE cpprestsdk::cpprest) +target_link_libraries(spotify_volume_controller_lib PRIVATE nlohmann_json::nlohmann_json fmt::fmt cpr::cpr httplib::httplib) + +set(nlohmann-json_IMPLICIT_CONVERSIONS OFF) # ---- Declare executable ---- @@ -55,7 +57,7 @@ set_property(TARGET spotify_volume_controller_exe PROPERTY RESOURCE resources/co target_compile_features(spotify_volume_controller_exe PRIVATE cxx_std_20) target_compile_definitions(spotify_volume_controller_exe PRIVATE _SILENCE_ALL_MS_EXT_DEPRECATION_WARNINGS=1) target_compile_definitions(spotify_volume_controller_exe PRIVATE VERSION="${PROJECT_VERSION}") -target_link_libraries(spotify_volume_controller_exe PRIVATE spotify_volume_controller_lib argparse::argparse cpprestsdk::cpprest) +target_link_libraries(spotify_volume_controller_exe PRIVATE spotify_volume_controller_lib argparse::argparse) # ---- Install rules ---- if(NOT CMAKE_SKIP_INSTALL_RULES) diff --git a/source/Client.cpp b/source/Client.cpp index 5361896..5bfc911 100644 --- a/source/Client.cpp +++ b/source/Client.cpp @@ -1,140 +1,130 @@ +#include +#include + #include "Client.h" -#include "http_utils.h" +#include +#include +#include + #include "oauth.h" + +// for convenience +using json = nlohmann::json; namespace spotify_volume_controller { -const web::uri Client::BASE_API_URI(L"https://api.spotify.com"); +constexpr std::string_view API_URL {"https://api.spotify.com"}; -Client::Client(web::json::value& token_info, const Config& config) +Client::Client(token_t token_info, const Config& config) : m_token_info(token_info) , m_config(config) { - client = new web::http::client::http_client(BASE_API_URI); } -Client::~Client() +[[nodiscard]] cpr::Response Client::api_request(const std::string_view endpoint) { - free(client); -} + cpr::Url url {fmt::format("{}{}", API_URL, endpoint)}; -web::http::http_response Client::api_request(const utility::string_t& endpoint, const web::http::method http_method) -{ - web::http::http_request request; - request.set_request_uri(endpoint); - request.set_method(http_method); - authorize_header(request); - return client->request(request).get(); + return cpr::Get(url, cpr::Bearer {get_token()}); } -web::http::http_response Client::api_request(const utility::string_t& endpoint, - const web::http::method http_method, - const utility::string_t& query) +[[nodiscard]] cpr::Response Client::put_api_request(const std::string_view endpoint, const cpr::Payload& payload) { - web::uri_builder api_uri(BASE_API_URI); - api_uri.append_path(endpoint); - api_uri.append_query(query, true); - - return api_request(api_uri.to_string(), http_method); + cpr::Url url {fmt::format("{}{}", API_URL, endpoint)}; + return cpr::Put(url, payload, cpr::Bearer {get_token()}); } -std::optional Client::get_devices() +[[nodiscard]] std::optional Client::get_devices() { - try { - web::http::http_response response = api_request(L"/v1/me/player/devices", web::http::methods::GET); - if (response.status_code() != web::http::status_codes::OK) { - print_error_message(response); - return {}; - } - return get_json_response_body(response).at(L"devices").as_array(); - } catch (web::http::http_exception e) { - std::wcout << "Spotify API error:" << std::endl; - std::cout << e.error_code().message() << std::endl; - + cpr::Response response = api_request("/v1/me/player/devices"); + if (response.status_code != cpr::status::HTTP_OK) { + print_error_message(response); return {}; } + json response_body = json::parse(response.text); + return response_body.at("devices"); } -std::optional Client::get_device_volume(const utility::string_t& id) +[[nodiscard]] std::optional Client::get_device_volume(const std::string_view id) { - std::optional devices = get_devices(); + std::optional devices = get_devices(); if (!devices.has_value()) return {}; for (auto&& device : devices.value()) { - if (device.has_string_field(L"id") && id == device[L"id"].as_string()) { - return device.has_integer_field(L"volume_percent") ? device[L"volume_percent"].as_number().to_uint32() - : std::optional {}; + if (device.contains("id") && id == device.at("id").template get()) { + return device.contains("volume_percent") ? device["volume_percent"].template get() + : std::optional {}; } } return {}; } -[[nodiscard]] web::http::http_response Client::set_device_volume(volume volume, const utility::string_t& device_id) +[[nodiscard]] cpr::Response Client::set_device_volume(volume volume, const std::string& device_id) { - utility::stringstream_t query; - query << "volume_percent=" << volume; + cpr::Parameters payload {{"volume_percent", fmt::format("{}", volume.m_volume)}}; if (!device_id.empty()) { - query << "&"; - query << "device_id=" << device_id; + payload.Add(cpr::Parameter {"device_id", device_id}); } - - return api_request(L"/v1/me/player/volume", web::http::methods::PUT, query.str()); + cpr::Url url {fmt::format("{}/{}", API_URL, "v1/me/player/volume")}; + return cpr::Put(url, payload, cpr::Bearer {get_token()}, cpr::Header {{"Content-Length", "0"}}); } -[[nodiscard]] web::http::http_response Client::set_volume(volume volume) +[[nodiscard]] cpr::Response Client::set_volume(volume volume) { return set_device_volume(volume); } std::optional Client::get_current_playing_volume() { - std::optional devices = get_devices(); + std::optional devices = get_devices(); if (!devices.has_value()) return {}; for (auto&& device : devices.value()) { - if (device[L"is_active"].as_bool()) { + if (device.at("is_active").template get()) { // Volume percentage may be null according to documentation - if (device[L"volume_percent"].is_integer()) - return device[L"volume_percent"].as_number().to_uint32(); + if (device["volume_percent"].is_number_integer()) + return device["volume_percent"].template get(); } } return {}; } -web::json::value Client::get_desktop_player() +[[nodiscard]] std::optional Client::get_desktop_player_id() { - std::optional devices = get_devices(); + std::optional devices = get_devices(); if (!devices.has_value()) return {}; for (auto&& device : devices.value()) { - if (device[L"type"].as_string().compare(L"Computer") == 0) - return device; + if (device["type"].template get() == "Computer") + return device.at("id").template get(); } - return web::json::value::Null; + return {}; } -void Client::authorize_header(web::http::http_request request) +[[nodiscard]] std::string Client::get_token() { if (oauth::token_is_expired(m_token_info)) { - oauth::refresh_token(m_token_info, m_config); + m_token_info = oauth::refresh_token(m_token_info, m_config); } - request.headers()[L"Authorization"] = L"Bearer " + m_token_info[ACCESS_TOKEN].as_string(); + + return m_token_info.access_token; } -void Client::print_error_message(const web::http::http_response& response) +void Client::print_error_message(const cpr::Response& response) const { - web::json::value body = get_json_response_body(response); - web::json::value error_body = body[L"error"]; - std::wcerr << "Status code: " << error_body[L"status"] << '\n'; - std::wcerr << "Error message: " << error_body[L"message"] << '\n'; - if (error_body.has_string_field(L"reason")) { - std::wcerr << "Reason: " << error_body[L"reason"]; - } - std::wcerr << std::endl; + // web::json::value body = get_json_response_body(response.text); + // web::json::value error_body = body[L"error"]; + // std::cerr << "Status code: " << response.error.code << '\n'; + + std::cerr << "Error message: " << response.error.message << '\n'; + // if (error_body.has_string_field(L"reason")) { + std::cerr << "Reason: " << response.reason; + // } + std::cerr << std::endl; } } // namespace spotify_volume_controller diff --git a/source/Client.h b/source/Client.h index a63fb20..91c7649 100644 --- a/source/Client.h +++ b/source/Client.h @@ -2,63 +2,50 @@ #include -#include +#include +#include +#include #include "Config.h" #include "data_types.h" - +// for convenience +using json = nlohmann::json; namespace spotify_volume_controller { class Client { public: - Client(web::json::value& token_info, const Config& m_config); - ~Client(); - /// - /// Makes a http request to *endpoint* using http method *http_method* - /// - web::http::http_response api_request(const utility::string_t& endpoint, const web::http::method http_method); - web::http::http_response api_request(const utility::string_t& endpoint, - const web::http::method http_method, - const utility::string_t& query); - - /// - /// Makes a request to the device endpoint - /// - std::optional get_devices(); - - std::optional get_device_volume(const utility::string_t& id); - - /// - /// Sets the volume of device_id to *volume* - /// - [[nodiscard]] web::http::http_response set_device_volume(volume volume, const utility::string_t& device_id = L""); - - /// - /// Sets the volume of currently playing device to *volume* - /// - [[nodiscard]] web::http::http_response set_volume(volume volume); - - /// - /// Returns the volume percent of the current playing device, or -1 if no playing devices - /// + Client(token_t token_info, const Config& m_config); + + [[nodiscard]] cpr::Response api_request(const std::string_view endpoint); + [[nodiscard]] cpr::Response put_api_request(const std::string_view endpoint, const cpr::Payload& parameters); + + + // Makes a request to the device endpoint + [[nodiscard]] std::optional get_devices(); + + [[nodiscard]] std::optional get_device_volume(const std::string_view id); + + // Sets the volume of device_id to *volume* + [[nodiscard]] cpr::Response set_device_volume(volume volume, const std::string& device_id = ""); + + // Sets the volume of currently playing device to *volume* + [[nodiscard]] cpr::Response set_volume(volume volume); + + // Returns the volume percent of the current playing device std::optional get_current_playing_volume(); - /// - /// Returns the first desktop device found - /// - web::json::value get_desktop_player(); + // Returns the ID of the first desktop device found + [[nodiscard]] std::optional get_desktop_player_id(); + + void print_error_message(const cpr::Response& response) const; - static void print_error_message(const web::http::http_response& response); private: - void authorize_header(web::http::http_request request); + [[nodiscard]] std::string get_token(); - web::json::value m_token_info; - web::http::client::http_client* client; + token_t m_token_info; const Config m_config; - const utility::string_t ACCESS_TOKEN = L"access_token"; - static const web::uri BASE_API_URI; }; } // namespace spotify_volume_controller diff --git a/source/Config.cpp b/source/Config.cpp index 952fdb7..15373fc 100644 --- a/source/Config.cpp +++ b/source/Config.cpp @@ -1,11 +1,13 @@ +#include #include #include #include "Config.h" -constexpr std::wstring_view VOLUME_INCREMENT_KEY = L"volume_increment"; +#include + +constexpr std::string_view VOLUME_INCREMENT_KEY = "volume_increment"; -using namespace web; namespace spotify_volume_controller { @@ -19,131 +21,132 @@ Config::Config(const std::string& path) parse_config_file(path); } -void Config::parse_config_file(const std::string& path) +void Config::parse_config_file(const std::filesystem::path& path) { - utility::ifstream_t config_file(path); + std::ifstream config_file(path); directory = std::filesystem::absolute(path).parent_path(); if (config_file) { try { - config = web::json::value::parse(config_file); - } catch (const web::json::json_exception& e) { + config = json::parse(config_file); + } catch (const json::exception& e) { std::cout << "Invalid config file." << std::endl; std::cerr << e.what() << std::endl; - config = web::json::value::null(); + config = json {}; } return; } std::cout << "No config file found, creating." << std::endl; - utility::ofstream_t new_config_file(directory / "config.json"); - utility::string_t input; + std::ofstream new_config_file(directory / "config.json"); + std::string input {}; - get_user_input(L"Please enter your spotify client id", input, true); - config[L"client_id"] = json::value::string(input); + get_user_input("Please enter your spotify client id", input, true); + config["client_id"] = input; - get_user_input(L"Please enter your spotify client secret", input, true); - config[L"client_secret"] = json::value::string(input); + get_user_input("Please enter your spotify client secret", input, true); + config["client_secret"] = input; - get_user_input(L"Enter callback url, or leave empty for default (" + Config::DEFAULT_CALLBACK_URL + L").", input); + get_user_input(fmt::format("Enter callback url, or leave empty for default ({}).", Config::DEFAULT_CALLBACK_URL), + input); if (input.empty()) { - config[L"redirect_url"] = json::value::string(Config::DEFAULT_CALLBACK_URL); + config["redirect_url"] = Config::DEFAULT_CALLBACK_URL; } else { - config[L"redirect_url"] = json::value::string(input); + config["redirect_url"] = input; } - get_user_input(L"Enter volume up virtual keycode as a number, or leave empty for default", input); + get_user_input("Enter volume up virtual keycode as a number, or leave empty for default", input); if (input.empty()) { - config[L"volume_up"] = json::value::string(L"default"); + config["volume_up"] = "default"; } else { - config[L"volume_up"] = json::value::string(input); + config["volume_up"] = input; } - get_user_input(L"Enter volume down virtual keycode as a number, or leave empty for default", input); + get_user_input("Enter volume down virtual keycode as a number, or leave empty for default", input); if (input.empty()) { - config[L"volume_down"] = json::value::string(L"default"); + config["volume_down"] = "default"; } else { - config[L"volume_down"] = json::value::string(input); + config["volume_down"] = input; } - config[L"print_keys"] = json::value::boolean(false); - config[L"hide_window"] = json::value::boolean(false); + config["print_keys"] = false; + config["hide_window"] = false; - config[VOLUME_INCREMENT_KEY.data()] = json::value::number(1); + config[VOLUME_INCREMENT_KEY.data()] = 1; - new_config_file << config.serialize(); + new_config_file << config; new_config_file.close(); } -utility::string_t Config::get_client_id() const +std::string Config::get_client_id() const { - return config.at(L"client_id").as_string(); + return config.at("client_id").template get(); } -utility::string_t Config::get_client_secret() const +std::string Config::get_client_secret() const { - return config.at(L"client_secret").as_string(); + return config.at("client_secret").template get(); } -utility::string_t Config::get_redirect_url() const +std::string Config::get_redirect_url() const { - return config.at(L"redirect_url").as_string(); + return config.at("redirect_url").template get(); } bool Config::should_print_keys() const { - return config.at(L"print_keys").as_bool(); + return config.at("print_keys").template get(); } keycode Config::get_volume_up() const { - if (!config.has_field(L"volume_up")) { + if (!config.contains("volume_up")) { throw std::runtime_error("Missing volume_up config"); } - web::json::value v_up = config.at(L"volume_up"); - if (!v_up.is_integer()) { + json v_up = config.at("volume_up"); + if (!v_up.is_number_integer()) { throw std::runtime_error("volume_up config is not a valid keycode"); } - return v_up.as_number().to_uint32(); + return v_up.template get(); } keycode Config::get_volume_down() const { - if (!config.has_field(L"volume_down")) { + if (!config.contains("volume_down")) { throw std::runtime_error("Missing volume_down config"); } - web::json::value v_down = config.at(L"volume_down"); + json v_down = config.at("volume_down"); - if (!v_down.is_integer()) { + if (!v_down.is_number_integer()) { throw std::runtime_error("volume_down config is not a valid keycode"); } - return v_down.as_number().to_uint32(); + return v_down.template get(); } bool Config::is_default_down() const { - if (!config.has_field(L"volume_down")) { + if (!config.contains("volume_down")) { return false; } - web::json::value v_down = config.at(L"volume_down"); - return v_down.is_string() && v_down.as_string().compare(L"default") == 0; + json v_down = config.at("volume_down"); + return v_down.is_string() && v_down.template get() == "default"; } bool Config::is_default_up() const { - if (!config.has_field(L"volume_up")) { + if (!config.contains("volume_up")) { return false; } - web::json::value v_up = config.at(L"volume_up"); - return v_up.is_string() && v_up.as_string().compare(L"default") == 0; + json v_up = config.at("volume_up"); + return v_up.is_string() && v_up.template get() == "default"; } -bool Config::is_valid() +bool Config::is_valid() const { return !config.is_null(); } bool Config::hide_window() const { - if (!config.has_field(L"hide_window")) { + if (!config.contains("hide_window")) { return false; } - return config.at(L"hide_window").as_bool(); + return config.at("hide_window").template get(); } [[nodiscard]] std::filesystem::path Config::config_directory() const @@ -153,26 +156,22 @@ bool Config::hide_window() const volume Config::volume_increment() const { - if (!config.has_field(VOLUME_INCREMENT_KEY.data())) { + if (!config.contains(VOLUME_INCREMENT_KEY.data())) { return 1; } - return config.at(VOLUME_INCREMENT_KEY.data()).as_number().to_uint32(); + return config.at(VOLUME_INCREMENT_KEY.data()).template get(); } -void Config::get_user_input(utility::string_t prompt, utility::string_t& input, bool not_empty) const +void Config::get_user_input(const std::string_view prompt, std::string& input, bool not_empty) const { input.clear(); - std::wcout << prompt << std::endl; - std::getline(std::wcin, input); + std::cout << prompt << std::endl; + std::getline(std::cin, input); while (not_empty && input.empty()) { std::cout << "Input can't be empty" << std::endl; - std::getline(std::wcin, input); + std::getline(std::cin, input); } } -const utility::string_t Config::SCOPES = L"user-read-playback-state user-modify-playback-state"; - -const web::uri Config::BASE_AUTHENTICATION_API_URI(L"https://accounts.spotify.com/api/"); - } // namespace spotify_volume_controller \ No newline at end of file diff --git a/source/Config.h b/source/Config.h index a859057..087a458 100644 --- a/source/Config.h +++ b/source/Config.h @@ -1,11 +1,12 @@ #pragma once #include -#include -#include +#include #include "data_types.h" +// for convenience +using json = nlohmann::json; namespace spotify_volume_controller { @@ -17,9 +18,9 @@ class Config ~Config() = default; - utility::string_t get_client_id() const; - utility::string_t get_client_secret() const; - utility::string_t get_redirect_url() const; + std::string get_client_id() const; + std::string get_client_secret() const; + std::string get_redirect_url() const; keycode get_volume_up() const; keycode get_volume_down() const; volume volume_increment() const; @@ -28,21 +29,18 @@ class Config bool is_default_up() const; bool should_print_keys() const; - bool is_valid(); + bool is_valid() const; bool hide_window() const; [[nodiscard]] std::filesystem::path config_directory() const; - static const utility::string_t SCOPES; - static const web::uri BASE_AUTHENTICATION_API_URI; - private: - const utility::string_t DEFAULT_CALLBACK_URL = L"http://localhost:5000/callback"; - web::json::value config; + static constexpr std::string_view DEFAULT_CALLBACK_URL = "http://localhost:5000/callback"; + json config; std::filesystem::path directory; - void get_user_input(utility::string_t prompt, utility::string_t& input, bool not_empty = false) const; - void parse_config_file(std::string const& path); + void get_user_input(const std::string_view prompt, std::string& input, bool not_empty = false) const; + void parse_config_file(const std::filesystem::path& path); }; } // namespace spotify_volume_controller \ No newline at end of file diff --git a/source/SpotifyListener.cpp b/source/SpotifyListener.cpp deleted file mode 100644 index 02f371a..0000000 --- a/source/SpotifyListener.cpp +++ /dev/null @@ -1,61 +0,0 @@ -#include -#include - -#include "SpotifyListener.h" - -#include -#include -#include -#include - -using namespace web; -using namespace web::http; - -namespace spotify_volume_controller -{ - -SpotifyListener::SpotifyListener(const uri& address) -{ - m_server = experimental::listener::http_listener(address); - m_server.support([&](http_request request) { request_handler(request); }); -} -SpotifyListener::SpotifyListener() = default; - -SpotifyListener::~SpotifyListener() -{ - if (!closed) { - close(); - } -} - -void SpotifyListener::open() -{ - // TODO Maybe change to local reference. See https://stackoverflow.com/a/42029220 - m_server.open().wait(); - closed = false; -} - -void SpotifyListener::close() -{ - m_server.close().wait(); - closed = true; -} - -void SpotifyListener::request_handler(http_request& request) -{ - auto queries = uri::split_query(request.relative_uri().query()); - if (queries.find(L"code") != queries.end()) { - m_authorization_code << queries[L"code"]; - } else { - m_authorization_code << L"Error: " << queries[L"error"]; - } -} - -auto SpotifyListener::get_authorization_code() -> utility::string_t -{ - while (m_authorization_code.str().empty()) { - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - } - return m_authorization_code.str(); -} -} // namespace spotify_volume_controller \ No newline at end of file diff --git a/source/SpotifyListener.h b/source/SpotifyListener.h deleted file mode 100644 index 987d9da..0000000 --- a/source/SpotifyListener.h +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once -#include -namespace spotify_volume_controller -{ - -class SpotifyListener -{ -public: - SpotifyListener(const web::uri& address); - SpotifyListener(); - ~SpotifyListener(); - - /// - /// Gets the authorization code when it's done or, if it failed, the reason why - /// - /// The authorization code or error message - utility::string_t get_authorization_code(); - - void open(); - void close(); - -private: - void request_handler(web::http::http_request& request); - - web::http::experimental::listener::http_listener m_server; - utility::stringstream_t m_authorization_code; - bool closed = false; -}; - -} // namespace spotify_volume_controller \ No newline at end of file diff --git a/source/VolumeController.cpp b/source/VolumeController.cpp index 42b14e5..9187d76 100644 --- a/source/VolumeController.cpp +++ b/source/VolumeController.cpp @@ -2,7 +2,11 @@ #include "VolumeController.h" +#include + #include "key_hooks.h" +// for convenience +using json = nlohmann::json; namespace spotify_volume_controller { @@ -18,12 +22,11 @@ VolumeController::~VolumeController() {} void VolumeController::set_desktop_device() { - web::json::value desktop = m_client.get_desktop_player(); - if (desktop == web::json::value::Null) + std::optional desktop = m_client.get_desktop_player_id(); + if (!desktop.has_value()) { return; - if (desktop.has_string_field(L"id")) { - m_desktop_device_id = desktop.at(L"id").as_string(); } + m_desktop_device_id = desktop; } [[nodiscard]] volume VolumeController::get_volume() const @@ -51,17 +54,16 @@ void VolumeController::set_volume(const volume to_volume) } } - web::http::http_response result = m_client.set_volume(new_volume); - if (result.status_code() == web::http::status_codes::NoContent) { + cpr::Response result = m_client.set_volume(new_volume); + if (result.status_code == cpr::status::HTTP_NO_CONTENT) { m_volume = new_volume; return; - } else if ((result.status_code() == web::http::status_codes::NotFound - || result.status_code() == web::http::status_codes::Forbidden) + } else if ((result.status_code == cpr::status::HTTP_NOT_FOUND || result.status_code == cpr::status::HTTP_FORBIDDEN || cpr::status::HTTP_LENGTH_REQUIRED) && m_desktop_device_id.has_value()) { result = m_client.set_device_volume(new_volume, m_desktop_device_id.value()); } - if (result.status_code() == web::http::status_codes::NoContent) { + if (result.status_code == cpr::status::HTTP_NO_CONTENT) { m_volume = new_volume; return; } diff --git a/source/VolumeController.h b/source/VolumeController.h index 58ec95d..a8e9328 100644 --- a/source/VolumeController.h +++ b/source/VolumeController.h @@ -29,7 +29,7 @@ class VolumeController Client m_client; const keycode m_volume_up_keycode; const keycode m_volume_down_keycode; - std::optional m_desktop_device_id = {}; + std::optional m_desktop_device_id = {}; }; } // namespace spotify_volume_controller diff --git a/source/data_types.h b/source/data_types.h index f710655..9a7d4b0 100644 --- a/source/data_types.h +++ b/source/data_types.h @@ -1,5 +1,7 @@ #pragma once +#include + namespace spotify_volume_controller { @@ -60,4 +62,35 @@ struct volume operator volume_t() const { return m_volume; } }; +struct token_t +{ + std::string access_token; + std::string token_type; + std::string scope; + std::uint64_t expires_in; + std::string refresh_token; + std::uint64_t expires_at; + + token_t(nlohmann::json j_token) + : access_token {j_token["access_token"].template get()} + , token_type {j_token["token_type"].template get()} + , scope {j_token["scope"].template get()} + , expires_in {j_token["expires_in"].template get()} + , refresh_token {j_token["refresh_token"].is_string() ? j_token["refresh_token"].template get() : ""} + , expires_at {j_token.contains("expires_at") ? j_token["expires_at"].template get() : 0} + { + } + + [[nodiscard]] nlohmann::json as_json() const { + nlohmann::json j_token{}; + j_token["access_token"] = access_token; + j_token["token_type"] = token_type; + j_token["scope"] = scope; + j_token["expires_in"] = expires_in; + j_token["refresh_token"] = refresh_token; + j_token["expires_at"] = expires_at; + return j_token; + } +}; + } // namespace spotify_volume_controller \ No newline at end of file diff --git a/source/http_utils.cpp b/source/http_utils.cpp deleted file mode 100644 index c0e4035..0000000 --- a/source/http_utils.cpp +++ /dev/null @@ -1,29 +0,0 @@ - -#include "http_utils.h" - -#include -#include -#include -#include -#include - -namespace spotify_volume_controller -{ - -auto get_json_response_body(const web::http::http_response& response) -> web::json::value -{ - return response.extract_json().get(); -} - -auto request(const web::uri& address, const web::http::method& http_method) -> pplx::task -{ - web::http::client::http_client client(address); - return client.request(http_method).then([](web::http::http_response response) { return response; }); -} - -auto request(web::http::http_request& request, const web::uri& address) -> pplx::task -{ - web::http::client::http_client client(address); - return client.request(request).then([](web::http::http_response response) { return response; }); -} -} // namespace spotify_volume_controller \ No newline at end of file diff --git a/source/http_utils.h b/source/http_utils.h deleted file mode 100644 index c2de825..0000000 --- a/source/http_utils.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once -#include -namespace spotify_volume_controller -{ - -/// -/// Gets the body from a http response. Only works if the the body follows the json format -/// -web::json::value get_json_response_body(const web::http::http_response& response); -/// -/// Sends a http request to *address* using http method *http_method* (GET if none provided). Returns the request task. -/// -pplx::task request(const web::uri& address, - const web::http::method& http_method = web::http::methods::GET); - -/// -/// Sends a web request to *address*. Returns the request task. -/// -pplx::task request(web::http::http_request& request, const web::uri& address); -} // namespace spotify_volume_controller \ No newline at end of file diff --git a/source/key_hooks.h b/source/key_hooks.h index 1a4a868..b33508c 100644 --- a/source/key_hooks.h +++ b/source/key_hooks.h @@ -1,10 +1,12 @@ #pragma once -#include "windows_helpers.h" +#include + #include #include #include "Client.h" #include "VolumeController.h" +#include "windows_helpers.h" namespace spotify_volume_controller::key_hooks { @@ -38,7 +40,7 @@ LRESULT CALLBACK PrintVKey(int nCode, WPARAM wParam, LPARAM lParam) } if (wParam == WM_KEYDOWN) { kbdStruct = *((KBDLLHOOKSTRUCT*)lParam); - std::wcout << kbdStruct.vkCode << std::endl; + std::cout << kbdStruct.vkCode << std::endl; } return CallNextHookEx(_hook, nCode, wParam, lParam); } diff --git a/source/main.cpp b/source/main.cpp index 96a959d..717884b 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -2,13 +2,11 @@ #include #include -// Must be here due to collision with boost -#include +#include #include "Client.h" #include "VolumeController.h" #include "oauth.h" -#include "updater.h" #include "windows_helpers.h" #ifndef VERSION @@ -27,7 +25,7 @@ int main(int argc, char* argv[]) return 1; } - std::wcout << "Starting..." << '\n'; + std::cout << "Starting..." << '\n'; spotify_volume_controller::Config config = program.is_used("--config") ? spotify_volume_controller::Config(program.get("--config")) @@ -37,15 +35,15 @@ int main(int argc, char* argv[]) std::cin.get(); return 1; } - web::json::value token = spotify_volume_controller::oauth::get_token(config); - if (token.is_null()) { - std::wcout << "Failed to connect to spotify, exiting..." << std::endl; + std::optional token = spotify_volume_controller::oauth::get_token(config); + if (!token.has_value()) { + std::cout << "Failed to connect to spotify, exiting..." << std::endl; std::cin.get(); return 1; } - spotify_volume_controller::Client client(token, config); + spotify_volume_controller::Client client(token.value(), config); - std::wcout << "Connected to spotify successfully!" << std::endl; + std::cout << "Connected to spotify successfully!" << std::endl; if (config.hide_window()) { FreeConsole(); } diff --git a/source/oauth.cpp b/source/oauth.cpp index 39f3151..4d964b4 100644 --- a/source/oauth.cpp +++ b/source/oauth.cpp @@ -4,126 +4,185 @@ #include #include #include +#include #include "oauth.h" -#include "SpotifyListener.h" -#include "http_utils.h" +#include +#include +#include +#include + #include "windows_helpers.h" -using namespace web; -using namespace web::http; -using namespace web::http::client; -using namespace web::http::experimental::listener; -using namespace utility; +// for convenience +using json = nlohmann::json; namespace spotify_volume_controller::oauth { -void open_uri(const uri& uri) +// Based on https://gist.github.com/williamdes/308b95ac9ef1ee89ae0143529c361d37 +static constexpr std::string_view b = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; //= +static constexpr std::string base64_encode(const std::string_view in) +{ + std::string out; + + int val = 0, val_b = -6; + for (unsigned char c : in) { + val = (val << 8) + c; + val_b += 8; + while (val_b >= 0) { + out.push_back(b[(val >> val_b) & 0x3F]); + val_b -= 6; + } + } + if (val_b > -6) + out.push_back(b[((val << 8) >> (val_b + 8)) & 0x3F]); + while (out.size() % 4) + out.push_back('='); + return out; +} + +void open_uri(const std::string_view uri) { std::stringstream ss; // TODO Implement Linux support ss << "cmd /c start \"\" \""; - // Converts wstring to string - ss << windows::wide_string_to_string(uri.to_string()); + ss << uri; ss << "\""; // Opens the uri with the std browser on windows system(ss.str().c_str()); } -utility::string_t get_authorization_code(const uri& callback_address, const Config& config) +[[nodiscard]] std::string get_authorization_code(const std::string& callback_address, const Config& config) { - SpotifyListener listener(callback_address); - listener.open(); - - web::uri uri = create_authorization_uri(config); - std::wcout << "Please accept the prompt in your browser." << std::endl; - std::wcout << "If your browser did not open, please go to the following link to accept the prompt:" << '\n'; - std::wcout << uri.to_string() << std::endl; - open_uri(uri); - - utility::string_t authorization_code = listener.get_authorization_code(); - listener.close(); - if (authorization_code.substr(0, 5) == L"Error") { - std::wcout << "Authorization failed. Reason: " << authorization_code << std::endl; - return L""; + cpr::CurlHolder c {}; + cpr::Parameters params = {{"client_id", config.get_client_id()}, + {"redirect_uri", config.get_redirect_url()}, + {"response_type", "code"}, + {"scope", std::string(SCOPES)}, + {"show_dialog", "true"}}; + + std::string auth_url = fmt::format("{}?{}", AUTHORIZATION_URL, params.GetContent(c)); + std::cout << "Please accept the prompt in your browser." << std::endl; + std::cout << "If your browser did not open, please go to the following link to accept the prompt:" << '\n'; + std::cout << auth_url << std::endl; + open_uri(auth_url); + std::string authorization_code {}; + { + std::thread th; + httplib::Server server; + server.Get("/callback", + [&](const httplib::Request& req, httplib::Response res) + { + if (req.has_param("code")) { + authorization_code = req.get_param_value("code"); + res.set_content("Successfully authenticated", "text/plain"); + } else if (req.has_param("error")) { + std::cerr << "Failed to get authorization code: " << req.get_param_value("error") << std::endl; + res.set_content("Failed to authenticate, check logs", "text/plain"); + } else { + std::cerr << "Failed to get authorization code: Unkown error" << std::endl; + res.set_content("Failed to authenticate, check logs", "text/plain"); + } + // Based on https://github.com/yhirose/cpp-httplib/blob/master/example/one_time_request.cc + th = std::thread([&]() { server.stop(); }); + }); + server.listen("0.0.0.0", 5000); + th.join(); } return authorization_code; } -web::json::value fetch_token(const utility::string_t& code, const utility::string_t& grant_type, const Config& config) +[[nodiscard]] json fetch_token(const std::string& code, const std::string& grant_type, const Config& config) { - http::http_request token_request = create_token_request(code, grant_type, config); + cpr::Payload payload {{"grant_type", grant_type}}; + if (grant_type == "authorization_code") { + payload.Add({"code", code}); + payload.Add({"redirect_uri", config.get_redirect_url()}); + } else { + payload.Add({"refresh_token", code}); + } - pplx::task response_task = request(token_request, Config::BASE_AUTHENTICATION_API_URI); - response_task.wait(); - http_response response = response_task.get(); - web::json::value token = get_json_response_body(response); - return token; + cpr::Response response = cpr::Post( + cpr::Url {fmt::format("{}/{}", BASE_AUTHENTICATION_API_URL, TOKEN_API_ENDPOINT)}, + payload, + cpr::Header { + {"Authorization", + fmt::format("Basic {}", + base64_encode(fmt::format("{}:{}", config.get_client_id(), config.get_client_secret())))}}); + return json::parse(response.text); } -web::json::value get_token(const utility::string_t& authorization_code, const Config& config) +[[nodiscard]] token_t get_token(const std::string& authorization_code, const Config& config) { - web::json::value token = fetch_token(authorization_code, L"authorization_code", config); + json j_token = fetch_token(authorization_code, "authorization_code", config); + if (j_token.contains("error")) { + // TODO what to do here? + } + token_t token {j_token}; // Adds the epoch time when the token will expire - auto expiration_time = std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()); - expiration_time += std::chrono::seconds(token[L"expires_in"].as_integer()); - token[L"expires_at"] = expiration_time.count(); + auto expiration_time = + std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()); + expiration_time += std::chrono::seconds(token.expires_in); + token.expires_at = expiration_time.count(); save_token(token, config.config_directory()); return token; } -web::json::value get_token(const Config& config) +[[nodiscard]] std::optional get_token(const Config& config) { - std::wcout << "Getting authorization token..." << std::endl; - web::json::value token = read_token(config.config_directory()); - if (token.is_null()) { - std::wcout << "No existing token found, creating new" << std::endl; - utility::string_t authorization_code = get_authorization_code(config.get_redirect_url(), config); + std::cout << "Getting authorization token..." << std::endl; + std::optional token = read_token(config.config_directory()); + if (!token.has_value()) { + std::cout << "No existing token found, creating new" << std::endl; + std::string authorization_code = get_authorization_code(config.get_redirect_url(), config); if (authorization_code.empty()) { - return json::value::null(); + return {}; } return get_token(authorization_code, config); - } else if (token_is_expired(token)) { - refresh_token(token, config); + } else if (token_is_expired(token.value())) { + token = refresh_token(token.value(), config); } return token; } -void refresh_token(web::json::value& token, const Config& config) +[[nodiscard]] token_t refresh_token(const token_t token, const Config& config) { - web::json::value new_token = fetch_token(token.at(L"refresh_token").as_string(), L"refresh_token", config); - if (new_token.has_field(L"error")) { - std::wcout << L"Failed to refresh token." << std::endl; - std::wcout << L"Error: " << new_token[L"error"] << std::endl; - std::wcout << L"Error description: " << new_token[L"error_description"] << std::endl; - if (new_token[L"error"].as_string().compare(L"invalid_client") == 0) { - std::wcout << L"Check that the client id/secret is correct." << std::endl; + json j_new_token = fetch_token(token.refresh_token, "refresh_token", config); + if (j_new_token.contains("error")) { + std::cout << "Failed to refresh token." << std::endl; + std::cout << "Error: " << j_new_token["error"] << std::endl; + std::cout << "Error description: " << j_new_token["error_description"] << std::endl; + if (j_new_token["error"].template get() == "invalid_client") { + std::cout << "Check that the client id/secret is correct." << std::endl; } std::cin.get(); exit(1); - return; } - token[L"access_token"] = new_token.at(L"access_token"); - token[L"token_type"] = new_token.at(L"token_type"); - token[L"scope"] = new_token.at(L"scope"); - token[L"expires_in"] = new_token.at(L"expires_in"); - + token_t new_token {j_new_token}; + // From https://developer.spotify.com/documentation/web-api/tutorials/refreshing-tokens + // Depending on the grant used to get the initial refresh token, a refresh token might not be included in each + // response. When a refresh token is not returned, continue using the existing token. + if (new_token.refresh_token.empty()) { + new_token.refresh_token = token.refresh_token; + } // Adds the epoch time when the token will expire - auto expiration_time = std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()); - expiration_time += std::chrono::seconds(token[L"expires_in"].as_integer()); - token[L"expires_at"] = expiration_time.count(); - save_token(token, config.config_directory()); + auto expiration_time = + std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()); + expiration_time += std::chrono::seconds(new_token.expires_in); + new_token.expires_at = expiration_time.count(); + save_token(new_token, config.config_directory()); + return new_token; } -void save_token(const web::json::value& token, const std::filesystem::path& token_directory) +void save_token(const token_t token, const std::filesystem::path& token_directory) { std::ofstream token_file(token_directory / TOKEN_FILE_NAME); if (token_file) { - token.serialize(token_file); + token_file << token.as_json(); token_file.close(); } else { std::cerr << "Failed to write token to file" @@ -131,71 +190,24 @@ void save_token(const web::json::value& token, const std::filesystem::path& toke } } -web::json::value read_token(const std::filesystem::path& token_directory) +std::optional read_token(const std::filesystem::path& token_directory) { - utility::ifstream_t token_file(token_directory / TOKEN_FILE_NAME); + std::ifstream token_file(token_directory / TOKEN_FILE_NAME); if (token_file) { - web::json::value token = json::value::parse(token_file); - return token; + return {json::parse(token_file)}; } else { std::cerr << "Failed to read token from file" << "\n"; - return json::value::null(); + return {}; } } -bool token_is_expired(const web::json::value& token) +[[nodiscard]] bool token_is_expired(const token_t token) { - uint64_t expiration_epoch_time = token.at(L"expires_at").as_number().to_uint64(); std::chrono::duration time_since_epoch = - std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()); - return time_since_epoch > std::chrono::seconds(expiration_epoch_time); -} - -http::http_request create_token_request(const utility::string_t& authorization_code, - const utility::string_t& grant_type, - const Config& config) -{ - http_request token_request; - token_request.set_request_uri(BASE_TOKEN_URI); - utility::string_t body = L"grant_type=" + grant_type; - if (grant_type == L"authorization_code") { - body.append(L"&code=" + authorization_code); - body.append(L"&redirect_uri=" + config.get_redirect_url()); - } else { - body.append(L"&refresh_token=" + authorization_code); - } - - token_request.set_body(body); - - token_request.headers().set_content_type(L"application/x-www-form-urlencoded"); - token_request.headers().add(L"Authorization", L"Basic " + get_authorize_string(config)); - token_request.set_method(methods::POST); - return token_request; -} - -utility::string_t get_authorize_string(const Config& config) -{ - utility::string_t unencoded; - unencoded.append(config.get_client_id() + L":" + config.get_client_secret()); - const std::string bytes = windows::wide_string_to_string(unencoded); - std::vector byte_vector; - std::ranges::copy( - std::views::transform(unencoded, [](const utility::char_t x) { return static_cast(x); }), - std::back_inserter(byte_vector)); - return utility::conversions::to_base64(byte_vector); -} - -web::uri create_authorization_uri(const Config& config) -{ - web::uri_builder authorization_uri(BASE_AUTHORIZATION_URI); - authorization_uri.append_query(L"client_id", config.get_client_id(), true); - authorization_uri.append_query(L"redirect_uri", config.get_redirect_url(), true); - authorization_uri.append_query(L"response_type", L"code", true); - authorization_uri.append_query(L"scope", Config::SCOPES, true); - authorization_uri.append_query(L"show_dialog", "true"); - return authorization_uri.to_uri(); + std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()); + return time_since_epoch > std::chrono::seconds(token.expires_at); } } // namespace spotify_volume_controller::oauth diff --git a/source/oauth.h b/source/oauth.h index ace9173..843ddd1 100644 --- a/source/oauth.h +++ b/source/oauth.h @@ -1,49 +1,36 @@ #pragma once -#include -#include -#include -#include +#include + +#include #include "Config.h" namespace spotify_volume_controller::oauth { -const web::uri BASE_AUTHORIZATION_URI(L"https://accounts.spotify.com/authorize"); -const web::uri BASE_TOKEN_URI(L"/token"); -const utility::string_t TOKEN_FILE_NAME = L".spotifytoken"; +constexpr std::string_view SCOPES = "user-read-playback-state user-modify-playback-state"; +constexpr std::string_view AUTHORIZATION_URL = "https://accounts.spotify.com/authorize"; +constexpr std::string_view BASE_AUTHENTICATION_API_URL = "https://accounts.spotify.com/api"; +constexpr std::string_view TOKEN_API_ENDPOINT = "token"; +constexpr std::string_view TOKEN_FILE_NAME = ".spotifytoken"; -utility::string_t get_authorization_code(const web::uri& callback_address, const Config& config); +[[nodiscard]] std::string get_authorization_code(const std::string& callback_address, const Config& config); -web::json::value get_token(const utility::string_t& authorization_code, const Config& config); -web::json::value get_token(const Config& config); +[[nodiscard]] token_t get_token(const std::string& authorization_code, const Config& config); +[[nodiscard]] std::optional get_token(const Config& config); -void refresh_token(web::json::value& token, const Config& config); +[[nodiscard]] token_t refresh_token(const token_t token, const Config& config); -web::json::value fetch_token(const utility::string_t& code, const utility::string_t& grant_type, const Config& config); +[[nodiscard]] nlohmann::json fetch_token(const std::string& code, const std::string& grant_type, const Config& config); // Saves *token* to a file -void save_token(const web::json::value& token, const std::filesystem::path& token_directory); +void save_token(const token_t token, const std::filesystem::path& token_directory); // Returns the token stored on the disk -web::json::value read_token(const std::filesystem::path& token_directory); - -bool token_is_expired(const web::json::value& token); - -void open_uri(const web::uri& uri); - -web::uri create_authorization_uri(const Config& config); +[[nodiscard]] std::optional read_token(const std::filesystem::path& token_directory); -/// -/// Creates the request to the token endpoint using authorization_code -/// -web::http::http_request create_token_request(const utility::string_t& authorization_code, - const utility::string_t& grant_type, - const Config& config); +[[nodiscard]] bool token_is_expired(const token_t token); -/// -/// Returns a base64 encoded string of the string: CLIENT_ID:CLIENT_SECRET -/// -utility::string_t get_authorize_string(const Config& config); +void open_uri(const std::string_view uri); } // namespace spotify_volume_controller::oauth diff --git a/source/updater.cpp b/source/updater.cpp deleted file mode 100644 index bcf5cfc..0000000 --- a/source/updater.cpp +++ /dev/null @@ -1,50 +0,0 @@ -#include -#include -#include -#include - -#include "updater.h" - -#include -#include -#include -#include -#include - -#include "http_utils.h" - -namespace spotify_volume_controller::updater -{ - -const web::uri release_uri(L"https://api.github.com/repos/Birath/spotify-volume-controller-cpp/releases"); - -web::uri get_latest_uri() -{ - web::http::http_response const response = request(release_uri).get(); - if (response.status_code() == web::http::status_codes::OK) { - web::json::array releases = get_json_response_body(response).as_array(); - if (releases[0][L"assets"].is_array()) { - for (auto&& asset : releases[0][L"assets"].as_array()) { - if (asset[L"content_type"].as_string() == L"application/x-msdownload") { - return {asset[L"browser_download_url"].as_string()}; - } - } - } else { - std::wcout << L"not array" << '\n'; - } - } - return {}; -} - -void download_release(web::uri& release_uri) -{ - auto stream = Concurrency::streams::fstream::open_ostream(L"spotify-volume-controller-cpp-tmp.exe", - std::ios::in | std::ios::binary) - .then( - [=](const pplx::streams::basic_ostream& buf) - { - request(release_uri).get().body().read_to_end(buf.streambuf()).get(); - buf.close(); - }); -} -} // namespace spotify_volume_controller::updater \ No newline at end of file diff --git a/source/updater.h b/source/updater.h deleted file mode 100644 index 1b25e18..0000000 --- a/source/updater.h +++ /dev/null @@ -1,9 +0,0 @@ -#pragma once -#include -namespace spotify_volume_controller::updater -{ - -web::uri get_latest_uri(); -void download_release(web::uri& release_uri); - -} // namespace spotify_volume_controller::updater \ No newline at end of file diff --git a/source/windows_helpers.cpp b/source/windows_helpers.cpp index a9e0887..e5b0e68 100644 --- a/source/windows_helpers.cpp +++ b/source/windows_helpers.cpp @@ -32,4 +32,15 @@ namespace spotify_volume_controller::windows nullptr); return result; } + +// https://gist.github.com/rosasurfer/33f0beb4b10ff8a8c53d943116f8a872 +[[nodiscard]] std::wstring string_to_wide_string(const std::string &str) +{ + int size_needed = MultiByteToWideChar(CP_UTF8, 0, &str[0], (int)str.size(), NULL, 0); + std::wstring w_str(size_needed, 0); + MultiByteToWideChar(CP_UTF8, 0, &str[0], (int)str.size(), &w_str[0], size_needed); + return w_str; +} + + } // namespace spotify_volume_controller::windows \ No newline at end of file diff --git a/source/windows_helpers.h b/source/windows_helpers.h index e6e4af4..0ab50c7 100644 --- a/source/windows_helpers.h +++ b/source/windows_helpers.h @@ -9,4 +9,6 @@ namespace spotify_volume_controller::windows [[nodiscard]] std::string wide_string_to_string(const std::wstring& wide_string); +[[nodiscard]] std::wstring string_to_wide_string(const std::string &str); + } \ No newline at end of file diff --git a/vcpkg.json b/vcpkg.json index f15fc36..6c4d0f9 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -6,8 +6,11 @@ "name": "fmt", "version>=": "10.1.0" }, - "cpprestsdk", - "argparse" + + "cpr", + "argparse", + "nlohmann-json", + "cpp-httplib" ], "default-features": [], "features": {