#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { auto read_asset(std::filesystem::path const &root, std::filesystem::path const &relative, std::size_t limit, std::string *resolved = nullptr) -> std::optional { auto error = std::error_code{}; auto canonical_root = std::filesystem::canonical(root, error); if (error) { return std::nullopt; } auto path = std::filesystem::canonical(canonical_root / relative, error); auto contained = path.lexically_relative(canonical_root); if (error || contained.empty() || *contained.begin() == ".." || !std::filesystem::is_regular_file(path, error) || error) { return std::nullopt; } auto size = std::filesystem::file_size(path, error); if (error || size == 0U || size > limit) { return std::nullopt; } auto bytes = std::string(static_cast(size), '\0'); auto input = std::ifstream{path, std::ios::binary}; if (!input.read(bytes.data(), static_cast(bytes.size())) || input.peek() != std::ifstream::traits_type::eof()) { return std::nullopt; } if (resolved != nullptr) { *resolved = path.string(); } return bytes; } struct title_file { std::string path; std::string bytes; }; auto read_title(std::filesystem::path const &root, std::string const &identifier) -> std::optional { if (!vamp_protocol::valid_title_id(identifier)) { return std::nullopt; } auto resolved = std::string{}; auto bytes = read_asset(root, std::filesystem::path{"pico/titles"} / (identifier + ".mask"), vamp_protocol::title_image_size, &resolved); if (!bytes || !vamp_protocol::valid_title_image(*bytes)) { return std::nullopt; } auto error = std::error_code{}; auto directory = std::filesystem::canonical(root / "pico/titles", error); if (error || std::filesystem::path{resolved}.parent_path() != directory) { return std::nullopt; } auto digest = std::array{}; SHA256(reinterpret_cast(bytes->data()), bytes->size(), digest.data()); auto hash = std::string{}; constexpr auto digits = std::string_view{"0123456789abcdef"}; for (auto byte : digest) { hash.push_back(digits[byte >> 4U]); hash.push_back(digits[byte & 15U]); } if (hash != identifier) { return std::nullopt; } return title_file{std::move(resolved), std::move(*bytes)}; } class title_assets { public: title_assets(std::filesystem::path root, vamp_zero::catalog const &catalog) : root_{std::move(root)} { for (auto const &part : catalog.parts) { identifiers_.insert(part.title_image); } } auto image(std::string const &identifier) const -> std::optional { return identifiers_.contains(identifier) ? read_title(root_, identifier) : std::nullopt; } private: std::filesystem::path root_; std::set identifiers_{}; }; auto remote_response(vamp_zero::app_state const &state) -> ligo::service_response { return ligo::text_response("text/plain; charset=utf-8", vamp_zero::remote_display_text(state)); } } auto vamp_zero::validated_title_path(std::string const &assets_path, std::string const &identifier) -> std::expected { auto title = read_title(assets_path, identifier); if (!title) { return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "invalid or missing generated Pico title; regenerate assets")); } return std::move(title->path); } vamp_zero::control_state::control_state(vamp_zero::config config, vamp_zero::app_state state) : config_{std::move(config)}, state_{std::move(state)} {} auto vamp_zero::control_state::config() const -> vamp_zero::config { auto lock = std::scoped_lock{mutex_}; return config_; } auto vamp_zero::control_state::pico_connected_locked() const -> std::bool_t { return last_heartbeat_ != std::chrono::steady_clock::time_point{} && std::chrono::steady_clock::now() - last_heartbeat_ < std::chrono::seconds{3}; } auto vamp_zero::control_state::snapshot_locked() const -> vamp_zero::app_state { auto copy = state_; copy.pico_connected = pico_connected_locked(); return copy; } auto vamp_zero::control_state::snapshot() const -> vamp_zero::app_state { auto lock = std::scoped_lock{mutex_}; return snapshot_locked(); } auto vamp_zero::control_state::apply(vamp_protocol::page_command command) -> vamp_zero::app_state { auto observer = std::function{}; auto updated = vamp_zero::app_state{}; { auto lock = std::scoped_lock{mutex_}; state_ = vamp_zero::apply_app_command(std::move(state_), command); state_.pico_connected = pico_connected_locked(); updated = state_; observer = observer_; } if (observer) { observer(updated); } return updated; } auto vamp_zero::control_state::apply(vamp_zero::menu_action const &action) -> vamp_zero::app_state { auto observer = std::function{}; auto updated = vamp_zero::app_state{}; { auto lock = std::scoped_lock{mutex_}; auto revision = state_.input_revision; state_ = vamp_zero::apply_menu_action(std::move(state_), action); updated = snapshot_locked(); if (revision != state_.input_revision) { observer = observer_; } } if (observer) { observer(updated); } return updated; } auto vamp_zero::control_state::heartbeat() -> vamp_zero::app_state { auto observer = std::function{}; auto updated = vamp_zero::app_state{}; { auto lock = std::scoped_lock{mutex_}; last_heartbeat_ = std::chrono::steady_clock::now(); auto notify = !state_.pico_connected; state_.pico_connected = true; updated = state_; if (notify) { observer = observer_; } } if (observer) { observer(updated); } return updated; } auto vamp_zero::control_state::refresh_connection() -> std::optional { auto observer = std::function{}; auto updated = vamp_zero::app_state{}; { auto lock = std::scoped_lock{mutex_}; auto connected = pico_connected_locked(); if (connected == state_.pico_connected) { return std::nullopt; } state_.pico_connected = connected; updated = state_; observer = observer_; } if (observer) { observer(updated); } return updated; } auto vamp_zero::control_state::set_observer(std::function observer) -> void { auto lock = std::scoped_lock{mutex_}; observer_ = std::move(observer); } auto vamp_zero::control_state::finish_playback(std::uint64_t session, std::string error, std::shared_ptr catalog) -> void { auto observer = std::function{}; auto updated = vamp_zero::app_state{}; { auto lock = std::scoped_lock{mutex_}; if (!state_.audio.playback_enabled || state_.score_session != session || (catalog && catalog != state_.catalog) || state_.mode != vamp_zero::app_mode::PERFORMANCE_SCORE) { return; } if (error.empty()) { state_ = vamp_zero::apply_app_command(std::move(state_), vamp_protocol::page_command::SELECT); } else { state_.audio.playback_error = std::move(error); } updated = snapshot_locked(); observer = observer_; } if (observer) { observer(std::move(updated)); } } auto vamp_zero::make_router(vamp_zero::control_state &state) -> ligo::service_router { auto router = ligo::service_router{}; auto assets = state.config().runtime.assets_path; auto artwork = std::make_shared(assets, *state.snapshot().catalog); router.add_response_route("POST", vamp_protocol::remote_title_path, [artwork](ligo::request_context, boost::json::value payload) -> boost::asio::awaitable> { auto const *id = payload.is_object() && payload.as_object().size() == 1U ? payload.as_object().if_contains("id") : nullptr; if (id == nullptr || !id->is_string() || !vamp_protocol::valid_title_id(id->as_string())) { co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "invalid title identifier")); } auto bytes = artwork->image(std::string{id->as_string()}); if (!bytes) { co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "generated title unavailable")); } co_return ligo::text_response("application/octet-stream", std::move(bytes->bytes)); }); router.add_route("GET", "/api/health", [](ligo::request_context, boost::json::value) -> boost::asio::awaitable> { co_return boost::json::object{{"product", "product-vamp"}, {"device", "zero"}, {"status", "ready"}}; }); router.add_response_route("GET", vamp_protocol::remote_display_path, [&state](ligo::request_context, boost::json::value) -> boost::asio::awaitable> { auto updated = state.heartbeat(); co_return remote_response(updated); }); router.add_response_route("POST", vamp_protocol::remote_heartbeat_path, [&state](ligo::request_context, boost::json::value) -> boost::asio::awaitable> { auto updated = state.heartbeat(); co_return remote_response(updated); }); router.add_response_route("POST", vamp_protocol::remote_next_path, [&state](ligo::request_context, boost::json::value) -> boost::asio::awaitable> { static_cast(state.heartbeat()); auto updated = state.apply(vamp_protocol::page_command::NEXT); co_return remote_response(updated); }); router.add_response_route("POST", vamp_protocol::remote_previous_path, [&state](ligo::request_context, boost::json::value) -> boost::asio::awaitable> { static_cast(state.heartbeat()); auto updated = state.apply(vamp_protocol::page_command::PREVIOUS); co_return remote_response(updated); }); router.add_response_route("POST", vamp_protocol::remote_select_path, [&state](ligo::request_context, boost::json::value) -> boost::asio::awaitable> { static_cast(state.heartbeat()); auto updated = state.apply(vamp_protocol::page_command::SELECT); co_return remote_response(updated); }); return router; }