diff --git a/cmake/ScoreTests.cmake b/cmake/ScoreTests.cmake index 81bc4fa997..4cb2bc71be 100644 --- a/cmake/ScoreTests.cmake +++ b/cmake/ScoreTests.cmake @@ -89,11 +89,14 @@ function(score_add_test NAME) ${ARG_LIBS} ${QT_PREFIX}::Core) - # The app/document fixtures library is defined late (tests/fixtures, after - # src/). Per-plugin unit tests built during src/ are app-free and don't need - # it, so only link it when it already exists. + # The fixtures target is defined late (tests/fixtures, after src/), so tests + # declared from inside src/ -- plug-ins and add-ons -- cannot link it yet. It + # is header-only, so hand those the include path instead, which is what the + # else branch below is for. if(TARGET score_test_fixtures AND NOT ARG_STANDALONE) target_link_libraries(${NAME} PRIVATE score_test_fixtures) + else() + target_include_directories(${NAME} PRIVATE "${SCORE_ROOT_SOURCE_DIR}/tests/fixtures") endif() if(ARG_GUI OR ARG_APP) diff --git a/src/lib/CMakeLists.txt b/src/lib/CMakeLists.txt index 83345f8726..626c84a424 100755 --- a/src/lib/CMakeLists.txt +++ b/src/lib/CMakeLists.txt @@ -211,6 +211,7 @@ set(HEADERS "${CMAKE_CURRENT_SOURCE_DIR}/score/serialization/CommonTypes.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/serialization/JSONValueVisitor.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/serialization/JSONVisitor.hpp" + "${CMAKE_CURRENT_SOURCE_DIR}/score/serialization/OpaquePayload.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/serialization/MapSerialization.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/serialization/MimeVisitor.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/serialization/StdVariantSerialization.hpp" @@ -229,6 +230,8 @@ set(HEADERS "${CMAKE_CURRENT_SOURCE_DIR}/score/tools/Cursor.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/tools/DeleteAll.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/tools/File.hpp" + "${CMAKE_CURRENT_SOURCE_DIR}/score/tools/Uri.hpp" + "${CMAKE_CURRENT_SOURCE_DIR}/score/tools/Environment.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/tools/FindStringInFile.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/tools/IdentifierGeneration.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/tools/MapCopy.hpp" @@ -328,6 +331,7 @@ set(HEADERS "${CMAKE_CURRENT_SOURCE_DIR}/score/widgets/DoubleSpinBox.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/widgets/IconProvider.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/widgets/IntSlider.hpp" + "${CMAKE_CURRENT_SOURCE_DIR}/score/widgets/FileDialog.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/widgets/FormWidget.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/widgets/ItemViewDrag.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/widgets/Layout.hpp" @@ -447,6 +451,7 @@ set(SRCS "${CMAKE_CURRENT_SOURCE_DIR}/score/selection/SelectionStack.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/serialization/DataStreamVisitor.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/serialization/JSONObjectVisitor.cpp" +"${CMAKE_CURRENT_SOURCE_DIR}/score/serialization/OpaquePayload.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/serialization/QtTypesJsonVisitors.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/model/path/ObjectIdentifierSerialization.cpp" @@ -463,6 +468,8 @@ set(SRCS "${CMAKE_CURRENT_SOURCE_DIR}/score/tools/std/String.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/tools/Cuda.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/tools/File.cpp" +"${CMAKE_CURRENT_SOURCE_DIR}/score/tools/Uri.cpp" +"${CMAKE_CURRENT_SOURCE_DIR}/score/tools/Environment.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/tools/FileContains.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/tools/FileWatch.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/tools/ProjectFiles.cpp" @@ -523,6 +530,7 @@ set(SRCS "${CMAKE_CURRENT_SOURCE_DIR}/score/widgets/ControlWidgets.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/widgets/DoubleSlider.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/widgets/DoubleSpinBox.cpp" +"${CMAKE_CURRENT_SOURCE_DIR}/score/widgets/FileDialog.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/widgets/FormWidget.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/widgets/IconProvider.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/widgets/IntSlider.cpp" diff --git a/src/lib/core/document/Document.cpp b/src/lib/core/document/Document.cpp index cb4f1b0131..be8bd62e49 100644 --- a/src/lib/core/document/Document.cpp +++ b/src/lib/core/document/Document.cpp @@ -123,6 +123,11 @@ const std::vector& DocumentContext::pluginModels() const return document.model().pluginModels(); } +score::Environment& DocumentContext::environment() const noexcept +{ + return document.environment(); +} + Document::Document( const QString& name, const Id& id, DocumentDelegateFactory& factory, QWidget* parentview, QObject* parent) @@ -156,6 +161,19 @@ Document::Document( // this, &Document::fileNameChanged); } +score::Environment& Document::environment() const noexcept +{ + if(!m_environment) + m_environment = std::make_unique(m_context); + return *m_environment; +} + +void Document::setEnvironment(std::unique_ptr env) +{ + SCORE_ASSERT(env); + m_environment = std::move(env); +} + void Document::init() { if(this->m_context.app.applicationSettings.gui) diff --git a/src/lib/core/document/Document.hpp b/src/lib/core/document/Document.hpp index fa1372f048..4ae705a500 100644 --- a/src/lib/core/document/Document.hpp +++ b/src/lib/core/document/Document.hpp @@ -1,5 +1,6 @@ #pragma once #include +#include #include #include #include @@ -15,6 +16,7 @@ #include #include +#include #include class QObject; @@ -73,6 +75,16 @@ class SCORE_LIB_BASE_EXPORT Document final : public QObject const DocumentContext& context() const noexcept { return m_context; } + //! Where this document's files are. Local unless something says otherwise. + //! + //! Created on demand rather than in init(): deserialization resolves paths, + //! and it runs from the constructors before init() would have had a chance. + score::Environment& environment() const noexcept; + + //! Take the files of this document to be somewhere else -- another machine, + //! typically, once it is being edited through a session. + void setEnvironment(std::unique_ptr env); + DocumentModel& model() const noexcept { return *m_model; } DocumentPresenter* presenter() const noexcept { return m_presenter; } @@ -150,6 +162,7 @@ class SCORE_LIB_BASE_EXPORT Document final : public QObject DocumentBackupManager* m_backupMgr{}; DocumentContext m_context; + mutable std::unique_ptr m_environment; std::optional m_initialData{}; bool m_virgin{false}; // Used to check if we can safely close it diff --git a/src/lib/core/document/DocumentSerialization.cpp b/src/lib/core/document/DocumentSerialization.cpp index e4d8c6f978..86fe1531ce 100644 --- a/src/lib/core/document/DocumentSerialization.cpp +++ b/src/lib/core/document/DocumentSerialization.cpp @@ -271,13 +271,22 @@ void Document::loadModel( } case JSONObject::type(): { auto doc = readJson(data); - bool ok = DocumentManager::checkAndUpdateJson(doc, m_context.app); - if(!ok) + const auto check = DocumentManager::checkAndUpdateJson(doc, m_context.app); + if(!check.loadable) { throw std::runtime_error( - "The save format is too old. Wait until the developers implement " - "loading of the older save format."); + "This document was written by a newer version of score, or by one " + "with a newer version of a plug-in you have, and cannot be read."); } + + for(const auto& plugin : check.missingPlugins) + { + qWarning() << "Opening a document that uses plug-in" + << score::uuids::toByteArray(plugin.impl()) + << "which this build does not have. What it contributed is " + "kept as-is and will be written back unchanged."; + } + m_model->loadDocumentAsJson(m_context, doc, factory); break; } diff --git a/src/lib/core/presenter/DocumentManager.cpp b/src/lib/core/presenter/DocumentManager.cpp index 61fa25d669..9f08f51b54 100644 --- a/src/lib/core/presenter/DocumentManager.cpp +++ b/src/lib/core/presenter/DocumentManager.cpp @@ -676,11 +676,12 @@ bool DocumentManager::preparingNewDocument() const return m_preparingNewDocument; } -bool DocumentManager::checkAndUpdateJson( +DocumentManager::Loadability DocumentManager::checkAndUpdateJson( rapidjson::Value& obj, const score::GUIApplicationContext& ctx) { + Loadability res; if(obj.GetType() != rapidjson::kObjectType) - return false; + return res; // Check the version Version loaded_version{0}; @@ -720,7 +721,7 @@ bool DocumentManager::checkAndUpdateJson( } else { - return false; + return res; } } @@ -737,16 +738,13 @@ bool DocumentManager::checkAndUpdateJson( else if(loaded_version < ctx.applicationSettings.saveFormatVersion) { // TODO update main - auto res - = updateJson(obj, loaded_version, ctx.applicationSettings.saveFormatVersion); - if(!res) + if(!updateJson(obj, loaded_version, ctx.applicationSettings.saveFormatVersion)) { - return false; + return res; } } // Check the plug-ins - bool pluginsAvailable = true; bool pluginsLoadable = true; for(const auto& plug : loading_plugins) @@ -754,7 +752,12 @@ bool DocumentManager::checkAndUpdateJson( auto it = local_plugins.find(plug.plugin); if(it == local_plugins.end()) { - pluginsAvailable = false; + // Not fatal. Refusing here used to make a document unopenable on any + // machine that did not have every plug-in it mentions -- which is every + // machine, once builds differ by platform -- and it could not see a + // factory missing inside a plug-in that *is* present anyway, so it never + // gave the guarantee it appeared to. + res.missingPlugins.push_back(plug.plugin); } else { @@ -771,7 +774,10 @@ bool DocumentManager::checkAndUpdateJson( } } - return mainLoadable && pluginsAvailable && pluginsLoadable; + // A plug-in older than the file's is a different matter: its factory *is* + // found, and would read data in a format it does not understand. + res.loadable = mainLoadable && pluginsLoadable; + return res; } bool DocumentManager::updateJson( diff --git a/src/lib/core/presenter/DocumentManager.hpp b/src/lib/core/presenter/DocumentManager.hpp index 29e81446f6..33389600d3 100644 --- a/src/lib/core/presenter/DocumentManager.hpp +++ b/src/lib/core/presenter/DocumentManager.hpp @@ -1,4 +1,5 @@ #pragma once +#include #include #include @@ -107,11 +108,23 @@ class SCORE_LIB_BASE_EXPORT DocumentManager bool preparingNewDocument() const; + struct Loadability + { + //! Whether the document can be opened at all. + bool loadable{}; + + //! Plug-ins the document names that this build does not have. It still + //! opens: what those plug-ins contributed -- processes, their ports, + //! devices -- is kept verbatim and written back unchanged, so the document + //! survives a round-trip through this machine. + std::vector> missingPlugins; + }; + /** * @brief checkAndUpdateJson - * @return boolean indicating if the document is loadable + * @return whether the document is loadable, and what it names that we lack */ - static bool + static Loadability checkAndUpdateJson(rapidjson::Value&, const score::GUIApplicationContext& ctx); public: diff --git a/src/lib/score/application/ApplicationComponents.cpp b/src/lib/score/application/ApplicationComponents.cpp index 3aa32381f6..88d96c956b 100644 --- a/src/lib/score/application/ApplicationComponents.cpp +++ b/src/lib/score/application/ApplicationComponents.cpp @@ -65,6 +65,16 @@ InterfaceListBase* ApplicationComponentsData::findInterfaceList( return nullptr; } +std::vector> +ApplicationComponents::availableCommands() const +{ + std::vector> res; + res.reserve(m_data.commands.size()); + for(const auto& [key, factory] : m_data.commands) + res.push_back(key); + return res; +} + Command* ApplicationComponents::instantiateUndoCommand(const CommandData& cmd) const { auto it = m_data.commands.find({cmd.parentKey, cmd.commandKey}); @@ -82,4 +92,31 @@ Command* ApplicationComponents::instantiateUndoCommand(const CommandData& cmd) c #endif return nullptr; } + +Command* +ApplicationComponents::instantiateUndoCommandIfAvailable(const CommandData& cmd) const noexcept +{ + auto it = m_data.commands.find({cmd.parentKey, cmd.commandKey}); + if(it == m_data.commands.end()) + return nullptr; + + try + { + return (*it->second)(cmd.data); + } + catch(const std::exception& e) + { + // The command exists but its payload does not deserialize here: it can name + // a factory this build lacks, e.g. a protocol compiled in under #if. + qDebug() << "Command" << cmd.parentKey.toString() << "::" + << cmd.commandKey.toString() << "could not be read:" << e.what(); + return nullptr; + } + catch(...) + { + qDebug() << "Command" << cmd.parentKey.toString() + << "::" << cmd.commandKey.toString() << "could not be read."; + return nullptr; + } +} } diff --git a/src/lib/score/application/ApplicationComponents.hpp b/src/lib/score/application/ApplicationComponents.hpp index ef5371be69..17673ae3e2 100644 --- a/src/lib/score/application/ApplicationComponents.hpp +++ b/src/lib/score/application/ApplicationComponents.hpp @@ -188,8 +188,22 @@ class SCORE_LIB_BASE_EXPORT ApplicationComponents throw; } + //! Every command this build can instantiate, as {group, key}. + //! + //! Peers mirror each other by exchanging commands, so which ones exist is + //! part of what makes two builds able to work together. + std::vector> availableCommands() const; + score::Command* instantiateUndoCommand(const CommandData& cmd) const; + //! Same, but returns nullptr instead of aborting or throwing when the command + //! is not registered in this build. + //! + //! For commands that did not originate locally: a peer in a networked session + //! may run a build with plug-ins we do not have, so an unknown command is a + //! situation to report, not a programming error to abort on. + score::Command* instantiateUndoCommandIfAvailable(const CommandData& cmd) const noexcept; + private: const score::ApplicationComponentsData& m_data; }; diff --git a/src/lib/score/application/ApplicationContext.hpp b/src/lib/score/application/ApplicationContext.hpp index 8bf36fe38d..258d981c5b 100644 --- a/src/lib/score/application/ApplicationContext.hpp +++ b/src/lib/score/application/ApplicationContext.hpp @@ -105,6 +105,18 @@ struct SCORE_LIB_BASE_EXPORT ApplicationContext return components.instantiateUndoCommand(cmd); } + /** + * @brief Like instantiateUndoCommand, but returns nullptr for a command this + * build does not have, instead of aborting. + * + * For commands that did not originate locally: a peer in a networked session + * may run a build with plug-ins we do not have. + */ + auto instantiateUndoCommandIfAvailable(const CommandData& cmd) const noexcept + { + return components.instantiateUndoCommandIfAvailable(cmd); + } + const score::DocumentContext* currentDocument() const noexcept; //! Access to start-up command-line settings diff --git a/src/lib/score/document/DocumentContext.hpp b/src/lib/score/document/DocumentContext.hpp index 6b62aafc70..6545bca9b1 100644 --- a/src/lib/score/document/DocumentContext.hpp +++ b/src/lib/score/document/DocumentContext.hpp @@ -14,6 +14,7 @@ class CommandStack; class SelectionStack; class ObjectLocker; class DocumentPlugin; +class Environment; struct SCORE_LIB_BASE_EXPORT DocumentContext { friend class score::Document; @@ -31,6 +32,10 @@ struct SCORE_LIB_BASE_EXPORT DocumentContext const std::vector& pluginModels() const; + //! Where this document's files are: on this machine, or on the one running + //! the score. Ask rather than assuming a path can be opened. + score::Environment& environment() const noexcept; + template T& model() const { diff --git a/src/lib/score/model/Component.cpp b/src/lib/score/model/Component.cpp index 9e376578bd..18697d6440 100644 --- a/src/lib/score/model/Component.cpp +++ b/src/lib/score/model/Component.cpp @@ -136,7 +136,8 @@ SerializableComponentFactory::~SerializableComponentFactory() { } SerializableComponentFactoryList::~SerializableComponentFactoryList() { } score::SerializableComponent* SerializableComponentFactoryList::loadMissing( - const VisitorVariant& vis, const DocumentContext& ctx, QObject* parent) const + const UuidKey& key, const VisitorVariant& vis, + const DocumentContext& ctx, QObject* parent) const { SCORE_TODO; return nullptr; diff --git a/src/lib/score/model/ComponentSerialization.hpp b/src/lib/score/model/ComponentSerialization.hpp index 70bf7f0066..9d9d0e405f 100644 --- a/src/lib/score/model/ComponentSerialization.hpp +++ b/src/lib/score/model/ComponentSerialization.hpp @@ -86,8 +86,8 @@ struct SCORE_LIB_BASE_EXPORT SerializableComponentFactoryList using object_type = score::SerializableComponent; ~SerializableComponentFactoryList(); score::SerializableComponent* loadMissing( - const VisitorVariant& vis, const score::DocumentContext& ctx, - QObject* parent) const; + const UuidKey& key, const VisitorVariant& vis, + const score::DocumentContext& ctx, QObject* parent) const; }; template diff --git a/src/lib/score/model/path/ObjectPath.hpp b/src/lib/score/model/path/ObjectPath.hpp index 7b8e45a782..000371b811 100644 --- a/src/lib/score/model/path/ObjectPath.hpp +++ b/src/lib/score/model/path/ObjectPath.hpp @@ -1,4 +1,5 @@ #pragma once +#include #include #include @@ -111,23 +112,30 @@ class SCORE_LIB_BASE_EXPORT ObjectPath template T& find(const score::DocumentContext& ctx) const { - // First see if the pointer is still loaded in the cache. - if(!m_cache.isNull()) - { - return *safe_cast(m_cache.data()); - } - else // Load it by hand - { - auto ptr = safe_cast::type*>(find_impl(ctx)); - m_cache = ptr; - return *ptr; - } + // Checked rather than assumed, for the reason given on try_find: an object + // of another type can be standing where this path points. safe_cast would + // abort in debug and cast blind in release; throwing lets the caller -- + // a command being replayed from another peer, typically -- report it. + auto raw = m_cache.isNull() ? find_impl(ctx) : m_cache.data(); + auto ptr = dynamic_cast::type*>(raw); + if(!ptr) + throw std::runtime_error{"the object this path names is of another type"}; + + m_cache = ptr; + return *ptr; } /** * @brief Tries to find an object * - * @return null if the object does not exist. + * @return null if the object does not exist, or is not of this type. + * + * The type has to be checked, not assumed. A path names an object by + * position and name, and the object standing at that position is not + * necessarily the type the path was written for: a build without a plug-in + * loads its processes and ports as stand-ins, which occupy the same place + * under the same ids. Casting blind returns a pointer to an object of the + * wrong type, which is then written through. */ template T* try_find(const score::DocumentContext& ctx) const noexcept @@ -136,13 +144,14 @@ class SCORE_LIB_BASE_EXPORT ObjectPath { if(!m_cache.isNull()) { - return safe_cast(m_cache.data()); + return dynamic_cast(m_cache.data()); } else // Load it by hand { - auto ptr - = static_cast::type*>(find_impl_unsafe(ctx)); - m_cache = ptr; + auto ptr = dynamic_cast::type*>( + find_impl_unsafe(ctx)); + if(ptr) + m_cache = ptr; return ptr; } } diff --git a/src/lib/score/plugins/SerializableHelpers.hpp b/src/lib/score/plugins/SerializableHelpers.hpp index 9dbffb9c8f..fcbcc674c7 100644 --- a/src/lib/score/plugins/SerializableHelpers.hpp +++ b/src/lib/score/plugins/SerializableHelpers.hpp @@ -25,10 +25,10 @@ auto deserialize_interface( DataStream::Deserializer sub{b}; // Deserialize the interface identifier + typename FactoryList_T::factory_type::ConcreteKey k; try { SCORE_DEBUG_CHECK_DELIMITER2(sub); - typename FactoryList_T::factory_type::ConcreteKey k; TSerializer::writeTo( sub, k); @@ -49,8 +49,9 @@ auto deserialize_interface( } // If the object could not be loaded, we try to load a "missing" version of - // it. - return factories.loadMissing(sub.toVariant(), std::forward(args)...); + // it. The key is handed over so that the stand-in can keep the identity of + // what it replaces and save it back unchanged. + return factories.loadMissing(k, sub.toVariant(), std::forward(args)...); } template @@ -63,10 +64,10 @@ auto deserialize_interface( DataStream::Deserializer sub{b}; // Deserialize the interface identifier + typename FactoryList_T::factory_type::ConcreteKey k; try { SCORE_DEBUG_CHECK_DELIMITER2(sub); - typename FactoryList_T::factory_type::ConcreteKey k; TSerializer::writeTo( sub, k); @@ -87,8 +88,9 @@ auto deserialize_interface( } // If the object could not be loaded, we try to load a "missing" version of - // it. - return factories.loadMissing(sub.toVariant(), std::forward(args)...); + // it. The key is handed over so that the stand-in can keep the identity of + // what it replaces and save it back unchanged. + return factories.loadMissing(k, sub.toVariant(), std::forward(args)...); } template @@ -97,9 +99,9 @@ auto deserialize_interface( typename FactoryList_T::object_type* { // Deserialize the interface identifier + typename FactoryList_T::factory_type::ConcreteKey k; try { - typename FactoryList_T::factory_type::ConcreteKey k; JSONWriter wr{des.obj[des.strings.uuid]}; TSerializer::writeTo( wr, k); @@ -116,8 +118,9 @@ auto deserialize_interface( } // If the object could not be loaded, we try to load a "missing" version of - // it. - return factories.loadMissing(des.toVariant(), std::forward(args)...); + // it. The key is handed over so that the stand-in can keep the identity of + // what it replaces and save it back unchanged. + return factories.loadMissing(k, des.toVariant(), std::forward(args)...); } template @@ -126,9 +129,9 @@ auto deserialize_interface( typename FactoryList_T::object_type* { // Deserialize the interface identifier + typename FactoryList_T::factory_type::ConcreteKey k; try { - typename FactoryList_T::factory_type::ConcreteKey k; JSONWriter wr{des.obj[des.strings.uuid]}; TSerializer::writeTo( wr, k); @@ -145,6 +148,7 @@ auto deserialize_interface( } // If the object could not be loaded, we try to load a "missing" version of - // it. - return factories.loadMissing(des.toVariant(), std::forward(args)...); + // it. The key is handed over so that the stand-in can keep the identity of + // what it replaces and save it back unchanged. + return factories.loadMissing(k, des.toVariant(), std::forward(args)...); } diff --git a/src/lib/score/plugins/documentdelegate/plugin/DocumentPlugin.cpp b/src/lib/score/plugins/documentdelegate/plugin/DocumentPlugin.cpp index 692c8bfaca..c9029853c9 100644 --- a/src/lib/score/plugins/documentdelegate/plugin/DocumentPlugin.cpp +++ b/src/lib/score/plugins/documentdelegate/plugin/DocumentPlugin.cpp @@ -2,10 +2,12 @@ // it. PVS-Studio Static Code Analyzer for C, C++ and C#: http://www.viva64.com #include "DocumentPlugin.hpp" +#include #include #include W_OBJECT_IMPL(score::DocumentPlugin) +W_OBJECT_IMPL(score::OpaqueDocumentPlugin) W_OBJECT_IMPL(score::SerializableDocumentPlugin) namespace score { @@ -28,11 +30,47 @@ DocumentPluginFactory::~DocumentPluginFactory() = default; DocumentPluginFactoryList::~DocumentPluginFactoryList() { } DocumentPluginFactoryList::object_type* DocumentPluginFactoryList::loadMissing( - const VisitorVariant& vis, DocumentContext& doc, QObject* parent) const + const UuidKey& key, const VisitorVariant& vis, + DocumentContext& doc, QObject* parent) const { - SCORE_TODO; + switch(vis.identifier) + { + case DataStream::type(): + return new OpaqueDocumentPlugin{ + key, doc, static_cast(vis.visitor), parent}; + case JSONObject::type(): + return new OpaqueDocumentPlugin{ + key, doc, static_cast(vis.visitor), parent}; + } return nullptr; } + +OpaqueDocumentPlugin::OpaqueDocumentPlugin( + const UuidKey& key, const score::DocumentContext& ctx, + DataStream::Deserializer& vis, QObject* parent) + : SerializableDocumentPlugin{ctx, vis, parent} + , m_key{key} + , m_payload{score::OpaquePayload::fromDataStream(vis)} +{ +} + +OpaqueDocumentPlugin::OpaqueDocumentPlugin( + const UuidKey& key, const score::DocumentContext& ctx, + JSONObject::Deserializer& vis, QObject* parent) + : SerializableDocumentPlugin{ctx, vis, parent} + , m_key{key} + // A document plug-in's base writes nothing but the key, so everything else + // in the object belongs to the plug-in. + , m_payload{score::OpaquePayload::fromJson(vis.base, {QStringLiteral("uuid")})} +{ +} + +OpaqueDocumentPlugin::~OpaqueDocumentPlugin() = default; + +void OpaqueDocumentPlugin::serialize_impl(const VisitorVariant& vis) const noexcept +{ + m_payload.write(vis); +} } template <> diff --git a/src/lib/score/plugins/documentdelegate/plugin/DocumentPluginCreator.hpp b/src/lib/score/plugins/documentdelegate/plugin/DocumentPluginCreator.hpp index 3797164a7f..15ea14c86d 100644 --- a/src/lib/score/plugins/documentdelegate/plugin/DocumentPluginCreator.hpp +++ b/src/lib/score/plugins/documentdelegate/plugin/DocumentPluginCreator.hpp @@ -38,7 +38,8 @@ class SCORE_LIB_BASE_EXPORT DocumentPluginFactoryList final using object_type = DocumentPlugin; ~DocumentPluginFactoryList(); object_type* loadMissing( - const VisitorVariant& vis, score::DocumentContext& doc, QObject* parent) const; + const UuidKey& key, const VisitorVariant& vis, + score::DocumentContext& doc, QObject* parent) const; }; template diff --git a/src/lib/score/plugins/documentdelegate/plugin/SerializableDocumentPlugin.hpp b/src/lib/score/plugins/documentdelegate/plugin/SerializableDocumentPlugin.hpp index 182f3f142d..0ae4e40cab 100644 --- a/src/lib/score/plugins/documentdelegate/plugin/SerializableDocumentPlugin.hpp +++ b/src/lib/score/plugins/documentdelegate/plugin/SerializableDocumentPlugin.hpp @@ -1,6 +1,10 @@ #pragma once #include +#include + +#include + #include namespace score @@ -35,4 +39,35 @@ class SCORE_LIB_BASE_EXPORT SerializableDocumentPlugin virtual ~SerializableDocumentPlugin(); }; +/** + * @brief Stands in for a document plug-in this build does not have. + * + * Document plug-ins carry whole subsystems' worth of state -- the network + * add-on keeps its groups in one -- and there was nowhere to put that when the + * plug-in was absent, so it was dropped and saving wrote the document back + * without it. Keeping it means a session document opened by a peer without the + * add-on still describes its groups when it gets back to one that has it. + */ +class SCORE_LIB_BASE_EXPORT OpaqueDocumentPlugin final + : public SerializableDocumentPlugin +{ + W_OBJECT(OpaqueDocumentPlugin) +public: + OpaqueDocumentPlugin( + const UuidKey& key, const score::DocumentContext& ctx, + DataStream::Deserializer& vis, QObject* parent); + OpaqueDocumentPlugin( + const UuidKey& key, const score::DocumentContext& ctx, + JSONObject::Deserializer& vis, QObject* parent); + ~OpaqueDocumentPlugin() override; + + //! The key of the plug-in we replace, so that saving names it and not us. + UuidKey concreteKey() const noexcept override { return m_key; } + void serialize_impl(const VisitorVariant& vis) const noexcept override; + +private: + UuidKey m_key; + score::OpaquePayload m_payload; +}; + } diff --git a/src/lib/score/serialization/OpaquePayload.cpp b/src/lib/score/serialization/OpaquePayload.cpp new file mode 100644 index 0000000000..545ff1e63e --- /dev/null +++ b/src/lib/score/serialization/OpaquePayload.cpp @@ -0,0 +1,121 @@ +#include + +#include + +namespace score +{ +namespace +{ +// A JSON payload has to be carried inside a binary blob sometimes -- autosave +// and interval moves both serialise to the binary format regardless of where +// the document came from. These say which of the two is inside, so that +// reading it back does not have to guess. +// +// Nothing a plug-in writes can be mistaken for either: the binary marker is a +// byte sequence with an embedded NUL, and the JSON key is not a name anyone +// would choose. +constexpr auto foreign_json_marker = "\0score-opaque-json"; +constexpr int foreign_json_marker_size = 18; +constexpr auto foreign_binary_key = "$score-opaque-binary"; + +QByteArray membersExcept(const rapidjson::Value& base, const QStringList& owned) +{ + rapidjson::StringBuffer buf; + JsonWriter w{buf}; + w.StartObject(); + for(const auto& m : base.GetObject()) + { + const auto name = QString::fromUtf8(m.name.GetString(), m.name.GetStringLength()); + if(owned.contains(name)) + continue; + w.Key(m.name.GetString(), m.name.GetStringLength()); + m.value.Accept(w); + } + w.EndObject(); + + // "{}" -- there was nothing but what we already own. + if(buf.GetLength() <= 2) + return {}; + return QByteArray{buf.GetString(), (int)buf.GetLength()}; +} +} + +OpaquePayload +OpaquePayload::fromJson(const rapidjson::Value& base, const QStringList& owned) noexcept +{ + if(!base.IsObject()) + return {}; + + // Written by a previous pass that had binary data to keep. + if(auto it = base.FindMember(foreign_binary_key); + it != base.MemberEnd() && it->value.IsString()) + { + return OpaquePayload{ + DataStream::type(), + QByteArray::fromBase64(QByteArray{ + it->value.GetString(), (int)it->value.GetStringLength()})}; + } + + auto members = membersExcept(base, owned); + if(members.isEmpty()) + return {}; + return OpaquePayload{JSONObject::type(), std::move(members)}; +} + +OpaquePayload OpaquePayload::fromDataStream(DataStream::Deserializer& vis) noexcept +{ + auto* dev = vis.m_stream.stream.device(); + if(!dev) + return {}; + + auto tail = dev->readAll(); + if(tail.isEmpty()) + return {}; + + if(tail.startsWith(QByteArray::fromRawData( + foreign_json_marker, foreign_json_marker_size))) + { + return OpaquePayload{ + JSONObject::type(), tail.mid(foreign_json_marker_size)}; + } + + return OpaquePayload{DataStream::type(), std::move(tail)}; +} + +void OpaquePayload::write(const VisitorVariant& vis) const noexcept +{ + if(empty()) + return; + + if(vis.identifier == DataStream::type()) + { + auto& s = static_cast(vis.visitor); + if(format == JSONObject::type()) + s.m_stream.stream.writeRawData(foreign_json_marker, foreign_json_marker_size); + s.m_stream.stream.writeRawData(bytes.constData(), bytes.size()); + } + else if(vis.identifier == JSONObject::type()) + { + auto& s = static_cast(vis.visitor); + + if(format == DataStream::type()) + { + const auto encoded = bytes.toBase64(); + s.stream.Key(foreign_binary_key); + s.stream.String(encoded.constData(), encoded.size()); + return; + } + + rapidjson::Document d; + d.Parse(bytes.data(), bytes.size()); + if(d.HasParseError() || !d.IsObject()) + return; + + for(const auto& m : d.GetObject()) + { + s.stream.Key(m.name.GetString(), m.name.GetStringLength()); + m.value.Accept(s.stream); + } + } +} +} diff --git a/src/lib/score/serialization/OpaquePayload.hpp b/src/lib/score/serialization/OpaquePayload.hpp new file mode 100644 index 0000000000..092edd7eef --- /dev/null +++ b/src/lib/score/serialization/OpaquePayload.hpp @@ -0,0 +1,65 @@ +#pragma once +#include +#include + +#include +#include + +#include + +/** + * @file + * @brief Keeping data whose meaning we do not know. + * + * score is not the same program everywhere: processes, ports, protocols and + * document plug-ins are registered conditionally, so a document routinely names + * things the build reading it cannot construct. Dropping them means that saving + * from that machine destroys them for every machine that could, which is the + * one outcome nothing recovers from. + * + * The two formats give this to us differently. In JSON an object's members are + * addressable, so the ones score itself owns can be told from the ones the + * plug-in wrote and only the latter kept. In the binary format each polymorphic + * object is written into its own length-delimited blob, so once the base has + * read what it recognises, the rest of that blob is the plug-in's and nobody + * else's. + */ + +namespace score +{ +/** + * @brief A plug-in's own data, kept without being understood. + * + * It remembers which format it was read in, because it is not always written + * back out in the same one. A document read from .score is written to the + * binary format on every autosave, and moving an interval serialises its + * processes to the binary format and rebuilds them from those bytes -- so a + * payload that could only be written in the format it came from would be lost + * by dragging a box. + * + * Written into the other format it is wrapped, so that reading it back + * recognises what it is holding. That keeps score's own round-trips exact in + * every direction. What it cannot do is make the *plug-in* able to read it: + * a .scorebin saved from a document that came from .score holds the plug-in's + * JSON inside a binary blob, and only score knows that. Moving a document + * between machines that differ should use .score, which never needs wrapping. + */ +struct SCORE_LIB_BASE_EXPORT OpaquePayload +{ + //! DataStream::type() or JSONObject::type(); 0 when there is nothing. + SerializationIdentifier format{}; + QByteArray bytes; + + bool empty() const noexcept { return bytes.isEmpty(); } + + //! Everything in `base` except the members named, which score owns. + static OpaquePayload + fromJson(const rapidjson::Value& base, const QStringList& owned) noexcept; + + //! Whatever is left of this object's blob after the base has read its part. + static OpaquePayload fromDataStream(DataStream::Deserializer& vis) noexcept; + + //! Write it back into whichever format is being written now. + void write(const VisitorVariant& vis) const noexcept; +}; +} diff --git a/src/lib/score/tools/Environment.cpp b/src/lib/score/tools/Environment.cpp new file mode 100644 index 0000000000..cfc350e6ea --- /dev/null +++ b/src/lib/score/tools/Environment.cpp @@ -0,0 +1,106 @@ +#include + +#include +#include +#include +#include + +namespace score +{ +namespace +{ +//! Both sides of a callback pair: report the failure if anyone is listening, +//! and say nothing otherwise rather than pretending it worked. +void fail(const Environment::Callback& onFailed, QString why) +{ + if(onFailed) + onFailed(std::move(why)); +} +} + +Environment::~Environment() = default; + +LocalEnvironment::LocalEnvironment(const DocumentContext& ctx) + : m_ctx{ctx} +{ +} + +LocalEnvironment::~LocalEnvironment() = default; + +QString LocalEnvironment::resolve(const Uri& uri) const +{ + return uri.resolve(m_ctx); +} + +void LocalEnvironment::list( + const Uri& uri, Callback> onListed, Callback onFailed) +{ + const auto path = resolve(uri); + QDir dir{path}; + if(path.isEmpty() || !dir.exists()) + { + fail(onFailed, QObject::tr("%1 is not a directory here").arg(uri.toString())); + return; + } + + std::vector entries; + for(const auto& info : + dir.entryInfoList(QDir::NoDotAndDotDot | QDir::AllEntries, QDir::Name)) + { + const auto name = info.fileName(); + entries.push_back(DirEntry{ + Uri{uri.scheme, uri.path.isEmpty() ? name : uri.path + '/' + name}, name, + info.isDir(), info.isDir() ? 0 : info.size()}); + } + + if(onListed) + onListed(std::move(entries)); +} + +void LocalEnvironment::read( + const Uri& uri, Callback onRead, Callback onFailed) +{ + const auto path = resolve(uri); + QFile f{path}; + if(path.isEmpty() || !f.exists()) + { + fail(onFailed, QObject::tr("%1 is not here").arg(uri.toString())); + return; + } + if(!f.open(QIODevice::ReadOnly)) + { + fail(onFailed, QObject::tr("%1 cannot be read").arg(uri.toString())); + return; + } + + if(onRead) + onRead(f.readAll()); +} + +void LocalEnvironment::write( + const Uri& uri, QByteArray data, Done onWritten, Callback onFailed) +{ + const auto path = resolve(uri); + if(path.isEmpty()) + { + fail(onFailed, QObject::tr("%1 does not point anywhere here").arg(uri.toString())); + return; + } + + QDir{}.mkpath(QFileInfo{path}.absolutePath()); + QFile f{path}; + if(!f.open(QIODevice::WriteOnly)) + { + fail(onFailed, QObject::tr("%1 cannot be written").arg(uri.toString())); + return; + } + if(f.write(data) != data.size()) + { + fail(onFailed, QObject::tr("%1 could not be written in full").arg(uri.toString())); + return; + } + + if(onWritten) + onWritten(); +} +} diff --git a/src/lib/score/tools/Environment.hpp b/src/lib/score/tools/Environment.hpp new file mode 100644 index 0000000000..2e99d65d02 --- /dev/null +++ b/src/lib/score/tools/Environment.hpp @@ -0,0 +1,108 @@ +#pragma once +#include + +#include +#include + +#include + +#include +#include + +namespace score +{ +struct DocumentContext; + +//! One entry of a listing. +struct DirEntry +{ + Uri uri; + QString name; + bool directory{}; + qint64 size{}; +}; + +/** + * @brief Where the files of a score actually are. + * + * A score refers to things -- sound files, shaders, the project folder -- that + * live on a machine. Usually that is this machine, and reading one is a matter + * of opening a path. But the machine running the score need not be the one + * being typed at: a score playing on a headless box is edited from a laptop, + * and score in a browser has no filesystem at all. + * + * So the question "give me the bytes of this" has more than one answer, and + * code that wants them should ask rather than assume. That is all this is: the + * asking, separated from the answering. + * + * Every call is asynchronous, including the ones a local implementation could + * answer immediately. Not because a local read is slow, but because a remote + * one cannot be made synchronous -- and a browser cannot even open a file + * picker without returning first. An interface that let callers wait would be + * an interface only the local implementation could satisfy. + */ +class SCORE_LIB_BASE_EXPORT Environment +{ +public: + template + using Callback = std::function; + + //! Reports why something could not be done. + using Failure = QString; + + //! For a call whose success carries no value of its own. + using Done = std::function; + + virtual ~Environment(); + + //! Whether these files are reachable as paths by this process. False when + //! they are on another machine, which is what decides whether code may take + //! the shortcut of opening one directly. + virtual bool isLocal() const noexcept = 0; + + //! Where this is on the local filesystem, or empty when it is not there. + //! Only meaningful when isLocal(). + virtual QString resolve(const Uri& uri) const = 0; + + virtual void + list(const Uri& uri, Callback> onListed, + Callback onFailed = {}) + = 0; + + virtual void + read(const Uri& uri, Callback onRead, Callback onFailed = {}) + = 0; + + virtual void write( + const Uri& uri, QByteArray data, Done onWritten, Callback onFailed = {}) + = 0; +}; + +/** + * @brief The files are here, on this machine. + * + * What score has always done, behind the interface. The callbacks are invoked + * before the call returns; nothing is queued. + */ +class SCORE_LIB_BASE_EXPORT LocalEnvironment final : public Environment +{ +public: + explicit LocalEnvironment(const DocumentContext& ctx); + ~LocalEnvironment() override; + + bool isLocal() const noexcept override { return true; } + QString resolve(const Uri& uri) const override; + + void + list(const Uri& uri, Callback> onListed, + Callback onFailed) override; + void + read(const Uri& uri, Callback onRead, Callback onFailed) override; + void write( + const Uri& uri, QByteArray data, Done onWritten, + Callback onFailed) override; + +private: + const DocumentContext& m_ctx; +}; +} diff --git a/src/lib/score/tools/File.cpp b/src/lib/score/tools/File.cpp index b102766e84..fc906c328f 100644 --- a/src/lib/score/tools/File.cpp +++ b/src/lib/score/tools/File.cpp @@ -1,6 +1,9 @@ #include #include +#include +#include + #include #include diff --git a/src/lib/score/tools/Uri.cpp b/src/lib/score/tools/Uri.cpp new file mode 100644 index 0000000000..a158948117 --- /dev/null +++ b/src/lib/score/tools/Uri.cpp @@ -0,0 +1,163 @@ +#include + +#include + +#include +#include +#include +#include + +namespace score +{ +namespace +{ +constexpr auto project_token = ":"; +constexpr auto library_token = ":"; +constexpr auto cache_token = ":"; + +// Windows and macOS reach the same file through different spellings, so a +// comparison that respects case would fail to notice a file is inside the +// project folder and store an absolute path instead. +constexpr Qt::CaseSensitivity path_case() +{ +#if defined(_WIN32) || defined(__APPLE__) + return Qt::CaseInsensitive; +#else + return Qt::CaseSensitive; +#endif +} + +QString withoutTrailingSlash(QString dir) +{ + while(dir.size() > 1 && dir.endsWith('/')) + dir.chop(1); + return dir; +} + +QString projectRoot(const DocumentContext& ctx) +{ + return QFileInfo{ctx.document.metadata().fileName()}.canonicalPath(); +} + +QString libraryRoot() +{ + QSettings set; + const auto library = set.value("Library/RootPath").toString(); + if(library.isEmpty() || !QDir{library}.exists()) + return {}; + return QFileInfo{library}.canonicalFilePath(); +} + +QString join(const QString& dir, const QString& rest) +{ + if(dir.isEmpty()) + return {}; + return QFileInfo{withoutTrailingSlash(dir) + '/' + rest}.absoluteFilePath(); +} + +//! The part of `path` below `dir`, assuming isUnder(path, dir). +QString below(const QString& path, const QString& dir) +{ + QString rest = path.mid(withoutTrailingSlash(dir).size()); + while(rest.startsWith('/')) + rest.remove(0, 1); + return rest; +} +} + +bool isUnder(const QString& path, const QString& dir) noexcept +{ + if(dir.isEmpty() || path.isEmpty()) + return false; + + const QString root = withoutTrailingSlash(dir); + if(!path.startsWith(root, path_case())) + return false; + + // Equal, or the next character starts a new component. Without this, + // "/a/proj2/x" counts as being under "/a/proj". + return path.size() == root.size() || path[root.size()] == '/'; +} + +QString mediaCacheRoot() noexcept +{ + const auto base = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation); + if(base.isEmpty()) + return {}; + return base + "/media"; +} + +Uri Uri::parse(const QString& stored) noexcept +{ + if(stored.startsWith(project_token)) + return {UriScheme::Project, stored.mid(qstrlen(project_token))}; + if(stored.startsWith(library_token)) + return {UriScheme::Library, stored.mid(qstrlen(library_token))}; + if(stored.startsWith(cache_token)) + return {UriScheme::Cache, stored.mid(qstrlen(cache_token))}; + + if(!stored.isEmpty() && !QFileInfo{stored}.isAbsolute()) + return {UriScheme::Relative, stored}; + + return {UriScheme::Absolute, stored}; +} + +Uri Uri::relativize(const QString& absolute, const DocumentContext& ctx) noexcept +{ + const QFileInfo info{absolute}; + if(!info.isAbsolute()) + return parse(absolute); + + // Resolve symlinks so the comparison sees the same spelling the roots do. + // A file that does not exist yet has no canonical path; use it as given. + QString path = info.canonicalFilePath(); + if(path.isEmpty()) + path = absolute; + + if(const auto root = projectRoot(ctx); isUnder(path, root)) + return {UriScheme::Project, below(path, root)}; + + if(const auto root = libraryRoot(); isUnder(path, root)) + return {UriScheme::Library, below(path, root)}; + + if(const auto root = mediaCacheRoot(); isUnder(path, root)) + return {UriScheme::Cache, below(path, root)}; + + return {UriScheme::Absolute, path}; +} + +QString Uri::toString() const noexcept +{ + switch(scheme) + { + case UriScheme::Project: + return project_token + path; + case UriScheme::Library: + return library_token + path; + case UriScheme::Cache: + return cache_token + path; + case UriScheme::Relative: + case UriScheme::Absolute: + break; + } + return path; +} + +QString Uri::resolve(const DocumentContext& ctx) const noexcept +{ + switch(scheme) + { + case UriScheme::Project: + return join(projectRoot(ctx), path); + case UriScheme::Library: + return join(libraryRoot(), path); + case UriScheme::Cache: + return join(mediaCacheRoot(), path); + case UriScheme::Relative: + return join(projectRoot(ctx), path); + case UriScheme::Absolute: + break; + } + return path.isEmpty() ? QString{} : QFileInfo{path}.absoluteFilePath(); +} +} diff --git a/src/lib/score/tools/Uri.hpp b/src/lib/score/tools/Uri.hpp new file mode 100644 index 0000000000..0ff351be74 --- /dev/null +++ b/src/lib/score/tools/Uri.hpp @@ -0,0 +1,73 @@ +#pragma once +#include + +#include + +namespace score +{ +struct DocumentContext; + +//! How a path stored in a document is expressed. +enum class UriScheme +{ + //! A path on this machine, and meaningful on no other. Documents that hold + //! these do not survive being moved. + Absolute, + + //! Relative to the document's folder, from before the tokens below existed. + //! Equivalent to Project, and still written by nothing. + Relative, + + //! Under the document's own folder: ":". + Project, + + //! Under the user's library: ":". + Library, + + //! Content-addressed media: ":". Not authored by hand -- these name a + //! file by its hash, so the same media is the same entry on every machine + //! that has it, and a machine that does not can be told exactly what to + //! fetch. + Cache +}; + +/** + * @brief A path as a document stores it. + * + * score already wrote ":" and ":" into save files; this puts + * a name on that and makes the two directions -- resolving and relativizing -- + * one thing rather than two functions that had to be kept agreeing. + */ +struct SCORE_LIB_BASE_EXPORT Uri +{ + //! Read what a document stored. Never fails: anything unrecognised is an + //! absolute or relative path, which is what old documents contain. + static Uri parse(const QString& stored) noexcept; + + //! Express an absolute path in the most portable scheme that fits. + static Uri relativize(const QString& absolute, const DocumentContext& ctx) noexcept; + + //! What to store in a document. + QString toString() const noexcept; + + //! Where to read it from on this machine. Empty if it cannot be placed. + QString resolve(const DocumentContext& ctx) const noexcept; + + //! Whether opening the document elsewhere can still find this. + bool isPortable() const noexcept { return scheme != UriScheme::Absolute; } + + UriScheme scheme{UriScheme::Absolute}; + + //! The remainder after the scheme, or the whole path when there is none. + QString path; +}; + +//! Where ":" resolves to. +SCORE_LIB_BASE_EXPORT QString mediaCacheRoot() noexcept; + +//! Whether `path` is `dir` itself or something under it. +//! +//! Distinct from a prefix test, which answers yes for "/a/proj2" under +//! "/a/proj" and would then relativize it to a path meaning a different file. +SCORE_LIB_BASE_EXPORT bool isUnder(const QString& path, const QString& dir) noexcept; +} diff --git a/src/lib/score/widgets/FileDialog.cpp b/src/lib/score/widgets/FileDialog.cpp new file mode 100644 index 0000000000..d9dc249258 --- /dev/null +++ b/src/lib/score/widgets/FileDialog.cpp @@ -0,0 +1,24 @@ +#include + +#include + +namespace score +{ +bool selectExistingDirectory( + QWidget* parent, const QString& title, const QString& startDir, QString& out) +{ +#if defined(__EMSCRIPTEN__) + // There is no directory to name: the browser hands over individual files. + // Returning an empty string would be indistinguishable from a cancellation + // and leave the caller waiting for something that cannot arrive. + qWarning() << "Cannot ask for a directory here:" << title; + return false; +#else + const QString dir = QFileDialog::getExistingDirectory(parent, title, startDir); + if(dir.isEmpty()) + return false; + out = dir; + return true; +#endif +} +} diff --git a/src/lib/score/widgets/FileDialog.hpp b/src/lib/score/widgets/FileDialog.hpp new file mode 100644 index 0000000000..81c1dbc689 --- /dev/null +++ b/src/lib/score/widgets/FileDialog.hpp @@ -0,0 +1,85 @@ +#pragma once +#include + +#include +#include +#include + +#include + +#include + +class QWidget; + +namespace score +{ +/** + * @brief Ask the user for files to bring into the document. + * + * Callback-shaped rather than returning a path, because not every platform can + * answer immediately: the browser has no synchronous dialog and no filesystem + * to name, so the bytes arrive later and have to be written somewhere the rest + * of score can open by path. Callers that returned a path directly did not work + * there at all. + * + * `onPicked(const QString& path)` is called once per chosen file, with a path + * that can be opened. It is not called if the user cancels. + * + * `startDir` is where the picker opens; see score::pickerStartFolder. It has no + * meaning on a platform with no filesystem to point at, and is ignored there. + */ +template +void openFileToImport( + const QString& filters, const QString& startDir, F onPicked, + QWidget* parent = nullptr) +{ +#if defined(__EMSCRIPTEN__) + QFileDialog::getOpenFileContent( + filters, + [onPicked = std::move(onPicked)]( + const QString& name, const QByteArray& data) mutable { + if(name.isEmpty() || data.isEmpty()) + return; + if(QString staged = score::stageImportedFile(name, data); !staged.isEmpty()) + onPicked(staged); + }); +#else + const QString fn + = QFileDialog::getOpenFileName( + parent, QObject::tr("Open File"), startDir, filters); + if(!fn.isEmpty()) + onPicked(fn); +#endif +} + +/** + * @brief The same, for a set of files. + * + * On the browser the picker yields one file per call, so `onPicked` is invoked + * as each arrives rather than once with the whole list. + */ +template +void openFilesToImport( + const QString& title, const QString& filters, const QString& startDir, F onPicked, + QWidget* parent = nullptr) +{ +#if defined(__EMSCRIPTEN__) + openFileToImport(filters, startDir, std::move(onPicked), parent); +#else + const QStringList files + = QFileDialog::getOpenFileNames(parent, title, startDir, filters); + for(const auto& f : files) + onPicked(f); +#endif +} + +/** + * @brief Ask the user for a directory on this machine. + * + * Unlike the file pickers this has no meaning where there is no filesystem to + * point at, so it reports that rather than quietly behaving as a cancellation. + * Returns false when no directory could be asked for or the user cancelled. + */ +SCORE_LIB_BASE_EXPORT bool selectExistingDirectory( + QWidget* parent, const QString& title, const QString& startDir, QString& out); +} diff --git a/src/lib/score/widgets/MessageBox.cpp b/src/lib/score/widgets/MessageBox.cpp index dfbabf7b3f..e60e44d8d6 100644 --- a/src/lib/score/widgets/MessageBox.cpp +++ b/src/lib/score/widgets/MessageBox.cpp @@ -1,15 +1,31 @@ #include "MessageBox.hpp" #include +#include #include #include #include +#include #include namespace score { +namespace +{ +//! A modal box needs someone to dismiss it, and exec() does not return until +//! one does. `gui` alone does not promise that: an embedder that builds the +//! application without a window -- the test fixtures, anything using +//! MinimalApplication -- leaves it set, and every report of a problem then +//! hangs the process instead of printing. A main window is what actually says +//! there is a person here. +[[maybe_unused]] bool canShowModal() noexcept +{ + return score::AppContext().applicationSettings.gui + && score::GUIAppContext().mainWindow; +} +} #if defined(__EMSCRIPTEN__) namespace { @@ -39,7 +55,7 @@ int notify( int question(QWidget* parent, const QString& title, const QString& text) { #if !defined(__EMSCRIPTEN__) - if(score::AppContext().applicationSettings.gui) + if(canShowModal()) { auto msg = new QMessageBox{{}, title, text, QMessageBox::Yes | QMessageBox::No, parent}; @@ -64,7 +80,7 @@ int information(QWidget* parent, const QString& title, const QString& text) return notify( parent, title, text, QStringLiteral(":/icons/message_information.png")); #else - if(score::AppContext().applicationSettings.gui) + if(canShowModal()) { auto msg = new QMessageBox{{}, title, text, QMessageBox::Ok, parent}; msg->setIconPixmap( @@ -87,7 +103,7 @@ int warning(QWidget* parent, const QString& title, const QString& text) #if defined(__EMSCRIPTEN__) return notify(parent, title, text, QStringLiteral(":/icons/message_warning.png")); #else - if(score::AppContext().applicationSettings.gui) + if(canShowModal()) { auto msg = new QMessageBox{{}, title, text, QMessageBox::Ok, parent}; msg->setIconPixmap(score::get_pixmap(QStringLiteral(":/icons/message_warning.png"))); diff --git a/src/plugins/score-lib-device/Device/Protocol/DeviceSettings.hpp b/src/plugins/score-lib-device/Device/Protocol/DeviceSettings.hpp index df37102222..8c5297dcac 100644 --- a/src/plugins/score-lib-device/Device/Protocol/DeviceSettings.hpp +++ b/src/plugins/score-lib-device/Device/Protocol/DeviceSettings.hpp @@ -21,12 +21,25 @@ struct DeviceSettings UuidKey protocol; QString name; QVariant deviceSpecificSettings; + + //! The protocol-specific settings as a JSON object, kept verbatim when this + //! build has no factory for `protocol`. + //! + //! Protocols are registered conditionally inside plug-ins that exist + //! everywhere -- Syphon and Spout are both compiled into score-plugin-gfx -- + //! so a document authored on macOS routinely names protocols a Windows build + //! cannot instantiate. Without this, saving from such a build writes back a + //! device with the right name and UUID and no settings at all, silently + //! destroying the configuration for every machine that *does* have the + //! protocol. Empty whenever the factory was found, i.e. in the common case. + QByteArray opaqueSettings; }; inline bool operator==(const DeviceSettings& lhs, const DeviceSettings& rhs) noexcept { return lhs.protocol == rhs.protocol && lhs.name == rhs.name - && lhs.deviceSpecificSettings == rhs.deviceSpecificSettings; + && lhs.deviceSpecificSettings == rhs.deviceSpecificSettings + && lhs.opaqueSettings == rhs.opaqueSettings; } struct UDPPortDeviceResource diff --git a/src/plugins/score-lib-device/Device/Protocol/DeviceSettingsSerialization.cpp b/src/plugins/score-lib-device/Device/Protocol/DeviceSettingsSerialization.cpp index 2a7d6f03b3..e19c5c9770 100644 --- a/src/plugins/score-lib-device/Device/Protocol/DeviceSettingsSerialization.cpp +++ b/src/plugins/score-lib-device/Device/Protocol/DeviceSettingsSerialization.cpp @@ -14,8 +14,77 @@ #include #include + +#include + SCORE_SERALIZE_DATASTREAM_DEFINE(Device::DeviceSettings) +namespace +{ +[[noreturn]] void throwMissingProtocol(const Device::DeviceSettings& n) +{ + // The binary format writes protocol settings inline with no length prefix, so + // a reader without the factory cannot skip them: it lands mid-payload on the + // trailing delimiter and reports the whole file as corrupt (and SIGTRAPs on + // the way, since checkDelimiter breakpoints before it throws). Say what is + // actually wrong instead. + const QString msg + = QStringLiteral( + "device '%1' uses protocol %2, which this build does not have. The " + "binary format cannot preserve settings for an unknown protocol: use " + "the JSON .score format to move this document between machines.") + .arg(n.name) + .arg(QString::fromUtf8(score::uuids::toByteArray(n.protocol.impl()))); + throw std::runtime_error{msg.toStdString()}; +} + +bool isReservedMember(const rapidjson::Value::Member& m) noexcept +{ + const auto& s = score::StringConstant(); + const std::string_view name{m.name.GetString(), m.name.GetStringLength()}; + return name == s.Name || name == s.Protocol; +} + +//! Everything in the serialized device except the two members score itself +//! owns, i.e. exactly what the protocol factory would have written. +QByteArray captureProtocolMembers(const rapidjson::Value& base) +{ + if(!base.IsObject()) + return {}; + + rapidjson::StringBuffer buf; + JsonWriter w{buf}; + w.StartObject(); + for(const auto& m : base.GetObject()) + { + if(isReservedMember(m)) + continue; + w.Key(m.name.GetString(), m.name.GetStringLength()); + m.value.Accept(w); + } + w.EndObject(); + + // An object with no protocol-specific members is not worth carrying around. + if(buf.GetLength() <= 2) + return {}; + return QByteArray{buf.GetString(), (int)buf.GetLength()}; +} + +void writeProtocolMembers(JsonWriter& stream, const QByteArray& blob) +{ + rapidjson::Document d; + d.Parse(blob.data(), blob.size()); + if(d.HasParseError() || !d.IsObject()) + return; + + for(const auto& m : d.GetObject()) + { + stream.Key(m.name.GetString(), m.name.GetStringLength()); + m.value.Accept(stream); + } +} +} + template <> SCORE_LIB_DEVICE_EXPORT void DataStreamReader::read(const Device::DeviceSettings& n) { @@ -30,9 +99,17 @@ SCORE_LIB_DEVICE_EXPORT void DataStreamReader::read(const Device::DeviceSettings { prot->serializeProtocolSpecificSettings(n.deviceSpecificSettings, this->toVariant()); } - else + else if(!n.opaqueSettings.isEmpty()) { - qDebug() << "Warning: could not serialize device " << n.name; + // Deliberately not fatal: this path also runs when a document is opened, + // not only when the user asks to save, so throwing would make documents + // naming an unknown protocol impossible to open at all. The settings stay + // in `opaqueSettings` and survive a JSON save; only the binary format + // cannot carry them. + qDebug() << "Warning: settings of device" << n.name << "use protocol" + << score::uuids::toByteArray(n.protocol.impl()) + << "which is not available; they cannot be written to the binary " + "format. Save as .score to preserve them."; } insertDelimiter(); @@ -44,17 +121,28 @@ SCORE_LIB_DEVICE_EXPORT void DataStreamWriter::write(Device::DeviceSettings& n) m_stream >> n.name >> n.protocol; auto& pl = components.interfaces(); - auto prot = pl.get(n.protocol); - if(prot) + if(auto prot = pl.get(n.protocol)) { n.deviceSpecificSettings = prot->makeProtocolSpecificSettings(this->toVariant()); - } - else - { - qDebug() << "Warning: could not load device " << n.name; + checkDelimiter(); + return; } - checkDelimiter(); + // No factory here. If the writer had none either it wrote no payload, so the + // delimiter comes next and the round-trip is consistent -- that case has to + // keep working. Otherwise the payload was written by a build that did have + // the protocol, and since it carries no length prefix there is no way to skip + // it: the read cannot continue. + // + // Telling the two apart by whether the next four bytes are the delimiter is + // a guess, and a protocol whose payload happens to begin with them would be + // read as having none. Nothing better is available without changing the + // format, which .scorebin has no version field to migrate on; JSON, which is + // what documents move between machines as, does not need any of this. + int32_t next{}; + m_stream.stream >> next; + if(next != int32_t(0xDEADBEEF)) + throwMissingProtocol(n); } template <> @@ -72,7 +160,7 @@ SCORE_LIB_DEVICE_EXPORT void JSONReader::read(const Device::DeviceSettings& n) } else { - qDebug() << "Warning: could not serialize device " << n.name; + writeProtocolMembers(stream, n.opaqueSettings); } stream.EndObject(); } @@ -89,10 +177,9 @@ SCORE_LIB_DEVICE_EXPORT void JSONWriter::write(Device::DeviceSettings& n) if(auto prot = pl->get(n.protocol)) { n.deviceSpecificSettings = prot->makeProtocolSpecificSettings(this->toVariant()); - } - else - { - qDebug() << "Warning: could not load device " << n.name; + return; } } + + n.opaqueSettings = captureProtocolMembers(base); } diff --git a/src/plugins/score-lib-process/CMakeLists.txt b/src/plugins/score-lib-process/CMakeLists.txt index 9257181683..bcb021ca56 100755 --- a/src/plugins/score-lib-process/CMakeLists.txt +++ b/src/plugins/score-lib-process/CMakeLists.txt @@ -41,6 +41,7 @@ set(PROCESS_HDRS "${CMAKE_CURRENT_SOURCE_DIR}/Process/ProcessFactory.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/Process/GenericProcessFactory.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/Process/Process.hpp" +"${CMAKE_CURRENT_SOURCE_DIR}/Process/OpaqueProcess.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/Process/ProcessFlags.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/Process/ProcessComponent.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/Process/ProcessMetadata.hpp" @@ -153,6 +154,7 @@ set(PROCESS_SRCS "${CMAKE_CURRENT_SOURCE_DIR}/Process/HeaderDelegate.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Process/ProcessFactory.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Process/Process.cpp" +"${CMAKE_CURRENT_SOURCE_DIR}/Process/OpaqueProcess.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Process/LayerPresenter.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Process/LayerView.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Process/TimeValue.cpp" diff --git a/src/plugins/score-lib-process/Process/Dataflow/ControlWidgets.hpp b/src/plugins/score-lib-process/Process/Dataflow/ControlWidgets.hpp index 4290c303e2..db0f69adf3 100644 --- a/src/plugins/score-lib-process/Process/Dataflow/ControlWidgets.hpp +++ b/src/plugins/score-lib-process/Process/Dataflow/ControlWidgets.hpp @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -1037,30 +1038,14 @@ struct ProgramEdit } }; -// Open a file for import. On wasm this uses the async getOpenFileContent API and -// stages the picked bytes into MEMFS (there is no local filesystem / synchronous -// dialog); `onPicked` then receives a real, readable path. On desktop it is the -// usual synchronous getOpenFileName, opened in `startDir` (see -// score::pickerStartFolder). `onPicked(const QString& path)`. -template -inline void openFileToImport(const QString& filters, const QString& startDir, F onPicked) +namespace { -#if defined(__EMSCRIPTEN__) - QFileDialog::getOpenFileContent( - filters, - [onPicked = std::move(onPicked)]( - const QString& name, const QByteArray& data) mutable { - if(name.isEmpty() || data.isEmpty()) - return; - if(QString staged = score::stageImportedFile(name, data); !staged.isEmpty()) - onPicked(staged); - }); -#else - const QString fn - = QFileDialog::getOpenFileName(nullptr, QObject::tr("Open File"), startDir, filters); - if(!fn.isEmpty()) - onPicked(fn); -#endif +inline QString selectedDirectory(QWidget* parent, const QString& startDir) +{ + QString dir; + score::selectExistingDirectory(parent, QObject::tr("Open Folder"), startDir, dir); + return dir; +} } struct FileChooser @@ -1080,8 +1065,9 @@ struct FileChooser act->setIcon(QIcon(":/icons/search.png")); sl->setPlaceholderText(QObject::tr("Open File")); auto on_open = [=, &ctx, &inlet] { - const auto current = QString::fromStdString(ossia::convert(inlet.value())); - openFileToImport( + const auto current + = QString::fromStdString(ossia::convert(inlet.value())); + score::openFileToImport( inlet.filters(), score::pickerStartFolder(current, ctx), [=, &ctx](const QString& filename) { auto path = score::relativizeFilePath(filename, ctx); @@ -1116,8 +1102,9 @@ struct FileChooser auto bt = new score::QGraphicsTextButton{"Choose a file...", parent}; initWidgetProperties(inlet, *bt); auto on_open = [&inlet, &ctx] { - const auto current = QString::fromStdString(ossia::convert(inlet.value())); - openFileToImport( + const auto current + = QString::fromStdString(ossia::convert(inlet.value())); + score::openFileToImport( inlet.filters(), score::pickerStartFolder(current, ctx), [&inlet, &ctx](const QString& filename) { auto path = score::relativizeFilePath(filename, ctx); @@ -1191,10 +1178,10 @@ struct FolderChooser sl->setPlaceholderText(QObject::tr("Open Folder")); auto on_open = [=, &ctx, &inlet] { auto filename - = QFileDialog::getExistingDirectory( - nullptr, "Open Folder", - score::pickerStartFolder( - QString::fromStdString(ossia::convert(inlet.value())), ctx)); + = selectedDirectory( + nullptr, score::pickerStartFolder( + QString::fromStdString(ossia::convert(inlet.value())), + ctx)); if(filename.isEmpty()) return; auto path = score::relativizeFilePath(filename, ctx); @@ -1229,10 +1216,10 @@ struct FolderChooser initWidgetProperties(inlet, *bt); auto on_open = [&inlet, &ctx] { auto filename - = QFileDialog::getExistingDirectory( - nullptr, "Open Folder", - score::pickerStartFolder( - QString::fromStdString(ossia::convert(inlet.value())), ctx)); + = selectedDirectory( + nullptr, score::pickerStartFolder( + QString::fromStdString(ossia::convert(inlet.value())), + ctx)); if(filename.isEmpty()) return; diff --git a/src/plugins/score-lib-process/Process/Dataflow/NodeItem.cpp b/src/plugins/score-lib-process/Process/Dataflow/NodeItem.cpp index 0d6b35fdcf..c32eb17bbd 100644 --- a/src/plugins/score-lib-process/Process/Dataflow/NodeItem.cpp +++ b/src/plugins/score-lib-process/Process/Dataflow/NodeItem.cpp @@ -82,9 +82,12 @@ NodeItem::NodeItem( setAcceptDrops(true); setFlag(ItemIsFocusable, true); setFlag(ItemClipsChildrenToShape, true); - const auto& pf - = ctx.app.interfaces().get(process.concreteKey()); - setData(0xF1, pf->descriptor(process).documentationLink); + // Null for a process standing in for an absent plug-in. + if(auto* pf = ctx.app.interfaces().get( + process.concreteKey())) + { + setData(0xF1, pf->descriptor(process).documentationLink); + } if(process.flags() & Process::ProcessFlags::FullyCustomItem) { @@ -196,7 +199,14 @@ void NodeItem::updateTooltip() if(!m_fx || m_fx->toolTip().isEmpty()) { auto& p = this->m_context.app.interfaces(); - const auto& desc = p.get(m_model.concreteKey())->descriptor(m_model); + auto* fac = p.get(m_model.concreteKey()); + if(!fac) + { + // Standing in for a plug-in this build does not have. + setToolTip(m_model.prettyName()); + return; + } + const auto& desc = fac->descriptor(m_model); bool has_name = !desc.prettyName.isEmpty(); bool has_desc = !desc.description.isEmpty(); diff --git a/src/plugins/score-lib-process/Process/Dataflow/Port.cpp b/src/plugins/score-lib-process/Process/Dataflow/Port.cpp index 5b4f7ddc9a..00a4fc1437 100644 --- a/src/plugins/score-lib-process/Process/Dataflow/Port.cpp +++ b/src/plugins/score-lib-process/Process/Dataflow/Port.cpp @@ -1033,7 +1033,9 @@ Process::ControlLayout PortFactory::makeLabelItem( return ret; } -Port* PortFactoryList::loadMissing(const VisitorVariant& vis, QObject* parent) const +Port* PortFactoryList::loadMissing( + const UuidKey& key, const VisitorVariant& vis, + QObject* parent) const { return nullptr; } diff --git a/src/plugins/score-lib-process/Process/Dataflow/PortFactory.cpp b/src/plugins/score-lib-process/Process/Dataflow/PortFactory.cpp index 8fabc95b9c..9bc53a2df6 100644 --- a/src/plugins/score-lib-process/Process/Dataflow/PortFactory.cpp +++ b/src/plugins/score-lib-process/Process/Dataflow/PortFactory.cpp @@ -1,10 +1,66 @@ #include +#include + #include #include +#include + +#include namespace Process { +namespace +{ +// deserialize_interface cannot build a stand-in for a missing port, because it +// has no way to tell an inlet from an outlet -- only the caller knows which of +// the two arrays it is filling. So ports are read here instead, with the +// direction supplied as a template argument. +template +Port* loadOnePort( + DataStream::Deserializer& des, const PortFactoryList& pl, QObject* parent) +{ + QByteArray b; + des.stream() >> b; + DataStream::Deserializer sub{b}; + + UuidKey k; + TSerializer>::writeTo(sub, k); + + if(auto* fac = pl.get(k)) + return fac->load(sub.toVariant(), parent); + + return new Opaque_T{k, sub, parent}; +} + +template +Port* loadOnePort( + const rapidjson::Value& value, const PortFactoryList& pl, QObject* parent) +{ + JSONObject::Deserializer des{value}; + + UuidKey k; + { + JSONWriter wr{des.obj[des.strings.uuid]}; + TSerializer>::writeTo(wr, k); + } + + if(auto* fac = pl.get(k)) + return fac->load(des.toVariant(), parent); + + return new Opaque_T{k, des, parent}; +} + +template +void append(ossia::small_vector& vec, Port* p) +{ + if(p) + vec.push_back(safe_cast(p)); + else + qWarning() << "A port could not be read and was dropped"; +} +} + void readPorts( DataStreamReader& wr, const Process::Inlets& ins, const Process::Outlets& outs) { @@ -21,15 +77,14 @@ void writePorts( ins.clear(); outs.clear(); - ArrayEntitySerializer::writeTo( - wr, pl, parent, - [&](auto* port) { ins.push_back(safe_cast(port)); }, - [&] { SCORE_ABORT; }); + int32_t count{}; + wr.m_stream >> count; + for(; count-- > 0;) + append(ins, loadOnePort(wr, pl, parent)); - ArrayEntitySerializer::writeTo( - wr, pl, parent, - [&](auto* port) { outs.push_back(safe_cast(port)); }, - [&] { SCORE_ABORT; }); + wr.m_stream >> count; + for(; count-- > 0;) + append(outs, loadOnePort(wr, pl, parent)); } void readPorts(JSONReader& obj, const Process::Inlets& ins, const Process::Outlets& outs) { @@ -46,14 +101,10 @@ void writePorts( ins.clear(); outs.clear(); - ArrayEntitySerializer::writeTo( - JSONWriter{obj.obj["Inlets"]}, pl, parent, - [&](auto* port) { ins.push_back(safe_cast(port)); }, - [&](const auto&) { SCORE_ABORT; }); + for(const auto& v : obj.base["Inlets"].GetArray()) + append(ins, loadOnePort(v, pl, parent)); - ArrayEntitySerializer::writeTo( - JSONWriter{obj.obj["Outlets"]}, pl, parent, - [&](auto* port) { outs.push_back(safe_cast(port)); }, - [&](const auto&) { SCORE_ABORT; }); + for(const auto& v : obj.base["Outlets"].GetArray()) + append(outs, loadOnePort(v, pl, parent)); } } diff --git a/src/plugins/score-lib-process/Process/Dataflow/PortFactory.hpp b/src/plugins/score-lib-process/Process/Dataflow/PortFactory.hpp index 281599bd94..8741c81d8a 100644 --- a/src/plugins/score-lib-process/Process/Dataflow/PortFactory.hpp +++ b/src/plugins/score-lib-process/Process/Dataflow/PortFactory.hpp @@ -83,7 +83,9 @@ class SCORE_LIB_PROCESS_EXPORT PortFactoryList final public: using object_type = Process::Port; ~PortFactoryList(); - Process::Port* loadMissing(const VisitorVariant& vis, QObject* parent) const; + Process::Port* loadMissing( + const UuidKey& key, const VisitorVariant& vis, + QObject* parent) const; }; template diff --git a/src/plugins/score-lib-process/Process/OpaqueProcess.cpp b/src/plugins/score-lib-process/Process/OpaqueProcess.cpp new file mode 100644 index 0000000000..208340febc --- /dev/null +++ b/src/plugins/score-lib-process/Process/OpaqueProcess.cpp @@ -0,0 +1,194 @@ +#include "OpaqueProcess.hpp" + +#include + +#include +#include +#include +#include + +#include + +#include +W_OBJECT_IMPL(Process::OpaqueProcessModel) +W_OBJECT_IMPL(Process::OpaqueInlet) +W_OBJECT_IMPL(Process::OpaqueOutlet) + +namespace Process +{ +namespace +{ +const QStringList& portMemberNames() noexcept +{ + static const QStringList names{QStringLiteral("Inlets"), QStringLiteral("Outlets")}; + return names; +} + +} + +const QStringList& OpaqueProcessModel::baseMemberNames() noexcept +{ + // Written by readFromAbstract, IdentifiedObject, Entity and ProcessModel. A + // serialized process holds these plus whatever its plug-in added; we own the + // former and must not duplicate them, and must preserve the latter. + // OpaqueProcessBaseMembersTest fails if this drifts. + static const QStringList names{ + QStringLiteral("uuid"), QStringLiteral("ObjectName"), + QStringLiteral("id"), QStringLiteral("Metadata"), + QStringLiteral("Duration"), QStringLiteral("Height"), + QStringLiteral("StartOffset"), QStringLiteral("LoopDuration"), + QStringLiteral("Pos"), QStringLiteral("Size"), + QStringLiteral("Loops"), QStringLiteral("FoldMode")}; + return names; +} + +OpaqueProcessModel::OpaqueProcessModel( + const UuidKey& key, DataStream::Deserializer& vis, QObject* parent) + : ProcessModel{vis, parent} + , m_key{key} +{ + // The base consumed everything score itself writes, so the rest of this + // object's blob is the plug-in's. deserialize_interface gave each polymorphic + // object its own length-delimited buffer, which is what makes this safe: the + // tail is exactly one process and stops where it should. + m_payload = score::OpaquePayload::fromDataStream(vis); + + // No way to find the ports inside an opaque binary blob. + m_portsInPayload = true; +} + +OpaqueProcessModel::OpaqueProcessModel( + const UuidKey& key, JSONObject::Deserializer& vis, QObject* parent) + : ProcessModel{vis, parent} + , m_key{key} +{ + auto skip = baseMemberNames(); + + // Rebuild the ports when the plug-in stored them the usual way, so that + // cables to this process still resolve and its controls still hold values. + const bool hasPorts = vis.base.IsObject() && vis.base.HasMember("Inlets") + && vis.base.HasMember("Outlets"); + if(hasPorts) + { + auto& pl = score::AppContext().interfaces(); + writePorts(vis, pl, m_inlets, m_outlets, this); + m_portsInPayload = false; + skip += portMemberNames(); + } + + m_payload = score::OpaquePayload::fromJson(vis.base, skip); +} + +OpaqueProcessModel::~OpaqueProcessModel() = default; + +void OpaqueProcessModel::serialize_impl(const VisitorVariant& vis) const noexcept +{ + if(vis.identifier == JSONObject::type() && !m_portsInPayload) + { + // Live ports rather than the ones in the payload: they may have been + // edited, and the payload no longer describes them. + readPorts(static_cast(vis.visitor), m_inlets, m_outlets); + } + m_payload.write(vis); +} + +QString OpaqueProcessModel::missingFactory() const noexcept +{ + return QString::fromUtf8(score::uuids::toByteArray(m_key.impl())); +} + +QString OpaqueProcessModel::prettyShortName() const noexcept +{ + const auto& name = metadata().getName(); + return name.isEmpty() ? QObject::tr("Unavailable") : name; +} + +QString OpaqueProcessModel::category() const noexcept +{ + return QObject::tr("Unavailable"); +} + +QStringList OpaqueProcessModel::tags() const noexcept +{ + return {}; +} + +ProcessFlags OpaqueProcessModel::flags() const noexcept +{ + // TimeIndependent is not a guess about what we are replacing so much as a + // statement about ourselves: nothing here knows how to rescale a plug-in's + // data, so the parent duration changing must not be taken to change it. Left + // out, the interval rewrote a stand-in's duration on every resize while its + // contents stayed as they were -- and the processes most often standing in + // like this, VST and LV2, declare it themselves. + // + // SupportsTemporal so it can still be shown where it was. Not ControlSurface + // or RequiresCustomData: those promise things we cannot do. + return ProcessFlags::SupportsTemporal | ProcessFlags::TimeIndependent; +} +} + +namespace Process +{ +const QStringList& portBaseMemberNames() noexcept +{ + // Written by readFromAbstract, IdentifiedObject and Port. The last four are + // only emitted when set, which is fine: we copy what is there. + static const QStringList names{ + QStringLiteral("uuid"), QStringLiteral("ObjectName"), + QStringLiteral("id"), QStringLiteral("Hidden"), + QStringLiteral("Custom"), QStringLiteral("Exposed"), + QStringLiteral("Description"), QStringLiteral("Address")}; + return names; +} + +namespace +{ +} + +OpaqueInlet::OpaqueInlet( + const UuidKey& key, DataStream::Deserializer& vis, QObject* parent) + : Inlet{vis, parent} + , m_key{key} + , m_payload{score::OpaquePayload::fromDataStream(vis)} +{ +} + +OpaqueInlet::OpaqueInlet( + const UuidKey& key, JSONObject::Deserializer& vis, QObject* parent) + : Inlet{vis, parent} + , m_key{key} + , m_payload{score::OpaquePayload::fromJson(vis.base, portBaseMemberNames())} +{ +} + +OpaqueInlet::~OpaqueInlet() = default; + +void OpaqueInlet::serialize_impl(const VisitorVariant& vis) const noexcept +{ + m_payload.write(vis); +} + +OpaqueOutlet::OpaqueOutlet( + const UuidKey& key, DataStream::Deserializer& vis, QObject* parent) + : Outlet{vis, parent} + , m_key{key} + , m_payload{score::OpaquePayload::fromDataStream(vis)} +{ +} + +OpaqueOutlet::OpaqueOutlet( + const UuidKey& key, JSONObject::Deserializer& vis, QObject* parent) + : Outlet{vis, parent} + , m_key{key} + , m_payload{score::OpaquePayload::fromJson(vis.base, portBaseMemberNames())} +{ +} + +OpaqueOutlet::~OpaqueOutlet() = default; + +void OpaqueOutlet::serialize_impl(const VisitorVariant& vis) const noexcept +{ + m_payload.write(vis); +} +} diff --git a/src/plugins/score-lib-process/Process/OpaqueProcess.hpp b/src/plugins/score-lib-process/Process/OpaqueProcess.hpp new file mode 100644 index 0000000000..67fec0dc46 --- /dev/null +++ b/src/plugins/score-lib-process/Process/OpaqueProcess.hpp @@ -0,0 +1,153 @@ +#pragma once +#include + +#include +#include +#include + +#include + +namespace Process +{ +/** + * @brief Stands in for a process whose factory this build does not have. + * + * Processes are provided by plug-ins that are not the same everywhere: VST and + * LV2 do not exist in the wasm build, JIT needs x86_64, and several are + * compiled in conditionally even on desktop. Before this existed, such a + * process could not be loaded at all, and a document containing one was either + * refused outright or -- worse -- opened with the process quietly dropped and + * written back out without it. + * + * The point of this class is that a document must survive a round-trip through + * a machine that cannot understand all of it: open on the machine that lacks + * the plug-in, edit something else, save, reopen where the plug-in exists, and + * find the process intact. + * + * So it keeps two things the plain ProcessModel base cannot: + * + * - the concrete key of the process it replaces, returned from concreteKey(). + * Forging that identity is the whole trick: saving writes the original UUID, + * so the file still names the real process rather than this placeholder. + * - the plug-in's own serialized data, verbatim, re-emitted untouched. + * + * Ports are an exception to "verbatim": they are pulled out of the payload and + * rebuilt as real ports, so cables still connect and controls still hold and + * report values. That is only possible in JSON, where they are stored under + * known keys; the binary format writes them at a process-specific offset with + * nothing to locate them by, so a binary payload is kept whole and the process + * has no ports. + */ +class SCORE_LIB_PROCESS_EXPORT OpaqueProcessModel final : public ProcessModel +{ + W_OBJECT(OpaqueProcessModel) + SCORE_SERIALIZE_FRIENDS + +public: + OpaqueProcessModel( + const UuidKey& key, DataStream::Deserializer& vis, QObject* parent); + OpaqueProcessModel( + const UuidKey& key, JSONObject::Deserializer& vis, QObject* parent); + ~OpaqueProcessModel() override; + + //! The key of the process we replace, not one of our own. + UuidKey concreteKey() const noexcept override { return m_key; } + void serialize_impl(const VisitorVariant& vis) const noexcept override; + + QString prettyShortName() const noexcept override; + QString category() const noexcept override; + QStringList tags() const noexcept override; + ProcessFlags flags() const noexcept override; + + //! Uuid of the absent process, for telling the user what is missing. + QString missingFactory() const noexcept; + + //! True when the payload could not be split, so the ports are inside it and + //! this process has none of its own. + bool portsAreOpaque() const noexcept { return m_portsInPayload; } + + //! The names of the JSON members written by ProcessModel and its bases. + //! Anything else in a serialized process belongs to its plug-in. + static const QStringList& baseMemberNames() noexcept; + +private: + UuidKey m_key; + + // The plug-in's own data, and which format it was read in. Minus the ports + // when those could be rebuilt. + score::OpaquePayload m_payload; + bool m_portsInPayload{true}; +}; + +/** + * @brief Stands in for a port whose factory this build does not have. + * + * A process can be understood while its ports are not: VST and LV2 bring their + * own control port types along with the process itself. Without this, + * reconstructing such a process aborted -- writePorts had SCORE_ABORT as its + * failure path -- and the process could not be kept at all. + * + * Like OpaqueProcessModel it reports the key of the port it replaces and holds + * the plug-in's data verbatim, so the port survives a save from here. It also + * keeps the port's id, which is what cables resolve against: a stand-in with a + * different id would silently break every cable pointing at it. + */ +class SCORE_LIB_PROCESS_EXPORT OpaqueInlet final : public Inlet +{ + W_OBJECT(OpaqueInlet) + SCORE_SERIALIZE_FRIENDS +public: + OpaqueInlet( + const UuidKey& key, DataStream::Deserializer& vis, QObject* parent); + OpaqueInlet( + const UuidKey& key, JSONObject::Deserializer& vis, QObject* parent); + ~OpaqueInlet() override; + + UuidKey concreteKey() const noexcept override { return m_key; } + void serialize_impl(const VisitorVariant& vis) const noexcept override; + PortType type() const noexcept override { return PortType::Message; } + +private: + UuidKey m_key; + score::OpaquePayload m_payload; +}; + +class SCORE_LIB_PROCESS_EXPORT OpaqueOutlet final : public Outlet +{ + W_OBJECT(OpaqueOutlet) + SCORE_SERIALIZE_FRIENDS +public: + OpaqueOutlet( + const UuidKey& key, DataStream::Deserializer& vis, QObject* parent); + OpaqueOutlet( + const UuidKey& key, JSONObject::Deserializer& vis, QObject* parent); + ~OpaqueOutlet() override; + + UuidKey concreteKey() const noexcept override { return m_key; } + void serialize_impl(const VisitorVariant& vis) const noexcept override; + PortType type() const noexcept override { return PortType::Message; } + +private: + UuidKey m_key; + score::OpaquePayload m_payload; +}; + +//! The names of the JSON members written by Port and its bases. +SCORE_LIB_PROCESS_EXPORT const QStringList& portBaseMemberNames() noexcept; + +/** + * @brief The layer used for a process no factory claims. + * + * The interval presenters build header and footer delegates from the result of + * findDefaultFactory without checking it, so an OpaqueProcessModel needs some + * factory or displaying it crashes. LayerFactory's defaults already produce a + * usable plain layer, so this only has to exist and declare itself a fallback. + */ +class SCORE_LIB_PROCESS_EXPORT OpaqueLayerFactory final : public LayerFactory +{ + SCORE_CONCRETE("64a5b1ba-9d1e-4ba6-b6f5-e6f0aa0d0f7a") + + bool matches(const UuidKey&) const override { return false; } + bool isFallback() const noexcept override { return true; } +}; +} diff --git a/src/plugins/score-lib-process/Process/ProcessFactory.cpp b/src/plugins/score-lib-process/Process/ProcessFactory.cpp index a7eaf5f08d..663ffc170c 100644 --- a/src/plugins/score-lib-process/Process/ProcessFactory.cpp +++ b/src/plugins/score-lib-process/Process/ProcessFactory.cpp @@ -2,6 +2,8 @@ // it. PVS-Studio Static Code Analyzer for C, C++ and C#: http://www.viva64.com #include "ProcessFactory.hpp" +#include + #include #include #include @@ -150,21 +152,48 @@ bool LayerFactory::matches(const ProcessModel& p) const return matches(p.concreteKey()); } +bool LayerFactory::isFallback() const noexcept +{ + return false; +} + bool LayerFactory::matches(const UuidKey& p) const { return false; } ProcessFactoryList::object_type* ProcessFactoryList::loadMissing( - const VisitorVariant& vis, const score::DocumentContext& ctx, QObject* parent) const + const UuidKey& key, const VisitorVariant& vis, + const score::DocumentContext& ctx, QObject* parent) const { - SCORE_TODO; + // No factory for this process: keep it as an opaque stand-in rather than + // dropping it, so that saving from here does not delete it from the document + // for everyone who does have the plug-in. + switch(vis.identifier) + { + case DataStream::type(): { + auto& des = static_cast(vis.visitor); + return new OpaqueProcessModel{key, des, parent}; + } + case JSONObject::type(): { + auto& des = static_cast(vis.visitor); + return new OpaqueProcessModel{key, des, parent}; + } + } return nullptr; } LayerFactory* LayerFactoryList::findDefaultFactory(const ProcessModel& proc) const { - return findDefaultFactory(proc.concreteKey()); + if(auto* fac = findDefaultFactory(proc.concreteKey())) + return fac; + + // Only a process standing in for an absent plug-in gets the fallback. Plenty + // of ordinary processes have no layer either, and are deliberately not drawn + // in a slot: handing them one would start rendering them. + if(dynamic_cast(&proc)) + return fallbackFactory(); + return nullptr; } LayerFactory* @@ -172,12 +201,24 @@ LayerFactoryList::findDefaultFactory(const UuidKey& proc) const { for(auto& fac : *this) { + if(fac.isFallback()) + continue; if(fac.matches(proc)) return &fac; } return nullptr; } +LayerFactory* LayerFactoryList::fallbackFactory() const +{ + for(auto& fac : *this) + { + if(fac.isFallback()) + return &fac; + } + return nullptr; +} + QString ProcessModelFactory::customConstructionData() const noexcept { return {}; diff --git a/src/plugins/score-lib-process/Process/ProcessFactory.hpp b/src/plugins/score-lib-process/Process/ProcessFactory.hpp index 501397cd6d..e4b59eb3a0 100644 --- a/src/plugins/score-lib-process/Process/ProcessFactory.hpp +++ b/src/plugins/score-lib-process/Process/ProcessFactory.hpp @@ -103,5 +103,10 @@ class SCORE_LIB_PROCESS_EXPORT LayerFactory : public score::InterfaceBase bool matches(const Process::ProcessModel& p) const; virtual bool matches(const UuidKey&) const = 0; + + //! A fallback is used only for processes no other factory claims, e.g. one + //! whose plug-in this build does not have. It is never consulted through + //! matches(), which would make the choice depend on hash-map ordering. + virtual bool isFallback() const noexcept; }; } diff --git a/src/plugins/score-lib-process/Process/ProcessList.hpp b/src/plugins/score-lib-process/Process/ProcessList.hpp index c446443f12..70d64d42b1 100644 --- a/src/plugins/score-lib-process/Process/ProcessList.hpp +++ b/src/plugins/score-lib-process/Process/ProcessList.hpp @@ -13,8 +13,8 @@ class SCORE_LIB_PROCESS_EXPORT ProcessFactoryList final ~ProcessFactoryList(); object_type* loadMissing( - const VisitorVariant& vis, const score::DocumentContext& ctx, - QObject* parent) const; + const UuidKey& key, const VisitorVariant& vis, + const score::DocumentContext& ctx, QObject* parent) const; }; class SCORE_LIB_PROCESS_EXPORT LayerFactoryList final @@ -23,7 +23,12 @@ class SCORE_LIB_PROCESS_EXPORT LayerFactoryList final public: ~LayerFactoryList(); + //! Resolves the layer for a process. A process standing in for a plug-in we + //! do not have falls back to a plain layer, so that it can still be shown. LayerFactory* findDefaultFactory(const Process::ProcessModel& proc) const; + + //! The factory used for processes no other one claims, if any is registered. + LayerFactory* fallbackFactory() const; LayerFactory* findDefaultFactory(const UuidKey& proc) const; LayerFactory* get(const UuidKey& proc) const { diff --git a/src/plugins/score-plugin-curve/Curve/CurveModelSerialization.cpp b/src/plugins/score-plugin-curve/Curve/CurveModelSerialization.cpp index 622fbe56d1..bab5b72067 100644 --- a/src/plugins/score-plugin-curve/Curve/CurveModelSerialization.cpp +++ b/src/plugins/score-plugin-curve/Curve/CurveModelSerialization.cpp @@ -61,8 +61,8 @@ SCORE_PLUGIN_CURVE_EXPORT void DataStreamWriter::write(Curve::Model& curve) auto seg = deserialize_interface(csl, *this, &curve); if(seg) segts.push_back(seg); - else - SCORE_TODO; + // else: SegmentList::loadMissing has said why, and the curve is short a + // segment rather than wrong about one. } std::sort( segts.begin(), segts.end(), [](Curve::SegmentModel* a, Curve::SegmentModel* b) { diff --git a/src/plugins/score-plugin-curve/Curve/Segment/CurveSegmentFactory.cpp b/src/plugins/score-plugin-curve/Curve/Segment/CurveSegmentFactory.cpp index ca6c6dffbb..e9b6666a55 100644 --- a/src/plugins/score-plugin-curve/Curve/Segment/CurveSegmentFactory.cpp +++ b/src/plugins/score-plugin-curve/Curve/Segment/CurveSegmentFactory.cpp @@ -3,16 +3,26 @@ #include "CurveSegmentFactory.hpp" #include "CurveSegmentList.hpp" + +#include namespace Curve { SegmentFactory::~SegmentFactory() { } SegmentList::~SegmentList() { } -SegmentList::object_type* -SegmentList::loadMissing(const VisitorVariant& vis, QObject* parent) const +SegmentList::object_type* SegmentList::loadMissing( + const UuidKey& key, const VisitorVariant& vis, + QObject* parent) const { - SCORE_TODO; + // Deliberately not preserved, unlike processes, ports, devices and document + // plug-ins. A segment is asked for its value at a point -- valueAt, + // makeDoubleFunction -- and those answers are played. A stand-in would have + // to invent them, and silently wrong automation is worse than a missing + // segment. It also cannot arise: segments come only from score-plugin-curve, + // so a build without it has no curves to put them in. + qWarning() << "Dropping a curve segment of unknown type" + << score::uuids::toByteArray(key.impl()); return nullptr; } } diff --git a/src/plugins/score-plugin-curve/Curve/Segment/CurveSegmentList.hpp b/src/plugins/score-plugin-curve/Curve/Segment/CurveSegmentList.hpp index 2db7e44c0e..b8433180f4 100644 --- a/src/plugins/score-plugin-curve/Curve/Segment/CurveSegmentList.hpp +++ b/src/plugins/score-plugin-curve/Curve/Segment/CurveSegmentList.hpp @@ -14,6 +14,8 @@ class SCORE_PLUGIN_CURVE_EXPORT SegmentList final using object_type = Curve::SegmentModel; virtual ~SegmentList(); - object_type* loadMissing(const VisitorVariant& vis, QObject* parent) const; + object_type* loadMissing( + const UuidKey& key, const VisitorVariant& vis, + QObject* parent) const; }; } diff --git a/src/plugins/score-plugin-deviceexplorer/Explorer/DocumentPlugin/DeviceDocumentPlugin.cpp b/src/plugins/score-plugin-deviceexplorer/Explorer/DocumentPlugin/DeviceDocumentPlugin.cpp index b40aaa7b24..ff7975285a 100644 --- a/src/plugins/score-plugin-deviceexplorer/Explorer/DocumentPlugin/DeviceDocumentPlugin.cpp +++ b/src/plugins/score-plugin-deviceexplorer/Explorer/DocumentPlugin/DeviceDocumentPlugin.cpp @@ -136,6 +136,17 @@ void DeviceDocumentPlugin::asyncConnect(Device::DeviceInterface& newdev) connect( &newdev, &Device::DeviceInterface::connectionChanged, &b, [&b] { b.accept(); }); + + // A device that never answers would otherwise hold the dialog, and the + // whole window with it, for as long as the application runs. Nothing + // downstream distinguishes "gave up" from "cancelled": both leave the + // device unconnected, which is a state score already handles. + constexpr int connectionTimeout = 30000; + QTimer::singleShot(connectionTimeout, &b, [&b, &newdev] { + qWarning() << "Gave up waiting for device" << newdev.settings().name; + b.reject(); + }); + QTimer::singleShot(1, [&] { newdev.reconnect(); }); b.exec(); diff --git a/src/plugins/score-plugin-deviceexplorer/Explorer/DocumentPlugin/NodeUpdateProxy.cpp b/src/plugins/score-plugin-deviceexplorer/Explorer/DocumentPlugin/NodeUpdateProxy.cpp index e9a1c07a3b..f2e12c5180 100644 --- a/src/plugins/score-plugin-deviceexplorer/Explorer/DocumentPlugin/NodeUpdateProxy.cpp +++ b/src/plugins/score-plugin-deviceexplorer/Explorer/DocumentPlugin/NodeUpdateProxy.cpp @@ -51,16 +51,16 @@ void NodeUpdateProxy::loadDevice(const Device::Node& node) void NodeUpdateProxy::updateDevice( const QString& name, const Device::DeviceSettings& dev) { - auto& device = devModel.list().device(name); - // The device reconnects with its new settings - synchronously or not, // depending on the protocol, hence hooking before applying them; once it // has, its namespace is explored again so that the explorer shows the tree // of e.g. the new OSCQuery host, and not the one of the previous host // replayed into it. - devModel.refreshDeviceTreeOnReconnect(device); - - device.updateSettings(dev); + if(auto* impl = devModel.list().findDevice(name)) + { + devModel.refreshDeviceTreeOnReconnect(*impl); + impl->updateSettings(dev); + } devModel.explorer().updateDevice(name, dev); } @@ -73,12 +73,13 @@ void NodeUpdateProxy::removeDevice(const Device::DeviceSettings& dev) }); SCORE_ASSERT(it != rootNode.end()); auto dev_i = devModel.list().findDevice(dev.name); - SCORE_ASSERT(dev_i); - devModel.setupConnections(*dev_i, false); + if(dev_i) + devModel.setupConnections(*dev_i, false); devModel.explorer().removeNode(it); - devModel.list().removeDevice(dev.name); + if(dev_i) + devModel.list().removeDevice(dev.name); } void NodeUpdateProxy::addAddress( @@ -100,9 +101,9 @@ void NodeUpdateProxy::addAddress( settings, Device::address(*parentnode)); // Add in the device implementation - devModel.list() - .device(dev_node.template get().name) - .addAddress(full); + if(auto* impl = devModel.list().findDevice( + dev_node.template get().name)) + impl->addAddress(full); // Add in the device explorer if(settings.name.contains('/')) @@ -118,7 +119,11 @@ void NodeUpdateProxy::addAddress( void NodeUpdateProxy::addAddress(const Device::FullAddressSettings& full) { // Add in the device implementation - auto& dev = devModel.list().device(full.address.device); + auto* dev_p = devModel.list().findDevice(full.address.device); + if(!dev_p) + return; + + auto& dev = *dev_p; bool learning = dev.isLearning(); dev.setLearning(true); dev.addAddress(full); @@ -164,7 +169,8 @@ void NodeUpdateProxy::updateAddress( full.address.path.last() = settings.name; // Update in the device implementation - devModel.list().device(addr.address.device).updateAddress(addr.address, full); + if(auto* impl = devModel.list().findDevice(addr.address.device)) + impl->updateAddress(addr.address, full); // Update in the device explorer devModel.explorer().updateAddress(node, settings); @@ -201,9 +207,9 @@ void NodeUpdateProxy::removeNode( // Remove from the device implementation const auto& dev_node = devModel.rootNode().childAt(parentPath.at(0)); - devModel.list() - .device(dev_node.template get().name) - .removeNode(addr.address); + if(auto* impl = devModel.list().findDevice( + dev_node.template get().name)) + impl->removeNode(addr.address); // Remove from the device explorer auto it = findChildNode_it(*lastparentnode, lastnode->displayName()); @@ -223,9 +229,9 @@ void NodeUpdateProxy::removeNode( // Remove from the device implementation const auto& dev_node = devModel.rootNode().childAt(parentPath.at(0)); - devModel.list() - .device(dev_node.template get().name) - .removeNode(addr); + if(auto* impl = devModel.list().findDevice( + dev_node.template get().name)) + impl->removeNode(addr); // Remove from the device explorer auto it = std::find_if( @@ -345,7 +351,6 @@ void NodeUpdateProxy::updateLocalSettings( void NodeUpdateProxy::updateRemoteValue( const State::Address& addr, const ossia::value& val) { - // TODO add these checks everywhere. if(auto dev = devModel.list().findDevice(addr.device)) { // Update in the device implementation @@ -424,23 +429,23 @@ void NodeUpdateProxy::refreshRemoteValues(const Device::NodeList& nodes) if(n->template is()) { auto dev_name = n->template get().name; - auto& dev = devModel.list().device(dev_name); - if(!dev.capabilities().canRefreshValue) + auto* dev = devModel.list().findDevice(dev_name); + if(!dev || !dev->capabilities().canRefreshValue) continue; for(auto& child : *n) { - rec_refreshRemoteValues(child, dev); + rec_refreshRemoteValues(child, *dev); } } else { auto addr = Device::address(*n); - auto& dev = devModel.list().device(addr.address.device); - if(!dev.capabilities().canRefreshValue) + auto* dev = devModel.list().findDevice(addr.address.device); + if(!dev || !dev->capabilities().canRefreshValue) continue; - rec_refreshRemoteValues(*n, dev); + rec_refreshRemoteValues(*n, *dev); } } } diff --git a/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/DeviceExplorerModel.cpp b/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/DeviceExplorerModel.cpp index 9828d7686c..3e49d823a5 100644 --- a/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/DeviceExplorerModel.cpp +++ b/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/DeviceExplorerModel.cpp @@ -421,8 +421,8 @@ QVariant DeviceExplorerModel::data(const QModelIndex& index, int role) const else if(n.is()) { auto& dev_set = n.get(); - return Device::deviceNameColumnData( - n, deviceModel().list().device(dev_set.name).connected(), role); + auto* impl = deviceModel().list().findDevice(dev_set.name); + return Device::deviceNameColumnData(n, impl && impl->connected(), role); } return {}; } @@ -729,9 +729,10 @@ QMimeData* DeviceExplorerModel::mimeData(const QModelIndexList& indexes) const auto node = uniqueNodes.parents[0]; if(node->is()) { - auto& dev = deviceModel().list().device(node->get().name); - if(auto d = dev.mimeData()) - return d; + if(auto* dev + = deviceModel().list().findDevice(node->get().name)) + if(auto d = dev->mimeData()) + return d; } } diff --git a/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/DeviceExplorerWidget.cpp b/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/DeviceExplorerWidget.cpp index d72cc13e60..64190002a7 100644 --- a/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/DeviceExplorerWidget.cpp +++ b/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/DeviceExplorerWidget.cpp @@ -980,8 +980,12 @@ void DeviceExplorerWidget::refresh() { // Create a thread, ask the device, when it is done put a command on the // chain. - auto& dev - = m->deviceModel().list().device(select.get().name); + auto* dev_p + = m->deviceModel().list().findDevice(select.get().name); + if(!dev_p) + return; + + auto& dev = *dev_p; if(!dev.capabilities().canRefreshTree) return; @@ -1024,7 +1028,11 @@ void DeviceExplorerWidget::refreshValue() // Device checks auto addr = Device::address(*node); - auto& dev = model()->deviceModel().list().device(addr.address.device); + auto* dev_p = model()->deviceModel().list().findDevice(addr.address.device); + if(!dev_p) + return; + + auto& dev = *dev_p; if(!dev.capabilities().canRefreshValue) return; if(!dev.connected()) @@ -1055,9 +1063,9 @@ void DeviceExplorerWidget::disconnect() if(select.is()) { - auto& dev - = m->deviceModel().list().device(select.get().name); - dev.disconnect(); + if(auto* dev = m->deviceModel().list().findDevice( + select.get().name)) + dev->disconnect(); } } } @@ -1078,8 +1086,12 @@ void DeviceExplorerWidget::reconnect() proxyModel()->mapToSource(m_ntView->selectedIndexes().at(i))); if(select.is()) { - auto& dev - = m->deviceModel().list().device(select.get().name); + auto* dev_p + = m->deviceModel().list().findDevice(select.get().name); + if(!dev_p) + continue; + + auto& dev = *dev_p; auto con_handle = std::make_shared(); *con_handle = con( dev, &Device::DeviceInterface::deviceChanged, this, diff --git a/src/plugins/score-plugin-gfx/Gfx/Images/ImageListChooser.cpp b/src/plugins/score-plugin-gfx/Gfx/Images/ImageListChooser.cpp index 41b2e2db81..19bdb2d373 100644 --- a/src/plugins/score-plugin-gfx/Gfx/Images/ImageListChooser.cpp +++ b/src/plugins/score-plugin-gfx/Gfx/Images/ImageListChooser.cpp @@ -10,6 +10,8 @@ #include +#include + #include #include #include @@ -142,15 +144,15 @@ class EditableTable : public QWidget private: void on_addItems() { - auto files = QFileDialog::getOpenFileNames( - this, tr("Choose images..."), score::pickerStartFolder({}, ctx), - QString{"Images (*.png *.jpg *.jpeg *.gif *.bmp *.tiff *.heic *.jp2 *.svg *.tga " - "*.wbmp)"}); - for(auto f : files) - { + score::openFilesToImport( + tr("Choose images..."), + QString{"Images (*.png *.jpg *.jpeg *.gif *.bmp *.tiff *.heic *.jp2 *.svg " + "*.tga *.wbmp)"}, + score::pickerStartFolder({}, ctx), [this](const QString& f) { addItem(f); - } - itemsChanged(); + itemsChanged(); + }, + this); } void on_removeItem() diff --git a/src/plugins/score-plugin-js/JS/Qml/EditContext.device.cpp b/src/plugins/score-plugin-js/JS/Qml/EditContext.device.cpp index 7ab84f9dbe..eda464f212 100644 --- a/src/plugins/score-plugin-js/JS/Qml/EditContext.device.cpp +++ b/src/plugins/score-plugin-js/JS/Qml/EditContext.device.cpp @@ -96,8 +96,8 @@ void EditJsContext::setDeviceLearn(const QString& name, bool fun) { if(node.displayName() == name) { - Device::DeviceInterface& dev = plug.list().device(node.displayName()); - dev.setLearning(fun); + if(auto* dev = plug.list().findDevice(node.displayName())) + dev->setLearning(fun); //cmd->addCommand(new Explorer::Command::Remove{plug, node}); } } diff --git a/src/plugins/score-plugin-media/Media/AudioFileChooserWidget.hpp b/src/plugins/score-plugin-media/Media/AudioFileChooserWidget.hpp index 6303fbf59d..b5035204d5 100644 --- a/src/plugins/score-plugin-media/Media/AudioFileChooserWidget.hpp +++ b/src/plugins/score-plugin-media/Media/AudioFileChooserWidget.hpp @@ -68,8 +68,9 @@ struct AudioFileChooser : WidgetFactory::FileChooser { auto bt = new score::QGraphicsWaveformButton{parent}; auto on_open = [&inlet, &ctx] { - const auto current = QString::fromStdString(ossia::convert(inlet.value())); - WidgetFactory::openFileToImport( + const auto current + = QString::fromStdString(ossia::convert(inlet.value())); + score::openFileToImport( inlet.filters(), score::pickerStartFolder(current, ctx), [&inlet, &ctx](const QString& filename) { // On wasm `filename` is the staged MEMFS path; relativize so it is diff --git a/src/plugins/score-plugin-media/Media/Effect/Settings/PluginTab.cpp b/src/plugins/score-plugin-media/Media/Effect/Settings/PluginTab.cpp index adb9e951f9..d71c0ce9f0 100644 --- a/src/plugins/score-plugin-media/Media/Effect/Settings/PluginTab.cpp +++ b/src/plugins/score-plugin-media/Media/Effect/Settings/PluginTab.cpp @@ -1,6 +1,7 @@ #include "PluginTab.hpp" #include +#include #include #include @@ -96,10 +97,9 @@ QWidget* makePluginSettingsWidget(PluginTabSpec spec) QObject::connect( addPath, &QPushButton::clicked, pathList, [pathList, items, commit = spec.commitPaths, splitter] { - auto path - = QFileDialog::getExistingDirectory( - splitter, QObject::tr("Plug-in path"), score::pickerStartFolder({})); - if(!path.isEmpty()) + QString path; + if(score::selectExistingDirectory( + splitter, QObject::tr("Plug-in path"), score::pickerStartFolder({}), path)) { pathList->addItem(path); items->push_back(path); diff --git a/src/plugins/score-plugin-remotecontrol/RemoteControl/Settings/Model.cpp b/src/plugins/score-plugin-remotecontrol/RemoteControl/Settings/Model.cpp index f00ef8c686..5f1c55e47e 100644 --- a/src/plugins/score-plugin-remotecontrol/RemoteControl/Settings/Model.cpp +++ b/src/plugins/score-plugin-remotecontrol/RemoteControl/Settings/Model.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -19,13 +20,18 @@ SETTINGS_PARAMETER_IMPL(WebUiPath){QStringLiteral("RemoteControl/WebUiPath"), "" SETTINGS_PARAMETER_IMPL(ServerAddress){QStringLiteral("RemoteControl/ServerAddress"), "0.0.0.0"}; SETTINGS_PARAMETER_IMPL(ServerPort){QStringLiteral("RemoteControl/ServerPort"), 10111}; SETTINGS_PARAMETER_IMPL(ServerEnabled){QStringLiteral("RemoteControl/ServerEnabled"), false}; +SETTINGS_PARAMETER_IMPL(Token){QStringLiteral("RemoteControl/Token"), QString{}}; +SETTINGS_PARAMETER_IMPL(AllowScripting){ + QStringLiteral("RemoteControl/AllowScripting"), false}; static auto list() { return std::tie(Enabled , WebUiPath , ServerAddress , ServerPort - , ServerEnabled); + , ServerEnabled + , Token + , AllowScripting); } } @@ -46,6 +52,9 @@ Model::Model( if (QDir{path}.exists()) setWebUiPath(path); } + + if(m_Token.isEmpty()) + setToken(QUuid::createUuid().toString(QUuid::WithoutBraces)); } SCORE_SETTINGS_PARAMETER_CPP(bool, Model, Enabled) @@ -53,5 +62,7 @@ SCORE_SETTINGS_PARAMETER_CPP(QString, Model, WebUiPath) SCORE_SETTINGS_PARAMETER_CPP(QString, Model, ServerAddress) SCORE_SETTINGS_PARAMETER_CPP(unsigned short, Model, ServerPort) SCORE_SETTINGS_PARAMETER_CPP(bool, Model, ServerEnabled) +SCORE_SETTINGS_PARAMETER_CPP(QString, Model, Token) +SCORE_SETTINGS_PARAMETER_CPP(bool, Model, AllowScripting) } } diff --git a/src/plugins/score-plugin-remotecontrol/RemoteControl/Settings/Model.hpp b/src/plugins/score-plugin-remotecontrol/RemoteControl/Settings/Model.hpp index ad0bd1fa28..94b30c7bd9 100644 --- a/src/plugins/score-plugin-remotecontrol/RemoteControl/Settings/Model.hpp +++ b/src/plugins/score-plugin-remotecontrol/RemoteControl/Settings/Model.hpp @@ -15,6 +15,8 @@ class SCORE_PLUGIN_REMOTECONTROL_EXPORT Model : public score::SettingsDelegateMo QString m_WebUiPath{}; QString m_ServerAddress{"0.0.0.0"}; unsigned short m_ServerPort{8080}; + QString m_Token; + bool m_AllowScripting{false}; public: Model( @@ -26,6 +28,13 @@ class SCORE_PLUGIN_REMOTECONTROL_EXPORT Model : public score::SettingsDelegateMo SCORE_SETTINGS_PARAMETER_HPP(SCORE_PLUGIN_REMOTECONTROL_EXPORT, QString, ServerAddress) SCORE_SETTINGS_PARAMETER_HPP(SCORE_PLUGIN_REMOTECONTROL_EXPORT, unsigned short, ServerPort) SCORE_SETTINGS_PARAMETER_HPP(SCORE_PLUGIN_REMOTECONTROL_EXPORT, bool, ServerEnabled) + + //! Shared secret a client must present to be served, generated on first use. + SCORE_SETTINGS_PARAMETER_HPP(SCORE_PLUGIN_REMOTECONTROL_EXPORT, QString, Token) + + //! Whether clients may run arbitrary JavaScript here. That is control of the + //! machine score runs on, not of the score, so it stays off unless asked for. + SCORE_SETTINGS_PARAMETER_HPP(SCORE_PLUGIN_REMOTECONTROL_EXPORT, bool, AllowScripting) }; SCORE_SETTINGS_PARAMETER(Model, Enabled) @@ -33,5 +42,7 @@ SCORE_SETTINGS_PARAMETER(Model, WebUiPath) SCORE_SETTINGS_PARAMETER(Model, ServerAddress) SCORE_SETTINGS_PARAMETER(Model, ServerPort) SCORE_SETTINGS_PARAMETER(Model, ServerEnabled) +SCORE_SETTINGS_PARAMETER(Model, Token) +SCORE_SETTINGS_PARAMETER(Model, AllowScripting) } } diff --git a/src/plugins/score-plugin-remotecontrol/RemoteControl/Settings/Presenter.cpp b/src/plugins/score-plugin-remotecontrol/RemoteControl/Settings/Presenter.cpp index bc60258824..30d2fb699d 100644 --- a/src/plugins/score-plugin-remotecontrol/RemoteControl/Settings/Presenter.cpp +++ b/src/plugins/score-plugin-remotecontrol/RemoteControl/Settings/Presenter.cpp @@ -69,6 +69,9 @@ Presenter::Presenter(Model& m, View& v, QObject* parent) v.setServerPort(m.getServerPort()); v.setServerEnabled(m.getServerEnabled()); } + + SETTINGS_PRESENTER(Token); + SETTINGS_PRESENTER(AllowScripting); } QString Presenter::settingsName() diff --git a/src/plugins/score-plugin-remotecontrol/RemoteControl/Settings/View.cpp b/src/plugins/score-plugin-remotecontrol/RemoteControl/Settings/View.cpp index 2623df04e1..3155fcd6d1 100644 --- a/src/plugins/score-plugin-remotecontrol/RemoteControl/Settings/View.cpp +++ b/src/plugins/score-plugin-remotecontrol/RemoteControl/Settings/View.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include W_OBJECT_IMPL(RemoteControl::Settings::View) @@ -118,23 +119,45 @@ View::View() web_lay->addRow(m_server_enabled); lay->addRow(m_web_ui); } + + { + m_token = new QLineEdit; + m_token->setToolTip( + tr("Clients present this on the connection URL. It is generated for you; " + "change it if it has been shared too widely.")); + connect(m_token, &QLineEdit::editingFinished, this, [&] { + TokenChanged(m_token->text()); + }); + lay->addRow(tr("Token"), m_token); + } + + { + m_allowScripting = new QCheckBox{tr("Allow clients to run scripts")}; + m_allowScripting->setToolTip( + tr("A script runs with the same rights as score itself, so this gives a " + "client control of this computer and not only of the score.")); + connect(m_allowScripting, SignalUtils::QCheckBox_checkStateChanged(), this, + [&](int t) { AllowScriptingChanged(t == Qt::Checked); }); + lay->addRow(m_allowScripting); + } } void View::setEnabled(bool val) { - switch(m_enabled->checkState()) - { - case Qt::Unchecked: - if(val) - m_enabled->setChecked(true); - break; - case Qt::Checked: - if(!val) - m_enabled->setChecked(false); - break; - default: - break; - } + if(m_enabled->isChecked() != val) + m_enabled->setChecked(val); +} + +void View::setToken(const QString& val) +{ + if(m_token->text() != val) + m_token->setText(val); +} + +void View::setAllowScripting(bool val) +{ + if(m_allowScripting->isChecked() != val) + m_allowScripting->setChecked(val); } void View::setWebUiPath(const QString& val) diff --git a/src/plugins/score-plugin-remotecontrol/RemoteControl/Settings/View.hpp b/src/plugins/score-plugin-remotecontrol/RemoteControl/Settings/View.hpp index cdf0d4b8ca..14f6646802 100644 --- a/src/plugins/score-plugin-remotecontrol/RemoteControl/Settings/View.hpp +++ b/src/plugins/score-plugin-remotecontrol/RemoteControl/Settings/View.hpp @@ -3,7 +3,9 @@ #include class QCheckBox; +class QLabel; class QLineEdit; +class QSpinBox; class QVBoxLayout; namespace score @@ -25,15 +27,20 @@ class View : public score::GlobalSettingsView void setServerAddress(const QString&); void setServerPort(unsigned short); void setServerEnabled(bool); + void setToken(const QString&); + void setAllowScripting(bool); void enabledChanged(bool b) W_SIGNAL(enabledChanged, b); void webUiPathChanged(QString s) W_SIGNAL(webUiPathChanged, s); void serverAddressChanged(QString s) W_SIGNAL(serverAddressChanged, s); void serverPortChanged(unsigned short s) W_SIGNAL(serverPortChanged, s); void serverEnabledChanged(bool b) W_SIGNAL(serverEnabledChanged, b); + void TokenChanged(const QString& t) W_SIGNAL(TokenChanged, t); + void AllowScriptingChanged(bool b) W_SIGNAL(AllowScriptingChanged, b); private: QWidget* getWidget() override; + score::FormWidget* m_widg{}; QCheckBox* m_enabled{}; @@ -43,6 +50,8 @@ class View : public score::GlobalSettingsView QLineEdit* m_server_address{}; QSpinBox* m_server_port{}; QCheckBox* m_server_enabled{}; + QLineEdit* m_token{}; + QCheckBox* m_allowScripting{}; }; } diff --git a/src/plugins/score-plugin-remotecontrol/RemoteControl/Websockets/DocumentPlugin.cpp b/src/plugins/score-plugin-remotecontrol/RemoteControl/Websockets/DocumentPlugin.cpp index 63075ee32f..ba518d2580 100644 --- a/src/plugins/score-plugin-remotecontrol/RemoteControl/Websockets/DocumentPlugin.cpp +++ b/src/plugins/score-plugin-remotecontrol/RemoteControl/Websockets/DocumentPlugin.cpp @@ -19,6 +19,7 @@ #include #include +#include #include #include @@ -30,23 +31,21 @@ namespace RemoteControl::WS using namespace std::literals; DocumentPlugin::DocumentPlugin(const score::DocumentContext& doc, QObject* parent) : score::DocumentPlugin{doc, "RemoteControl::WS::DocumentPlugin", parent} - , receiver{doc, 10212} + , receiver{doc} { auto& set = m_context.app.settings(); - if(set.getEnabled()) - { - create(); - } - - con( - set, &Settings::Model::EnabledChanged, this, - [this](bool b) { - if(b) - create(); - else - cleanup(); - }, - Qt::QueuedConnection); + apply(set); + + // Every one of these decides whether or how the port is open, so they all + // have to take effect without reopening the document. + auto reapply = [this] { + apply(m_context.app.settings()); + }; + con(set, &Settings::Model::EnabledChanged, this, reapply, Qt::QueuedConnection); + con(set, &Settings::Model::ServerPortChanged, this, reapply, Qt::QueuedConnection); + con(set, &Settings::Model::ServerAddressChanged, this, reapply, Qt::QueuedConnection); + con(set, &Settings::Model::TokenChanged, this, reapply, Qt::QueuedConnection); + con(set, &Settings::Model::AllowScriptingChanged, this, reapply, Qt::QueuedConnection); // TODO put this as a setting instead startTimer(100); @@ -100,8 +99,26 @@ void DocumentPlugin::unregisterInterval(Scenario::IntervalModel& m) m_intervals.erase(m.id().val()); } +void DocumentPlugin::apply(const Settings::Model& set) +{ + if(!set.getEnabled()) + { + // The socket used to be opened by the constructor and never closed, so the + // port was served whether or not remote control was switched on. + receiver.close(); + cleanup(); + return; + } + + receiver.open(ReceiverSettings{ + set.getServerPort(), set.getServerAddress(), set.getToken(), + set.getAllowScripting()}); + create(); +} + void DocumentPlugin::on_documentClosing() { + receiver.close(); cleanup(); } @@ -154,15 +171,12 @@ static Path readPathFromValue(const rapidjson::Value& val) } } -Receiver::Receiver(const score::DocumentContext& doc, quint16 port) +Receiver::Receiver(const score::DocumentContext& doc) : m_server{"i-score-ctrl", QWebSocketServer::NonSecureMode} , m_dev{doc.plugin()} { - if(m_server.listen(QHostAddress::Any, port)) - { - connect( - &m_server, &QWebSocketServer::newConnection, this, &Receiver::onNewConnection); - } + connect( + &m_server, &QWebSocketServer::newConnection, this, &Receiver::onNewConnection); m_answers.insert( std::make_pair("Trigger", [&](const rapidjson::Value& obj, const WSClient&) { @@ -221,6 +235,13 @@ Receiver::Receiver(const score::DocumentContext& doc, quint16 port) m_answers.insert( std::make_pair("Console", [&](const rapidjson::Value& obj, const WSClient&) { + if(!m_settings.allowScripting) + { + qWarning() << "Remote control: refused a script. Enable scripting in the " + "remote control settings if this is wanted -- it gives the " + "client control of this machine, not just of the score."; + return; + } auto it = obj.FindMember("Code"); if(it == obj.MemberEnd()) return; @@ -264,10 +285,69 @@ Receiver::Receiver(const score::DocumentContext& doc, quint16 port) } Receiver::~Receiver() +{ + close(); +} + +void Receiver::open(const ReceiverSettings& settings) +{ + close(); + m_settings = settings; + + QHostAddress address; + if(settings.address.isEmpty() || !address.setAddress(settings.address)) + address = QHostAddress::Any; + + if(!m_server.listen(address, settings.port)) + { + qWarning() << "Remote control: could not listen on port" << settings.port << ":" + << m_server.errorString(); + return; + } + + qDebug() << "Remote control: listening on" << m_server.serverAddress().toString() + << m_server.serverPort(); +} + +void Receiver::close() { m_server.close(); - for(auto c : m_clients) + + // Taken first: ~QWebSocket emits disconnected() synchronously, which lands + // in socketDisconnected and erases from m_clients -- mutating the container + // being walked, and handing a socket being destroyed to handlers that will + // try to write to it. This is the ordinary path now that the setting can be + // switched off with clients connected, not only teardown. + auto clients = std::exchange(m_clients, {}); + m_listenedAddresses.clear(); + + for(auto& c : clients) + { + if(!c.socket) + continue; + disconnect(c.socket, nullptr, this, nullptr); delete c.socket; + } +} + +bool Receiver::isOpen() const noexcept +{ + return m_server.isListening(); +} + +quint16 Receiver::port() const noexcept +{ + return m_server.serverPort(); +} + +bool Receiver::authorize(const QWebSocket& socket) const noexcept +{ + // The token rides on the connection URL rather than in a handshake message: + // a browser cannot set headers on a WebSocket, and this way a client only + // needs the right address to be written down, not new code. + const auto given + = QUrlQuery{socket.requestUrl().query()}.queryItemValue(QStringLiteral("token")); + return !m_settings.token.isEmpty() && given == m_settings.token; } void Receiver::addHandler(QObject* context, Handler&& handler) @@ -334,7 +414,20 @@ void Receiver::unregisterSync(Path tn) void Receiver::onNewConnection() { - WSClient client{m_server.nextPendingConnection()}; + auto* socket = m_server.nextPendingConnection(); + if(!socket) + return; + + if(!authorize(*socket)) + { + qWarning() << "Remote control: refused a connection from" + << socket->peerAddress().toString() << "- wrong or missing token"; + socket->close(QWebSocketProtocol::CloseCodePolicyViolated, "Unauthorized"); + socket->deleteLater(); + return; + } + + WSClient client{socket}; connect( client.socket, &QWebSocket::textMessageReceived, this, diff --git a/src/plugins/score-plugin-remotecontrol/RemoteControl/Websockets/DocumentPlugin.hpp b/src/plugins/score-plugin-remotecontrol/RemoteControl/Websockets/DocumentPlugin.hpp index 94b3539f03..8903251397 100644 --- a/src/plugins/score-plugin-remotecontrol/RemoteControl/Websockets/DocumentPlugin.hpp +++ b/src/plugins/score-plugin-remotecontrol/RemoteControl/Websockets/DocumentPlugin.hpp @@ -28,6 +28,10 @@ namespace Scenario class IntervalModel; class TimeSyncModel; } +namespace RemoteControl::Settings +{ +class Model; +} namespace RemoteControl::WS { class Interval; @@ -79,15 +83,42 @@ struct Handler } }; +//! What the server needs from the settings, resolved when it is opened. +struct ReceiverSettings +{ + int port{10212}; + + //! Which interface to serve. score assumes a trusted network, so every + //! interface by default; a stricter deployment narrows it here. + QString address{"0.0.0.0"}; + + //! Presented by the client as ?token=... on the connection URL. + QString token; + + //! Whether a client may evaluate JavaScript here. That is control of the + //! machine score runs on, not of the score. + bool allowScripting{false}; +}; + struct SCORE_PLUGIN_REMOTECONTROL_EXPORT Receiver : public QObject , public Nano::Observer { public: - explicit Receiver(const score::DocumentContext& doc, quint16 port); + explicit Receiver(const score::DocumentContext& doc); ~Receiver(); + //! Start listening. Closes first if already open, so this doubles as + //! "apply the settings again". + void open(const ReceiverSettings& settings); + void close(); + bool isOpen() const noexcept; + + //! The port actually bound, which differs from the requested one when 0 was + //! asked for. + quint16 port() const noexcept; + void addHandler(QObject* context, Handler&& handler); void removeHandler(QObject* context); @@ -108,7 +139,11 @@ struct SCORE_PLUGIN_REMOTECONTROL_EXPORT Receiver private: void on_valueUpdated(const ::State::Address& addr, const ossia::value& v); + //! Whether this socket presented the right token when it connected. + bool authorize(const QWebSocket& socket) const noexcept; + QWebSocketServer m_server; + ReceiverSettings m_settings; std::vector m_clients; Explorer::DeviceDocumentPlugin& m_dev; @@ -137,6 +172,7 @@ class SCORE_PLUGIN_REMOTECONTROL_EXPORT DocumentPlugin : public score::DocumentP Receiver receiver; private: + void apply(const Settings::Model& set); void create(); void cleanup(); diff --git a/src/plugins/score-plugin-remotecontrol/js-remote/remote.html b/src/plugins/score-plugin-remotecontrol/js-remote/remote.html index b24e4979ad..6933629f94 100644 --- a/src/plugins/score-plugin-remotecontrol/js-remote/remote.html +++ b/src/plugins/score-plugin-remotecontrol/js-remote/remote.html @@ -7,6 +7,8 @@
IP:
+ Token:
+


diff --git a/src/plugins/score-plugin-remotecontrol/js-remote/remote.js b/src/plugins/score-plugin-remotecontrol/js-remote/remote.js index 516bb771ef..b61d1d76d4 100644 --- a/src/plugins/score-plugin-remotecontrol/js-remote/remote.js +++ b/src/plugins/score-plugin-remotecontrol/js-remote/remote.js @@ -128,13 +128,44 @@ let messageProcessor = { }, } +function setStatus(text) { + const el = document.getElementById("status"); + if (el !== null) { + el.textContent = text; + } +} + function connectToWS() { var endpoint = document.getElementById("endpoint").value; if (ws !== undefined) { ws.close() } + // score requires a token, which it shows in its remote control settings. + // It goes on the URL because a browser cannot set headers on a WebSocket. + const tokenField = document.getElementById("token"); + const token = tokenField === null ? "" : tokenField.value.trim(); + if (token !== "" && endpoint.indexOf("token=") === -1) { + endpoint += (endpoint.indexOf("?") === -1 ? "?" : "&") + + "token=" + encodeURIComponent(token); + } + + setStatus("connecting..."); ws = new WebSocket(endpoint); + ws.onopen = function () { + setStatus("connected"); + } + // Without these a wrong or missing token looks exactly like nothing + // happening: score closes the socket and the page says nothing. + ws.onclose = function (event) { + setStatus(event.wasClean && event.code === 1000 + ? "disconnected" + : "refused -- check the token in score's remote control settings" + + " (code " + event.code + ")"); + } + ws.onerror = function () { + setStatus("could not reach " + endpoint); + } ws.onmessage = function (event) { var obj = JSON.parse(event.data); const handler = messageProcessor[obj.Message]; diff --git a/src/plugins/score-plugin-scenario/Scenario/Document/Interval/FullView/FullViewIntervalPresenter.cpp b/src/plugins/score-plugin-scenario/Scenario/Document/Interval/FullView/FullViewIntervalPresenter.cpp index 5654d0badb..f0404dfcc6 100644 --- a/src/plugins/score-plugin-scenario/Scenario/Document/Interval/FullView/FullViewIntervalPresenter.cpp +++ b/src/plugins/score-plugin-scenario/Scenario/Document/Interval/FullView/FullViewIntervalPresenter.cpp @@ -252,7 +252,7 @@ void FullViewIntervalPresenter::setupSlot( auto& ld = slot.layers.emplace_back(&proc); // Create layers - const auto factory = m_context.processList.findDefaultFactory(proc.concreteKey()); + const auto factory = m_context.processList.findDefaultFactory(proc); const auto gui_width = m_model.duration.guiDuration().toPixels(m_zoomRatio); const auto def_width = m_model.duration.defaultDuration().toPixels(m_zoomRatio); diff --git a/src/plugins/score-plugin-scenario/Scenario/Document/Interval/LayerData.cpp b/src/plugins/score-plugin-scenario/Scenario/Document/Interval/LayerData.cpp index 01e824cb3f..943c4c19ac 100644 --- a/src/plugins/score-plugin-scenario/Scenario/Document/Interval/LayerData.cpp +++ b/src/plugins/score-plugin-scenario/Scenario/Document/Interval/LayerData.cpp @@ -46,9 +46,19 @@ void LayerData::addView( auto view = factory.makeLayerView(*m_model, context, container); if(view->toolTip().isEmpty()) { + // No factory when the process is standing in for a plug-in this build does + // not have: there is no descriptor to read, and its own name is all we can + // say about it. auto& p = context.app.interfaces(); - const auto& desc = p.get(m_model->concreteKey())->descriptor({}); - view->setToolTip(QString("%1\n%2").arg(desc.prettyName, desc.description)); + if(auto* fac = p.get(m_model->concreteKey())) + { + const auto& desc = fac->descriptor({}); + view->setToolTip(QString("%1\n%2").arg(desc.prettyName, desc.description)); + } + else + { + view->setToolTip(m_model->prettyName()); + } } double startX = m_model->flags() & Process::ProcessFlags::HandlesLooping @@ -171,7 +181,7 @@ void LayerData::updateLoops( qreal parent_default_width, qreal slot_height, QGraphicsItem* parentItem, QObject* parent) { - auto f = ctx.processList.findDefaultFactory(m_model->concreteKey()); + auto f = ctx.processList.findDefaultFactory(*m_model); SCORE_ASSERT(f); if(m_model->loops() && !(m_model->flags() & Process::ProcessFlags::HandlesLooping)) { diff --git a/src/plugins/score-plugin-scenario/Scenario/Document/Interval/Temporal/TemporalIntervalPresenter.cpp b/src/plugins/score-plugin-scenario/Scenario/Document/Interval/Temporal/TemporalIntervalPresenter.cpp index 748b529e99..c8cfb8c0bb 100644 --- a/src/plugins/score-plugin-scenario/Scenario/Document/Interval/Temporal/TemporalIntervalPresenter.cpp +++ b/src/plugins/score-plugin-scenario/Scenario/Document/Interval/Temporal/TemporalIntervalPresenter.cpp @@ -513,8 +513,7 @@ void TemporalIntervalPresenter::createCollapsedSlot(int pos, const Slot& slt) const Id& id = *frontLayer; auto proc = m_model.processes.find(id); SCORE_ASSERT(proc != m_model.processes.end()); - const auto& procKey = proc->concreteKey(); - auto factory = m_context.processList.findDefaultFactory(procKey); + auto factory = m_context.processList.findDefaultFactory(*proc); { p.headerDelegate = factory->makeHeaderDelegate(*proc, m_context, nullptr); @@ -861,8 +860,7 @@ void TemporalIntervalPresenter::on_layerModelPutToFront( { if(auto pres = ld.mainPresenter(); bool(pres)) { - auto factory - = m_context.processList.findDefaultFactory(ld.model().concreteKey()); + auto factory = m_context.processList.findDefaultFactory(ld.model()); ld.putToFront(); ld.setZValue(2); { diff --git a/src/plugins/score-plugin-scenario/score_plugin_scenario.cpp b/src/plugins/score-plugin-scenario/score_plugin_scenario.cpp index 007bf2b1d0..777cb3fa96 100644 --- a/src/plugins/score-plugin-scenario/score_plugin_scenario.cpp +++ b/src/plugins/score-plugin-scenario/score_plugin_scenario.cpp @@ -1,6 +1,7 @@ // This is an open source non-commercial project. Dear PVS-Studio, please check // it. PVS-Studio Static Code Analyzer for C, C++ and C#: http://www.viva64.com +#include #include #include @@ -251,7 +252,7 @@ std::vector score_plugin_scenario::factories( >, FW, + Scenario::TempoLayerFactory, Process::OpaqueLayerFactory>, FW, FW +#include +#include +#include + +#include +#include +#include +#include + +#include + +#include + +#include +#include + +#include + +namespace +{ +constexpr auto absent_uuid = "11111111-2222-3333-4444-555555555555"; + +Device::DeviceSettings ghostSettings(const QString& name = QStringLiteral("ghost")) +{ + Device::DeviceSettings s; + s.protocol = UuidKey::fromString(QString{absent_uuid}); + s.name = name; + return s; +} + +//! A device in the tree with nothing behind it, through the same path a load +//! takes: loadDeviceFromNode fails to find the factory and the node is kept. +Explorer::DeviceDocumentPlugin& +withGhostDevice(score::Document& doc, const QString& name = QStringLiteral("ghost")) +{ + auto& plug = doc.context().plugin(); + plug.updateProxy.loadDevice(Device::Node{ghostSettings(name), nullptr}); + return plug; +} +} + +TEST_CASE("A device with no factory stays in the tree unimplemented", "[explorer]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + + auto& plug = withGhostDevice(*doc); + + // The node is there... + REQUIRE(plug.rootNode().childCount() >= 1); + const auto& node = plug.rootNode().childAt(0); + REQUIRE(node.is()); + CHECK(node.get().name == QStringLiteral("ghost")); + + // ... and nothing is behind it. Everything below depends on this: if a + // factory did turn up, the test would be exercising the ordinary path. + REQUIRE(plug.list().findDevice(QStringLiteral("ghost")) == nullptr); + }); +} + +TEST_CASE("An unimplemented device can be displayed", "[explorer]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + + auto& plug = withGhostDevice(*doc); + REQUIRE(plug.list().findDevice(QStringLiteral("ghost")) == nullptr); + + auto& model = plug.explorer(); + const auto idx = model.index(0, (int)Explorer::Column::Name, QModelIndex{}); + REQUIRE(idx.isValid()); + + // data() asks the device whether it is connected. Reaching for one that is + // not there aborted on every repaint of the explorer. + const auto name = model.data(idx, Qt::DisplayRole); + CHECK(name.toString().contains(QStringLiteral("ghost"))); + }); +} + +TEST_CASE("Addresses can be edited on an unimplemented device", "[explorer]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + + auto& plug = withGhostDevice(*doc); + REQUIRE(plug.list().findDevice(QStringLiteral("ghost")) == nullptr); + + // The model still has to accept the edit: on a machine that lacks the + // protocol -- or a terminal, which has no device implementations at all -- + // the document is what carries the address, and it has to survive a save. + Device::AddressSettings addr; + addr.name = QStringLiteral("param"); + + Device::NodePath devicePath; + devicePath.push_back(0); + + plug.updateProxy.addAddress(devicePath, addr, 0); + + REQUIRE(plug.rootNode().childCount() >= 1); + const auto& device = plug.rootNode().childAt(0); + REQUIRE(device.childCount() == 1); + CHECK(device.childAt(0).displayName() == QStringLiteral("param")); + + // And removing it again. + plug.updateProxy.removeNode(devicePath, addr); + CHECK(plug.rootNode().childAt(0).childCount() == 0); + }); +} + +TEST_CASE("An unimplemented device can be updated and removed", "[explorer]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + + auto& plug = withGhostDevice(*doc); + REQUIRE(plug.list().findDevice(QStringLiteral("ghost")) == nullptr); + + auto renamed = ghostSettings(QStringLiteral("ghost")); + renamed.deviceSpecificSettings = QVariant::fromValue(42); + plug.updateProxy.updateDevice(QStringLiteral("ghost"), renamed); + + REQUIRE(plug.rootNode().childCount() >= 1); + CHECK( + plug.rootNode().childAt(0).get().deviceSpecificSettings + == renamed.deviceSpecificSettings); + + plug.updateProxy.removeDevice(renamed); + CHECK(plug.rootNode().childCount() == 0); + }); +} diff --git a/tests/integration/DocumentLifecycleTest.cpp b/tests/integration/DocumentLifecycleTest.cpp index 687c05e09b..65852955a5 100644 --- a/tests/integration/DocumentLifecycleTest.cpp +++ b/tests/integration/DocumentLifecycleTest.cpp @@ -5,11 +5,21 @@ // This validates the whole vertical slice: SCORE_TESTING build wiring, // score_test_fixtures, runtime plugin discovery, and document creation. +#include + +#include + #include #include #include #include +#include + +#include +#include +#include +#include #include @@ -30,3 +40,62 @@ TEST_CASE("A headless document can be created and serialized", "[integration][do CHECK(bytes.size() > 0); }); } + + +TEST_CASE("A document that resolves paths while loading can be opened", "[document]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + // Deserialization resolves paths -- a sound process turns the stored + // : reference into something it can open -- and that happens from + // the constructors, before any post-construction setup has run. Anything + // locateFilePath depends on has to exist by then. + const QString path + = QStringLiteral("%1/docs/main-page.score").arg(SCORE_ROOT_SOURCE_DIR); + REQUIRE(QFile::exists(path)); + + auto* doc = ctx.docManager.loadFile(ctx, path); + REQUIRE(doc); + + // And the document can say where its files are, which is what the load + // path was asking it before it had an answer. + CHECK(doc->environment().isLocal()); + CHECK_FALSE( + score::locateFilePath(":anything", doc->context()).isEmpty()); + }); +} + + +TEST_CASE("A document whose process has no factory can be displayed", "[document]") +{ + score::test::run_in_gui_app([](const score::GUIApplicationContext& ctx) { + // Loading a stand-in was covered; showing one was not, and showing it is + // what an application does immediately after loading. Several places look + // up the process factory by the key the stand-in reports -- which is the + // absent plug-in's -- and dereference the result. + const QString path + = QStringLiteral("%1/tests/testdata/missing-plugin.score") + .arg(SCORE_ROOT_SOURCE_DIR); + REQUIRE(QFile::exists(path)); + + auto* doc = ctx.docManager.loadFile(ctx, path); + REQUIRE(doc); + + REQUIRE(doc->presenter() != nullptr); + + // Loading builds the presenter but not necessarily the layers. Ask for the + // interval to be displayed, which is what the application does and what + // builds them -- otherwise this test passes without reaching the code it + // is here for. + auto* pres = safe_cast( + doc->presenter()->presenterDelegate()); + REQUIRE(pres); + auto& model = safe_cast( + doc->model().modelDelegate()); + + pres->setDisplayedInterval(&model.baseScenario().interval()); + QApplication::processEvents(); + + // Reached the layers: there is a process in this document and it is shown. + CHECK(model.baseScenario().interval().processes.size() > 0); + }); +} diff --git a/tests/integration/RemoteControlAuthTest.cpp b/tests/integration/RemoteControlAuthTest.cpp new file mode 100644 index 0000000000..1d1656a3fb --- /dev/null +++ b/tests/integration/RemoteControlAuthTest.cpp @@ -0,0 +1,265 @@ +// The remote-control WebSocket API is a way in to the machine score runs on: +// it sets device parameters, drives transport, and -- if allowed -- evaluates +// JavaScript. These pin the controls on who may do that. + +#include +#include + +#include + +#include +#include + +#include + +#include + +#include +#include +#include + +#include +#include + +#include + +namespace +{ +//! Run the event loop until `pred` holds or we give up. +template +bool spin_until(Pred pred, int timeoutMs = 3000) +{ + QElapsedTimer t; + t.start(); + while(!pred()) + { + if(t.elapsed() > timeoutMs) + return false; + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + } + return true; +} + +RemoteControl::WS::ReceiverSettings testSettings(const QString& token) +{ + RemoteControl::WS::ReceiverSettings s; + s.port = 0; // let the OS pick, so parallel test runs do not collide + s.address = QStringLiteral("127.0.0.1"); + s.token = token; + s.allowScripting = false; + return s; +} +} + +TEST_CASE("The remote control server refuses a client with no token", "[remote]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + + RemoteControl::WS::Receiver receiver{doc->context()}; + receiver.open(testSettings(QStringLiteral("the-right-token"))); + REQUIRE(receiver.isOpen()); + + QWebSocket client; + bool connected{}; + QObject::connect(&client, &QWebSocket::connected, [&] { connected = true; }); + + client.open(QUrl{QStringLiteral("ws://127.0.0.1:%1/").arg(receiver.port())}); + + // The socket may reach "connected" at the protocol level before the server + // hangs up, so what matters is that it does not stay a client of ours. + spin_until([&] { return !receiver.clients().empty(); }, 1000); + CHECK(receiver.clients().empty()); + }); +} + +TEST_CASE("The remote control server refuses a client with the wrong token", "[remote]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + + RemoteControl::WS::Receiver receiver{doc->context()}; + receiver.open(testSettings(QStringLiteral("the-right-token"))); + REQUIRE(receiver.isOpen()); + + QWebSocket client; + client.open(QUrl{QStringLiteral("ws://127.0.0.1:%1/?token=guess") + .arg(receiver.port())}); + + spin_until([&] { return !receiver.clients().empty(); }, 1000); + CHECK(receiver.clients().empty()); + }); +} + +TEST_CASE("The remote control server serves a client with the token", "[remote]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + + RemoteControl::WS::Receiver receiver{doc->context()}; + receiver.open(testSettings(QStringLiteral("the-right-token"))); + REQUIRE(receiver.isOpen()); + + QWebSocket client; + QStringList received; + QObject::connect( + &client, &QWebSocket::textMessageReceived, + [&](const QString& m) { received.push_back(m); }); + + client.open(QUrl{QStringLiteral("ws://127.0.0.1:%1/?token=the-right-token") + .arg(receiver.port())}); + + REQUIRE(spin_until([&] { return !receiver.clients().empty(); })); + + // On accepting a client the server sends it the device tree. + REQUIRE(spin_until([&] { return !received.empty(); })); + CHECK(received.front().contains("DeviceTree")); + }); +} + +TEST_CASE("An empty token serves nobody", "[remote]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + + // A blank token must not degrade to "no password required": the settings + // generate one precisely so that this state is unreachable, and if it is + // reached anyway the server has to stay shut rather than open wide. + RemoteControl::WS::Receiver receiver{doc->context()}; + receiver.open(testSettings(QString{})); + REQUIRE(receiver.isOpen()); + + QWebSocket client; + client.open(QUrl{QStringLiteral("ws://127.0.0.1:%1/").arg(receiver.port())}); + + spin_until([&] { return !receiver.clients().empty(); }, 1000); + CHECK(receiver.clients().empty()); + }); +} + +TEST_CASE("Scripting is off unless asked for", "[remote]") +{ + score::test::run_in_gui_app([](const score::GUIApplicationContext& ctx) { + // The Console message evaluates arbitrary JavaScript in this process. It + // used to be served to anyone who could reach the port. + auto& settings = ctx.settings(); + CHECK_FALSE(settings.getAllowScripting()); + CHECK_FALSE(settings.getEnabled()); + CHECK_FALSE(settings.getToken().isEmpty()); + }); +} + +namespace +{ +//! Ask the server to run some JavaScript, over an accepted connection. +void sendConsole( + RemoteControl::WS::Receiver& receiver, QWebSocket& client, const QString& code) +{ + rapidjson::StringBuffer buf; + JsonWriter w{buf}; + w.StartObject(); + w.Key("Message"); + w.String("Console"); + w.Key("Code"); + const auto utf8 = code.toUtf8(); + w.String(utf8.constData(), utf8.size()); + w.EndObject(); + + client.sendTextMessage(QString::fromUtf8(buf.GetString(), buf.GetLength())); +} + +JS::PanelDelegate& console(const score::GUIApplicationContext& ctx) +{ + auto* p = ctx.findPanel(); + SCORE_ASSERT(p); + return *p; +} + +int probeValue(const score::GUIApplicationContext& ctx) +{ + return console(ctx) + .engine() + .globalObject() + .property(QStringLiteral("__remoteProbe")) + .toInt(); +} +} + +TEST_CASE("Scripting refused is scripting not run", "[remote]") +{ + score::test::run_in_gui_app([](const score::GUIApplicationContext& ctx) { + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + + // The settings default was all that was ever asserted, and a default is not + // an enforcement: deleting the guard in the Console handler left every one + // of these tests passing. + auto settings = testSettings(QStringLiteral("tok")); + settings.allowScripting = false; + + RemoteControl::WS::Receiver receiver{doc->context()}; + receiver.open(settings); + REQUIRE(receiver.isOpen()); + + QWebSocket client; + client.open(QUrl{ + QStringLiteral("ws://127.0.0.1:%1/?token=tok").arg(receiver.port())}); + REQUIRE(spin_until([&] { return !receiver.clients().empty(); })); + + // Both ends: the server accepting is not the client being ready to send, + // and sendTextMessage on a socket that is not open yet is dropped -- which + // would make a refusal indistinguishable from a message never sent. + REQUIRE(spin_until( + [&] { return client.state() == QAbstractSocket::ConnectedState; })); + + console(ctx).engine().evaluate(QStringLiteral("__remoteProbe = 0")); + REQUIRE(probeValue(ctx) == 0); + + sendConsole(receiver, client, QStringLiteral("__remoteProbe = 1")); + + // Nothing to wait for when it works, so give it time to fail: a refusal + // that only looks like one because the message had not arrived yet would + // pass whatever the guard did. + spin_until([&] { return probeValue(ctx) != 0; }, 1500); + CHECK(probeValue(ctx) == 0); + }); +} + +TEST_CASE("Scripting allowed is scripting run", "[remote]") +{ + score::test::run_in_gui_app([](const score::GUIApplicationContext& ctx) { + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + + // The other half: with the setting on, the same message does evaluate -- + // otherwise the test above would pass against a Console handler that was + // simply broken, and the feature would be silently dead. + auto settings = testSettings(QStringLiteral("tok")); + settings.allowScripting = true; + + RemoteControl::WS::Receiver receiver{doc->context()}; + receiver.open(settings); + REQUIRE(receiver.isOpen()); + + QWebSocket client; + client.open(QUrl{ + QStringLiteral("ws://127.0.0.1:%1/?token=tok").arg(receiver.port())}); + REQUIRE(spin_until([&] { return !receiver.clients().empty(); })); + + // Both ends: the server accepting is not the client being ready to send, + // and sendTextMessage on a socket that is not open yet is dropped -- which + // would make a refusal indistinguishable from a message never sent. + REQUIRE(spin_until( + [&] { return client.state() == QAbstractSocket::ConnectedState; })); + + console(ctx).engine().evaluate(QStringLiteral("__remoteProbe = 0")); + REQUIRE(probeValue(ctx) == 0); + + sendConsole(receiver, client, QStringLiteral("__remoteProbe = 7")); + REQUIRE(spin_until([&] { return probeValue(ctx) == 7; })); + }); +} diff --git a/tests/testdata/missing-plugin.score b/tests/testdata/missing-plugin.score new file mode 100644 index 0000000000..ddd8eeca9c --- /dev/null +++ b/tests/testdata/missing-plugin.score @@ -0,0 +1 @@ +{"Document": {"ObjectName": "Scenario::ScenarioDocumentModel", "id": 1, "BaseScenario": {"ObjectName": "Scenario::BaseScenario", "id": 0, "Constraint": {"ObjectName": "Scenario::IntervalModel", "id": 0, "Metadata": {"ScriptingName": "authored", "Comment": "", "Color": "Transparent1", "Label": "", "Touched": true}, "Inlet": {"uuid": "a1574bb0-cbd4-4c7d-9417-0c25cfd1187b", "ObjectName": "Inlet", "id": 0, "Hidden": false, "Custom": "Audio In", "Exposed": "audio in"}, "Outlet": {"uuid": "a1d97535-18ac-444a-8417-0cbc1692d897", "ObjectName": "Outlet", "id": 0, "Hidden": false, "Custom": "Audio Out", "Exposed": "audio out", "GainInlet": {"uuid": "9a13fb32-269a-47bf-99a9-930188c1f19c", "ObjectName": "Inlet", "id": 10000, "Hidden": false, "Custom": "Gain", "Exposed": "gain", "Value": {}, "Init": {}, "Domain": {"Float": {"Min": 0.0, "Max": 1.0}}}, "PanInlet": {"uuid": "9a13fb32-269a-47bf-99a9-930188c1f19c", "ObjectName": "Inlet", "id": 10001, "Hidden": false, "Custom": "Pan", "Exposed": "pan", "Value": {}, "Init": {}, "Domain": {}}, "Gain": 1.0, "Pan": [1.0, 1.0], "Propagate": true}, "Processes": [{"uuid": "11111111-2222-3333-4444-555555555555", "ObjectName": "Automation", "id": 2, "Metadata": {"ScriptingName": "Automation (float).2", "Comment": "", "Color": "Transparent1", "Label": "", "Touched": false}, "Duration": 10584000000, "Height": 300.0, "StartOffset": 0, "LoopDuration": 10584000000, "Pos": [60.0, 60.0], "Size": [200.0, 100.0], "Loops": false, "Outlet": {"uuid": "047e4cc2-4d99-4e8b-bf98-206018d02274", "ObjectName": "Outlet", "id": 0, "Hidden": false, "Custom": "Out", "Exposed": "out", "MinInlet": {"uuid": "af2b4fc3-aecb-4c15-a5aa-1c573a239925", "ObjectName": "Inlet", "id": 0, "Hidden": false, "Custom": "Min", "Exposed": "min", "Value": {"Float": 0.0}, "Init": {}, "Domain": {}}, "MaxInlet": {"uuid": "af2b4fc3-aecb-4c15-a5aa-1c573a239925", "ObjectName": "Inlet", "id": 1, "Hidden": false, "Custom": "Max", "Exposed": "max", "Value": {"Float": 1.0}, "Init": {}, "Domain": {}}}, "Curve": {"ObjectName": "CurveModel", "id": 45345, "Segments": [{"uuid": "1e7cb83f-4e47-4b14-814d-2242a9c75991", "ObjectName": "CurveSegmentModel", "id": 1, "Previous": null, "Following": null, "Start": [0.0, 0.0], "End": [1.0, 1.0], "Power": 1.0}]}, "Tween": false, "PluginState": "authored by a build that had the plug-in"}, {"uuid": "de035912-5b03-49a8-bc4d-b2cba68e21d9", "ObjectName": "Scenario", "id": 1, "Metadata": {"ScriptingName": "Scenario.1", "Comment": "", "Color": "Transparent1", "Label": "", "Touched": false}, "Duration": 10584000000, "Height": 1500.0, "StartOffset": 0, "LoopDuration": 10584000000, "Pos": [40.0, 40.0], "Size": [200.0, 100.0], "Loops": false, "Inlet": {"uuid": "a1574bb0-cbd4-4c7d-9417-0c25cfd1187b", "ObjectName": "Inlet", "id": 0, "Hidden": false, "Custom": "In", "Exposed": "in"}, "Outlet": {"uuid": "a1d97535-18ac-444a-8417-0cbc1692d897", "ObjectName": "Outlet", "id": 0, "Hidden": false, "Custom": "Out", "Exposed": "out", "GainInlet": {"uuid": "9a13fb32-269a-47bf-99a9-930188c1f19c", "ObjectName": "Inlet", "id": 10000, "Hidden": false, "Custom": "Gain", "Exposed": "gain", "Value": {}, "Init": {}, "Domain": {"Float": {"Min": 0.0, "Max": 1.0}}}, "PanInlet": {"uuid": "9a13fb32-269a-47bf-99a9-930188c1f19c", "ObjectName": "Inlet", "id": 10001, "Hidden": false, "Custom": "Pan", "Exposed": "pan", "Value": {}, "Init": {}, "Domain": {}}, "Gain": 1.0, "Pan": [1.0, 1.0], "Propagate": true}, "StartTimeNodeId": 0, "StartEventId": 0, "StartStateId": 0, "Exclusive": false, "TimeNodes": [{"ObjectName": "Scenario::TimeSyncModel", "id": 0, "Metadata": {"ScriptingName": "Sync.start", "Comment": "", "Color": "Gray", "Label": "", "Touched": true}, "Date": 0, "Events": [0], "MusicalSync": -1.0, "AutoTrigger": false, "Start": true, "Active": false, "Expression": " { true == false } "}], "Events": [{"ObjectName": "Scenario::EventModel", "id": 0, "Metadata": {"ScriptingName": "Event.start", "Comment": "", "Color": "Emphasis4", "Label": "", "Touched": true}, "TimeNode": 0, "States": [0], "Condition": "", "Date": 0, "Offset": 0}], "States": [{"ObjectName": "Scenario::StateModel", "id": 0, "Metadata": {"ScriptingName": "State.start", "Comment": "", "Color": "Base1", "Label": "", "Touched": true}, "Event": 0, "PreviousConstraint": null, "NextConstraint": null, "HeightPercentage": 0.02, "Messages": {"Name": "", "Accessors": [], "Unit": "none", "Previous": [], "Following": [], "User": null, "Priorities": [1, 2, 0]}, "Controls": [], "StateProcesses": []}], "Constraints": [], "Comments": []}], "SmallViewRack": [{"Processes": [2], "Process": 2, "Height": 200.0, "Nodal": false}], "FullViewRack": [{"Process": 1, "Nodal": false}, {"Process": 2, "Nodal": false}], "DefaultDuration": 10584000000, "MinDuration": 10584000000, "MaxDuration": 11113200000, "GuiDuration": 11642400000, "Speed": 1.0, "Rigidity": false, "MinNull": false, "MaxInf": true, "Signatures": [[0, [4, 4]]], "StartState": 0, "EndState": 1, "StartDate": 0, "HeightPercentage": 0.0, "NodalSlotHeight": 100.0, "QuantizationRate": -1.0, "Zoom": -1.0, "Center": 0, "ViewMode": 0, "SmallViewShown": true, "HasSignature": true}, "StartTimeNode": {"ObjectName": "Scenario::TimeSyncModel", "id": 0, "Metadata": {"ScriptingName": "Sync.start", "Comment": "", "Color": "Gray", "Label": "", "Touched": true}, "Date": 0, "Events": [0], "MusicalSync": -1.0, "AutoTrigger": false, "Start": true, "Active": false, "Expression": " { true == false } "}, "EndTimeNode": {"ObjectName": "Scenario::TimeSyncModel", "id": 1, "Metadata": {"ScriptingName": "Sync.end", "Comment": "", "Color": "Gray", "Label": "", "Touched": true}, "Date": 10584000000, "Events": [1], "MusicalSync": -1.0, "AutoTrigger": false, "Start": false, "Active": true, "Expression": " { true == false } "}, "StartEvent": {"ObjectName": "Scenario::EventModel", "id": 0, "Metadata": {"ScriptingName": "Event.start", "Comment": "", "Color": "Emphasis4", "Label": "", "Touched": true}, "TimeNode": 0, "States": [0], "Condition": "", "Date": 0, "Offset": 0}, "EndEvent": {"ObjectName": "Scenario::EventModel", "id": 1, "Metadata": {"ScriptingName": "Event.end", "Comment": "", "Color": "Emphasis4", "Label": "", "Touched": true}, "TimeNode": 1, "States": [1], "Condition": "", "Date": 10584000000, "Offset": 0}, "StartState": {"ObjectName": "Scenario::StateModel", "id": 0, "Metadata": {"ScriptingName": "State.start", "Comment": "", "Color": "Base1", "Label": "", "Touched": true}, "Event": 0, "PreviousConstraint": null, "NextConstraint": 0, "HeightPercentage": 0.0, "Messages": {"Name": "", "Accessors": [], "Unit": "none", "Previous": [], "Following": [], "User": null, "Priorities": [1, 2, 0]}, "Controls": [], "StateProcesses": []}, "EndState": {"ObjectName": "Scenario::StateModel", "id": 1, "Metadata": {"ScriptingName": "State.end", "Comment": "", "Color": "Base1", "Label": "", "Touched": true}, "Event": 1, "PreviousConstraint": 0, "NextConstraint": null, "HeightPercentage": 0.0, "Messages": {"Name": "", "Accessors": [], "Unit": "none", "Previous": [], "Following": [], "User": null, "Priorities": [1, 2, 0]}, "Controls": [], "StateProcesses": []}}, "Speed": 1.0, "Cables": [], "BusIntervals": []}, "Plugins": [{"uuid": "1f923578-08c3-49be-9ba9-69c144ee2e32", "Refresh": false, "Reconnect": false, "MidiRatio": 1.0}, {"uuid": "6e610e1f-9de2-4c36-90dd-0ef570002a21", "RootNode": {}, "Children": []}, {"uuid": "05e72689-e02c-4c9d-a0bf-fe84c32d3d96", "Data": ""}], "Version": 4, "Commit": "0d6d626ac6ac196028c11473f91eafc575b46cb2", "Tag": "3.8.2"} \ No newline at end of file diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 10dc2c66e8..7295c3bfc7 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -595,3 +595,15 @@ score_add_test(test_unit_dshow_subtype SOURCES DirectShowSubtypeResolveTest.cpp PLUGINS score_plugin_gfx LIBS avutil avcodec) +# --- heterogeneous builds: what happens when a factory is absent ----------- +# Pins the behaviour of documents referencing processes / protocols this build +# does not have: Syphon and Spout are compiled into score-plugin-gfx under #if, +# so a present plug-in is no guarantee that its factories are registered. +score_add_test(test_unit_heterogeneous_build + SOURCES HeterogeneousBuildTest.cpp + APP + PLUGINS score_lib_device score_lib_process score_plugin_scenario) + +# --- how a path is stored in a document ------------------------------------ +score_add_test(test_unit_uri + SOURCES UriTest.cpp) diff --git a/tests/unit/HeterogeneousBuildTest.cpp b/tests/unit/HeterogeneousBuildTest.cpp new file mode 100644 index 0000000000..ffa17fa89a --- /dev/null +++ b/tests/unit/HeterogeneousBuildTest.cpp @@ -0,0 +1,725 @@ +// What happens when a document references a process or protocol that this build +// does not have. +// +// Protocols and processes are registered conditionally inside plug-ins that ship +// everywhere -- Syphon and Spout are both compiled into score-plugin-gfx under +// #if -- so this is routine rather than exotic: a macOS document opened on +// Windows, or anything at all opened in the wasm build. +// +// Cases still marked as pinning current behaviour are ones no fix has landed for +// yet; they record what is lost, not what is wanted. + +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include +#include + +#include +#include +#include + +#include +#include + +#include +#include + +#include + +namespace +{ +// A protocol UUID that no build registers: stands in for Syphon-on-Windows. +constexpr auto absent_uuid = "11111111-2222-3333-4444-555555555555"; + +UuidKey absentProtocol() +{ + return UuidKey::fromString(QString{absent_uuid}); +} + +// Bytes as a build that *has* the protocol emits them: name, protocol key, a +// protocol-specific payload, then the trailing delimiter. +QByteArray settingsFromRicherBuild(const QString& name) +{ + QByteArray b; + DataStreamReader r{&b}; + r.m_stream << name << absentProtocol(); + r.m_stream << QStringLiteral("host-only protocol payload"); + r.insertDelimiter(); + return b; +} +} + +TEST_CASE("DeviceSettings DataStream reports the missing protocol", "[heterogeneous]") +{ + score::test::run_in_app([](const score::GUIApplicationContext&) { + // The binary format writes protocol settings inline with no length prefix, + // so a reader without the factory genuinely cannot skip them: the payload + // is unrecoverable and the only honest outcome is a clear diagnostic. What + // must NOT happen is the old behaviour, where the delimiter check landed + // mid-payload and blamed the whole file for being corrupt. + const QByteArray bytes = settingsFromRicherBuild("syphon-in"); + + Device::DeviceSettings s; + DataStreamWriter w{bytes}; + REQUIRE_THROWS_AS(w.writeTo(s), std::runtime_error); + + // The device and protocol are named, so the message can tell the user which + // machine to open the document on. + try + { + Device::DeviceSettings s2; + DataStreamWriter w2{bytes}; + w2.writeTo(s2); + } + catch(const std::runtime_error& e) + { + const QString what = QString::fromStdString(e.what()); + CHECK(what.contains("syphon-in")); + CHECK(what.contains(absent_uuid)); + CHECK(what.contains(".score")); + } + }); +} + +TEST_CASE("DeviceSettings DataStream round-trips when nobody has the protocol", + "[heterogeneous]") +{ + score::test::run_in_app([](const score::GUIApplicationContext&) { + // Symmetric case: the writer had no factory either, so it wrote no payload + // and the delimiter follows the protocol key directly. Nothing is lost and + // this must keep working -- it is how a device whose plug-in is absent on + // *both* ends survives a local save/load. + Device::DeviceSettings in; + in.name = "syphon-in"; + in.protocol = absentProtocol(); + + QByteArray bytes; + { + DataStreamReader r{&bytes}; + r.readFrom(in); + } + + Device::DeviceSettings out; + DataStreamWriter w{bytes}; + REQUIRE_NOTHROW(w.writeTo(out)); + CHECK(out.name == in.name); + CHECK(out.protocol == in.protocol); + }); +} + +TEST_CASE("DeviceSettings JSON preserves the settings of an absent protocol", + "[heterogeneous]") +{ + score::test::run_in_app([](const score::GUIApplicationContext&) { + const QByteArray json + = QStringLiteral(R"({"Name":"syphon-in","Protocol":"%1",)" + R"("ServerName":"Resolume","AppName":"Arena","Rate":60})") + .arg(absent_uuid) + .toUtf8(); + + auto doc = readJson(json); + REQUIRE(!doc.HasParseError()); + + Device::DeviceSettings s; + JSONWriter w{doc}; + w.writeTo(s); + + CHECK(s.name == "syphon-in"); + CHECK(s.protocol == absentProtocol()); + // No factory, so nothing could be parsed into a typed settings object... + CHECK(s.deviceSpecificSettings.isNull()); + // ...but the raw members are kept. + CHECK_FALSE(s.opaqueSettings.isEmpty()); + + // Saving from this build must reproduce what the authoring machine wrote, + // so the device still works when the document goes back to a build that + // has the protocol. + JSONReader rd; + rd.readFrom(s); + + auto out = readJson(rd.toByteArray()); + REQUIRE(!out.HasParseError()); + REQUIRE(out.IsObject()); + CHECK(out["Name"] == "syphon-in"); + CHECK(out["ServerName"] == "Resolume"); + CHECK(out["AppName"] == "Arena"); + CHECK(out["Rate"].GetInt() == 60); + CHECK(out.MemberCount() == doc.MemberCount()); + }); +} + +TEST_CASE("Preserved settings survive repeated round-trips", "[heterogeneous]") +{ + score::test::run_in_app([](const score::GUIApplicationContext&) { + // Machine B opening, re-saving and re-opening must not erode the payload: + // nested objects and arrays have to come back byte-identical too. + const QByteArray original + = QStringLiteral(R"({"Name":"cam","Protocol":"%1","Nested":{"a":[1,2,3],)" + R"("b":null},"Flag":true,"Ratio":0.5})") + .arg(absent_uuid) + .toUtf8(); + + QByteArray current = original; + for(int i = 0; i < 3; i++) + { + auto doc = readJson(current); + REQUIRE(!doc.HasParseError()); + + Device::DeviceSettings s; + JSONWriter{doc}.writeTo(s); + + JSONReader rd; + rd.readFrom(s); + current = rd.toByteArray(); + } + + auto first = readJson(original); + auto last = readJson(current); + REQUIRE(!last.HasParseError()); + CHECK(first == last); + }); +} + +TEST_CASE("An unavailable command can be reported instead of aborting", + "[heterogeneous]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + // instantiateUndoCommand() aborts (debug) or throws (release) on an unknown + // command, which is right for a local programming error but fatal when the + // command arrived from a peer running a different build. The network + // handlers use the checked form instead. + score::CommandData cmd; + cmd.parentKey = CommandGroupKey{"NoSuchCommandGroup"}; + cmd.commandKey = CommandKey{"NoSuchCommand"}; + + CHECK(ctx.instantiateUndoCommandIfAvailable(cmd) == nullptr); + }); +} + +TEST_CASE("Polymorphic DataStream payloads are length-delimited", "[heterogeneous]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + // This is what makes opaque preservation possible for processes and ports: + // readFromAbstract wraps each polymorphic object in its own QByteArray. + Process::ControlInlet inlet{QStringLiteral("ctl"), Id{0}, nullptr}; + + QByteArray b; + { + DataStreamReader r{&b}; + r.readFrom(static_cast(inlet)); + } + + DataStreamWriter w{b}; + QByteArray inner; + w.m_stream >> inner; + + CHECK(inner.size() > 0); + CHECK(w.m_stream.stream.atEnd()); // the blob was the whole message + + // And an unknown concrete key leaves the outer stream correctly positioned, + // so loadMissing receives the complete sub-payload and the caller can carry + // on reading the next object. + QByteArray two; + { + DataStreamReader r{&two}; + r.readFrom(static_cast(inlet)); + r.readFrom(static_cast(inlet)); + } + DataStreamWriter w2{two}; + QByteArray first, second; + w2.m_stream >> first; + w2.m_stream >> second; + CHECK(first == second); + CHECK(w2.m_stream.stream.atEnd()); + + (void)ctx; + }); +} + +TEST_CASE("A process with no factory keeps its identity and data", "[heterogeneous]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + auto& dctx = doc->context(); + + auto& procs = ctx.interfaces(); + REQUIRE_FALSE(procs.empty()); + + // Serialize a real process, then pretend we are a build that does not have + // its plug-in by renaming the factory it points at. That is exactly the + // Syphon / VST situation: the bytes were written by a richer build. + Process::ProcessModel* original{}; + for(auto& fac : procs) + { + original = fac.make(TimeVal::fromMsecs(1000), {}, Id{7}, + dctx, doc); + if(original) + break; + } + REQUIRE(original); + const auto realKey = original->concreteKey(); + const auto inlets = original->inlets().size(); + const auto outlets = original->outlets().size(); + + JSONReader r; + r.readFrom(*original); + auto authored = readJson(r.toByteArray()); + REQUIRE(authored.IsObject()); + REQUIRE(authored.HasMember("uuid")); + authored["uuid"].SetString(absent_uuid, authored.GetAllocator()); + + // What a plug-in of its own would have written. Without this the test + // asserts that no data survives no data. + auto& alloc = authored.GetAllocator(); + authored.AddMember("PluginState", "opaque-and-preserved", alloc); + rapidjson::Value nested{rapidjson::kObjectType}; + nested.AddMember("depth", 3, alloc); + nested.AddMember("ratio", 0.25, alloc); + authored.AddMember("Nested", nested, alloc); + + auto* loaded = deserialize_interface( + procs, JSONObject::Deserializer{authored}, dctx, doc); + REQUIRE(loaded); + + auto* opaque = dynamic_cast(loaded); + REQUIRE(opaque); + + // It reports the key of what it replaces, not one of its own: saving must + // write the original UUID or the process is lost for everybody. + CHECK(opaque->concreteKey() != realKey); + CHECK(opaque->concreteKey() + == UuidKey::fromString(QString{absent_uuid})); + + // Ports were rebuilt rather than swallowed, so cables to this process still + // resolve and its controls still hold values. + CHECK_FALSE(opaque->portsAreOpaque()); + CHECK(opaque->inlets().size() == inlets); + CHECK(opaque->outlets().size() == outlets); + + // And saving reproduces what the authoring machine wrote. + JSONReader out; + out.readFrom(*loaded); + auto reserialized = readJson(out.toByteArray()); + REQUIRE(reserialized.IsObject()); + CHECK(reserialized == authored); + + // rapidjson's operator== ignores member order, so it cannot see a payload + // that came back rearranged. Name the members that matter directly. + REQUIRE(reserialized.HasMember("PluginState")); + CHECK(reserialized["PluginState"] == "opaque-and-preserved"); + REQUIRE(reserialized.HasMember("Nested")); + CHECK(reserialized["Nested"]["depth"].GetInt() == 3); + CHECK(reserialized["Nested"]["ratio"].GetDouble() == 0.25); + + // Telling our members from the plug-in's is by name, so one that score + // gains but the list does not know about would be captured *and* written + // by the base: a duplicate key, copied again on every load. Counting + // catches that; comparing values does not, since the first of a duplicate + // pair reads back correctly. + CHECK(reserialized.MemberCount() == authored.MemberCount()); + + auto* again = deserialize_interface( + procs, JSONObject::Deserializer{reserialized}, dctx, doc); + REQUIRE(again); + JSONReader third; + third.readFrom(*again); + auto thrice = readJson(third.toByteArray()); + CHECK(thrice.MemberCount() == authored.MemberCount()); + }); +} + +TEST_CASE("A stand-in survives being written in the other format", "[heterogeneous]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + // A document read from .score is written to the binary format on every + // autosave, and moving an interval serialises its processes to the binary + // format and rebuilds them from those bytes. A payload that could only be + // written in the format it arrived in would be lost by dragging a box. + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + auto& dctx = doc->context(); + auto& procs = ctx.interfaces(); + + Process::ProcessModel* original{}; + for(auto& fac : procs) + { + original = fac.make(TimeVal::fromMsecs(1000), {}, Id{31}, + dctx, doc); + if(original) + break; + } + REQUIRE(original); + + JSONReader r; + r.readFrom(*original); + auto authored = readJson(r.toByteArray()); + authored["uuid"].SetString(absent_uuid, authored.GetAllocator()); + authored.AddMember("PluginState", "must survive both", authored.GetAllocator()); + + auto* fromJson = deserialize_interface( + procs, JSONObject::Deserializer{authored}, dctx, doc); + REQUIRE(dynamic_cast(fromJson)); + + // JSON in, binary out, binary in, JSON out. + QByteArray binary; + { + DataStreamReader w{&binary}; + w.readFrom(static_cast(*fromJson)); + } + + DataStreamWriter dw{binary}; + auto* fromBinary = deserialize_interface(procs, dw, dctx, doc); + REQUIRE(fromBinary); + auto* opaque = dynamic_cast(fromBinary); + REQUIRE(opaque); + CHECK(opaque->concreteKey() + == UuidKey::fromString(QString{absent_uuid})); + + JSONReader back; + back.readFrom(*fromBinary); + auto out = readJson(back.toByteArray()); + REQUIRE(out.IsObject()); + REQUIRE(out.HasMember("PluginState")); + CHECK(out["PluginState"] == "must survive both"); + }); +} + +TEST_CASE("A port with no factory keeps its id and data", "[heterogeneous]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + // VST and LV2 bring their own control port types along with the process, so + // a build without them meets unknown ports as well as unknown processes. + // This used to abort: writePorts had SCORE_ABORT as its failure path. + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + auto& dctx = doc->context(); + + auto& procs = ctx.interfaces(); + Process::ProcessModel* original{}; + for(auto& fac : procs) + { + auto* p = fac.make(TimeVal::fromMsecs(1000), {}, Id{21}, + dctx, doc); + if(p && !p->inlets().empty()) + { + original = p; + break; + } + } + REQUIRE(original); + const auto portId = original->inlets().front()->id(); + + JSONReader r; + r.readFrom(*original); + auto authored = readJson(r.toByteArray()); + REQUIRE(authored.HasMember("Inlets")); + REQUIRE(authored["Inlets"].IsArray()); + REQUIRE(authored["Inlets"].Size() > 0); + authored["Inlets"][0]["uuid"].SetString(absent_uuid, authored.GetAllocator()); + + auto* loaded = deserialize_interface( + procs, JSONObject::Deserializer{authored}, dctx, doc); + REQUIRE(loaded); + REQUIRE_FALSE(loaded->inlets().empty()); + + auto* opaque = dynamic_cast(loaded->inlets().front()); + REQUIRE(opaque); + + // The id is what cables resolve against, so a stand-in that renumbered the + // port would silently break every cable pointing at it. + CHECK(opaque->id() == portId); + CHECK(opaque->concreteKey() + == UuidKey::fromString(QString{absent_uuid})); + + JSONReader out; + out.readFrom(*loaded); + auto reserialized = readJson(out.toByteArray()); + CHECK(reserialized == authored); + }); +} + +TEST_CASE("A port with no factory survives the binary format too", "[heterogeneous]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + // Unlike a whole process, a port *can* be recovered from a binary payload: + // deserialize_interface gives each one its own length-delimited blob, so + // the tail after the key is exactly this port's data. + Process::ControlInlet inlet{QStringLiteral("ctl"), Id{42}, nullptr}; + + QByteArray one; + { + DataStreamReader r{&one}; + r.readFrom(static_cast(inlet)); + } + + // The blob is written as [quint32 length][16-byte key][data], so renaming + // the factory is a patch in place -- this is what a build that *has* the + // plug-in would have produced. + REQUIRE(one.size() > 20); + const auto absent = UuidKey::fromString(QString{absent_uuid}); + std::memcpy(one.data() + 4, &absent.impl(), 16); + + QByteArray ports; + { + QDataStream s{&ports, QIODevice::WriteOnly}; + s << (int32_t)1; + s.writeRawData(one.constData(), one.size()); + s << (int32_t)0; + } + + Process::Inlets ins; + Process::Outlets outs; + DataStreamWriter w{ports}; + Process::writePorts( + w, ctx.interfaces(), ins, outs, nullptr); + + REQUIRE(ins.size() == 1); + auto* opaque = dynamic_cast(ins.front()); + REQUIRE(opaque); + CHECK(opaque->id() == Id{42}); + CHECK(opaque->concreteKey() == absent); + + // And it writes back exactly what it was given. + QByteArray again; + { + DataStreamReader r{&again}; + r.readFrom(static_cast(*opaque)); + } + CHECK(again == one); + + qDeleteAll(ins); + qDeleteAll(outs); + }); +} + +TEST_CASE("A document plug-in with no factory keeps its data", "[heterogeneous]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + // Document plug-ins carry whole subsystems' state -- the network add-on + // keeps its groups in one -- and were dropped outright when absent, so + // saving wrote the document back without them. + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + + const QByteArray authored + = QStringLiteral(R"({"uuid":"%1","Groups":["all","band"],"Tempo":120})") + .arg(absent_uuid) + .toUtf8(); + + auto json = readJson(authored); + REQUIRE(!json.HasParseError()); + + auto& facs = ctx.interfaces(); + auto& dctx = const_cast(doc->context()); + auto* loaded + = deserialize_interface(facs, JSONObject::Deserializer{json}, dctx, doc); + REQUIRE(loaded); + + auto* opaque = dynamic_cast(loaded); + REQUIRE(opaque); + CHECK(opaque->concreteKey() + == UuidKey::fromString(QString{absent_uuid})); + + JSONReader out; + out.readFrom(static_cast(*opaque)); + auto reserialized = readJson(out.toByteArray()); + CHECK(reserialized == json); + + delete loaded; + }); +} + +TEST_CASE("An unclaimed process still gets a layer", "[heterogeneous]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + // The interval presenters build header and footer delegates from this + // without checking it, and LayerData asserts on it, so a process no factory + // claims must still resolve to something displayable. + auto& layers = ctx.interfaces(); + const auto unknown + = UuidKey::fromString(QString{absent_uuid}); + + // The fallback is registered and never wins a normal lookup: findDefaultFactory + // iterates an unordered map, so one that took part in matching would shadow + // real factories at random. + auto* fallback = layers.fallbackFactory(); + REQUIRE(fallback); + CHECK(fallback->isFallback()); + CHECK(layers.findDefaultFactory(unknown) == nullptr); + + // It is reached only through the process, and only for a stand-in. Ordinary + // processes without a layer keep resolving to nothing, so that they are not + // suddenly drawn in a slot. + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + + auto& procs = ctx.interfaces(); + Process::ProcessModel* plain{}; + for(auto& fac : procs) + { + plain = fac.make(TimeVal::fromMsecs(1000), {}, Id{11}, + doc->context(), doc); + if(plain) + break; + } + REQUIRE(plain); + if(auto* f = layers.findDefaultFactory(*plain)) + CHECK_FALSE(f->isFallback()); + }); +} + +TEST_CASE("The list of members owned by ProcessModel has not drifted", + "[heterogeneous]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + // OpaqueProcessModel tells its own members from the plug-in's by name. If + // score gains or renames one and this list is not updated, the member is + // written twice on save, or captured and then lost. Serialize a real + // process and check the two agree. + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + + auto& procs = ctx.interfaces(); + Process::ProcessModel* p{}; + for(auto& fac : procs) + { + p = fac.make(TimeVal::fromMsecs(1000), {}, Id{9}, + doc->context(), doc); + if(p) + break; + } + REQUIRE(p); + + JSONReader r; + r.readFrom(*p); + auto obj = readJson(r.toByteArray()); + REQUIRE(obj.IsObject()); + + const auto& base = Process::OpaqueProcessModel::baseMemberNames(); + for(const auto& name : base) + { + INFO("ProcessModel is expected to write " << name.toStdString()); + CHECK(obj.HasMember(name.toUtf8().constData())); + } + }); +} + +TEST_CASE("checkAndUpdateJson cannot see factories missing inside a present plugin", + "[heterogeneous]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + // A document whose Plugins array lists only plugins this build has, but + // whose content references a protocol UUID it does not have. This is the + // Syphon case: score_plugin_gfx is present on every platform, so the + // plugin-level check cannot express "Syphon is missing". + const QByteArray json + = QStringLiteral(R"({"Version":%1,"Plugins":[],)" + R"("Device":{"Name":"syphon-in","Protocol":"%2"}})") + .arg(ctx.applicationSettings.saveFormatVersion.value()) + .arg(absent_uuid) + .toUtf8(); + + auto doc = readJson(json); + REQUIRE(!doc.HasParseError()); + + // Nothing here can tell that Syphon is missing, and nothing could: the + // check works on plug-in keys, and score_plugin_gfx is present. Which is + // why the device itself has to preserve what it cannot parse. + const auto check = score::DocumentManager::checkAndUpdateJson(doc, ctx); + CHECK(check.loadable); + CHECK(check.missingPlugins.empty()); + }); +} + +TEST_CASE("A document naming a plug-in we lack still opens", "[heterogeneous]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + // Refusing outright made a document unopenable on any machine that did not + // have every plug-in it mentions, which is every machine once builds differ + // by platform. It opens now, and reports what is missing so the caller can + // say so. + const QByteArray json + = QStringLiteral(R"({"Version":%1,"Plugins":[{"Key":"%2","Version":1}]})") + .arg(ctx.applicationSettings.saveFormatVersion.value()) + .arg(absent_uuid) + .toUtf8(); + + auto doc = readJson(json); + REQUIRE(!doc.HasParseError()); + + const auto check = score::DocumentManager::checkAndUpdateJson(doc, ctx); + CHECK(check.loadable); + REQUIRE(check.missingPlugins.size() == 1); + CHECK(check.missingPlugins.front() + == UuidKey::fromString(QString{absent_uuid})); + }); +} + +TEST_CASE("A document from a newer score is still refused", "[heterogeneous]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + // The opposite case must keep failing: here the factory *is* found, and + // would read data written in a format it does not know. + const QByteArray json + = QStringLiteral(R"({"Version":%1,"Plugins":[]})") + .arg(ctx.applicationSettings.saveFormatVersion.value() + 1) + .toUtf8(); + + auto doc = readJson(json); + REQUIRE(!doc.HasParseError()); + CHECK_FALSE(score::DocumentManager::checkAndUpdateJson(doc, ctx).loadable); + }); +} + +TEST_CASE("A path does not resolve to an object of another type", "[heterogeneous]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + // A path names an object by position and name, and a stand-in keeps the id + // and name of what it replaces -- so a path written for the real type + // resolves to it. Commands then write through that pointer: + // Process::SetControlValue holds a Path and lives in a + // library every build has, so a peer without the plug-in that provided the + // port receives one and nothing else stands in the way. + // + // Provoked here with two ordinary types, since what is being checked is the + // resolution and not the stand-in. + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + auto& dctx = doc->context(); + + auto& model = doc->model().modelDelegate(); + auto& interval + = safe_cast(model).baseScenario().interval(); + + const Path right{interval}; + REQUIRE(right.try_find(dctx) == &interval); + REQUIRE_NOTHROW(right.find(dctx)); + + // The same position, asked for as something it is not. + const Path wrong{ + right.unsafePath(), Path::UnsafeDynamicCreation{}}; + + CHECK(wrong.try_find(dctx) == nullptr); + CHECK_THROWS(wrong.find(dctx)); + }); +} diff --git a/tests/unit/UriTest.cpp b/tests/unit/UriTest.cpp new file mode 100644 index 0000000000..ac6dae0816 --- /dev/null +++ b/tests/unit/UriTest.cpp @@ -0,0 +1,76 @@ +// How a path is stored in a document, and what it resolves back to. +// +// Documents move between machines, so a path is only useful if it says what it +// is relative to. score already wrote ":" and ":"; these pin +// the round-trip and the containment rule that decides which one applies. + +#include + +#include +#include + +#include + +TEST_CASE("A path under a directory is told from one merely spelled like it", "[uri]") +{ + using score::isUnder; + + CHECK(isUnder("/a/proj/sound.wav", "/a/proj")); + CHECK(isUnder("/a/proj/sub/sound.wav", "/a/proj")); + CHECK(isUnder("/a/proj", "/a/proj")); + + // The one that mattered: a prefix test says yes here, and the path then + // relativizes to something that resolves to a different file. + CHECK_FALSE(isUnder("/a/proj2/sound.wav", "/a/proj")); + CHECK_FALSE(isUnder("/a/projector/sound.wav", "/a/proj")); + + CHECK_FALSE(isUnder("/b/other/sound.wav", "/a/proj")); + CHECK_FALSE(isUnder("/a/proj/sound.wav", "")); + CHECK_FALSE(isUnder("", "/a/proj")); + + // A trailing slash on the root is not a different root. + CHECK(isUnder("/a/proj/sound.wav", "/a/proj/")); +} + +TEST_CASE("Stored paths round-trip through parse and toString", "[uri]") +{ + using score::Uri; + using score::UriScheme; + + const auto check = [](const QString& stored, UriScheme scheme, const QString& path) { + const auto uri = Uri::parse(stored); + INFO(stored.toStdString()); + CHECK(uri.scheme == scheme); + CHECK(uri.path == path); + CHECK(uri.toString() == stored); + }; + + check(":sounds/a.wav", UriScheme::Project, "sounds/a.wav"); + check(":Presets/b.wav", UriScheme::Library, "Presets/b.wav"); + check(":sha256-abc/c.wav", UriScheme::Cache, "sha256-abc/c.wav"); + check("sounds/a.wav", UriScheme::Relative, "sounds/a.wav"); +#if !defined(_WIN32) + check("/abs/a.wav", UriScheme::Absolute, "/abs/a.wav"); +#endif +} + +TEST_CASE("Only an absolute path fails to travel", "[uri]") +{ + using score::Uri; + using score::UriScheme; + + CHECK(Uri::parse(":a.wav").isPortable()); + CHECK(Uri::parse(":a.wav").isPortable()); + CHECK(Uri::parse(":a.wav").isPortable()); + CHECK(Uri::parse("a.wav").isPortable()); +#if !defined(_WIN32) + CHECK_FALSE(Uri::parse("/abs/a.wav").isPortable()); +#endif +} + +TEST_CASE("An empty path stays empty rather than becoming the current directory", + "[uri]") +{ + using score::Uri; + CHECK(Uri::parse("").toString().isEmpty()); +}