@@ -205,6 +205,10 @@ auto validate_score_pages(boost::asio::any_io_executor executor, vamp_zero::conf
|
||||
|
||||
auto validate_catalog(boost::asio::any_io_executor executor, vamp_zero::config const &config, vamp_zero::catalog const &catalog) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
for (auto const &part : catalog.performance.parts) {
|
||||
auto title = vamp_zero::validated_title_path(config.runtime.assets_path, part.title_image);
|
||||
if (!title) {
|
||||
co_return std::unexpected(title.error());
|
||||
}
|
||||
auto reference = vamp_zero::score_reference{.category = vamp_zero::score_category::PERFORMANCE, .id = part.id};
|
||||
auto valid = co_await validate_score_pages(executor, config, std::move(reference));
|
||||
if (!valid) {
|
||||
@@ -213,6 +217,10 @@ auto validate_catalog(boost::asio::any_io_executor executor, vamp_zero::config c
|
||||
}
|
||||
for (auto const &exercise : catalog.practice.exercises) {
|
||||
for (auto const &practice_part : exercise.parts) {
|
||||
auto title = vamp_zero::validated_title_path(config.runtime.assets_path, practice_part.title_image);
|
||||
if (!title) {
|
||||
co_return std::unexpected(title.error());
|
||||
}
|
||||
auto reference = vamp_zero::score_reference{.category = vamp_zero::score_category::PRACTICE, .id = practice_part.id};
|
||||
auto valid = co_await validate_score_pages(executor, config, std::move(reference));
|
||||
if (!valid) {
|
||||
|
||||
@@ -88,8 +88,13 @@ auto count_value(noto::sqlite_value const &value) -> std::size_t {
|
||||
return count == nullptr || *count <= 0 ? 0U : static_cast<std::size_t>(*count);
|
||||
}
|
||||
|
||||
auto valid_title_image(noto::sqlite_value const &value) -> std::bool_t {
|
||||
auto text = text_value(value);
|
||||
return text.size() == 64U && std::all_of(text.begin(), text.end(), [](std::char_t c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); });
|
||||
}
|
||||
|
||||
auto compatible_table(noto::connection &connection, std::string const &table) -> boost::asio::awaitable<std::bool_t> {
|
||||
auto columns = std::vector<std::string_view>{"id", "title", "composer", "key_signature", "source_path", "source_json", "measure_count", "total_pages"};
|
||||
auto columns = std::vector<std::string_view>{"id", "title", "composer", "key_signature", "source_path", "source_json", "measure_count", "total_pages", "title_image"};
|
||||
if (table == "practice_exercises") {
|
||||
columns.push_back("exercise_id");
|
||||
}
|
||||
@@ -159,7 +164,7 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec
|
||||
co_return std::unexpected(connected.error());
|
||||
}
|
||||
auto selected =
|
||||
co_await connection.async_prepare("SELECT id, title, composer, part, source_path, measure_count, key_signature, total_pages FROM "
|
||||
co_await connection.async_prepare("SELECT id, title, composer, part, source_path, measure_count, key_signature, total_pages, title_image FROM "
|
||||
"songs ORDER BY title COLLATE NOCASE, part COLLATE NOCASE");
|
||||
if (!selected) {
|
||||
auto close = co_await connection.async_close();
|
||||
@@ -180,7 +185,7 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec
|
||||
break;
|
||||
}
|
||||
auto const &row = **next;
|
||||
if (row.size() != 8U || count_value(row[5]) == 0U || count_value(row[7]) == 0U) {
|
||||
if (row.size() != 9U || count_value(row[5]) == 0U || count_value(row[7]) == 0U || !valid_title_image(row[8])) {
|
||||
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "invalid performance metadata"));
|
||||
break;
|
||||
}
|
||||
@@ -198,6 +203,7 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec
|
||||
.source_path = std::move(*source),
|
||||
.measure_count = count_value(row[5]),
|
||||
.total_pages = count_value(row[7]),
|
||||
.title_image = text_value(row[8]),
|
||||
});
|
||||
}
|
||||
auto closed = co_await close_catalog_query(connection, *selected, std::move(status));
|
||||
@@ -214,7 +220,7 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo
|
||||
if (!connected) {
|
||||
co_return std::unexpected(connected.error());
|
||||
}
|
||||
auto selected = co_await connection.async_prepare("SELECT id, title, composer, key_signature, source_path, measure_count, total_pages, part, exercise_id "
|
||||
auto selected = co_await connection.async_prepare("SELECT id, title, composer, key_signature, source_path, measure_count, total_pages, part, exercise_id, title_image "
|
||||
"FROM practice_exercises ORDER BY exercise_id, id");
|
||||
if (!selected) {
|
||||
auto close = co_await connection.async_close();
|
||||
@@ -235,7 +241,7 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo
|
||||
break;
|
||||
}
|
||||
auto const &row = **next;
|
||||
if (row.size() != 9U || count_value(row[5]) == 0U || count_value(row[6]) == 0U || text_value(row[8]).empty() || text_value(row[3]).empty()) {
|
||||
if (row.size() != 10U || count_value(row[5]) == 0U || count_value(row[6]) == 0U || text_value(row[8]).empty() || text_value(row[3]).empty() || !valid_title_image(row[9])) {
|
||||
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "invalid practice metadata"));
|
||||
break;
|
||||
}
|
||||
@@ -253,6 +259,7 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo
|
||||
.source_path = std::move(*source),
|
||||
.measure_count = count_value(row[5]),
|
||||
.total_pages = count_value(row[6]),
|
||||
.title_image = text_value(row[9]),
|
||||
};
|
||||
exercises.push_back({
|
||||
.id = text_value(row[8]),
|
||||
|
||||
@@ -435,6 +435,7 @@ auto vamp_zero::app_status_json(vamp_zero::app_state const &state) -> boost::jso
|
||||
|
||||
auto vamp_zero::remote_display_text(vamp_zero::app_state const &state) -> std::string {
|
||||
auto title = std::string{};
|
||||
auto title_image = std::string{};
|
||||
auto part = std::string{};
|
||||
auto page = std::size_t{};
|
||||
auto pages = std::size_t{};
|
||||
@@ -443,6 +444,7 @@ auto vamp_zero::remote_display_text(vamp_zero::app_state const &state) -> std::s
|
||||
if (auto const *selected = active_part(state); selected != nullptr) {
|
||||
active = true;
|
||||
title = selected->title;
|
||||
title_image = selected->title_image;
|
||||
part = selected->part;
|
||||
page = state.page_index + 1U;
|
||||
pages = selected->total_pages;
|
||||
@@ -453,6 +455,7 @@ auto vamp_zero::remote_display_text(vamp_zero::app_state const &state) -> std::s
|
||||
active = true;
|
||||
title = selected->title;
|
||||
part = practice_part == nullptr ? std::string{} : practice_part->part;
|
||||
title_image = practice_part == nullptr ? std::string{} : practice_part->title_image;
|
||||
page = state.page_index + 1U;
|
||||
pages = practice_part == nullptr ? 0U : practice_part->total_pages;
|
||||
}
|
||||
@@ -461,6 +464,7 @@ auto vamp_zero::remote_display_text(vamp_zero::app_state const &state) -> std::s
|
||||
output << "mode=" << vamp_zero::to_string(state.mode) << "\n";
|
||||
output << "active=" << (active ? "1" : "0") << "\n";
|
||||
output << "title=" << sanitize_line_value(title) << "\n";
|
||||
output << "title_image=" << title_image << "\n";
|
||||
output << "part=" << sanitize_line_value(part) << "\n";
|
||||
output << "page=" << page << "\n";
|
||||
output << "pages=" << pages << "\n";
|
||||
|
||||
+116
-1
@@ -1,24 +1,111 @@
|
||||
#include <vamp_zero/portal.hpp>
|
||||
|
||||
#include <chrono>
|
||||
#include <array>
|
||||
#include <expected>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include <boost/asio/awaitable.hpp>
|
||||
#include <boost/json/object.hpp>
|
||||
#include <boost/json/value.hpp>
|
||||
#include <openssl/sha.h>
|
||||
|
||||
#include <vamp_protocol/page_control.hpp>
|
||||
#include <vamp_zero/page_control.hpp>
|
||||
#include <vamp_zero/errors.hpp>
|
||||
|
||||
|
||||
namespace {
|
||||
|
||||
auto read_asset(std::filesystem::path const &root, std::filesystem::path const &relative, std::size_t limit, std::string *resolved = nullptr) -> std::optional<std::string> {
|
||||
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<std::size_t>(size), '\0');
|
||||
auto input = std::ifstream{path, std::ios::binary};
|
||||
if (!input.read(bytes.data(), static_cast<std::streamsize>(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<title_file> {
|
||||
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<std::uchar_t, SHA256_DIGEST_LENGTH>{};
|
||||
SHA256(reinterpret_cast<std::uchar_t const *>(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.performance.parts) {
|
||||
identifiers_.insert(part.title_image);
|
||||
}
|
||||
for (auto const &exercise : catalog.practice.exercises) {
|
||||
for (auto const &part : exercise.parts) {
|
||||
identifiers_.insert(part.title_image);
|
||||
}
|
||||
}
|
||||
}
|
||||
auto image(std::string const &identifier) const -> std::optional<title_file> {
|
||||
return identifiers_.contains(identifier) ? read_title(root_, identifier) : std::nullopt;
|
||||
}
|
||||
private:
|
||||
std::filesystem::path root_;
|
||||
std::set<std::string> identifiers_{};
|
||||
};
|
||||
|
||||
auto portal_html() -> std::string {
|
||||
return R"html(<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Product Vamp</title></head><body><main><h1>Product Vamp</h1><pre id="status">Loading...</pre><p><button id="prev">Previous</button> <button id="select">Select</button> <button id="next">Next</button></p><script>
|
||||
return R"html(<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Product Vamp</title><style>@font-face{font-family:PetalumaScript;src:url('/assets/PetalumaScript.otf') format('opentype');font-display:swap}body,button,pre{font-family:PetalumaScript;font-size:22px}pre{white-space:pre-wrap;overflow-wrap:anywhere}button{font:inherit}</style></head><body><main><h1>Product Vamp</h1><pre id="status">Loading...</pre><p><button id="prev">Previous</button> <button id="select">Select</button> <button id="next">Next</button></p><script>
|
||||
async function refresh(){const r=await fetch('/api/status');const j=await r.json();document.getElementById('status').textContent=JSON.stringify(j.payload||j,null,2);}
|
||||
document.getElementById('prev').onclick=async()=>{await fetch('/api/remote/previous',{method:'POST'});refresh();};
|
||||
document.getElementById('select').onclick=async()=>{await fetch('/api/remote/select',{method:'POST'});refresh();};
|
||||
@@ -41,6 +128,14 @@ auto remote_response(vamp_zero::app_state const &state) -> ligo::service_respons
|
||||
|
||||
}
|
||||
|
||||
auto vamp_zero::validated_title_path(std::string const &assets_path, std::string const &identifier) -> std::expected<std::string, modulus::error> {
|
||||
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)} {}
|
||||
|
||||
@@ -163,6 +258,26 @@ auto vamp_zero::portal_status_json(vamp_zero::config const &config, vamp_zero::a
|
||||
|
||||
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<title_assets const>(assets, *state.snapshot().catalog);
|
||||
router.add_response_route("GET", "/assets/PetalumaScript.otf", [assets](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
|
||||
auto font = read_asset(assets, "fonts/petaluma/PetalumaScript.otf", 1024U * 1024U);
|
||||
if (!font) {
|
||||
co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "PetalumaScript font unavailable"));
|
||||
}
|
||||
co_return ligo::text_response("font/otf", std::move(*font));
|
||||
});
|
||||
router.add_response_route("POST", vamp_protocol::remote_title_path, [artwork](ligo::request_context, boost::json::value payload) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
|
||||
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<std::expected<boost::json::value, modulus::error>> {
|
||||
co_return boost::json::object{{"product", "product-vamp"}, {"device", "zero"}, {"status", "ready"}};
|
||||
});
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
#include <luma.hpp>
|
||||
|
||||
#include <vamp_style.hpp>
|
||||
#include <vamp_style/font.hpp>
|
||||
#include <vamp_zero/color.hpp>
|
||||
#include <vamp_zero/database.hpp>
|
||||
#include <vamp_zero/errors.hpp>
|
||||
|
||||
Reference in New Issue
Block a user