diff --git a/ci/common.deps.sh b/ci/common.deps.sh index 5f4d35e5cf..155c0ba871 100755 --- a/ci/common.deps.sh +++ b/ci/common.deps.sh @@ -63,7 +63,9 @@ clone_addon() { ) } -clone_addon https://github.com/ossia/iscore-addon-network +# Paired with the session work on the add-on: the two repositories change +# together, so CI has to build the branch that matches rather than master. +clone_addon https://github.com/ossia/iscore-addon-network remote-control clone_addon https://github.com/ossia/score-addon-synthimi clone_addon https://github.com/ossia/score-addon-jk clone_addon https://github.com/ossia/GBAP diff --git a/src/lib/CMakeLists.txt b/src/lib/CMakeLists.txt index cbb9e27a0c..4ed6996114 100755 --- a/src/lib/CMakeLists.txt +++ b/src/lib/CMakeLists.txt @@ -103,6 +103,7 @@ set(HEADERS "${CMAKE_CURRENT_SOURCE_DIR}/score/application/ApplicationComponents.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/application/ApplicationContext.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/application/ApplicationServices.hpp" + "${CMAKE_CURRENT_SOURCE_DIR}/score/application/ScriptEvaluator.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/application/GUIApplicationContext.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/command/AggregateCommand.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/command/Command.hpp" @@ -413,6 +414,7 @@ set(SRCS "${CMAKE_CURRENT_SOURCE_DIR}/score/application/ApplicationContext.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/application/ApplicationComponents.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/application/ApplicationServices.cpp" +"${CMAKE_CURRENT_SOURCE_DIR}/score/application/ScriptEvaluator.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/actions/Action.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/score/actions/ActionManager.cpp" diff --git a/src/lib/core/application/ApplicationSettings.cpp b/src/lib/core/application/ApplicationSettings.cpp index 0c89ef1c87..7e86cdfcd6 100644 --- a/src/lib/core/application/ApplicationSettings.cpp +++ b/src/lib/core/application/ApplicationSettings.cpp @@ -10,14 +10,56 @@ #include #include #include +#include + +#if defined(__EMSCRIPTEN__) +#include +#endif #include #include namespace score { +#if defined(__EMSCRIPTEN__) +namespace +{ +//! A page has no command line: ?network-join=host:port&network-terminal +//! reaches score as --network-join=host:port --network-terminal. +//! +//! Passed through without an allow-list, so a link can ask a page to do +//! whatever a command line could. +QStringList argumentsFromUrl() +{ + QStringList out; + const char* raw = emscripten_run_script_string("location.search"); + if(!raw || !*raw) + return out; + + QString search = QString::fromUtf8(raw); + if(search.startsWith('?')) + search.remove(0, 1); + + const QUrlQuery query{search}; + for(const auto& [key, value] : query.queryItems(QUrl::FullyDecoded)) + { + if(key.isEmpty()) + continue; + + out += value.isEmpty() ? QStringLiteral("--%1").arg(key) + : QStringLiteral("--%1=%2").arg(key, value); + } + return out; +} +} +#endif + void ApplicationSettings::parse(QStringList cargs, int& argc, char** argv) { +#if defined(__EMSCRIPTEN__) + cargs += argumentsFromUrl(); +#endif + arguments = cargs; opengl = false; diff --git a/src/lib/core/command/CommandStackSerialization.hpp b/src/lib/core/command/CommandStackSerialization.hpp index d3756ab440..913d2ca278 100644 --- a/src/lib/core/command/CommandStackSerialization.hpp +++ b/src/lib/core/command/CommandStackSerialization.hpp @@ -24,12 +24,15 @@ void loadCommandStack( stack.updateStack([&]() { stack.setSavedIndex(-1); + // A command we cannot read stops the history there rather than the load: + // what precedes it is consistent, what follows would undo against a state + // we never reached. bool ok = true; for(const auto& elt : undoStack) { - auto cmd = components.instantiateUndoCommand(elt); + auto cmd = components.instantiateUndoCommandIfAvailable(elt); - if(redo_fun(cmd)) + if(cmd && redo_fun(cmd)) { stack.undoable().push(cmd); } @@ -44,7 +47,9 @@ void loadCommandStack( { for(const auto& elt : redoStack) { - auto cmd = components.instantiateUndoCommand(elt); + auto cmd = components.instantiateUndoCommandIfAvailable(elt); + if(!cmd) + break; stack.redoable().push(cmd); } diff --git a/src/lib/core/document/Document.cpp b/src/lib/core/document/Document.cpp index be8bd62e49..e3791a0425 100644 --- a/src/lib/core/document/Document.cpp +++ b/src/lib/core/document/Document.cpp @@ -123,11 +123,26 @@ const std::vector& DocumentContext::pluginModels() const return document.model().pluginModels(); } +void Document::setScriptSink(ScriptSink s) +{ + m_scriptSink = std::move(s); +} + +const Document::ScriptSink& Document::scriptSink() const noexcept +{ + return m_scriptSink; +} + score::Environment& DocumentContext::environment() const noexcept { return document.environment(); } +score::DocumentRole DocumentContext::role() const noexcept +{ + return document.role(); +} + Document::Document( const QString& name, const Id& id, DocumentDelegateFactory& factory, QWidget* parentview, QObject* parent) diff --git a/src/lib/core/document/Document.hpp b/src/lib/core/document/Document.hpp index 4ae705a500..7f3333784c 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 @@ -13,6 +14,8 @@ #include #include + +#include #include #include @@ -85,6 +88,21 @@ class SCORE_LIB_BASE_EXPORT Document final : public QObject //! typically, once it is being edited through a session. void setEnvironment(std::unique_ptr env); + //! Where a script typed here should run. Set when this document is a view of + //! a score running elsewhere: the console then asks that machine instead of + //! answering from a document with no devices and no execution behind it. + //! `onResult` is called with what the other machine printed. + using ScriptSink + = std::function onResult)>; + void setScriptSink(ScriptSink s); + const ScriptSink& scriptSink() const noexcept; + + //! Whether this document may drive the hardware of the machine it is open on. + //! + //! Fixed at construction: devices connect while their plug-in deserializes, + //! so a document that must not claim them has to say so before it is read. + DocumentRole role() const noexcept { return m_role; } + DocumentModel& model() const noexcept { return *m_model; } DocumentPresenter* presenter() const noexcept { return m_presenter; } @@ -130,7 +148,8 @@ class SCORE_LIB_BASE_EXPORT Document final : public QObject Document( const QString& name, const QByteArray& data, SerializationIdentifier format, - DocumentDelegateFactory& type, QWidget* parentview, QObject* parent); + DocumentDelegateFactory& type, QWidget* parentview, QObject* parent, + DocumentRole role = DocumentRole::Local); // Restore Document( @@ -163,6 +182,8 @@ class SCORE_LIB_BASE_EXPORT Document final : public QObject DocumentContext m_context; mutable std::unique_ptr m_environment; + ScriptSink m_scriptSink; + DocumentRole m_role{DocumentRole::Local}; std::optional m_initialData{}; bool m_virgin{false}; // Used to check if we can safely close it diff --git a/src/lib/core/document/DocumentBuilder.cpp b/src/lib/core/document/DocumentBuilder.cpp index 24d6c569aa..9248ec0353 100644 --- a/src/lib/core/document/DocumentBuilder.cpp +++ b/src/lib/core/document/DocumentBuilder.cpp @@ -123,13 +123,14 @@ Document* DocumentBuilder::loadDocument( SCORE_LIB_BASE_EXPORT Document* DocumentBuilder::loadDocument( const score::GUIApplicationContext& ctx, QString filename, QByteArray data, - SerializationIdentifier format, DocumentDelegateFactory& doctype) + SerializationIdentifier format, DocumentDelegateFactory& doctype, DocumentRole role) { Document* doc = nullptr; auto& doclist = ctx.documents.documents(); try { - doc = new Document{filename, data, format, doctype, m_parentView, m_parentPresenter}; + doc = new Document{ + filename, data, format, doctype, m_parentView, m_parentPresenter, role}; for(auto& appPlug : ctx.guiApplicationPlugins()) { appPlug->on_loadedDocument(*doc); diff --git a/src/lib/core/document/DocumentBuilder.hpp b/src/lib/core/document/DocumentBuilder.hpp index 67390b64a6..a33bbaca44 100644 --- a/src/lib/core/document/DocumentBuilder.hpp +++ b/src/lib/core/document/DocumentBuilder.hpp @@ -1,4 +1,5 @@ #pragma once +#include #include #include @@ -36,7 +37,8 @@ class SCORE_LIB_BASE_EXPORT DocumentBuilder score::DocumentDelegateFactory& doctype); Document* loadDocument( const score::GUIApplicationContext& ctx, QString filename, QByteArray data, - SerializationIdentifier format, score::DocumentDelegateFactory& doctype); + SerializationIdentifier format, score::DocumentDelegateFactory& doctype, + DocumentRole role = DocumentRole::Local); Document* restoreDocument( const score::GUIApplicationContext& ctx, const score::RestorableDocument& doc, score::DocumentDelegateFactory& doctype); diff --git a/src/lib/core/document/DocumentSerialization.cpp b/src/lib/core/document/DocumentSerialization.cpp index 86fe1531ce..3f46a3f906 100644 --- a/src/lib/core/document/DocumentSerialization.cpp +++ b/src/lib/core/document/DocumentSerialization.cpp @@ -166,13 +166,17 @@ Document::Document( Document::Document( const QString& fileName, const QByteArray& data, SerializationIdentifier format, - DocumentDelegateFactory& factory, QWidget* parentview, QObject* parent) + DocumentDelegateFactory& factory, QWidget* parentview, QObject* parent, + DocumentRole role) : QObject{parent} , m_metadata{fileName} , m_commandStack{*this} , m_objectLocker{this} , m_context{*this} + , m_role{role} { + // Before loadModel: devices are instantiated as the device plug-in is read, + // and a terminal must not claim any of them. loadModel(fileName, data, format, factory); if(parentview) diff --git a/src/lib/core/presenter/DocumentManager.cpp b/src/lib/core/presenter/DocumentManager.cpp index 61b8fd57cf..fe95c89ac4 100644 --- a/src/lib/core/presenter/DocumentManager.cpp +++ b/src/lib/core/presenter/DocumentManager.cpp @@ -744,11 +744,8 @@ DocumentManager::Loadability DocumentManager::checkAndUpdateJson( auto it = local_plugins.find(plug.plugin); if(it == local_plugins.end()) { - // 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. + // Not fatal: it never gave the guarantee it looked like, since a + // factory missing inside a present plug-in is invisible to it. res.missingPlugins.push_back(plug.plugin); } else diff --git a/src/lib/core/view/QRecentFilesMenu.h b/src/lib/core/view/QRecentFilesMenu.h old mode 100755 new mode 100644 diff --git a/src/lib/score/application/ScriptEvaluator.cpp b/src/lib/score/application/ScriptEvaluator.cpp new file mode 100644 index 0000000000..5882ebd10b --- /dev/null +++ b/src/lib/score/application/ScriptEvaluator.cpp @@ -0,0 +1,12 @@ +#include + +namespace score +{ +ScriptEvaluator::~ScriptEvaluator() = default; + +ScriptEvaluator*& scriptEvaluator() noexcept +{ + static ScriptEvaluator* instance{}; + return instance; +} +} diff --git a/src/lib/score/application/ScriptEvaluator.hpp b/src/lib/score/application/ScriptEvaluator.hpp new file mode 100644 index 0000000000..050e9871a2 --- /dev/null +++ b/src/lib/score/application/ScriptEvaluator.hpp @@ -0,0 +1,37 @@ +#pragma once +#include + +#include + +namespace score +{ +struct DocumentContext; + +/** + * @brief Running a script on this machine, for somebody who is not here. + * + * A terminal's console edits a score that runs elsewhere. Commands happen to + * replicate, so `Score.createProcess` appears to work -- but everything that is + * not a command runs against the terminal's own document, where there are no + * devices, no execution and no hardware. `Score.device("x")` is null there and + * always will be. + * + * Rather than forwarding one call at a time, the script itself goes to the + * machine that can answer it. Which means the session layer has to run + * JavaScript, and it has no business knowing what JavaScript is: it looks up + * this interface, which the JS plug-in registers if it is loaded, and finds + * nothing if it is not. + */ +struct SCORE_LIB_BASE_EXPORT ScriptEvaluator +{ + virtual ~ScriptEvaluator(); + + //! Evaluate `code` against `ctx`. The returned string is what a console + //! would have printed -- the value, or the error. + virtual QString evaluate(const score::DocumentContext& ctx, const QString& code) = 0; +}; + +//! The evaluator for this process, or null when nothing registered one. +//! Set once at startup by whichever plug-in can actually run scripts. +SCORE_LIB_BASE_EXPORT ScriptEvaluator*& scriptEvaluator() noexcept; +} diff --git a/src/lib/score/document/DocumentContext.hpp b/src/lib/score/document/DocumentContext.hpp index 6545bca9b1..0b1f41b606 100644 --- a/src/lib/score/document/DocumentContext.hpp +++ b/src/lib/score/document/DocumentContext.hpp @@ -2,6 +2,7 @@ #include #include #include +#include #include class IdentifiedObjectAbstract; class QTimer; @@ -36,6 +37,9 @@ struct SCORE_LIB_BASE_EXPORT DocumentContext //! the score. Ask rather than assuming a path can be opened. score::Environment& environment() const noexcept; + //! Whether this document may claim the hardware of the machine it is open on. + score::DocumentRole role() const noexcept; + template T& model() const { diff --git a/src/lib/score/document/DocumentRole.hpp b/src/lib/score/document/DocumentRole.hpp new file mode 100644 index 0000000000..e5ccc1ae02 --- /dev/null +++ b/src/lib/score/document/DocumentRole.hpp @@ -0,0 +1,34 @@ +#pragma once + +namespace score +{ +/** + * @brief What a document is allowed to do to the machine it is open on. + * + * A score names hardware: sound cards, MIDI ports, cameras, OSC sockets, + * render windows. Opening one has always meant claiming all of it, because the + * machine holding the document was the machine running the show. + * + * That stops being true once a score can be edited from somewhere else. A + * laptop driving a score that plays on a headless box must not open that box's + * MIDI ports on itself, nor put its render window on the wrong screen -- and + * the browser it might be a tab in has none of those things to offer anyway. + * + * Distinct from score::Environment, which answers where the *files* are: a + * peer in a multiplayer session reads its score from another machine and still + * plays it on its own hardware. Both answers are needed, and they differ. + * + * Known before the document is read rather than set afterwards: devices + * connect while their plug-in deserializes, so anything decided later is + * decided too late. + */ +enum class DocumentRole +{ + //! Ordinary. Devices connect, execution runs here, windows open here. + Local, + + //! The score runs elsewhere. This copy is for editing and watching it: no + //! ports, no hardware, no rendering, no executor. + Terminal +}; +} diff --git a/src/lib/score/model/path/ObjectPath.hpp b/src/lib/score/model/path/ObjectPath.hpp index 000371b811..6f0a2f1350 100644 --- a/src/lib/score/model/path/ObjectPath.hpp +++ b/src/lib/score/model/path/ObjectPath.hpp @@ -112,10 +112,8 @@ class SCORE_LIB_BASE_EXPORT ObjectPath template T& find(const score::DocumentContext& ctx) const { - // 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. + // Checked: another type can stand where this path points, and safe_cast + // aborts in debug and casts blind in release. auto raw = m_cache.isNull() ? find_impl(ctx) : m_cache.data(); auto ptr = dynamic_cast::type*>(raw); if(!ptr) diff --git a/src/lib/score/serialization/DataStreamVisitor.cpp b/src/lib/score/serialization/DataStreamVisitor.cpp index c936671300..5738737b34 100644 --- a/src/lib/score/serialization/DataStreamVisitor.cpp +++ b/src/lib/score/serialization/DataStreamVisitor.cpp @@ -44,6 +44,15 @@ DataStreamWriter::DataStreamWriter(QIODevice* dev) { } +namespace score +{ +bool& readingUntrustedData() noexcept +{ + static thread_local bool b = false; + return b; +} +} + void DataStreamWriter::checkDelimiter() { int val{}; @@ -51,7 +60,8 @@ void DataStreamWriter::checkDelimiter() if(val != int32_t(0xDEADBEEF)) { - SCORE_BREAKPOINT; + if(!score::readingUntrustedData()) + SCORE_BREAKPOINT; throw std::runtime_error("Corrupt save file."); } } diff --git a/src/lib/score/serialization/DataStreamVisitor.hpp b/src/lib/score/serialization/DataStreamVisitor.hpp index 2bff99420b..6807d3bf0c 100644 --- a/src/lib/score/serialization/DataStreamVisitor.hpp +++ b/src/lib/score/serialization/DataStreamVisitor.hpp @@ -21,6 +21,10 @@ namespace score template class Entity; class ApplicationComponents; + +//! While set, a failed delimiter check throws without stopping in the +//! debugger: the breakpoint is for a corrupt file, not for a peer's bytes. +SCORE_LIB_BASE_EXPORT bool& readingUntrustedData() noexcept; } class SCORE_LIB_BASE_EXPORT DataStreamReader : public AbstractVisitor diff --git a/src/lib/score/serialization/OpaquePayload.cpp b/src/lib/score/serialization/OpaquePayload.cpp index 545ff1e63e..074f476bf3 100644 --- a/src/lib/score/serialization/OpaquePayload.cpp +++ b/src/lib/score/serialization/OpaquePayload.cpp @@ -6,14 +6,8 @@ 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. +// Which format is inside, since a JSON payload can end up in a binary blob: +// autosave and interval moves serialise to binary whatever the document is. constexpr auto foreign_json_marker = "\0score-opaque-json"; constexpr int foreign_json_marker_size = 18; constexpr auto foreign_binary_key = "$score-opaque-binary"; @@ -82,6 +76,35 @@ OpaquePayload OpaquePayload::fromDataStream(DataStream::Deserializer& vis) noexc return OpaquePayload{DataStream::type(), std::move(tail)}; } +QByteArray OpaquePayload::toBlob() const noexcept +{ + if(empty()) + return {}; + + QByteArray out; + QDataStream s{&out, QIODevice::WriteOnly}; + s << (int32_t)format << bytes; + return out; +} + +OpaquePayload OpaquePayload::fromBlob(const QByteArray& blob) noexcept +{ + if(blob.isEmpty()) + return {}; + + QDataStream s{blob}; + int32_t fmt{}; + QByteArray b; + s >> fmt >> b; + + if(s.status() != QDataStream::Ok) + return {}; + if(fmt != DataStream::type() && fmt != JSONObject::type()) + return {}; + + return OpaquePayload{fmt, std::move(b)}; +} + void OpaquePayload::write(const VisitorVariant& vis) const noexcept { if(empty()) diff --git a/src/lib/score/serialization/OpaquePayload.hpp b/src/lib/score/serialization/OpaquePayload.hpp index 092edd7eef..be3639c294 100644 --- a/src/lib/score/serialization/OpaquePayload.hpp +++ b/src/lib/score/serialization/OpaquePayload.hpp @@ -61,5 +61,11 @@ struct SCORE_LIB_BASE_EXPORT OpaquePayload //! Write it back into whichever format is being written now. void write(const VisitorVariant& vis) const noexcept; + + //! Self-contained bytes: the format tag, then the payload. For a payload in + //! the middle of a stream, where "the rest of the blob" is not an answer. + //! Always tagged, so a peer that has the factory cannot misread one. + QByteArray toBlob() const noexcept; + static OpaquePayload fromBlob(const QByteArray& blob) noexcept; }; } diff --git a/src/lib/score/tools/Environment.cpp b/src/lib/score/tools/Environment.cpp index cfc350e6ea..47a6196476 100644 --- a/src/lib/score/tools/Environment.cpp +++ b/src/lib/score/tools/Environment.cpp @@ -5,6 +5,11 @@ #include #include +#include +#include + +#include + namespace score { namespace @@ -20,6 +25,86 @@ void fail(const Environment::Callback& onFailed, QString w Environment::~Environment() = default; +qint64 maxInlineTransferBytes() noexcept +{ + return 8 * 1024 * 1024; +} + +namespace +{ +//! One walk in flight. Shared: a listing can arrive long after the call that +//! asked for it returned, and several directories are in flight at once. +struct RecursiveWalk +{ + Environment& env; + QString suffix; + Environment::Callback> onListed; + std::vector found; + int pending{}; + QPointer context; +}; + +void walkDone(const std::shared_ptr& st) +{ + // The directory that launched its children is only finished after they are + // all launched, so this cannot reach zero early on a local environment, where + // every listing answers before `list` returns. + if(--st->pending == 0 && st->onListed) + st->onListed(std::move(st->found)); +} + +void listDir(const std::shared_ptr& st, const Uri& dir, int depth); + +//! Counted before it is scheduled, so a walk that yields cannot look finished +//! in between. +void walkDir(const std::shared_ptr& st, const Uri& dir, int depth) +{ + st->pending++; + + if(st->context) + { + QMetaObject::invokeMethod( + st->context, [st, dir, depth] { listDir(st, dir, depth); }, + Qt::QueuedConnection); + return; + } + + listDir(st, dir, depth); +} + +void listDir(const std::shared_ptr& st, const Uri& dir, int depth) +{ + st->env.list( + dir, + [st, depth](std::vector entries) { + for(auto& e : entries) + { + if(e.directory) + { + if(depth > 0) + walkDir(st, e.uri, depth - 1); + } + else if(st->suffix.isEmpty() || e.name.endsWith(st->suffix, Qt::CaseInsensitive)) + { + st->found.push_back(std::move(e)); + } + } + walkDone(st); + }, + [st](const Environment::Failure&) { walkDone(st); }); +} +} + +void listRecursive( + Environment& env, const Uri& root, const QString& suffix, + Environment::Callback> onListed, int maxDepth, + QObject* context) +{ + auto st = std::make_shared( + env, suffix, std::move(onListed), std::vector{}, 0, context); + walkDir(st, root, maxDepth); +} + LocalEnvironment::LocalEnvironment(const DocumentContext& ctx) : m_ctx{ctx} { diff --git a/src/lib/score/tools/Environment.hpp b/src/lib/score/tools/Environment.hpp index 2e99d65d02..d6dba7419e 100644 --- a/src/lib/score/tools/Environment.hpp +++ b/src/lib/score/tools/Environment.hpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -78,6 +79,36 @@ class SCORE_LIB_BASE_EXPORT Environment = 0; }; +//! What a read or a write may carry in one piece. The channel that answers +//! these also carries the edits, so a whole video would stall them; anything +//! larger is refused rather than truncated, and needs a channel of its own. +SCORE_LIB_BASE_EXPORT qint64 maxInlineTransferBytes() noexcept; + +/** + * @brief Every file under `root` whose name ends with `suffix`. + * + * `list` answers for one directory, which is the right primitive and never what + * a caller looking for presets or library files wants. Walking it is a round + * trip per directory on another machine, so the result arrives once, at the + * end, rather than a listing at a time. + * + * Directories that cannot be listed are skipped rather than failing the walk: a + * library with one unreadable folder should still offer the rest. `maxDepth` + * bounds both the round trips and a symlink that points at its own parent. + * `onListed` is called exactly once. + * + * `context`, when given, is what the walk yields to between directories. A + * local environment answers a listing inline, so without it the whole tree is + * walked in one go on the calling thread -- which, for a library on the UI + * thread, is the dialog not appearing until the walk is done. With it, each + * directory is a separate turn of the event loop and the interface stays + * answerable. The walk stops if `context` goes away. + */ +SCORE_LIB_BASE_EXPORT void listRecursive( + Environment& env, const Uri& root, const QString& suffix, + Environment::Callback> onListed, int maxDepth = 8, + QObject* context = nullptr); + /** * @brief The files are here, on this machine. * diff --git a/src/lib/score/tools/File.cpp b/src/lib/score/tools/File.cpp index 40f42237d8..e15880f7bb 100644 --- a/src/lib/score/tools/File.cpp +++ b/src/lib/score/tools/File.cpp @@ -2,10 +2,12 @@ #include #include +#include #include #include +#include #include #include #include @@ -159,6 +161,83 @@ bool fileContains(QFile& f, std::string_view pattern) return fast_contains(std::string_view(g_file_search_buffer, sz), pattern); } +static QString sanitizeImportName(const QString& suggestedName) noexcept +{ + QString name = QFileInfo{suggestedName}.fileName(); + name.replace(QRegularExpression{"[^A-Za-z0-9._-]"}, "_"); + if(name.isEmpty()) + name = "import.bin"; + return name; +} + +QString importFile( + const QString& suggestedName, const QByteArray& data, Environment& env) noexcept +{ + const QString root = mediaCacheRoot(); + if(root.isEmpty()) + return {}; + + // Named by content: two machines that import the same file agree on the + // entry, so a second import is a hit rather than a duplicate, and the name + // keeps the original so a process is not called after a hash. + const auto digest + = QCryptographicHash::hash(data, QCryptographicHash::Sha1).toHex().left(16); + const QString entry = QString::fromUtf8(digest) + "-" + sanitizeImportName(suggestedName); + + if(!env.isLocal() && data.size() > maxInlineTransferBytes()) + { + // Better to refuse than to make a process that names a file the machine + // running it will never have. + qWarning() << "importFile: too large to send to the other machine:" << suggestedName + << data.size(); + return {}; + } + + QDir{}.mkpath(root); + const QString dest = root + "/" + entry; + + // Same content, same name: already here, and rewriting it would only race + // with something reading it. + if(!QFileInfo::exists(dest)) + { + QFile f{dest}; + if(!f.open(QIODevice::WriteOnly)) + return {}; + if(f.write(data) != data.size()) + return {}; + f.close(); + } + + if(!env.isLocal()) + { + env.write( + Uri{UriScheme::Cache, entry}, data, {}, [suggestedName](const QString& why) { + qWarning() << "importFile: the other machine did not take" << suggestedName << ':' + << why; + }); + } + + return dest; +} + +QString importPickedFile(const QString& chosenPath, Environment& env) noexcept +{ + if(chosenPath.isEmpty()) + return {}; + + // The machine that will open it is this one: it is already where it needs to + // be, and copying every file a user ever picks would be a copy for nothing. + if(env.isLocal()) + return chosenPath; + + QFile f{chosenPath}; + if(!f.open(QIODevice::ReadOnly)) + return {}; + + return importFile(QFileInfo{chosenPath}.fileName(), f.readAll(), env); +} + + #if defined(__EMSCRIPTEN__) static constexpr auto imports_dir = "/score/imports"; @@ -168,14 +247,6 @@ static QString ensureImportsDir() noexcept return imports_dir; } -static QString sanitizeImportName(const QString& suggestedName) noexcept -{ - QString name = QFileInfo{suggestedName}.fileName(); - name.replace(QRegularExpression{"[^A-Za-z0-9._-]"}, "_"); - if(name.isEmpty()) - name = "import.bin"; - return name; -} QString stageImportedFile(const QString& suggestedName, const QByteArray& data) noexcept { diff --git a/src/lib/score/tools/File.hpp b/src/lib/score/tools/File.hpp index 29a8c32997..d36587fa26 100644 --- a/src/lib/score/tools/File.hpp +++ b/src/lib/score/tools/File.hpp @@ -8,6 +8,7 @@ namespace score { +class Environment; // Used instead of QFileInfo // as it does a stat which can be super expensive @@ -94,6 +95,37 @@ inline QString readFileAsQString(QFile& f) noexcept SCORE_LIB_BASE_EXPORT bool fileContains(QFile& file, std::string_view pattern); +/** + * @brief Take in a file that has just been imported, and say where it now is. + * + * Dropping a file names it by a path, and a path is only meaningful on the + * machine holding it. That is fine while the score runs here; it is not when it + * runs on another machine, which cannot open `/score/imports/kick.wav` in a + * browser's memory or `/home/me/kick.wav` on a laptop. So the bytes go into the + * media cache, named by content -- the same media is one entry on every machine + * -- and, when the score is elsewhere, they are sent there too. + * + * Returns the path to use here, empty if the file could not be taken in. The + * returned path is under the cache, so relativizing it gives ":", which + * is what the document must store: it means the same thing on both machines. + */ +SCORE_LIB_BASE_EXPORT +QString importFile( + const QString& suggestedName, const QByteArray& data, + score::Environment& env) noexcept; + +/** + * @brief A file the user picked by name here, made available to whoever opens it. + * + * The same problem as a dropped file, arriving by the other route. When the + * score runs here the chosen path is already the answer; when it runs elsewhere + * the bytes have to go with it, so this reads them and imports them. + * + * Returns the path to use, or empty if the file could not be taken in. + */ +SCORE_LIB_BASE_EXPORT +QString importPickedFile(const QString& chosenPath, score::Environment& env) noexcept; + #if defined(__EMSCRIPTEN__) // Persist an imported file into a stable, session-lifetime MEMFS location // (/score/imports) so that the existing path-based media decoders can open and diff --git a/src/lib/score/tools/ThreadPool.cpp b/src/lib/score/tools/ThreadPool.cpp index f87e9c73f8..8942e45db1 100644 --- a/src/lib/score/tools/ThreadPool.cpp +++ b/src/lib/score/tools/ThreadPool.cpp @@ -15,19 +15,7 @@ ThreadPool::ThreadPool() { } ThreadPool::~ThreadPool() { - if(m_threads) - { - for(int i = 0; i < m_numThreads; i++) - { - m_threads[i].quit(); - } - for(int i = 0; i < m_numThreads; i++) - { - m_threads[i].wait(); - } - - m_threads.reset(); - } + shutdown(); } ThreadPool& ThreadPool::instance() @@ -39,6 +27,7 @@ ThreadPool& ThreadPool::instance() QThread* ThreadPool::acquireThread() { + std::lock_guard lock{m_mutex}; if(!m_threads) { m_numThreads = std::thread::hardware_concurrency(); @@ -46,48 +35,95 @@ QThread* ThreadPool::acquireThread() m_numThreads = m_numThreads / 2; if(m_numThreads < 2) m_numThreads = 2; + +#if defined(__EMSCRIPTEN__) + // A browser hands out a fixed pool of workers, and creating one past it has + // to return to the event loop -- which a thread being started from the main + // thread cannot do. It deadlocks instead. + if(m_numThreads > 2) + m_numThreads = 2; +#endif + m_threads = std::make_unique(m_numThreads); + m_started = std::make_unique(m_numThreads); + m_currentThread = 0; - for(int i = 0; i < m_numThreads; i++) + // The threads now outlive every release, so the application going away is + // what ends them. A worker whose event loop is still running once + // QCoreApplication is gone warns and has nothing left to run on. + if(auto* app = QCoreApplication::instance()) { -#if __has_include() - ::rlimit lim{0, 0}; - getrlimit(RLIMIT_STACK, &lim); - - if(lim.rlim_cur > m_threads[i].stackSize()) - m_threads[i].setStackSize(lim.rlim_cur); -#endif - m_threads[i].setObjectName(QString("ossia uitask %1").arg(i)); - m_threads[i].start(); - m_threads[i].setPriority(QThread::Priority::HighPriority); + QObject::connect(app, &QCoreApplication::aboutToQuit, app, [this] { shutdown(); }); + QObject::connect(app, &QObject::destroyed, app, [this] { shutdown(); }); } - m_currentThread = 0; } + // Started as they are handed out: starting all of them to answer one request + // costs the whole machine's worth of threads, and a 5 MB stack each, for a + // single waveform. QThread& t = m_threads[m_currentThread]; + if(!m_started[m_currentThread]) + { +#if __has_include() && !defined(__EMSCRIPTEN__) + ::rlimit lim{0, 0}; + getrlimit(RLIMIT_STACK, &lim); + + if(lim.rlim_cur > t.stackSize()) + t.setStackSize(lim.rlim_cur); +#endif + t.setObjectName(QString("ossia uitask %1").arg(m_currentThread)); + t.start(); + t.setPriority(QThread::Priority::HighPriority); + m_started[m_currentThread] = true; + } + m_currentThread++; m_currentThread = m_currentThread % m_numThreads; m_inFlight++; return &t; } -void ThreadPool::releaseThread() +int ThreadPool::startedThreadCount() const noexcept { - m_inFlight--; + std::lock_guard lock{m_mutex}; + if(!m_started) + return 0; + int n = 0; + for(int i = 0; i < m_numThreads; i++) + if(m_started[i]) + ++n; + return n; +} - if(m_inFlight == 0) - { - for(int i = 0; i < m_numThreads; i++) - { +void ThreadPool::shutdown() +{ + std::lock_guard lock{m_mutex}; + if(!m_threads) + return; + + for(int i = 0; i < m_numThreads; i++) + if(m_started[i]) m_threads[i].quit(); - } - for(int i = 0; i < m_numThreads; i++) - { + for(int i = 0; i < m_numThreads; i++) + if(m_started[i]) m_threads[i].wait(); - } - m_threads.reset(); - } + m_threads.reset(); + m_started.reset(); + m_currentThread = 0; +} + +void ThreadPool::releaseThread() +{ + std::lock_guard lock{m_mutex}; + if(m_inFlight > 0) + m_inFlight--; + + // The threads outlive the last release, and go away with the pool. Stopping + // them here meant joining them from whoever released last -- usually the UI + // thread, which then waited on work it was itself supposed to let run. In a + // browser the wait never ends: a worker only finishes starting once the main + // thread returns to the event loop. } TaskPool::TaskPool() diff --git a/src/lib/score/tools/ThreadPool.hpp b/src/lib/score/tools/ThreadPool.hpp index 8def9d8ec2..b22478661f 100644 --- a/src/lib/score/tools/ThreadPool.hpp +++ b/src/lib/score/tools/ThreadPool.hpp @@ -1,5 +1,6 @@ #pragma once #include +#include #include #include @@ -7,6 +8,7 @@ #include #include +#include #include namespace score { @@ -21,8 +23,20 @@ class SCORE_LIB_BASE_EXPORT ThreadPool QThread* acquireThread(); void releaseThread(); + //! How many threads have actually been started, as opposed to reserved. + //! Handing out one used to start all of them. + int startedThreadCount() const noexcept; + + //! Stop and join the workers. Called when the application goes away: doing it + //! on the last release meant joining them from the UI thread mid-session. + void shutdown(); + private: + // Acquired from the UI thread, released from whoever held the last reference + // to the work -- so every field below has two writers. + mutable std::mutex m_mutex; std::unique_ptr m_threads; + std::unique_ptr m_started; int m_numThreads{}; int m_currentThread{}; diff --git a/src/lib/score/tools/Uri.cpp b/src/lib/score/tools/Uri.cpp index a158948117..52257e64c9 100644 --- a/src/lib/score/tools/Uri.cpp +++ b/src/lib/score/tools/Uri.cpp @@ -9,6 +9,12 @@ namespace score { +const QString& remoteUriMimeType() noexcept +{ + static const QString t = QStringLiteral("application/x-score-remote-uri"); + return t; +} + namespace { constexpr auto project_token = ":"; diff --git a/src/lib/score/tools/Uri.hpp b/src/lib/score/tools/Uri.hpp index 0ff351be74..edde2fc04a 100644 --- a/src/lib/score/tools/Uri.hpp +++ b/src/lib/score/tools/Uri.hpp @@ -5,6 +5,10 @@ namespace score { +//! Drag-and-drop payload: newline-separated score::Uri. Not text/uri-list, +//! which every drop handler takes to mean a file it can open. +SCORE_LIB_BASE_EXPORT const QString& remoteUriMimeType() noexcept; + struct DocumentContext; //! How a path stored in a document is expressed. @@ -24,10 +28,7 @@ enum class UriScheme //! 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. + //! Content-addressed media: ":", naming a file by its hash. Cache }; @@ -65,9 +66,7 @@ struct SCORE_LIB_BASE_EXPORT Uri //! 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. +//! Whether `path` is `dir` itself or something under it. Not a prefix test, +//! which says yes for "/a/proj2" under "/a/proj". SCORE_LIB_BASE_EXPORT bool isUnder(const QString& path, const QString& dir) noexcept; } diff --git a/src/lib/score/widgets/FileDialog.hpp b/src/lib/score/widgets/FileDialog.hpp index 5555f14527..a1468d649c 100644 --- a/src/lib/score/widgets/FileDialog.hpp +++ b/src/lib/score/widgets/FileDialog.hpp @@ -1,7 +1,11 @@ #pragma once +#include +#include #include +#include #include +#include #include #include @@ -26,23 +30,29 @@ namespace score * that can be opened. It is not called if the user cancels. */ template -void openFileToImport(const QString& filters, F onPicked, QWidget* parent = nullptr) +void openFileToImport( + const score::DocumentContext& ctx, const QString& filters, F onPicked, + QWidget* parent = nullptr) { #if defined(__EMSCRIPTEN__) QFileDialog::getOpenFileContent( filters, - [onPicked = std::move(onPicked)]( + [&ctx, 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); + if(QString imported = score::importFile(name, data, ctx.environment()); + !imported.isEmpty()) + onPicked(imported); }); #else const QString fn = QFileDialog::getOpenFileName(parent, QObject::tr("Open File"), {}, filters); - if(!fn.isEmpty()) - onPicked(fn); + // A path chosen here names nothing on the machine running the score, so when + // that is not this one the bytes go with it. + if(QString imported = score::importPickedFile(fn, ctx.environment()); + !imported.isEmpty()) + onPicked(imported); #endif } @@ -54,15 +64,20 @@ void openFileToImport(const QString& filters, F onPicked, QWidget* parent = null */ template void openFilesToImport( - const QString& title, const QString& filters, F onPicked, QWidget* parent = nullptr) + const score::DocumentContext& ctx, const QString& title, const QString& filters, + F onPicked, QWidget* parent = nullptr) { #if defined(__EMSCRIPTEN__) - openFileToImport(filters, std::move(onPicked), parent); + openFileToImport(ctx, filters, std::move(onPicked), parent); #else const QStringList files = QFileDialog::getOpenFileNames(parent, title, QString{}, filters); - for(const auto& f : files) - onPicked(f); + for(const auto& fn : files) + { + if(QString imported = score::importPickedFile(fn, ctx.environment()); + !imported.isEmpty()) + onPicked(imported); + } #endif } diff --git a/src/lib/score/widgets/MessageBox.cpp b/src/lib/score/widgets/MessageBox.cpp index e60e44d8d6..88f5a157de 100644 --- a/src/lib/score/widgets/MessageBox.cpp +++ b/src/lib/score/widgets/MessageBox.cpp @@ -14,12 +14,8 @@ 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. +//! A modal box needs someone to dismiss it. `gui` does not promise one -- +//! MinimalApplication leaves it set with no window -- a main window does. [[maybe_unused]] bool canShowModal() noexcept { return score::AppContext().applicationSettings.gui diff --git a/src/plugins/score-lib-device/CMakeLists.txt b/src/plugins/score-lib-device/CMakeLists.txt index d1bae84c04..4298a0baee 100755 --- a/src/plugins/score-lib-device/CMakeLists.txt +++ b/src/plugins/score-lib-device/CMakeLists.txt @@ -20,6 +20,7 @@ set(HEADERS "${CMAKE_CURRENT_SOURCE_DIR}/Device/Node/NodeListMimeSerialization.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/Device/Protocol/DeviceInterface.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/Device/Protocol/DeviceSettings.hpp" +"${CMAKE_CURRENT_SOURCE_DIR}/Device/Protocol/DeviceCatalog.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/Device/Protocol/ProtocolFactoryInterface.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/Device/Protocol/ProtocolList.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/Device/Protocol/ProtocolSettingsWidget.hpp" @@ -40,6 +41,7 @@ set(SRCS "${CMAKE_CURRENT_SOURCE_DIR}/Device/Node/DeviceNodeSerialization.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Device/Protocol/DeviceInterface.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Device/Protocol/DeviceSettingsSerialization.cpp" +"${CMAKE_CURRENT_SOURCE_DIR}/Device/Protocol/DeviceCatalog.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Device/Protocol/ProtocolFactoryInterface.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Device/Protocol/ProtocolSettingsWidget.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Device/Widgets/DeviceModelProvider.cpp" diff --git a/src/plugins/score-lib-device/Device/Protocol/DeviceCatalog.cpp b/src/plugins/score-lib-device/Device/Protocol/DeviceCatalog.cpp new file mode 100644 index 0000000000..4103824df6 --- /dev/null +++ b/src/plugins/score-lib-device/Device/Protocol/DeviceCatalog.cpp @@ -0,0 +1,6 @@ +#include + +namespace Device +{ +DeviceCatalog::~DeviceCatalog() = default; +} diff --git a/src/plugins/score-lib-device/Device/Protocol/DeviceCatalog.hpp b/src/plugins/score-lib-device/Device/Protocol/DeviceCatalog.hpp new file mode 100644 index 0000000000..2e83d4be28 --- /dev/null +++ b/src/plugins/score-lib-device/Device/Protocol/DeviceCatalog.hpp @@ -0,0 +1,59 @@ +#pragma once +#include + +#include + +#include + +#include + +#include +#include + +namespace Device +{ +class ProtocolFactory; + +/** + * @brief What can be added to a document, and where the hardware is. + * + * "Add a device" has always meant this machine's protocols and this machine's + * hardware, because the score ran here. For a score that runs somewhere else it + * has to mean that machine's: its MIDI ports, its cameras, its protocols. + * Offering ours would offer something the score can never reach. + * + * Asynchronous for the same reason score::Environment is: the answer may have + * to come over a socket, and an interface that let callers wait would be one + * only the local implementation could satisfy. The dialog already fills its + * list from signals as enumerators discover things, so this fits how it works. + */ +class SCORE_LIB_DEVICE_EXPORT DeviceCatalog +{ +public: + virtual ~DeviceCatalog(); + + struct Protocol + { + UuidKey key; + QString name; + QString category; + + //! Whether this build has the factory. When it does not, there is no + //! settings widget to show -- the widget is C++ in a plug-in we do not + //! have -- so such a protocol can only be used through what it enumerates. + bool constructible{}; + }; + + //! (category, name, settings). The category is the enumerator's -- "Cameras", + //! "Screens" -- and is what the list groups by, so it stays a field rather + //! than being folded into the name. + using OnDevice = std::function; + + virtual std::vector protocols() const = 0; + + //! Hardware currently present, for one protocol. The callback may be invoked + //! any number of times, and later than this returns. + virtual void enumerate(const UuidKey& protocol, OnDevice) = 0; +}; +} diff --git a/src/plugins/score-lib-device/Device/Protocol/DeviceInterface.cpp b/src/plugins/score-lib-device/Device/Protocol/DeviceInterface.cpp index bf422e0ec8..a3a975e218 100644 --- a/src/plugins/score-lib-device/Device/Protocol/DeviceInterface.cpp +++ b/src/plugins/score-lib-device/Device/Protocol/DeviceInterface.cpp @@ -1072,4 +1072,9 @@ void releaseDevice( } } } + +DeviceKinds DeviceInterface::kinds() const noexcept +{ + return {}; +} } diff --git a/src/plugins/score-lib-device/Device/Protocol/DeviceInterface.hpp b/src/plugins/score-lib-device/Device/Protocol/DeviceInterface.hpp index f07c5a0198..89dd9e5541 100644 --- a/src/plugins/score-lib-device/Device/Protocol/DeviceInterface.hpp +++ b/src/plugins/score-lib-device/Device/Protocol/DeviceInterface.hpp @@ -60,6 +60,27 @@ enum DeviceLogging : int8_t LogEverything }; +/** + * @brief What a device can stand at the end of. + * + * The inspector's port combo boxes each ask one of these questions -- which + * devices can be a texture source, which are MIDI inputs -- and each used to + * ask it with a lambda that casts to a plug-in's own C++ type. That answer only + * exists where the device object does, so a peer editing a score that runs + * elsewhere had nothing to offer: no device objects, empty combo box. + * + * Asked of the device instead, it is a fact that can be reported over a wire + * by the machine that has it. + */ +enum class DeviceKind +{ + MidiIn = (1 << 0), + MidiOut = (1 << 1), + TextureIn = (1 << 2), + TextureOut = (1 << 3) +}; +Q_DECLARE_FLAGS(DeviceKinds, DeviceKind) + class SCORE_LIB_DEVICE_EXPORT DeviceInterface : public QObject , public Nano::Observer @@ -80,6 +101,9 @@ class SCORE_LIB_DEVICE_EXPORT DeviceInterface virtual void disconnect(); virtual bool reconnect() = 0; + + //! What this device can be plugged into. Empty unless it says otherwise. + virtual DeviceKinds kinds() const noexcept; virtual void recreate(const Device::Node&); // Argument is the node of the // device, used for recreation virtual bool connected() const; diff --git a/src/plugins/score-lib-device/Device/Protocol/DeviceSettings.hpp b/src/plugins/score-lib-device/Device/Protocol/DeviceSettings.hpp index 8c5297dcac..4d7e31e106 100644 --- a/src/plugins/score-lib-device/Device/Protocol/DeviceSettings.hpp +++ b/src/plugins/score-lib-device/Device/Protocol/DeviceSettings.hpp @@ -22,16 +22,9 @@ struct DeviceSettings 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. + //! The protocol's own settings, kept verbatim when this build has no factory + //! for `protocol` -- a score::OpaquePayload blob. Empty in the common case, + //! where the factory was found. QByteArray opaqueSettings; }; diff --git a/src/plugins/score-lib-device/Device/Protocol/DeviceSettingsSerialization.cpp b/src/plugins/score-lib-device/Device/Protocol/DeviceSettingsSerialization.cpp index e19c5c9770..0763cb4b84 100644 --- a/src/plugins/score-lib-device/Device/Protocol/DeviceSettingsSerialization.cpp +++ b/src/plugins/score-lib-device/Device/Protocol/DeviceSettingsSerialization.cpp @@ -12,76 +12,68 @@ #include #include #include +#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 +//! The members score itself owns; everything else in the object is the +//! protocol's. +const QStringList& scoreOwnedMembers() { - const auto& s = score::StringConstant(); - const std::string_view name{m.name.GetString(), m.name.GetStringLength()}; - return name == s.Name || name == s.Protocol; + static const QStringList members{ + QString::fromStdString(score::StringConstant().Name), + QString::fromStdString(score::StringConstant().Protocol)}; + return members; } -//! 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) +//! Read a payload the protocol wrote, in whichever format it was written: +//! a .score saved as .scorebin carries the protocol's JSON inside the blob. +//! +//! `n` is the device the payload belongs to. It is needed because the protocol +//! wrote its settings into the *same* object as the device's own Name and +//! Protocol, and some protocols -- evdev, for one -- call a setting of their +//! own "Name" too. Building the payload strips the members score owns, so that +//! writing it back cannot duplicate them; that also takes the protocol's Name +//! with it. Putting them back here hands the protocol the object it wrote. +QVariant makeSettings( + const Device::ProtocolFactory& prot, const score::OpaquePayload& payload, + const Device::DeviceSettings& n) { - if(!base.IsObject()) - return {}; - - rapidjson::StringBuffer buf; - JsonWriter w{buf}; - w.StartObject(); - for(const auto& m : base.GetObject()) + if(payload.format == DataStream::type()) { - if(isReservedMember(m)) - continue; - w.Key(m.name.GetString(), m.name.GetStringLength()); - m.value.Accept(w); + DataStream::Deserializer sub{payload.bytes}; + return prot.makeProtocolSpecificSettings(sub.toVariant()); } - 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()) + if(payload.format == JSONObject::type()) { - stream.Key(m.name.GetString(), m.name.GetStringLength()); - m.value.Accept(stream); + rapidjson::Document doc; + doc.Parse(payload.bytes.data(), payload.bytes.size()); + if(doc.HasParseError() || !doc.IsObject()) + return {}; + + auto& alloc = doc.GetAllocator(); + const auto restore = [&](const std::string& key, const QString& value) { + if(doc.HasMember(key)) + return; + const auto utf8 = value.toUtf8(); + doc.AddMember( + rapidjson::Value{key.data(), (rapidjson::SizeType)key.size(), alloc}.Move(), + rapidjson::Value{utf8.constData(), (rapidjson::SizeType)utf8.size(), alloc} + .Move(), + alloc); + }; + restore(score::StringConstant().Name, n.name); + + JSONObject::Deserializer sub{doc}; + return prot.makeProtocolSpecificSettings(sub.toVariant()); } + + return {}; } } @@ -90,28 +82,27 @@ SCORE_LIB_DEVICE_EXPORT void DataStreamReader::read(const Device::DeviceSettings { m_stream << n.name << n.protocol; - // TODO try to see if this pattern is refactorable with the similar thing - // usef for CurveSegmentData. - + // In its own blob, as readFromAbstract does for every other polymorphic + // kind: a reader without the factory can skip it by length. + score::OpaquePayload payload; auto& pl = components.interfaces(); - auto prot = pl.get(n.protocol); - if(prot) + if(auto prot = pl.get(n.protocol)) { - prot->serializeProtocolSpecificSettings(n.deviceSpecificSettings, this->toVariant()); + QByteArray bytes; + { + DataStream::Serializer sub{&bytes}; + prot->serializeProtocolSpecificSettings( + n.deviceSpecificSettings, sub.toVariant()); + } + payload = score::OpaquePayload{DataStream::type(), std::move(bytes)}; } - else if(!n.opaqueSettings.isEmpty()) + else { - // 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."; + // Nothing here understands them, so pass on exactly what we were given. + payload = score::OpaquePayload::fromBlob(n.opaqueSettings); } + m_stream << payload.toBlob(); insertDelimiter(); } @@ -120,29 +111,17 @@ SCORE_LIB_DEVICE_EXPORT void DataStreamWriter::write(Device::DeviceSettings& n) { m_stream >> n.name >> n.protocol; + QByteArray blob; + m_stream >> blob; + auto& pl = components.interfaces(); if(auto prot = pl.get(n.protocol)) - { - n.deviceSpecificSettings = prot->makeProtocolSpecificSettings(this->toVariant()); - checkDelimiter(); - return; - } + n.deviceSpecificSettings + = makeSettings(*prot, score::OpaquePayload::fromBlob(blob), n); + else + n.opaqueSettings = std::move(blob); - // 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); + checkDelimiter(); } template <> @@ -153,15 +132,11 @@ SCORE_LIB_DEVICE_EXPORT void JSONReader::read(const Device::DeviceSettings& n) obj[strings.Protocol] = n.protocol; auto& pl = components.interfaces(); - auto prot = pl.get(n.protocol); - if(prot) - { + if(auto prot = pl.get(n.protocol)) prot->serializeProtocolSpecificSettings(n.deviceSpecificSettings, this->toVariant()); - } else - { - writeProtocolMembers(stream, n.opaqueSettings); - } + score::OpaquePayload::fromBlob(n.opaqueSettings).write(this->toVariant()); + stream.EndObject(); } @@ -181,5 +156,5 @@ SCORE_LIB_DEVICE_EXPORT void JSONWriter::write(Device::DeviceSettings& n) } } - n.opaqueSettings = captureProtocolMembers(base); + n.opaqueSettings = score::OpaquePayload::fromJson(base, scoreOwnedMembers()).toBlob(); } diff --git a/src/plugins/score-lib-process/CMakeLists.txt b/src/plugins/score-lib-process/CMakeLists.txt index 4d90644e09..55616ef009 100755 --- a/src/plugins/score-lib-process/CMakeLists.txt +++ b/src/plugins/score-lib-process/CMakeLists.txt @@ -42,6 +42,7 @@ set(PROCESS_HDRS "${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/RemoteState.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/ProcessFactory.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Process/Process.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Process/OpaqueProcess.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Process/RemoteState.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/AudioPortComboBox.cpp b/src/plugins/score-lib-process/Process/Dataflow/AudioPortComboBox.cpp index 95ea2943ef..8112825905 100644 --- a/src/plugins/score-lib-process/Process/Dataflow/AudioPortComboBox.cpp +++ b/src/plugins/score-lib-process/Process/Dataflow/AudioPortComboBox.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -113,20 +114,30 @@ QComboBox* makeAddressCombo( return edit; } +std::optional droppedDeviceAddress( + const Device::FreeNodeList& nodes, Process::PortType type) noexcept +{ + if(nodes.empty() || type == Process::PortType::Message) + return std::nullopt; + + const auto& [address, node] = nodes.front(); + if(!node.template is() || address.device.isEmpty()) + return std::nullopt; + + return State::Address{address.device, {}}; +} + QComboBox* makeDeviceCombo( - std::function condition, Device::DeviceList& list, - const Process::Port& port, const score::DocumentContext& ctx, QWidget* parent) + Device::DeviceKind kind, Device::DeviceList& list, const Process::Port& port, + const score::DocumentContext& ctx, QWidget* parent) { using namespace Device; auto edit = new QComboBox{parent}; edit->addItem(""); - auto on_add = [condition, edit](Device::DeviceInterface* dev) { - if(condition(*dev)) - { - auto& set = dev->settings(); - edit->addItem(set.name); - } + auto on_add = [kind, edit](Device::DeviceInterface* dev) { + if(dev && dev->kinds().testFlag(kind)) + edit->addItem(dev->settings().name); }; list.apply([on_add](Device::DeviceInterface& dev) { on_add(&dev); }); QObject::connect(&list, &Device::DeviceList::deviceAdded, edit, on_add); @@ -138,6 +149,20 @@ QComboBox* makeDeviceCombo( edit->removeItem(idx); }); + // Nothing here is a device object when the score runs on another machine, so + // the names come from what that machine reported instead. + if(auto* plug = ctx.findPlugin()) + { + auto on_remote = [kind, edit, plug] { + for(const auto& name : plug->remoteDevicesOfKind(kind)) + if(edit->findText(name) < 0) + edit->addItem(name); + }; + on_remote(); + QObject::connect( + plug, &Explorer::DeviceDocumentPlugin::remoteKindsChanged, edit, on_remote); + } + edit->setCurrentText(port.address().address.device); QObject::connect( diff --git a/src/plugins/score-lib-process/Process/Dataflow/AudioPortComboBox.hpp b/src/plugins/score-lib-process/Process/Dataflow/AudioPortComboBox.hpp index a4b4f48588..af60c05767 100644 --- a/src/plugins/score-lib-process/Process/Dataflow/AudioPortComboBox.hpp +++ b/src/plugins/score-lib-process/Process/Dataflow/AudioPortComboBox.hpp @@ -2,8 +2,13 @@ #include #include +#include #include +#include + +#include + #include #include @@ -39,15 +44,24 @@ class SCORE_LIB_PROCESS_EXPORT AudioPortComboBox final : public QComboBox std::vector m_child; }; +//! The device a drop of these nodes names. Audio, MIDI and texture ports hold +//! a device and no path; a message port needs a parameter, which a device +//! is not. +SCORE_LIB_PROCESS_EXPORT +std::optional droppedDeviceAddress( + const Device::FreeNodeList& nodes, Process::PortType type) noexcept; + SCORE_LIB_PROCESS_EXPORT QComboBox* makeAddressCombo( State::Address root, const Device::Node& out_node, const Process::Port& port, const score::DocumentContext& ctx, QWidget* parent); +//! Devices that can stand at the other end of `port`. A kind rather than a +//! predicate on the object, since a terminal holds no device objects to ask. SCORE_LIB_PROCESS_EXPORT QComboBox* makeDeviceCombo( - std::function condition, Device::DeviceList& devices, - const Process::Port& port, const score::DocumentContext& ctx, QWidget* parent); + Device::DeviceKind kind, Device::DeviceList& devices, const Process::Port& port, + const score::DocumentContext& ctx, QWidget* parent); /* class SCORE_LIB_PROCESS_EXPORT MidiPortComboBox final : public QComboBox { diff --git a/src/plugins/score-lib-process/Process/Dataflow/ControlWidgets.hpp b/src/plugins/score-lib-process/Process/Dataflow/ControlWidgets.hpp index a4114d4a42..99074db103 100644 --- a/src/plugins/score-lib-process/Process/Dataflow/ControlWidgets.hpp +++ b/src/plugins/score-lib-process/Process/Dataflow/ControlWidgets.hpp @@ -1065,7 +1065,7 @@ struct FileChooser act->setIcon(QIcon(":/icons/search.png")); sl->setPlaceholderText(QObject::tr("Open File")); auto on_open = [=, &ctx, &inlet] { - score::openFileToImport(inlet.filters(), [=, &ctx](const QString& filename) { + score::openFileToImport(ctx, inlet.filters(), [=, &ctx](const QString& filename) { auto path = score::relativizeFilePath(filename, ctx); sl->setText(path); }); @@ -1098,7 +1098,7 @@ struct FileChooser auto bt = new score::QGraphicsTextButton{"Choose a file...", parent}; initWidgetProperties(inlet, *bt); auto on_open = [&inlet, &ctx] { - score::openFileToImport(inlet.filters(), [&inlet, &ctx](const QString& filename) { + score::openFileToImport(ctx, inlet.filters(), [&inlet, &ctx](const QString& filename) { auto path = score::relativizeFilePath(filename, ctx); CommandDispatcher<>{ctx.commandStack}.submit>( inlet, path.toStdString()); diff --git a/src/plugins/score-lib-process/Process/Drop/ProcessDropHandler.cpp b/src/plugins/score-lib-process/Process/Drop/ProcessDropHandler.cpp index 7a49865840..0aeceff7dd 100644 --- a/src/plugins/score-lib-process/Process/Drop/ProcessDropHandler.cpp +++ b/src/plugins/score-lib-process/Process/Drop/ProcessDropHandler.cpp @@ -1,5 +1,6 @@ #include +#include #include #include @@ -101,25 +102,34 @@ std::vector ProcessDropHandlerList::getDrop( initCaches(); + // Two reasons a dropped file cannot be used where it lies, and both end the + // same way -- take the bytes now, and hand everything downstream a path that + // will still mean something later. + // + // - In a browser, Qt deletes the file as soon as this callback returns. + // - When the score runs on another machine, a path here names nothing there: + // the process would be created on the host pointing at a file only this + // machine has. score::importFile sends the bytes along in that case. + // + // Rewriting the URLs covers BOTH the custom-drop path (which re-reads + // mime.urls() itself, e.g. Sound) and the file-extension path. #if defined(__EMSCRIPTEN__) - // On wasm, Qt writes dropped files to a transient /qt/tmp/ dir and deletes - // them immediately after this callback returns. Move each dropped file into - // score's persistent import area up front and rewrite the URLs, so BOTH the - // custom-drop path (which re-reads mime.urls() itself, e.g. Sound) and the - // file-extension path see a path that survives the drop and can be re-opened - // later (undo/redo, reload of the process). + const bool takeCopy = true; +#else + const bool takeCopy = !ctx.environment().isLocal(); +#endif + QMimeData stagedMime; bool didStage = false; + if(takeCopy) { QList newUrls; for(const QUrl& url : originalMime.urls()) { // On the JSPI build (which we use), Qt hands dropped files a - // "weblocalfile:/N/name" URL backed by the JS File object; QUrl::toLocalFile() - // is empty but QFile can read it through QWasmFileEngine. On non-asyncify - // builds files land at /qt/tmp instead. In both cases read the bytes here - // (while the source is alive) and copy them into a real, persistent MEMFS - // path so the fopen-based decoders (ffmpeg / sndfile) can open them later. + // "weblocalfile:/N/name" URL backed by the JS File object; + // QUrl::toLocalFile() is empty but QFile can read it through + // QWasmFileEngine. On non-asyncify builds files land at /qt/tmp instead. QString src; if(const QString local = url.toLocalFile(); !local.isEmpty() && QFileInfo::exists(local)) @@ -136,7 +146,8 @@ std::vector ProcessDropHandlerList::getDrop( QString name = url.fileName(); if(name.isEmpty()) name = QFileInfo{src}.fileName(); - if(QString staged = score::stageImportedFile(name, bytes); !staged.isEmpty()) + if(QString staged = score::importFile(name, bytes, ctx.environment()); + !staged.isEmpty()) { newUrls.push_back(QUrl::fromLocalFile(staged)); didStage = true; @@ -154,9 +165,6 @@ std::vector ProcessDropHandlerList::getDrop( } } const QMimeData& mime = didStage ? stagedMime : originalMime; -#else - const QMimeData& mime = originalMime; -#endif auto handleCustomDrop = [&](Process::ProcessDropHandler& handler) { auto before = res.size(); diff --git a/src/plugins/score-lib-process/Process/OpaqueProcess.cpp b/src/plugins/score-lib-process/Process/OpaqueProcess.cpp index 208340febc..40c66bc3ea 100644 --- a/src/plugins/score-lib-process/Process/OpaqueProcess.cpp +++ b/src/plugins/score-lib-process/Process/OpaqueProcess.cpp @@ -1,5 +1,7 @@ #include "OpaqueProcess.hpp" +#include + #include #include @@ -28,10 +30,7 @@ const QStringList& portMemberNames() noexcept 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. + // What score itself writes; everything else in the blob is the plug-in's. static const QStringList names{ QStringLiteral("uuid"), QStringLiteral("ObjectName"), QStringLiteral("id"), QStringLiteral("Metadata"), @@ -47,14 +46,52 @@ OpaqueProcessModel::OpaqueProcessModel( : 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. + // Ports when serialize_impl wrote them out; the payload takes the rest. + bool portsWritten{}; + vis.m_stream >> portsWritten; + if(portsWritten) + { + auto& pl = score::AppContext().interfaces(); + writePorts(vis, pl, m_inlets, m_outlets, this); + } + m_portsInPayload = !portsWritten; + 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, const TimeVal& duration, + const Id& id, QObject* parent) + : ProcessModel{duration, id, QStringLiteral("OpaqueProcess"), parent} + , m_key{key} + , m_incomplete{true} +{ + // No payload: creation data is not what the process would have written. + m_portsInPayload = false; + awaitingRemoteState().push_back(this); +} + +void OpaqueProcessModel::setState(const rapidjson::Value& serialized) +{ + if(!serialized.IsObject()) + return; + + // Only when absent: a stand-in can be filled in more than once. + if(m_inlets.empty() && m_outlets.empty() && serialized.HasMember("Inlets") + && serialized.HasMember("Outlets")) + { + JSONObject::Deserializer des{serialized}; + auto& pl = score::AppContext().interfaces(); + writePorts(des, pl, m_inlets, m_outlets, this); + m_portsInPayload = false; + } + + auto skip = baseMemberNames(); + if(!m_portsInPayload) + skip += portMemberNames(); + + m_payload = score::OpaquePayload::fromJson(serialized, skip); + m_incomplete = false; } OpaqueProcessModel::OpaqueProcessModel( @@ -83,12 +120,21 @@ 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. + if(vis.identifier == JSONObject::type()) { - // 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); + if(!m_portsInPayload) + readPorts(static_cast(vis.visitor), m_inlets, m_outlets); } + else if(vis.identifier == DataStream::type()) + { + auto& s = static_cast(vis.visitor); + s.m_stream << !m_portsInPayload; + if(!m_portsInPayload) + readPorts(s, m_inlets, m_outlets); + } + m_payload.write(vis); } @@ -115,15 +161,7 @@ QStringList OpaqueProcessModel::tags() const noexcept 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. + // TimeIndependent: nothing here can rescale a plug-in's data. return ProcessFlags::SupportsTemporal | ProcessFlags::TimeIndependent; } } diff --git a/src/plugins/score-lib-process/Process/OpaqueProcess.hpp b/src/plugins/score-lib-process/Process/OpaqueProcess.hpp index 67fec0dc46..eef268e0ca 100644 --- a/src/plugins/score-lib-process/Process/OpaqueProcess.hpp +++ b/src/plugins/score-lib-process/Process/OpaqueProcess.hpp @@ -7,6 +7,10 @@ #include +#include + +#include + namespace Process { /** @@ -48,6 +52,13 @@ class SCORE_LIB_PROCESS_EXPORT OpaqueProcessModel final : public ProcessModel const UuidKey& key, DataStream::Deserializer& vis, QObject* parent); OpaqueProcessModel( const UuidKey& key, JSONObject::Deserializer& vis, QObject* parent); + + //! For a command naming a process whose factory we do not have. Has no + //! state, and says so: see incomplete(). + OpaqueProcessModel( + const UuidKey& key, const TimeVal& duration, + const Id& id, QObject* parent); + ~OpaqueProcessModel() override; //! The key of the process we replace, not one of our own. @@ -66,6 +77,15 @@ class SCORE_LIB_PROCESS_EXPORT OpaqueProcessModel final : public ProcessModel //! this process has none of its own. bool portsAreOpaque() const noexcept { return m_portsInPayload; } + //! True when the state was never received: created by a command rather than + //! read from a document, so its emptiness is not authoritative. + bool incomplete() const noexcept { return m_incomplete; } + + //! Give it the state it never had, as its author serialized it. Rebuilds the + //! ports when the format allows, exactly as loading one from a document does. + void setState(const rapidjson::Value& serialized); + + //! 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; @@ -77,6 +97,7 @@ class SCORE_LIB_PROCESS_EXPORT OpaqueProcessModel final : public ProcessModel // when those could be rebuilt. score::OpaquePayload m_payload; bool m_portsInPayload{true}; + bool m_incomplete{false}; }; /** diff --git a/src/plugins/score-lib-process/Process/ProcessFactory.cpp b/src/plugins/score-lib-process/Process/ProcessFactory.cpp index 663ffc170c..5f2258ebac 100644 --- a/src/plugins/score-lib-process/Process/ProcessFactory.cpp +++ b/src/plugins/score-lib-process/Process/ProcessFactory.cpp @@ -183,6 +183,13 @@ ProcessFactoryList::object_type* ProcessFactoryList::loadMissing( return nullptr; } +ProcessFactoryList::object_type* ProcessFactoryList::makeMissing( + const UuidKey& key, const TimeVal& duration, + const Id& id, QObject* parent) const +{ + return new OpaqueProcessModel{key, duration, id, parent}; +} + LayerFactory* LayerFactoryList::findDefaultFactory(const ProcessModel& proc) const { if(auto* fac = findDefaultFactory(proc.concreteKey())) diff --git a/src/plugins/score-lib-process/Process/ProcessList.hpp b/src/plugins/score-lib-process/Process/ProcessList.hpp index 70d64d42b1..7ded4eac4d 100644 --- a/src/plugins/score-lib-process/Process/ProcessList.hpp +++ b/src/plugins/score-lib-process/Process/ProcessList.hpp @@ -15,6 +15,17 @@ class SCORE_LIB_PROCESS_EXPORT ProcessFactoryList final object_type* loadMissing( const UuidKey& key, const VisitorVariant& vis, const score::DocumentContext& ctx, QObject* parent) const; + + //! The creation counterpart of loadMissing: a command asks for a process + //! whose factory this build does not have. + //! + //! Deserialization can fall back because the bytes are there to keep; + //! creation has nothing to keep, so this returns a stand-in that reports + //! itself incomplete. Without it the command asserts and, in a session, that + //! aborts or throws on every peer built differently from the sender. + object_type* makeMissing( + const UuidKey& key, const TimeVal& duration, + const Id& id, QObject* parent) const; }; class SCORE_LIB_PROCESS_EXPORT LayerFactoryList final diff --git a/src/plugins/score-lib-process/Process/RemoteState.cpp b/src/plugins/score-lib-process/Process/RemoteState.cpp new file mode 100644 index 0000000000..5463134f5b --- /dev/null +++ b/src/plugins/score-lib-process/Process/RemoteState.cpp @@ -0,0 +1,10 @@ +#include + +namespace Process +{ +std::vector>& awaitingRemoteState() noexcept +{ + static std::vector> pending; + return pending; +} +} diff --git a/src/plugins/score-lib-process/Process/RemoteState.hpp b/src/plugins/score-lib-process/Process/RemoteState.hpp new file mode 100644 index 0000000000..fe99628b3c --- /dev/null +++ b/src/plugins/score-lib-process/Process/RemoteState.hpp @@ -0,0 +1,30 @@ +#pragma once +#include + +#include + +#include + +namespace Process +{ +class ProcessModel; + +/** + * @brief Objects a command created here whose real content is on another + * machine. + * + * A command carries what a factory would be *given*, not what the object would + * *write*, and what it is given can describe a world this machine is not in. A + * library entry for a shader carries the path of the file it was scanned from: + * the factory may well exist here and still produce an empty process, because + * the file is over there. A missing factory is only the loudest case of the + * same thing. + * + * So this is not "stand-ins": it is everything whose state has to come from the + * peer that issued the command. Whoever replicated the command is responsible + * for asking; this is how it finds them without walking the document after + * every edit, which at a control's update rate is not affordable. + */ +SCORE_LIB_PROCESS_EXPORT std::vector>& +awaitingRemoteState() noexcept; +} diff --git a/src/plugins/score-plugin-dataflow/Dataflow/MidiInletItem.cpp b/src/plugins/score-plugin-dataflow/Dataflow/MidiInletItem.cpp index 909744de3a..b81acce26f 100644 --- a/src/plugins/score-plugin-dataflow/Dataflow/MidiInletItem.cpp +++ b/src/plugins/score-plugin-dataflow/Dataflow/MidiInletItem.cpp @@ -25,20 +25,11 @@ void MidiInletFactory::setupInletInspector( auto& device = *ctx.findPlugin(); - auto cond = [](Device::DeviceInterface& dev) { - auto& set = dev.settings(); - if(set.protocol == midi_uuid) - { - const auto& midi_set - = set.deviceSpecificSettings.value(); - if(midi_set.io == Protocols::MIDISpecificSettings::IO::In) - return true; - } - return false; - }; + lay.addRow( - port.name(), Process::makeDeviceCombo(cond, device.list(), port, ctx, parent)); + port.name(), Process::makeDeviceCombo( + Device::DeviceKind::MidiIn, device.list(), port, ctx, parent)); #endif } diff --git a/src/plugins/score-plugin-dataflow/Dataflow/MidiOutletItem.cpp b/src/plugins/score-plugin-dataflow/Dataflow/MidiOutletItem.cpp index e07945428e..dbd0dba8ed 100644 --- a/src/plugins/score-plugin-dataflow/Dataflow/MidiOutletItem.cpp +++ b/src/plugins/score-plugin-dataflow/Dataflow/MidiOutletItem.cpp @@ -24,20 +24,11 @@ void MidiOutletFactory::setupOutletInspector( = Protocols::MIDIOutputProtocolFactory::static_concreteKey(); auto& device = *ctx.findPlugin(); - auto cond = [](Device::DeviceInterface& dev) { - auto& set = dev.settings(); - if(set.protocol == midi_uuid) - { - const auto& midi_set - = set.deviceSpecificSettings.value(); - if(midi_set.io == Protocols::MIDISpecificSettings::IO::Out) - return true; - } - return false; - }; + lay.addRow( - port.name(), Process::makeDeviceCombo(cond, device.list(), port, ctx, parent)); + port.name(), Process::makeDeviceCombo( + Device::DeviceKind::MidiOut, device.list(), port, ctx, parent)); #endif } } diff --git a/src/plugins/score-plugin-dataflow/Dataflow/PortItem.cpp b/src/plugins/score-plugin-dataflow/Dataflow/PortItem.cpp index 2ced8f180b..8e06e0ef10 100644 --- a/src/plugins/score-plugin-dataflow/Dataflow/PortItem.cpp +++ b/src/plugins/score-plugin-dataflow/Dataflow/PortItem.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include @@ -209,7 +210,12 @@ void AutomatablePortItem::dropEvent(QGraphicsSceneDragDropEvent* event) auto addr = nl[0].second.target(); if(!addr) + { + if(auto dev = Process::droppedDeviceAddress(nl, m_port.type())) + disp.submit(new Process::ChangePortAddress{ + m_port, State::AddressAccessor{*dev, {}}}); return; + } disp.submit(new Process::ChangePortSettings{ m_port, {State::AddressAccessor{nl[0].first}, std::move(*addr)}}); } diff --git a/src/plugins/score-plugin-deviceexplorer/Explorer/DocumentPlugin/DeviceDocumentPlugin.cpp b/src/plugins/score-plugin-deviceexplorer/Explorer/DocumentPlugin/DeviceDocumentPlugin.cpp index e06be7716b..9ca44083ba 100644 --- a/src/plugins/score-plugin-deviceexplorer/Explorer/DocumentPlugin/DeviceDocumentPlugin.cpp +++ b/src/plugins/score-plugin-deviceexplorer/Explorer/DocumentPlugin/DeviceDocumentPlugin.cpp @@ -180,12 +180,26 @@ void DeviceDocumentPlugin::timerEvent(QTimerEvent* event) Device::Node DeviceDocumentPlugin::createDeviceFromNode(const Device::Node& node) { + if(m_context.role() == score::DocumentRole::Terminal) + return node; + try { auto& fact = m_context.app.interfaces(); // Instantiate a real device. auto proto = fact.get(node.get().protocol); + if(!proto) + { + // Nothing here can make it. Ordinary rather than exceptional: a session + // adds devices from whichever machine the user is typing at, and a + // document routinely names protocols a given build has no factory for. + // The node is kept, as loading one does -- what is not acceptable is + // calling through the null. + qWarning() << "No protocol for device" << node.get().name; + return node; + } + auto newdev = proto->makeDevice(node.get(), *this, context()); @@ -220,6 +234,13 @@ Device::Node DeviceDocumentPlugin::createDeviceFromNode(const Device::Node& node std::optional DeviceDocumentPlugin::loadDeviceFromNode(const Device::Node& node) { + // The score runs on another machine and its devices belong to it: making + // them here would open that machine's ports, claim its MIDI and cameras, and + // put its render windows on this screen. The node stays in the tree with + // nothing behind it, which is the same shape as a protocol we do not have. + if(m_context.role() == score::DocumentRole::Terminal) + return {}; + try { // Instantiate a real device. @@ -262,6 +283,53 @@ DeviceDocumentPlugin::loadDeviceFromNode(const Device::Node& node) return {}; } +std::optional +DeviceDocumentPlugin::remoteConnected(const QString& device) const noexcept +{ + if(auto it = m_remoteConnected.find(device); it != m_remoteConnected.end()) + return it->second; + return {}; +} + +void DeviceDocumentPlugin::setRemoteConnected(const QString& device, bool connected) +{ + m_remoteConnected[device] = connected; + + // The explorer draws the state, so it has to be told the row changed. + if(m_explorer) + { + auto& root = m_rootNode; + for(int i = 0; i < root.childCount(); i++) + { + const auto& n = root.childAt(i); + if(n.is() + && n.get().name == device) + { + const auto idx = m_explorer->index(i, 0, QModelIndex{}); + m_explorer->dataChanged(idx, idx); + break; + } + } + } +} + +std::vector +DeviceDocumentPlugin::remoteDevicesOfKind(Device::DeviceKind kind) const +{ + std::vector out; + for(const auto& [name, kinds] : m_remoteKinds) + if(kinds.testFlag(kind)) + out.push_back(name); + return out; +} + +void DeviceDocumentPlugin::setRemoteKinds( + const QString& device, Device::DeviceKinds kinds) +{ + m_remoteKinds[device] = kinds; + remoteKindsChanged(device); +} + void DeviceDocumentPlugin::setConnection(bool b) { if(b) @@ -438,6 +506,8 @@ void DeviceDocumentPlugin::on_valueUpdated( { ossia::qt::run_async(this, [this, aa = State::AddressAccessor{addr}, v] { updateProxy.updateLocalValue(aa, v); + if(m_valueObserver) + m_valueObserver(aa.address, v); }); } diff --git a/src/plugins/score-plugin-deviceexplorer/Explorer/DocumentPlugin/DeviceDocumentPlugin.hpp b/src/plugins/score-plugin-deviceexplorer/Explorer/DocumentPlugin/DeviceDocumentPlugin.hpp index c2b85985fb..e156c9f992 100644 --- a/src/plugins/score-plugin-deviceexplorer/Explorer/DocumentPlugin/DeviceDocumentPlugin.hpp +++ b/src/plugins/score-plugin-deviceexplorer/Explorer/DocumentPlugin/DeviceDocumentPlugin.hpp @@ -1,5 +1,7 @@ #pragma once #include +#include +#include #include #include @@ -12,6 +14,7 @@ #include +#include #include #include @@ -83,11 +86,54 @@ class SCORE_PLUGIN_DEVICEEXPLORER_EXPORT DeviceDocumentPlugin final return m_asioContext; } + //! What may be added, and whose hardware is offered. Null for an ordinary + //! document, where it is this machine's. + Device::DeviceCatalog* catalog() const noexcept { return m_catalog; } + void setCatalog(Device::DeviceCatalog* c) noexcept { m_catalog = c; } + + //! Whether a device is connected, as reported by the machine that has it. + //! Empty for an ordinary document, where the device itself is the answer. + std::optional remoteConnected(const QString& device) const noexcept; + void setRemoteConnected(const QString& device, bool connected); + + //! Devices the machine running the score reported as being of this kind. + //! Empty for an ordinary document, where the objects can be asked directly. + std::vector remoteDevicesOfKind(Device::DeviceKind kind) const; + void setRemoteKinds(const QString& device, Device::DeviceKinds kinds); + + //! Where a value edited here goes when the device is on another machine. + //! Unset for an ordinary document, which has the device to send to. + using ValueSink = std::function; + void setValueSink(ValueSink s) { m_valueSink = std::move(s); } + const ValueSink& valueSink() const noexcept { return m_valueSink; } + + //! Told what one of this machine's devices reported, as opposed to what + //! somebody asked for. A peer that does not run the score has no device to + //! hear it from, so this is the only way it learns a value moved. + void setValueObserver(ValueSink s) { m_valueObserver = std::move(s); } + + //! The peer reported what a device is; arrives after the join. + void remoteKindsChanged(const QString& device) + E_SIGNAL(SCORE_PLUGIN_DEVICEEXPLORER_EXPORT, remoteKindsChanged, device) + + //! A device's own tree changed here: refreshed, or discovered something. + //! What is inside a device is known only where the device is. + void deviceTreeChanged(const QString& device) + E_SIGNAL(SCORE_PLUGIN_DEVICEEXPLORER_EXPORT, deviceTreeChanged, device) + + //! One of this machine's devices reported a value. Called by the device + //! itself, through the callback installed when it is opened. + void on_valueUpdated(const State::Address& addr, const ossia::value& v); + private: void initDevice(Device::DeviceInterface&); - void on_valueUpdated(const State::Address& addr, const ossia::value& v); Device::Node m_rootNode; + Device::DeviceCatalog* m_catalog{}; + ValueSink m_valueSink; + ValueSink m_valueObserver; + ossia::hash_map m_remoteConnected; + ossia::hash_map m_remoteKinds; Device::DeviceList m_list; std::atomic_bool m_processMessages{}; std::thread m_asioThread; diff --git a/src/plugins/score-plugin-deviceexplorer/Explorer/DocumentPlugin/DeviceDocumentPluginSerialization.cpp b/src/plugins/score-plugin-deviceexplorer/Explorer/DocumentPlugin/DeviceDocumentPluginSerialization.cpp index acda2f7146..262a1f5dce 100644 --- a/src/plugins/score-plugin-deviceexplorer/Explorer/DocumentPlugin/DeviceDocumentPluginSerialization.cpp +++ b/src/plugins/score-plugin-deviceexplorer/Explorer/DocumentPlugin/DeviceDocumentPluginSerialization.cpp @@ -38,8 +38,13 @@ void JSONReader::read(const Explorer::DeviceDocumentPlugin& plug) SCORE_ASSERT(node.is()); const Device::DeviceSettings& dev = node.get(); auto actual = plug.list().findDevice(dev.name); - SCORE_ASSERT(actual); - if(actual->capabilities().canSerialize) + + // No implementation: the protocol is one this build does not have, or the + // document belongs to another machine. Its addresses exist only here, so + // they are written out -- dropping them would quietly empty the device for + // every machine that does have the protocol. canSerialize is a statement + // about a live device that can be asked again on load, and there is none. + if(!actual || actual->capabilities().canSerialize) { this->readFrom(node); } diff --git a/src/plugins/score-plugin-deviceexplorer/Explorer/DocumentPlugin/NodeUpdateProxy.cpp b/src/plugins/score-plugin-deviceexplorer/Explorer/DocumentPlugin/NodeUpdateProxy.cpp index 0fb6fab051..cbfd3696ca 100644 --- a/src/plugins/score-plugin-deviceexplorer/Explorer/DocumentPlugin/NodeUpdateProxy.cpp +++ b/src/plugins/score-plugin-deviceexplorer/Explorer/DocumentPlugin/NodeUpdateProxy.cpp @@ -348,6 +348,10 @@ void NodeUpdateProxy::updateRemoteValue( // Update in the device implementation dev->sendMessage(addr, val); } + else if(const auto& sink = devModel.valueSink()) + { + sink(addr, val); + } } ossia::value NodeUpdateProxy::refreshRemoteValue(const State::Address& addr) const diff --git a/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/DeviceExplorerModel.cpp b/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/DeviceExplorerModel.cpp index dcaaa40467..c55b413ce6 100644 --- a/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/DeviceExplorerModel.cpp +++ b/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/DeviceExplorerModel.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -72,10 +73,51 @@ DeviceExplorerModel::DeviceExplorerModel(DeviceDocumentPlugin& plug, QObject* pa { this->setObjectName("DeviceExplorerModel"); + // Structural changes only: a value arriving is not the tree changing, and + // re-sending a whole device on every value would be most of the socket. + auto changed = [this](const QModelIndex& parent, int, int) { + if(const auto name = deviceNameOf(parent); !name.isEmpty()) + m_devicePlugin.deviceTreeChanged(name); + }; + connect(this, &QAbstractItemModel::rowsInserted, this, changed); + connect(this, &QAbstractItemModel::rowsRemoved, this, changed); + + // A device appearing at the root. Its command carries the node as the + // machine that asked for it knew it, and a machine that cannot make the + // device knows nothing of its tree: what a mouse or a MIDI port turns out to + // contain is discovered here, when it is opened. So the tree still has to be + // announced, or the peer that asked sees a device with nothing under it. + connect( + this, &QAbstractItemModel::rowsInserted, this, + [this](const QModelIndex& parent, int first, int last) { + if(parent.isValid()) + return; + + for(int row = first; row <= last && row < rootNode().childCount(); row++) + { + const auto& n = rootNode().childAt(row); + if(n.is()) + m_devicePlugin.deviceTreeChanged(n.get().name); + } + }); + beginResetModel(); endResetModel(); } +QString DeviceExplorerModel::deviceNameOf(const QModelIndex& index) const +{ + // The device is the ancestor directly under the invisible root. An insertion + // at the root is a device appearing, which its own command already carries. + const Device::Node* n = index.isValid() ? &nodeFromModelIndex(index) : nullptr; + while(n && n->parent() && n->parent() != &m_rootNode) + n = n->parent(); + + if(!n || !n->is()) + return {}; + return n->get().name; +} + DeviceExplorerModel::~DeviceExplorerModel() { } DeviceDocumentPlugin& DeviceExplorerModel::deviceModel() const @@ -138,6 +180,31 @@ int DeviceExplorerModel::addDevice(Device::Node&& deviceNode) return row; } +void DeviceExplorerModel::replaceDevice(const QString& name, const Device::Node& node) +{ + auto& root = rootNode(); + auto it = ossia::find_if(root, [&](const Device::Node& n) { + return n.is() + && n.get().name == name; + }); + + if(it == root.end()) + { + addDevice(node); + return; + } + + const int row = root.indexOfChild(&*it); + + beginRemoveRows(QModelIndex{}, row, row); + auto next = root.erase(it); + endRemoveRows(); + + beginInsertRows(QModelIndex{}, row, row); + root.insert(next, node); + endInsertRows(); +} + void DeviceExplorerModel::updateDevice( const QString& name, const Device::DeviceSettings& dev) { @@ -239,7 +306,28 @@ bool DeviceExplorerModel::checkDeviceInstantiatable( auto& context = m_devicePlugin.context().app; auto prot = context.interfaces().get(n.protocol); if(!prot) - return false; + { + // This build has no such protocol. On a terminal that is ordinary: the + // device is made on the machine that does have it, and the settings came + // from there. Refusing here means a whole class of devices -- everything + // the other machine enumerates -- can never be added. + auto* catalog = m_devicePlugin.catalog(); + if(!catalog) + return false; + + const auto known = catalog->protocols(); + if(ossia::none_of( + known, [&](const auto& p) { return p.key == n.protocol; })) + return false; + + // Only the name can be judged here. Whether two devices of a protocol can + // coexist is that protocol's own rule, and it lives on the other machine. + return std::none_of( + rootNode().begin(), rootNode().end(), [&](const Device::Node& child) { + SCORE_ASSERT(child.is()); + return child.get().name == n.name; + }); + } // Look for other childs in the same protocol. bool none_incompatible = std::none_of( @@ -381,6 +469,13 @@ QVariant DeviceExplorerModel::data(const QModelIndex& index, int role) const else if(n.is()) { auto& dev_set = n.get(); + + // What the machine running the score says, when that is not this one. + // Otherwise every device reads as disconnected, which is not what + // "we have no implementation for it here" means. + if(auto remote = deviceModel().remoteConnected(dev_set.name)) + return Device::deviceNameColumnData(n, *remote, role); + auto* impl = deviceModel().list().findDevice(dev_set.name); return Device::deviceNameColumnData(n, impl && impl->connected(), role); } diff --git a/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/DeviceExplorerModel.hpp b/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/DeviceExplorerModel.hpp index 867d3c6941..7ed2ca30ab 100644 --- a/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/DeviceExplorerModel.hpp +++ b/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/DeviceExplorerModel.hpp @@ -94,6 +94,16 @@ class SCORE_PLUGIN_DEVICEEXPLORER_EXPORT DeviceExplorerModel final int addDevice(const Device::Node& deviceNode); void updateDevice(const QString& name, const Device::DeviceSettings& dev); + //! Put the tree the machine that owns the device reports in place of ours. + //! + //! What a device contains is discovered by refreshing it, which a document + //! that holds no device cannot do. Added if it is not here yet; kept at the + //! same row otherwise, so the explorer does not reorder under the person. + void replaceDevice(const QString& name, const Device::Node& node); + + //! The device an index belongs to, empty if it is not under one. + QString deviceNameOf(const QModelIndex& index) const; + Device::Node* addAddress( Device::Node* parentNode, const Device::AddressSettings& addressSettings, int row); void updateAddress(Device::Node* node, const Device::AddressSettings& addressSettings); diff --git a/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/DeviceExplorerWidget.cpp b/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/DeviceExplorerWidget.cpp index 7d17c54d1f..b05e0beea4 100644 --- a/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/DeviceExplorerWidget.cpp +++ b/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/DeviceExplorerWidget.cpp @@ -519,9 +519,11 @@ void DeviceExplorerWidget::contextMenuEvent(QContextMenuEvent* event) if(node.is()) { auto& lst = m->deviceModel().list(); - auto& dev = lst.device(node.get().name); - dev.setupContextMenu(*contextMenu); - contextMenu->addSeparator(); + if(auto* dev = lst.findDevice(node.get().name)) + { + dev->setupContextMenu(*contextMenu); + contextMenu->addSeparator(); + } } } } @@ -617,13 +619,22 @@ void DeviceExplorerWidget::populateColumnCBox() } // The bool indicates if the passed node was a device +// +// A device with no implementation -- a protocol this build does not have, or a +// score that runs on another machine -- reports the default capabilities rather +// than throwing. This runs from updateActions and from the drag handler, so +// throwing here surfaced as "Internal error" the moment anything was selected. std::pair getCapas(Device::Node* p, const Device::DeviceList& lst) { + auto capasOf = [&lst](const Device::Node& n) { + auto* dev = lst.findDevice(n.get().name); + return dev ? dev->capabilities() : Device::DeviceCapas{}; + }; + if(p->is()) - { - return {lst.device(p->get().name).capabilities(), true}; - } + return {capasOf(*p), true}; + while(p && !p->is()) { p = p->parent(); @@ -631,7 +642,7 @@ getCapas(Device::Node* p, const Device::DeviceList& lst) if(!p) throw std::runtime_error("Cannot get capabilities of no device"); - return {lst.device(p->get().name).capabilities(), false}; + return {capasOf(*p), false}; } void DeviceExplorerWidget::updateActions() @@ -1028,7 +1039,17 @@ void DeviceExplorerWidget::addDevice() SCORE_ASSERT(model()); auto node = m_deviceDialog->getDevice(); - auto& deviceSettings = *node.target(); + auto* settings = node.target(); + if(!settings) + { + // An empty node carries no settings, and reading them anyway is a null + // dereference on the way to doing nothing. + m_deviceDialog->deleteLater(); + m_deviceDialog = nullptr; + return; + } + + auto& deviceSettings = *settings; if(!model()->checkDeviceInstantiatable(deviceSettings)) { m_deviceDialog->deleteLater(); diff --git a/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/ListeningManager.cpp b/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/ListeningManager.cpp index 3dd0230c63..db1ce270ad 100644 --- a/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/ListeningManager.cpp +++ b/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/ListeningManager.cpp @@ -11,13 +11,13 @@ namespace Explorer { -Device::DeviceInterface& +Device::DeviceInterface* ListeningManager::deviceFromProxyModelIndex(const QModelIndex& idx) { return deviceFromNode(nodeFromProxyModelIndex(idx)); } -Device::DeviceInterface& ListeningManager::deviceFromModelIndex(const QModelIndex& idx) +Device::DeviceInterface* ListeningManager::deviceFromModelIndex(const QModelIndex& idx) { return deviceFromNode(m_model.nodeFromModelIndex(idx)); } @@ -46,10 +46,12 @@ ListeningManager::ListeningManager( void ListeningManager::enableListening(Device::Node& node) { - auto& dev = deviceFromNode(node); + auto* dev = deviceFromNode(node); + if(!dev) + return; - m_handler.setListening(dev, node, true); - dev.request(node); + m_handler.setListening(*dev, node, true); + dev->request(node); } void ListeningManager::disableListening_rec( @@ -92,35 +94,38 @@ void ListeningManager::enableListening_rec( } } -Device::DeviceInterface& ListeningManager::deviceFromNode(const Device::Node& node) +Device::DeviceInterface* ListeningManager::deviceFromNode(const Device::Node& node) { auto& list = m_model.deviceModel().list(); if(node.is()) { // OPTIMIZEME by just going to the top node auto addr = Device::address(node); - return list.device(addr.address.device); + return list.findDevice(addr.address.device); } else if(node.is()) { - return list.device(node.get().name); + return list.findDevice(node.get().name); } - SCORE_ABORT; + return nullptr; } void ListeningManager::setListening(const QModelIndex& idx, bool b) { - auto& dev = deviceFromProxyModelIndex(idx); + auto* dev = deviceFromProxyModelIndex(idx); + if(!dev) + return; + if(b) { - enableListening_rec(idx, dev, m_handler); + enableListening_rec(idx, *dev, m_handler); } else { for(const auto& child : nodeFromProxyModelIndex(idx)) { - disableListening_rec(child, dev, m_handler); + disableListening_rec(child, *dev, m_handler); } } } @@ -128,17 +133,19 @@ void ListeningManager::setListening(const QModelIndex& idx, bool b) void ListeningManager::resetListening(Device::Node& node) { auto idx = m_model.modelIndexFromNode(node, 0); - auto& dev = deviceFromModelIndex(idx); + auto* dev = deviceFromModelIndex(idx); + if(!dev) + return; for(const auto& child : node) { - disableListening_rec(child, dev, m_handler); + disableListening_rec(child, *dev, m_handler); } auto view_idx = m_widget.proxyIndex(idx); if(m_widget.view()->isExpanded(view_idx)) { - enableListening_rec(view_idx, dev, m_handler); + enableListening_rec(view_idx, *dev, m_handler); } } diff --git a/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/ListeningManager.hpp b/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/ListeningManager.hpp index 2b96fefbf5..f870580c47 100644 --- a/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/ListeningManager.hpp +++ b/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/ListeningManager.hpp @@ -35,9 +35,12 @@ class SCORE_PLUGIN_DEVICEEXPLORER_EXPORT ListeningManager : public QObject void enableListening_rec( const QModelIndex& index, Device::DeviceInterface&, ListeningHandler& lm); - Device::DeviceInterface& deviceFromNode(const Device::Node&); - Device::DeviceInterface& deviceFromProxyModelIndex(const QModelIndex&); - Device::DeviceInterface& deviceFromModelIndex(const QModelIndex& idx); + //! Null when the node names a device with no implementation behind it: a + //! protocol this build does not have, or a score that runs on another + //! machine. There is nothing to listen to in either case. + Device::DeviceInterface* deviceFromNode(const Device::Node&); + Device::DeviceInterface* deviceFromProxyModelIndex(const QModelIndex&); + Device::DeviceInterface* deviceFromModelIndex(const QModelIndex& idx); Device::Node& nodeFromProxyModelIndex(const QModelIndex&); Device::Node& nodeFromModelIndex(const QModelIndex&); diff --git a/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/Widgets/DeviceEditDialog.cpp b/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/Widgets/DeviceEditDialog.cpp index e267fff631..930f141b4e 100644 --- a/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/Widgets/DeviceEditDialog.cpp +++ b/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/Widgets/DeviceEditDialog.cpp @@ -10,12 +10,15 @@ #include #include +#include +#include +#include #include +#include +#include #include #include #include -#include -#include #include #include #include @@ -28,16 +31,15 @@ #include #include #include -#include #include #include #include #include #include -#include #include +#include +#include #include -#include #include #include #include @@ -111,6 +113,7 @@ DeviceEditDialog::DeviceEditDialog( m_column1Stack->addWidget(m_protocols); m_presets = new QTreeWidget{this}; + m_presets->setObjectName("PresetList"); m_presets->header()->hide(); m_presets->setSelectionMode(QAbstractItemView::SingleSelection); m_column1Stack->addWidget(m_presets); @@ -149,6 +152,7 @@ DeviceEditDialog::DeviceEditDialog( m_devicesLabel->setAlignment(Qt::AlignTop); m_devicesLabel->setAlignment(Qt::AlignHCenter); m_devices = new QTreeWidget{this}; + m_devices->setObjectName("DeviceList"); m_devices->header()->hide(); m_devices->setSelectionMode(QAbstractItemView::SingleSelection); column2_layout->addWidget(m_devices); @@ -250,26 +254,53 @@ void DeviceEditDialog::initAvailableProtocols() // initialize previous settings m_previousSettings.clear(); - std::vector sorted; - for(auto& elt : m_protocolList) + // The catalog's protocols when the score runs elsewhere; ours otherwise. + struct Listed { - sorted.push_back(&elt); + UuidKey key; + QString name; + QString category; + Device::DeviceSettings defaults; + int priority{}; + }; + std::vector listed; + + if(auto* cat = catalog()) + { + for(const auto& p : cat->protocols()) + { + Device::DeviceSettings def; + if(auto* fac = m_protocolList.get(p.key)) + def = fac->defaultSettings(); + else + { + def.protocol = p.key; + def.name = p.name; + } + listed.push_back(Listed{p.key, p.name, p.category, def, 0}); + } + } + else + { + for(auto& prot : m_protocolList) + listed.push_back(Listed{ + prot.concreteKey(), prot.prettyName(), prot.category(), + prot.defaultSettings(), prot.visualPriority()}); } - ossia::sort(sorted, [](Device::ProtocolFactory* lhs, Device::ProtocolFactory* rhs) { - return lhs->visualPriority() > rhs->visualPriority() - || (lhs->visualPriority() == rhs->visualPriority() - && lhs->prettyName() < rhs->prettyName()); + ossia::sort(listed, [](const Listed& lhs, const Listed& rhs) { + return lhs.priority > rhs.priority + || (lhs.priority == rhs.priority && lhs.name < rhs.name); }); - for(const auto& prot_pair : sorted) + + for(const auto& prot : listed) { - auto& prot = *prot_pair; - auto cat_list = m_protocols->findItems(prot.category(), Qt::MatchFixedString); + auto cat_list = m_protocols->findItems(prot.category, Qt::MatchFixedString); QTreeWidgetItem* categoryItem{}; if(cat_list.size() == 0) { categoryItem = new QTreeWidgetItem; - categoryItem->setText(0, prot.category()); + categoryItem->setText(0, prot.category); categoryItem->setFlags(Qt::ItemIsEnabled); m_protocols->addTopLevelItem(categoryItem); } @@ -279,10 +310,10 @@ void DeviceEditDialog::initAvailableProtocols() } auto item = new QTreeWidgetItem{categoryItem}; - item->setText(0, prot.prettyName()); - item->setData(0, Qt::UserRole, QVariant::fromValue(prot.concreteKey())); + item->setText(0, prot.name); + item->setData(0, Qt::UserRole, QVariant::fromValue(prot.key)); item->setFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable); - m_previousSettings.append(prot.defaultSettings()); + m_previousSettings.append(prot.defaults); } m_protocols->sortItems(0, Qt::AscendingOrder); @@ -301,49 +332,26 @@ void DeviceEditDialog::initPresets() { m_presets->clear(); - // Read the library root path directly from QSettings - // to avoid a dependency on score-plugin-library - QSettings s; - QString rootPath = s.value("Library/RootPath").toString(); - if(rootPath.isEmpty()) - { - auto paths = QStandardPaths::standardLocations(QStandardPaths::DocumentsLocation); - if(!paths.isEmpty()) - { - rootPath = QString("%1/%2/%3") - .arg( - paths[0], QCoreApplication::organizationName(), - QCoreApplication::applicationName()); - } - } - - if(rootPath.isEmpty()) - return; + // Through the environment rather than the local library folder: on a terminal + // the packages are on the other machine, and scanning here found nothing. + auto& ctx = score::IDocument::documentContext(m_model); + score::listRecursive( + ctx.environment(), score::Uri{score::UriScheme::Library, "packages"}, ".device", + [this, alive = QPointer{this}]( + std::vector presets) { + if(!alive) + return; - static score::RecursiveWatch r; - r.reset(); - r.registerWatch( - "device", score::RecursiveWatch::AsyncCallbacks{ - .filter = [&](std::string_view path) -> std::function { - const auto path_info = score::PathInfo{path}; - auto basename = QString::fromUtf8( - path_info.completeBaseName.data(), path_info.completeBaseName.size()); - auto absolutePath = QString::fromUtf8( - path_info.absoluteFilePath.data(), path_info.absoluteFilePath.size()); - - return - [this, basename = std::move(basename), absolutePath = std::move(absolutePath)] { + for(const score::DirEntry& preset : presets) + { auto item = new QTreeWidgetItem; - item->setText(0, basename); - item->setData(0, Qt::UserRole, absolutePath); + item->setText(0, QFileInfo{preset.name}.completeBaseName()); + item->setData(0, Qt::UserRole, preset.uri.toString()); item->setFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable); m_presets->addTopLevelItem(item); - }; - }}); - r.setWatchedFolder(rootPath.toStdString() + "/packages"); - r.scanAsync(this); - - m_presets->sortItems(0, Qt::AscendingOrder); + } + m_presets->sortItems(0, Qt::AscendingOrder); + }); } void DeviceEditDialog::selectedPresetChanged() @@ -355,19 +363,31 @@ void DeviceEditDialog::selectedPresetChanged() if(!item) return; - auto filePath = item->data(0, Qt::UserRole).toString(); - if(filePath.isEmpty()) + const auto stored = item->data(0, Qt::UserRole).toString(); + if(stored.isEmpty()) return; - // Load the full device node from the .device file - Device::Node n; - if(!Device::loadDeviceFromScoreJSON(filePath, n)) - return; + // The preset is a file, and the files are not necessarily here. + auto& ctx = score::IDocument::documentContext(m_model); + ctx.environment().read( + score::Uri::parse(stored), + [this, alive = QPointer{this}](const QByteArray& contents) { + if(!alive) + return; + Device::Node n; + if(Device::loadDeviceFromScoreJSON(readJson(contents), n)) + applyPreset(std::move(n)); + }); +} + +void DeviceEditDialog::applyPreset(Device::Node n) +{ if(!n.is()) return; auto& deviceSettings = n.get(); + m_chosenSettings = deviceSettings; // Find the protocol factory for this device auto protocol = m_protocolList.get(deviceSettings.protocol); @@ -393,8 +413,11 @@ void DeviceEditDialog::selectedPresetChanged() m_splitter->widget(0)->hide(); // Create the correct settings widget for this protocol - m_protocolNameLabel->setText(tr("Settings (%1)").arg(protocol->prettyName())); - m_protocolWidget = protocol->makeSettingsWidget(); + // No factory, no form: the settings widget lives in the absent plug-in. + m_protocolNameLabel->setText( + protocol ? tr("Settings (%1)").arg(protocol->prettyName()) + : tr("Configured on the other machine")); + m_protocolWidget = protocol ? protocol->makeSettingsWidget() : nullptr; if(m_protocolWidget) { @@ -419,6 +442,30 @@ void DeviceEditDialog::selectedPresetChanged() updateValidity(); } +void DeviceEditDialog::hideDevicesColumn() +{ + m_devices->setVisible(false); + m_devicesLabel->setVisible(false); + if(m_splitter->count() > 0) + m_splitter->widget(0)->hide(); +} + +void DeviceEditDialog::showDevicesColumn() +{ + if(m_devices->isVisible()) + return; + + m_devices->setVisible(true); + m_devicesLabel->setVisible(true); + m_devices->setRootIsDecorated(false); + m_devices->setExpandsOnDoubleClick(false); + if(m_splitter->count() > 0) + { + m_splitter->widget(0)->show(); + m_splitter->widget(0)->setMinimumWidth(200); + } +} + void DeviceEditDialog::selectedDeviceChanged() { if(!m_devices->isVisible()) @@ -433,12 +480,18 @@ void DeviceEditDialog::selectedDeviceChanged() auto name = item->text(0); auto data = item->data(0, Qt::UserRole).value(); + m_chosenSettings = data; if(m_protocolWidget) m_protocolWidget->setSettings(data); updateValidity(); } +Device::DeviceCatalog* DeviceEditDialog::catalog() const noexcept +{ + return m_model.deviceModel().catalog(); +} + void DeviceEditDialog::selectedProtocolChanged() { auto doc = score::GUIAppContext().currentDocument(); @@ -456,14 +509,19 @@ void DeviceEditDialog::selectedProtocolChanged() if(key == UuidKey{}) return; + m_currentProtocol = key; + // Clear preset state m_presetNode = Device::Node{}; + m_chosenSettings = Device::DeviceSettings{}; // Clear listener m_enumerators.clear(); - // Clear devices + // Clear devices. Hidden until something is in it: nothing else hides it, so + // a protocol with no hardware kept whatever the previous one had shown. m_devices->clear(); + hideDevicesColumn(); // Clear protocol widget if(m_protocolWidget) @@ -476,21 +534,67 @@ void DeviceEditDialog::selectedProtocolChanged() } auto protocol = m_protocolList.get(key); - for(auto [name, e] : protocol->getEnumerators(*doc)) - m_enumerators.emplace_back(name, e); + + // The other machine's hardware through the catalog, or this one's. + auto* remoteCatalog = catalog(); + if(remoteCatalog) + { + // The answer may outlive the dialog, or the choice of protocol. + auto addRemote = [self = QPointer{this}, key]( + const QString& category, const QString& name, + const Device::DeviceSettings& settings) { + if(!self || self->m_currentProtocol != key) + return; + auto* const me = self.data(); + // On the first one: plenty of protocols enumerate nothing, and an empty + // column claiming a device list is worse than no column. + me->showDevicesColumn(); + + auto item = new QTreeWidgetItem; + item->setFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable); + item->setText(0, name); + item->setData(0, Qt::UserRole, QVariant::fromValue(settings)); + + // A heading per enumerator, as for local hardware. + if(category.isEmpty()) + { + me->m_devices->addTopLevelItem(item); + return; + } + + QTreeWidgetItem* cat{}; + for(int i = 0; i < me->m_devices->topLevelItemCount() && !cat; i++) + { + auto* candidate = me->m_devices->topLevelItem(i); + if(candidate->text(0) == category) + cat = candidate; + } + if(!cat) + { + cat = new QTreeWidgetItem; + setCategoryStyle(cat); + cat->setText(0, category); + cat->setFlags(Qt::ItemIsEnabled); + me->m_devices->addTopLevelItem(cat); + } + cat->addChild(item); + cat->setExpanded(true); + }; + + remoteCatalog->enumerate(key, addRemote); + } + else if(protocol) + { + for(auto [name, e] : protocol->getEnumerators(*doc)) + m_enumerators.emplace_back(name, e); + } + std::sort(m_enumerators.begin(), m_enumerators.end(), [](const auto& a, const auto& b) { return a.first < b.first; }); - if(!m_enumerators.empty()) + // This machine's hardware; the catalog path above has shown its own. + if(!remoteCatalog && !m_enumerators.empty()) { - m_devices->setVisible(true); - m_devicesLabel->setVisible(true); - m_devices->setRootIsDecorated(false); - m_devices->setExpandsOnDoubleClick(false); - if(m_splitter->count() > 0) - { - m_splitter->widget(0)->show(); - m_splitter->widget(0)->setMinimumWidth(200); - } + showDevicesColumn(); for(auto& [name, e] : m_enumerators) { @@ -533,14 +637,16 @@ void DeviceEditDialog::selectedProtocolChanged() e->enumerate(addItem); } } - else + else if(!remoteCatalog) { m_devices->setVisible(false); m_devicesLabel->setVisible(false); m_splitter->widget(0)->hide(); } - m_protocolNameLabel->setText(tr("Settings (%1)").arg(protocol->prettyName())); - m_protocolWidget = protocol->makeSettingsWidget(); + m_protocolNameLabel->setText( + protocol ? tr("Settings (%1)").arg(protocol->prettyName()) + : tr("Configured on the other machine")); + m_protocolWidget = protocol ? protocol->makeSettingsWidget() : nullptr; if(m_protocolWidget) { @@ -566,13 +672,32 @@ Device::DeviceSettings DeviceEditDialog::getSettings() const if(m_protocolWidget) return m_protocolWidget->getSettings(); - return {}; + // No widget means this build has no such protocol -- the form is C++ in a + // plug-in we do not have. Such a device can still be added, through what the + // other machine enumerated or a preset, so what it sent is what we return. + return m_chosenSettings; } Device::Node DeviceEditDialog::getDevice() const { if(!m_protocolWidget) - return {}; + { + // No form for this protocol in this build, so there is nothing to read the + // device out of but what was chosen -- the other machine's hardware, or a + // preset. Returning nothing here is what made Add do nothing at all. + if(m_chosenSettings.protocol == UuidKey{}) + return {}; + + if(m_presetNode.is()) + { + Device::Node n = m_presetNode; + if(auto* dev = n.target()) + *dev = m_chosenSettings; + return n; + } + + return Device::Node{m_chosenSettings, nullptr}; + } // If a preset was loaded, return the full node (with address tree) // but re-apply the current widget settings (user may have edited name, ports, etc.) @@ -602,6 +727,11 @@ void DeviceEditDialog::setSettings(const Device::DeviceSettings& settings) { m_protocols->setCurrentItem(item); selectedProtocolChanged(); + + // Kept whether or not a widget can show them: editing a device of a + // protocol this build lacks must give back what it was handed, not an + // empty settings. + m_chosenSettings = settings; if(m_protocolWidget) { m_protocolWidget->setSettings(settings); diff --git a/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/Widgets/DeviceEditDialog.hpp b/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/Widgets/DeviceEditDialog.hpp index 5f2941d20b..3dcb3130b1 100644 --- a/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/Widgets/DeviceEditDialog.hpp +++ b/src/plugins/score-plugin-deviceexplorer/Explorer/Explorer/Widgets/DeviceEditDialog.hpp @@ -24,6 +24,7 @@ class QPushButton; namespace Device { +class DeviceCatalog; class ProtocolFactoryList; class ProtocolSettingsWidget; class DeviceEnumerator; @@ -61,11 +62,21 @@ class SCORE_PLUGIN_DEVICEEXPLORER_EXPORT DeviceEditDialog final : public QDialog void updateValidity(); private: + //! Null for a document whose score runs here: the dialogs then show this + //! machine's protocols and hardware, as they always have. + Device::DeviceCatalog* catalog() const noexcept; + void selectedProtocolChanged(); void selectedDeviceChanged(); void selectedPresetChanged(); void initAvailableProtocols(); void initPresets(); + void applyPreset(Device::Node n); + + //! The column listing what is plugged in. Shown when there is something in + //! it: most protocols enumerate nothing. + void showDevicesColumn(); + void hideDevicesColumn(); const DeviceExplorerModel& m_model; const Device::ProtocolFactoryList& m_protocolList; @@ -98,7 +109,15 @@ class SCORE_PLUGIN_DEVICEEXPLORER_EXPORT DeviceEditDialog final : public QDialog // For presets: the loaded node with full address tree Device::Node m_presetNode{}; + //! What was chosen from a preset or from the other machine's hardware. It is + //! what getSettings() answers when this build has no widget for the protocol. + Device::DeviceSettings m_chosenSettings{}; + QString m_originalName{}; int m_index{}; + + //! Which protocol the device list is currently showing, so that answers + //! arriving for a previous one are dropped. + UuidKey m_currentProtocol{}; }; } diff --git a/src/plugins/score-plugin-engine/Execution/DocumentPlugin.cpp b/src/plugins/score-plugin-engine/Execution/DocumentPlugin.cpp index 578a7c36eb..d3aed0681e 100644 --- a/src/plugins/score-plugin-engine/Execution/DocumentPlugin.cpp +++ b/src/plugins/score-plugin-engine/Execution/DocumentPlugin.cpp @@ -100,8 +100,10 @@ void DocumentPlugin::recreateBase() connect( m_base.get(), &Execution::BaseScenarioElement::finished, this, [this] { - auto& stop_action = context().doc.app.actions.action(); - stop_action.action()->trigger(); + // not the Stop action: it is registered by the GUI only + context().doc.app.guiApplicationPlugin() + .execution() + .request_stop(); }, Qt::QueuedConnection); } diff --git a/src/plugins/score-plugin-engine/Execution/ExecutionController.cpp b/src/plugins/score-plugin-engine/Execution/ExecutionController.cpp index cd34c533e1..2370984abc 100644 --- a/src/plugins/score-plugin-engine/Execution/ExecutionController.cpp +++ b/src/plugins/score-plugin-engine/Execution/ExecutionController.cpp @@ -163,8 +163,18 @@ TransportInterface& ExecutionController::transport() const noexcept return *m_transport; } +bool ExecutionController::executesHere() const +{ + auto doc = currentDocument(); + return !doc || doc->role() == score::DocumentRole::Local; +} + void ExecutionController::request_play_global(bool b) { + // Before the transport: declining later leaves the buttons showing play. + if(!executesHere()) + return; + if(b) { this->m_requestLocalPlay = false; @@ -178,6 +188,9 @@ void ExecutionController::request_play_global(bool b) void ExecutionController::request_play_local(bool b) { + if(!executesHere()) + return; + if(b) { this->m_requestLocalPlay = true; @@ -192,17 +205,27 @@ void ExecutionController::request_play_local(bool b) void ExecutionController::request_play_interval( Scenario::IntervalModel& itv, exec_setup_fun setup, TimeVal t) { + if(!executesHere()) + return; + m_intervalsToPlay.push_back({itv, std::move(setup), t}); m_transport->requestPlay(); } void ExecutionController::request_stop_interval(Scenario::IntervalModel& itv) { + if(!isPlaying()) + return; + stop_interval(itv); } void ExecutionController::request_stop() { + // What executes belongs to the document that started it, not the one in front. + if(!isPlaying()) + return; + m_transport->requestStop(); } @@ -315,7 +338,8 @@ void ExecutionController::on_play_local(bool b) void ExecutionController::on_pause() { - ensure_audio_engine(); + if(!has_audio_engine()) + return; if(m_clock) { @@ -477,30 +501,24 @@ void ExecutionController::request_end_scrub(TimeVal t) } } -void ExecutionController::ensure_audio_engine() +bool ExecutionController::has_audio_engine() { auto& audio_engine = this->context.guiApplicationPlugin(); - if(!audio_engine.audio) - { - if(this->context.mainWindow) - { - score::warning( - this->context.mainWindow, tr("Cannot play"), - tr("Cannot start playback. It looks like the audio engine is not " - "running.\n" - "Check the audio settings in the software settings to ensure " - "that a sound card " - "is correctly configured.\n\n" - "Check Settings > Audio > Device in particular. " - "The power-on icon at the bottom of the transport toolbar will " - "light up when the engine is running.")); - return; - } - else - { - qFatal("Cannot playback without an audio engine set up"); - } - } + if(audio_engine.audio) + return true; + + // Not fatal without a window: an unattended host declines instead. + score::warning( + this->context.mainWindow, tr("Cannot play"), + tr("Cannot start playback. It looks like the audio engine is not " + "running.\n" + "Check the audio settings in the software settings to ensure " + "that a sound card " + "is correctly configured.\n\n" + "Check Settings > Audio > Device in particular. " + "The power-on icon at the bottom of the transport toolbar will " + "light up when the engine is running.")); + return false; } void ExecutionController::play_interval( @@ -512,11 +530,16 @@ void ExecutionController::play_interval( auto& ctx = doc->context(); + // The score runs elsewhere: no devices here, and no claim on this audio. + if(doc->role() == score::DocumentRole::Terminal) + return; + auto exec_plug = ctx.findPlugin(); if(!exec_plug) return; - ensure_audio_engine(); + if(!has_audio_engine()) + return; if(m_playing) { @@ -604,7 +627,8 @@ void ExecutionController::stop_interval(Scenario::IntervalModel& cst) if(!exec_plug) return; - ensure_audio_engine(); + if(!has_audio_engine()) + return; if(m_playing) { @@ -629,6 +653,19 @@ TimeVal ExecutionController::execution_time() const auto& itv = m_clock->scenario->baseInterval().scoreInterval().duration; return TimeVal(itv.defaultDuration() * itv.playPercentage()); } + + // No clock on a terminal, but the host says where it has got to. + if(auto doc = currentDocument(); doc && doc->role() != score::DocumentRole::Local) + { + // closing() too: the timing widget keeps firing during teardown. + auto* sm = score::IDocument::try_get(*doc); + if(sm && !sm->closing()) + { + auto& itv = sm->baseInterval().duration; + return TimeVal(itv.defaultDuration() * itv.playPercentage()); + } + } + return TimeVal::zero(); } diff --git a/src/plugins/score-plugin-engine/Execution/ExecutionController.hpp b/src/plugins/score-plugin-engine/Execution/ExecutionController.hpp index 61bed2cd4d..caf4422e87 100644 --- a/src/plugins/score-plugin-engine/Execution/ExecutionController.hpp +++ b/src/plugins/score-plugin-engine/Execution/ExecutionController.hpp @@ -64,6 +64,10 @@ class SCORE_PLUGIN_ENGINE_EXPORT ExecutionController : public QObject void request_stop_interval(Scenario::IntervalModel&); void request_stop(); + //! Whether this application is executing a score right now, whichever + //! document it belongs to. + bool isPlaying() const noexcept { return m_playing || bool(m_clock); } + void request_begin_scrub(TimeVal t); void request_scrub(TimeVal t); void request_end_scrub(TimeVal t); @@ -84,7 +88,12 @@ class SCORE_PLUGIN_ENGINE_EXPORT ExecutionController : public QObject ::TimeVal t = ::TimeVal::zero()); void stop_interval(Scenario::IntervalModel&); - void ensure_audio_engine(); + //! False -- and says why -- when nothing can be played through. + bool has_audio_engine(); + + //! False when the current score runs on another machine, so the transport + //! belongs to that machine and nothing here may enter the state machine. + bool executesHere() const; void on_play_local(bool, ::TimeVal t); void on_pause(); diff --git a/src/plugins/score-plugin-engine/LocalTree/LocalTreeDocumentPlugin.cpp b/src/plugins/score-plugin-engine/LocalTree/LocalTreeDocumentPlugin.cpp index 9e1bc70886..164af38df5 100644 --- a/src/plugins/score-plugin-engine/LocalTree/LocalTreeDocumentPlugin.cpp +++ b/src/plugins/score-plugin-engine/LocalTree/LocalTreeDocumentPlugin.cpp @@ -56,6 +56,13 @@ LocalTree::DocumentPlugin::~DocumentPlugin() void LocalTree::DocumentPlugin::init() { + // A terminal exposes nothing. The tree is a control surface for a score that + // executes here, and this one does not; LocalDevice::init would also open an + // OSCQuery server on the same default ports as the machine actually running + // the score, which collide outright when that is the same machine. + if(m_context.role() != score::DocumentRole::Local) + return; + m_localDeviceWrapper.init(); auto& set = m_context.app.settings(); diff --git a/src/plugins/score-plugin-gfx/Gfx/Filter/Library.cpp b/src/plugins/score-plugin-gfx/Gfx/Filter/Library.cpp index e3ed3b271c..0b8a0d6e0d 100644 --- a/src/plugins/score-plugin-gfx/Gfx/Filter/Library.cpp +++ b/src/plugins/score-plugin-gfx/Gfx/Filter/Library.cpp @@ -119,9 +119,15 @@ std::optional LibraryHandler::scanPath(std::string_view p QWidget* LibraryHandler::previewWidget(const QString& path, QWidget* parent) const noexcept +{ + return previewWidget(path, QByteArray{}, parent); +} + +QWidget* LibraryHandler::previewWidget( + const QString& path, const QByteArray& contents, QWidget* parent) const noexcept { if(!qEnvironmentVariableIsSet("SCORE_DISABLE_SHADER_PREVIEW")) - return new ShaderPreviewWidget{path, parent}; + return new ShaderPreviewWidget{path, contents, parent}; else return nullptr; } diff --git a/src/plugins/score-plugin-gfx/Gfx/Filter/Library.hpp b/src/plugins/score-plugin-gfx/Gfx/Filter/Library.hpp index b1bd643b24..ee8128f945 100644 --- a/src/plugins/score-plugin-gfx/Gfx/Filter/Library.hpp +++ b/src/plugins/score-plugin-gfx/Gfx/Filter/Library.hpp @@ -26,6 +26,9 @@ class LibraryHandler final : public Library::LibraryInterface std::optional scanPath(std::string_view path) override; QWidget* previewWidget(const QString& path, QWidget* parent) const noexcept override; + QWidget* previewWidget( + const QString& path, const QByteArray& contents, + QWidget* parent) const noexcept override; QWidget* previewWidget(const Process::Preset& path, QWidget* parent) const noexcept override; diff --git a/src/plugins/score-plugin-gfx/Gfx/Filter/PreviewWidget.cpp b/src/plugins/score-plugin-gfx/Gfx/Filter/PreviewWidget.cpp index 7175e95344..090a1e0c05 100644 --- a/src/plugins/score-plugin-gfx/Gfx/Filter/PreviewWidget.cpp +++ b/src/plugins/score-plugin-gfx/Gfx/Filter/PreviewWidget.cpp @@ -280,13 +280,16 @@ class ShaderPreviewManager : public QObject g_shaderPreviewScheduledForDeletion = false; } - void load(const QString& path) + //! `contents` empty means "read it from `path`", which is what the two + //! program builders already do -- they only open the file when given no bytes. + //! A shader from the other machine arrives as bytes and no readable path. + void load(const QString& path, const QByteArray& contents) { ShaderSource program; if(path.contains(".fs") || path.contains(".frag")) - program = programFromISFFragmentShaderPath(path, {}); + program = programFromISFFragmentShaderPath(path, contents); if(path.contains(".vs") || path.contains(".vert")) - program = programFromVSAVertexShaderPath(path, {}); + program = programFromVSAVertexShaderPath(path, contents); if(const auto& [processed, error] = ProgramCache::instance().get(program); bool(processed)) @@ -472,6 +475,12 @@ class ShaderPreviewManager : public QObject }; ShaderPreviewWidget::ShaderPreviewWidget(const QString& path, QWidget* parent) + : ShaderPreviewWidget{path, QByteArray{}, parent} +{ +} + +ShaderPreviewWidget::ShaderPreviewWidget( + const QString& path, const QByteArray& contents, QWidget* parent) : QWidget{parent} { g_shaderPreviewScheduledForDeletion = false; @@ -479,7 +488,7 @@ ShaderPreviewWidget::ShaderPreviewWidget(const QString& path, QWidget* parent) { g_shaderPreview = new ShaderPreviewManager; } - g_shaderPreview->load(path); + g_shaderPreview->load(path, contents); setup(); } diff --git a/src/plugins/score-plugin-gfx/Gfx/Filter/PreviewWidget.hpp b/src/plugins/score-plugin-gfx/Gfx/Filter/PreviewWidget.hpp index e58e7ded5a..af305f968f 100644 --- a/src/plugins/score-plugin-gfx/Gfx/Filter/PreviewWidget.hpp +++ b/src/plugins/score-plugin-gfx/Gfx/Filter/PreviewWidget.hpp @@ -23,6 +23,8 @@ class ShaderPreviewWidget : public QWidget { public: ShaderPreviewWidget(const QString& path, QWidget* parent = nullptr); + ShaderPreviewWidget( + const QString& path, const QByteArray& contents, QWidget* parent = nullptr); ShaderPreviewWidget(const Process::Preset& path, QWidget* parent = nullptr); ~ShaderPreviewWidget(); diff --git a/src/plugins/score-plugin-gfx/Gfx/GfxDevice.hpp b/src/plugins/score-plugin-gfx/Gfx/GfxDevice.hpp index fc38fb9a0f..cd42abe398 100644 --- a/src/plugins/score-plugin-gfx/Gfx/GfxDevice.hpp +++ b/src/plugins/score-plugin-gfx/Gfx/GfxDevice.hpp @@ -12,6 +12,12 @@ namespace Gfx class gfx_protocol_base; class SCORE_PLUGIN_GFX_EXPORT GfxInputDevice : public Device::DeviceInterface { +public: + Device::DeviceKinds kinds() const noexcept override + { + return Device::DeviceKind::TextureIn; + } + W_OBJECT(GfxInputDevice) public: GfxInputDevice( @@ -34,6 +40,12 @@ class SCORE_PLUGIN_GFX_EXPORT GfxInputDevice : public Device::DeviceInterface class SCORE_PLUGIN_GFX_EXPORT GfxOutputDevice : public Device::DeviceInterface { +public: + Device::DeviceKinds kinds() const noexcept override + { + return Device::DeviceKind::TextureOut; + } + W_OBJECT(GfxOutputDevice) public: GfxOutputDevice( diff --git a/src/plugins/score-plugin-gfx/Gfx/Images/ImageListChooser.cpp b/src/plugins/score-plugin-gfx/Gfx/Images/ImageListChooser.cpp index a5bcfe826f..a50165758f 100644 --- a/src/plugins/score-plugin-gfx/Gfx/Images/ImageListChooser.cpp +++ b/src/plugins/score-plugin-gfx/Gfx/Images/ImageListChooser.cpp @@ -145,7 +145,7 @@ class EditableTable : public QWidget void on_addItems() { score::openFilesToImport( - tr("Choose images..."), + ctx, tr("Choose images..."), QString{"Images (*.png *.jpg *.jpeg *.gif *.bmp *.tiff *.heic *.jp2 *.svg " "*.tga *.wbmp)"}, [this](const QString& f) { diff --git a/src/plugins/score-plugin-gfx/Gfx/TexturePort.cpp b/src/plugins/score-plugin-gfx/Gfx/TexturePort.cpp index 5847097cec..444fe83068 100644 --- a/src/plugins/score-plugin-gfx/Gfx/TexturePort.cpp +++ b/src/plugins/score-plugin-gfx/Gfx/TexturePort.cpp @@ -385,10 +385,8 @@ void TextureInletFactory::setupInletInspector( { auto& device = *ctx.findPlugin(); - auto cond - = [](Device::DeviceInterface& dev) { return qobject_cast(&dev); }; - - lay.addRow(Process::makeDeviceCombo(cond, device.list(), port, ctx, parent)); + lay.addRow(Process::makeDeviceCombo( + Device::DeviceKind::TextureIn, device.list(), port, ctx, parent)); auto& inlet = safe_cast(port); // Size @@ -485,10 +483,8 @@ void TextureOutletFactory::setupOutletInspector( Inspector::Layout& lay, QObject* context) { auto& device = *ctx.findPlugin(); - auto cond = [](Device::DeviceInterface& dev) { - return qobject_cast(&dev); - }; - lay.addRow(Process::makeDeviceCombo(cond, device.list(), port, ctx, parent)); + lay.addRow(Process::makeDeviceCombo( + Device::DeviceKind::TextureOut, device.list(), port, ctx, parent)); auto& outlet = safe_cast(port); if(!qEnvironmentVariableIsSet("SCORE_DISABLE_SHADER_PREVIEW")) diff --git a/src/plugins/score-plugin-gfx/Gfx/VSA/Library.cpp b/src/plugins/score-plugin-gfx/Gfx/VSA/Library.cpp index e05803923b..8f391f3be4 100644 --- a/src/plugins/score-plugin-gfx/Gfx/VSA/Library.cpp +++ b/src/plugins/score-plugin-gfx/Gfx/VSA/Library.cpp @@ -41,9 +41,15 @@ std::optional LibraryHandler::scanPath(std::string_view p QWidget* LibraryHandler::previewWidget(const QString& path, QWidget* parent) const noexcept +{ + return previewWidget(path, QByteArray{}, parent); +} + +QWidget* LibraryHandler::previewWidget( + const QString& path, const QByteArray& contents, QWidget* parent) const noexcept { if(!qEnvironmentVariableIsSet("SCORE_DISABLE_SHADER_PREVIEW")) - return new ShaderPreviewWidget{path, parent}; + return new ShaderPreviewWidget{path, contents, parent}; else return nullptr; } diff --git a/src/plugins/score-plugin-gfx/Gfx/VSA/Library.hpp b/src/plugins/score-plugin-gfx/Gfx/VSA/Library.hpp index a01353c238..b21974c0c2 100644 --- a/src/plugins/score-plugin-gfx/Gfx/VSA/Library.hpp +++ b/src/plugins/score-plugin-gfx/Gfx/VSA/Library.hpp @@ -17,6 +17,9 @@ class LibraryHandler final : public Library::LibraryInterface std::optional scanPath(std::string_view path) override; QWidget* previewWidget(const QString& path, QWidget* parent) const noexcept override; + QWidget* previewWidget( + const QString& path, const QByteArray& contents, + QWidget* parent) const noexcept override; QWidget* previewWidget(const Process::Preset& path, QWidget* parent) const noexcept override; diff --git a/src/plugins/score-plugin-js/JS/ApplicationPlugin.cpp b/src/plugins/score-plugin-js/JS/ApplicationPlugin.cpp index 59573ddd69..bcc71a5d30 100644 --- a/src/plugins/score-plugin-js/JS/ApplicationPlugin.cpp +++ b/src/plugins/score-plugin-js/JS/ApplicationPlugin.cpp @@ -1,5 +1,7 @@ #include "ApplicationPlugin.hpp" +#include + #include #include #include @@ -33,6 +35,29 @@ namespace JS { +namespace +{ +//! The JS plug-in's answer to "run this here". Registered for the whole +//! process: the session layer needs to run a peer's script without knowing +//! what a QJSEngine is. +struct ConsoleEvaluator final : score::ScriptEvaluator +{ + QJSEngine& engine; + explicit ConsoleEvaluator(QJSEngine& e) + : engine{e} + { + } + + QString evaluate(const score::DocumentContext&, const QString& code) override + { + auto res = engine.evaluate(code); + if(res.isError()) + return QStringLiteral("ERROR: ") + res.toString(); + return res.isUndefined() ? QString{} : res.toString(); + } +}; +} + ApplicationPlugin::ApplicationPlugin(const score::GUIApplicationContext& ctx) : score::GUIApplicationPlugin{ctx} { @@ -45,6 +70,11 @@ ApplicationPlugin::ApplicationPlugin(const score::GUIApplicationContext& ctx) "Library", m_consoleEngine.newQObject(new JsLibrary)); m_consoleEngine.globalObject().setProperty("Device", m_consoleEngine.newQObject(new DeviceContext{m_consoleEngine})); m_consoleEngine.globalObject().setProperty("View", m_consoleEngine.newQObject(new JsViewContext)); + + // What a peer's script runs through when this machine is the one with the + // devices. Owned here, so it lasts exactly as long as the engine it wraps. + m_evaluator = std::make_unique(m_consoleEngine); + score::scriptEvaluator() = m_evaluator.get(); connect(&m_consoleEngine, &QQmlEngine::exit, this, [&] { for(auto& doc : score::GUIAppContext().docManager.documents()) doc->commandStack().markCurrentIndexAsSaved(); @@ -93,6 +123,10 @@ void ApplicationPlugin::on_newDocument(score::Document& doc) ApplicationPlugin::~ApplicationPlugin() { + // Or the next peer's script runs against an engine that no longer exists. + if(score::scriptEvaluator() == m_evaluator.get()) + score::scriptEvaluator() = nullptr; + m_processMessages = false; m_asioContext->context.stop(); m_asioThread.join(); @@ -128,7 +162,24 @@ void ApplicationPlugin::on_createdDocument(score::Document& doc) if(!m_start_script.isEmpty()) { - QTimer::singleShot(100, this, [this] { m_consoleEngine.evaluate(m_start_script); }); + // restarted per document: the last one created is the one to script + if(!m_start_script_timer) + { + m_start_script_timer = new QTimer{this}; + m_start_script_timer->setSingleShot(true); + connect(m_start_script_timer, &QTimer::timeout, this, [this] { + // --script takes either JavaScript source or the path to a file + QString source = m_start_script; + if(QFile f{m_start_script}; f.exists() && f.open(QIODevice::ReadOnly)) + source = QString::fromUtf8(f.readAll()); + + m_start_script.clear(); + auto res = m_consoleEngine.evaluate(source); + if(res.isError()) + qWarning() << "--script:" << res.toString(); + }); + } + m_start_script_timer->start(100); } } void ApplicationPlugin::afterStartup() diff --git a/src/plugins/score-plugin-js/JS/ApplicationPlugin.hpp b/src/plugins/score-plugin-js/JS/ApplicationPlugin.hpp index db4aae0d45..72367124e4 100644 --- a/src/plugins/score-plugin-js/JS/ApplicationPlugin.hpp +++ b/src/plugins/score-plugin-js/JS/ApplicationPlugin.hpp @@ -6,8 +6,12 @@ #include #include #include +#include + #include +#include + #include namespace ossia::net @@ -34,6 +38,12 @@ class ApplicationPlugin final // Used for processing whatever comes from the console QQmlEngine m_consoleEngine; + //! Registered as this process's evaluator for as long as this plug-in is + //! alive -- which a function-local static could not promise: it outlives the + //! engine it wraps, and a second application instance republishes the first + //! one's, pointing at an engine that is gone. + std::unique_ptr m_evaluator; + // Used for instantiating JS::Script* to verify that the script is valid // before updating, as well as for running JS UI scripts. QQmlEngine m_scriptProcessUIEngine; @@ -45,5 +55,6 @@ class ApplicationPlugin final ossia::net::network_context_ptr m_asioContext; QString m_start_script; + QTimer* m_start_script_timer{}; }; } diff --git a/src/plugins/score-plugin-js/JS/ConsolePanel.cpp b/src/plugins/score-plugin-js/JS/ConsolePanel.cpp index 64daab87bf..3f18fc02c7 100644 --- a/src/plugins/score-plugin-js/JS/ConsolePanel.cpp +++ b/src/plugins/score-plugin-js/JS/ConsolePanel.cpp @@ -2,6 +2,9 @@ #include +#include +#include +#include #include #include @@ -82,6 +85,22 @@ QJSEngine& PanelDelegate::engine() noexcept void PanelDelegate::evaluate(const QString& txt) { m_edit->appendPlainText("> " + txt); + + // A document that is a view of a score running elsewhere answers for that + // machine. Evaluating here would run against a document with no devices, no + // execution and no hardware -- Score.device() is null and stays null. + if(auto* doc = context().documents.currentDocument()) + { + if(const auto& sink = doc->scriptSink()) + { + sink(txt, [this, alive = QPointer{this}](const QString& reply) { + if(alive && !reply.isEmpty()) + m_edit->appendPlainText(reply + "\n"); + }); + return; + } + } + auto res = m_engine.evaluate(txt); if(res.isError()) { diff --git a/src/plugins/score-plugin-js/JS/LibraryHandler.hpp b/src/plugins/score-plugin-js/JS/LibraryHandler.hpp index 02f2963ac4..9d40af15fd 100644 --- a/src/plugins/score-plugin-js/JS/LibraryHandler.hpp +++ b/src/plugins/score-plugin-js/JS/LibraryHandler.hpp @@ -25,8 +25,11 @@ class ModuleLibraryHandler final if(!f.open(QIODevice::ReadOnly)) return false; + // there is no panel without a GUI if(!panel) - panel = &score::GUIAppContext().panel(); + panel = score::GUIAppContext().findPanel(); + if(!panel) + return false; panel->importModule(path); return true; 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 eda464f212..7b55cc8b2c 100644 --- a/src/plugins/score-plugin-js/JS/Qml/EditContext.device.cpp +++ b/src/plugins/score-plugin-js/JS/Qml/EditContext.device.cpp @@ -121,7 +121,10 @@ void EditJsContext::iterateDevice(const QString& name, const QJSValue& fun) { if(node.displayName() == name) { - auto& dev = list.device(name); + auto* dev_p = list.findDevice(name); + if(!dev_p) + return; + auto& dev = *dev_p; if(auto device = dev.getDevice()) { QJSEngine* engine = qjsEngine(this); diff --git a/src/plugins/score-plugin-library/CMakeLists.txt b/src/plugins/score-plugin-library/CMakeLists.txt index e8ec1785cd..cd30c550f0 100755 --- a/src/plugins/score-plugin-library/CMakeLists.txt +++ b/src/plugins/score-plugin-library/CMakeLists.txt @@ -12,6 +12,7 @@ set(HDRS "${CMAKE_CURRENT_SOURCE_DIR}/Library/Panel/LibraryPanelFactory.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/Library/FileSystemModel.hpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Library/RemoteFileSystemModel.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/Library/ItemModelFilterLineEdit.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/Library/LibraryInterface.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/Library/LibrarySettings.hpp" @@ -35,6 +36,7 @@ set(SRCS "${CMAKE_CURRENT_SOURCE_DIR}/Library/LibraryInterface.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Library/LibrarySettings.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Library/LibraryWidget.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Library/RemoteFileSystemModel.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Library/PresetItemModel.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Library/ProcessesItemModel.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Library/ProcessTreeView.cpp" diff --git a/src/plugins/score-plugin-library/Library/LibraryInterface.cpp b/src/plugins/score-plugin-library/Library/LibraryInterface.cpp index c38e30ac6c..4ff9c16452 100644 --- a/src/plugins/score-plugin-library/Library/LibraryInterface.cpp +++ b/src/plugins/score-plugin-library/Library/LibraryInterface.cpp @@ -62,6 +62,13 @@ LibraryInterface::previewWidget(const QString& path, QWidget* parent) const noex return nullptr; } +QWidget* LibraryInterface::previewWidget( + const QString& path, const QByteArray&, QWidget* parent) const noexcept +{ + // Whatever it can do with a path it has; nothing, for one it has not. + return previewWidget(path, parent); +} + QWidget* LibraryInterface::previewWidget( const Process::Preset& path, QWidget* parent) const noexcept { diff --git a/src/plugins/score-plugin-library/Library/LibraryInterface.hpp b/src/plugins/score-plugin-library/Library/LibraryInterface.hpp index 38f6081b4b..a421703676 100644 --- a/src/plugins/score-plugin-library/Library/LibraryInterface.hpp +++ b/src/plugins/score-plugin-library/Library/LibraryInterface.hpp @@ -32,6 +32,14 @@ class SCORE_PLUGIN_LIBRARY_EXPORT LibraryInterface : public score::InterfaceBase virtual QSet acceptedMimeTypes() const noexcept; virtual QWidget* previewWidget(const QString& path, QWidget* parent) const noexcept; + + //! The same, for a file this machine does not have: the library may be the + //! other machine's, and a preview cannot open a path that is not here. + //! `path` still names it, since the extension chooses how to read `contents`. + //! Defaults to ignoring the bytes, which is right for anything that can only + //! preview a real file. + virtual QWidget* previewWidget( + const QString& path, const QByteArray& contents, QWidget* parent) const noexcept; virtual QWidget* previewWidget(const Process::Preset& preset, QWidget* parent) const noexcept; diff --git a/src/plugins/score-plugin-library/Library/Panel/LibraryPanelDelegate.cpp b/src/plugins/score-plugin-library/Library/Panel/LibraryPanelDelegate.cpp index d485fa6169..029aed004e 100644 --- a/src/plugins/score-plugin-library/Library/Panel/LibraryPanelDelegate.cpp +++ b/src/plugins/score-plugin-library/Library/Panel/LibraryPanelDelegate.cpp @@ -6,8 +6,14 @@ #include #include +#include + +#include #include #include +#include + +#include #include #include @@ -22,14 +28,37 @@ namespace Library { UserPanel::UserPanel(const score::GUIApplicationContext& ctx) : score::PanelDelegate{ctx} - , m_widget{new SystemLibraryWidget{ctx, nullptr}} + , m_widget{new QStackedWidget{nullptr}} + , m_local{new SystemLibraryWidget{ctx, nullptr}} + , m_remote{new RemoteLibraryWidget{nullptr}} { + m_widget->addWidget(m_local); + m_widget->addWidget(m_remote); + score::setHelp(m_widget, QObject::tr("This panel allows to browse medias and presets in the documents. \n" "Check for library updates on \n" "github.com/ossia/score-user-library")); } +void UserPanel::on_modelChanged(score::MaybeDocument, score::MaybeDocument newm) +{ + const bool remote = newm && newm->role() != score::DocumentRole::Local; + if(!remote) + { + m_remote->clear(); + m_widget->setCurrentWidget(m_local); + return; + } + + // The user library of the machine the score runs on. Listed through the + // document's environment, which is what knows where that is. + m_remote->browse( + [doc = &newm->document] { return &doc->environment(); }, + score::Uri{score::UriScheme::Library, QString{}}); + m_widget->setCurrentWidget(m_remote); +} + QWidget* UserPanel::widget() { return m_widget; @@ -102,6 +131,20 @@ ProcessWidget& ProcessPanel::processWidget() const noexcept return *(ProcessWidget*)m_widget; } +void ProcessPanel::on_modelChanged(score::MaybeDocument oldm, score::MaybeDocument newm) +{ + // Only when the list currently describes another machine. rescan() resets a + // watch that is shared by every model and restarts an asynchronous scan, so + // doing it on every change raced the scan the constructor had just started -- + // which crashed the process, on a worker thread, some of the time. + const bool wasRemote = oldm && oldm->role() != score::DocumentRole::Local; + const bool isLocal = !newm || newm->role() == score::DocumentRole::Local; + if(!wasRemote || !isLocal) + return; + + processWidget().processModel().rescan(); +} + QWidget* ProcessPanel::widget() { return m_widget; diff --git a/src/plugins/score-plugin-library/Library/Panel/LibraryPanelDelegate.hpp b/src/plugins/score-plugin-library/Library/Panel/LibraryPanelDelegate.hpp index 762f63ecb9..1c4a238831 100644 --- a/src/plugins/score-plugin-library/Library/Panel/LibraryPanelDelegate.hpp +++ b/src/plugins/score-plugin-library/Library/Panel/LibraryPanelDelegate.hpp @@ -3,12 +3,14 @@ #include class QTabWidget; +class QStackedWidget; namespace Library { class ProjectLibraryWidget; class SystemLibraryWidget; class ProcessWidget; class FileSystemModel; +class RemoteLibraryWidget; class UserPanel final : public score::PanelDelegate { public: @@ -18,7 +20,13 @@ class UserPanel final : public score::PanelDelegate QWidget* widget() override; const score::PanelStatus& defaultPanelStatus() const override; - QWidget* m_widget{}; + //! The library is a place on a machine, and which machine depends on the + //! document: a score that runs elsewhere has its media there, not here. + void on_modelChanged(score::MaybeDocument oldm, score::MaybeDocument newm) override; + + QStackedWidget* m_widget{}; + SystemLibraryWidget* m_local{}; + RemoteLibraryWidget* m_remote{}; }; class ProjectPanel final : public score::PanelDelegate @@ -44,6 +52,13 @@ class SCORE_PLUGIN_LIBRARY_EXPORT ProcessPanel final : public score::PanelDelega QWidget* widget() override; const score::PanelStatus& defaultPanelStatus() const override; + //! The panel is one, documents are many, and what is available is a property + //! of the document: a score that runs on another machine can only use that + //! machine's processes. So the list is rebuilt from this build's factories + //! when a document that runs here replaces one that did not -- and only + //! then, since a rescan restarts an asynchronous scan and racing it is fatal. + void on_modelChanged(score::MaybeDocument oldm, score::MaybeDocument newm) override; + QWidget* m_widget{}; }; } diff --git a/src/plugins/score-plugin-library/Library/ProcessWidget.cpp b/src/plugins/score-plugin-library/Library/ProcessWidget.cpp index 0b0ba40f23..873fac975f 100644 --- a/src/plugins/score-plugin-library/Library/ProcessWidget.cpp +++ b/src/plugins/score-plugin-library/Library/ProcessWidget.cpp @@ -10,15 +10,22 @@ #include #include +#include +#include +#include #include #include #include +#include +#include + #include #include #include #include +#include #include #include #include @@ -129,6 +136,22 @@ class InfoWidget final : public QScrollArea QLabel m_documentationLink; }; +void ProcessWidget::showPreview(const QString& path, const QByteArray& contents) +{ + delete m_previewChild; + m_previewChild = nullptr; + + for(auto lib : libraryInterface(path)) + { + if((m_previewChild = lib->previewWidget(path, contents, &m_preview))) + { + m_preview.layout()->addWidget(m_previewChild); + m_preview.show(); + break; + } + } +} + ProcessWidget::ProcessWidget(const score::GUIApplicationContext& ctx, QWidget* parent) : QWidget{parent} , m_processModel{new ProcessesItemModel{ctx, this}} @@ -180,7 +203,7 @@ ProcessWidget::ProcessWidget(const score::GUIApplicationContext& ctx, QWidget* p connect( &m_tv, &ProcessTreeView::selected, this, - [this, infoWidg, filter, + [this, &ctx, infoWidg, filter, presetFilterProxy](const std::optional& pdata) { #if defined(_WIN32) const bool filter_had_focus = filter->hasFocus(); @@ -205,19 +228,25 @@ ProcessWidget::ProcessWidget(const score::GUIApplicationContext& ctx, QWidget* p // Update the preview delete m_previewChild; m_previewChild = nullptr; + m_awaitedPreview = pdata ? pdata->customData : QString{}; if(pdata) { if(QFile::exists(pdata->customData)) { - for(auto lib : libraryInterface(pdata->customData)) - { - if((m_previewChild = lib->previewWidget(pdata->customData, &m_preview))) - { - m_preview.layout()->addWidget(m_previewChild); - m_preview.show(); - break; - } - } + showPreview(pdata->customData, {}); + } + else if(auto* doc = ctx.documents.currentDocument(); + doc && !doc->context().environment().isLocal()) + { + // The library is the other machine's, so the file it names is not here. + // Small enough to read whole -- these are shaders, not media. + doc->context().environment().read( + score::Uri::parse(pdata->customData), + [this, alive = QPointer{this}, + path = pdata->customData](const QByteArray& contents) { + if(alive && m_awaitedPreview == path) + showPreview(path, contents); + }); } } diff --git a/src/plugins/score-plugin-library/Library/ProcessWidget.hpp b/src/plugins/score-plugin-library/Library/ProcessWidget.hpp index 14461b93b1..a27ddc0b58 100644 --- a/src/plugins/score-plugin-library/Library/ProcessWidget.hpp +++ b/src/plugins/score-plugin-library/Library/ProcessWidget.hpp @@ -50,6 +50,15 @@ class SCORE_PLUGIN_LIBRARY_EXPORT ProcessWidget : public QWidget { public: ProcessWidget(const score::GUIApplicationContext& ctx, QWidget* parent); + + //! Show what this file looks like, from its bytes when the file itself is on + //! another machine. + void showPreview(const QString& path, const QByteArray& contents); + + //! What the selection is waiting to preview. Bytes fetched from the other + //! machine arrive whenever they arrive, and by then the user may have + //! selected something else. + QString m_awaitedPreview; ~ProcessWidget(); ProcessesItemModel& processModel() const noexcept { return *m_processModel; } diff --git a/src/plugins/score-plugin-library/Library/ProcessesItemModel.cpp b/src/plugins/score-plugin-library/Library/ProcessesItemModel.cpp index d10acd60e6..e28eb8d19d 100644 --- a/src/plugins/score-plugin-library/Library/ProcessesItemModel.cpp +++ b/src/plugins/score-plugin-library/Library/ProcessesItemModel.cpp @@ -105,6 +105,19 @@ ProcessNode& ProcessesItemModel::addCategory(const QString& c) return *node; } +void ProcessesItemModel::clear() +{ + m_generation++; + m_pending.clear(); + m_anchors.clear(); + + beginResetModel(); + m_inReset = true; + m_root = ProcessNode{}; + m_inReset = false; + endResetModel(); +} + void ProcessesItemModel::rescan() { auto& procs = context.interfaces(); diff --git a/src/plugins/score-plugin-library/Library/ProcessesItemModel.hpp b/src/plugins/score-plugin-library/Library/ProcessesItemModel.hpp index c8f6906988..587d2184f4 100644 --- a/src/plugins/score-plugin-library/Library/ProcessesItemModel.hpp +++ b/src/plugins/score-plugin-library/Library/ProcessesItemModel.hpp @@ -70,6 +70,12 @@ class SCORE_PLUGIN_LIBRARY_EXPORT ProcessesItemModel //! anchor's key so only their per-plugin children are user-creatable. void clearAnchorKey(const Process::ProcessModelFactory::ConcreteKey& key); + //! Empty the tree inside a proper reset envelope: rescan()'s reset half + //! without the repopulate. A terminal document lists no local processes, + //! and QAbstractItemModel::begin/endResetModel are protected, so callers + //! outside the class cannot bracket the clear themselves. + void clear(); + //! Immediately publish everything still buffered. Mostly for tests. void flushPending(); diff --git a/src/plugins/score-plugin-library/Library/RemoteFileSystemModel.cpp b/src/plugins/score-plugin-library/Library/RemoteFileSystemModel.cpp new file mode 100644 index 0000000000..05c6c915af --- /dev/null +++ b/src/plugins/score-plugin-library/Library/RemoteFileSystemModel.cpp @@ -0,0 +1,271 @@ +#include "RemoteFileSystemModel.hpp" + +#include + +#include +#include +#include + + +namespace Library +{ +RemoteFileSystemModel::RemoteFileSystemModel(EnvironmentSource env, QObject* parent) + : QAbstractItemModel{parent} + , m_env{std::move(env)} + , m_root{std::make_unique()} +{ + m_root->directory = true; +} + +RemoteFileSystemModel::~RemoteFileSystemModel() = default; + +void RemoteFileSystemModel::setRoot(const score::Uri& uri) +{ + beginResetModel(); + m_root = std::make_unique(); + m_root->uri = uri; + m_root->directory = true; + endResetModel(); +} + +RemoteFileSystemModel::Entry* RemoteFileSystemModel::entryOf(const QModelIndex& idx) const +{ + if(!idx.isValid()) + return m_root.get(); + return static_cast(idx.internalPointer()); +} + +QModelIndex RemoteFileSystemModel::indexOf(Entry& e) const +{ + if(!e.parent) + return {}; + + auto& siblings = e.parent->children; + for(std::size_t i = 0; i < siblings.size(); i++) + if(siblings[i].get() == &e) + return createIndex((int)i, 0, &e); + + return {}; +} + +score::Uri RemoteFileSystemModel::uriAt(const QModelIndex& index) const +{ + auto* e = entryOf(index); + return e ? e->uri : score::Uri{}; +} + +bool RemoteFileSystemModel::isDirectory(const QModelIndex& index) const +{ + auto* e = entryOf(index); + return e && e->directory; +} + +QModelIndex +RemoteFileSystemModel::index(int row, int column, const QModelIndex& parent) const +{ + auto* p = entryOf(parent); + if(!p || row < 0 || row >= (int)p->children.size() || column != 0) + return {}; + + return createIndex(row, column, p->children[row].get()); +} + +QModelIndex RemoteFileSystemModel::parent(const QModelIndex& index) const +{ + auto* e = entryOf(index); + if(!e || !e->parent || e->parent == m_root.get()) + return {}; + + return indexOf(*e->parent); +} + +int RemoteFileSystemModel::rowCount(const QModelIndex& parent) const +{ + auto* p = entryOf(parent); + return p ? (int)p->children.size() : 0; +} + +int RemoteFileSystemModel::columnCount(const QModelIndex&) const +{ + return 1; +} + +QVariant RemoteFileSystemModel::data(const QModelIndex& index, int role) const +{ + auto* e = entryOf(index); + if(!e || e == m_root.get()) + return {}; + + switch(role) + { + case Qt::DisplayRole: + return e->name; + case Qt::ToolTipRole: + return e->uri.toString(); + case Qt::DecorationRole: + return score::IconProvider::instance().icon( + e->directory ? QFileIconProvider::Folder : QFileIconProvider::File); + default: + return {}; + } +} + +QVariant +RemoteFileSystemModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + if(orientation == Qt::Horizontal && role == Qt::DisplayRole && section == 0) + return tr("Name"); + return {}; +} + +Qt::ItemFlags RemoteFileSystemModel::flags(const QModelIndex& index) const +{ + if(!index.isValid()) + return Qt::NoItemFlags; + + auto f = Qt::ItemIsEnabled | Qt::ItemIsSelectable; + if(!isDirectory(index)) + f |= Qt::ItemIsDragEnabled; + return f; +} + +bool RemoteFileSystemModel::hasChildren(const QModelIndex& parent) const +{ + auto* p = entryOf(parent); + if(!p) + return false; + + // Assumed non-empty until opened, or the view shows no arrow to open it. + return p->directory && (!p->listed || !p->children.empty()); +} + +bool RemoteFileSystemModel::canFetchMore(const QModelIndex& parent) const +{ + auto* p = entryOf(parent); + return p && p->directory && !p->requested; +} + +void RemoteFileSystemModel::fetchMore(const QModelIndex& parent) +{ + auto* p = entryOf(parent); + if(!p || !p->directory || p->requested) + return; + + p->requested = true; + + // The answer comes back later; the document may be closed by then. + QPointer self = this; + Entry* target = p; + + auto* env = m_env ? m_env() : nullptr; + if(!env) + return; + + env->list( + p->uri, + [self, target](std::vector entries) { + if(!self) + return; + + // Folders first, then by name, as a file browser shows them. + std::sort( + entries.begin(), entries.end(), + [](const score::DirEntry& a, const score::DirEntry& b) { + if(a.directory != b.directory) + return a.directory; + return a.name.compare(b.name, Qt::CaseInsensitive) < 0; + }); + + // Still listed, but (0, -1) is last < first, which the contract forbids. + if(entries.empty()) + { + target->listed = true; + return; + } + + const auto idx = self->indexOf(*target); + self->beginInsertRows(idx, 0, (int)entries.size() - 1); + for(auto& de : entries) + { + auto child = std::make_unique(); + child->uri = de.uri; + child->name = de.name; + child->directory = de.directory; + child->size = de.size; + child->parent = target; + target->children.push_back(std::move(child)); + } + target->listed = true; + self->endInsertRows(); + }, + [self, target](const QString& err) { + if(!self) + return; + + // Listed and empty: asking again on every repaint helps nobody. + target->listed = true; + qDebug() << "Could not list a folder on the other machine:" << err; + }); +} + +QStringList RemoteFileSystemModel::mimeTypes() const +{ + return {score::remoteUriMimeType()}; +} + +QMimeData* RemoteFileSystemModel::mimeData(const QModelIndexList& indexes) const +{ + // Not text/uri-list: these files are not on this machine, and every drop + // handler treats that type as one it can open. + QStringList uris; + for(const auto& idx : indexes) + { + if(!idx.isValid() || idx.column() != 0 || isDirectory(idx)) + continue; + + uris.push_back(uriAt(idx).toString()); + } + + if(uris.empty()) + return nullptr; + + auto* mime = new QMimeData; + mime->setData(score::remoteUriMimeType(), uris.join('\n').toUtf8()); + mime->setText(uris.join('\n')); + return mime; +} +} + +namespace Library +{ +RemoteLibraryWidget::RemoteLibraryWidget(QWidget* parent) + : QTreeView{parent} +{ + setDragEnabled(true); + setDragDropMode(QAbstractItemView::DragOnly); + setSelectionMode(QAbstractItemView::ExtendedSelection); + setAlternatingRowColors(true); + header()->hide(); +} + +RemoteLibraryWidget::~RemoteLibraryWidget() = default; + +void RemoteLibraryWidget::browse( + RemoteFileSystemModel::EnvironmentSource env, const score::Uri& root) +{ + // A fresh model per document: entries name another machine's folders. + auto* old = m_model; + m_model = new RemoteFileSystemModel{std::move(env), this}; + setModel(m_model); + delete old; + + m_model->setRoot(root); +} + +void RemoteLibraryWidget::clear() +{ + setModel(nullptr); + delete m_model; + m_model = nullptr; +} +} diff --git a/src/plugins/score-plugin-library/Library/RemoteFileSystemModel.hpp b/src/plugins/score-plugin-library/Library/RemoteFileSystemModel.hpp new file mode 100644 index 0000000000..814cf61695 --- /dev/null +++ b/src/plugins/score-plugin-library/Library/RemoteFileSystemModel.hpp @@ -0,0 +1,115 @@ +#pragma once +#include +#include +#include + +#include +#include + +#include + +#include +#include +#include + +namespace Library +{ +/** + * @brief The files of a score that lives on another machine. + * + * QFileSystemModel cannot do this, and not by accident: it is built on paths + * this process can stat, and a listing that has to cross a socket is neither + * synchronous nor local. That is the same reason score::Environment is + * asynchronous, so this is the model that fits it. + * + * Listings are fetched when a folder is first expanded and kept afterwards. + * Nothing is polled: the other machine's disk is not being watched, so a folder + * shows what it held when it was opened. Collapsing and expanding again asks + * afresh, which is the cheapest honest refresh available. + */ +class SCORE_PLUGIN_LIBRARY_EXPORT RemoteFileSystemModel final + : public QAbstractItemModel +{ +public: + //! How to reach the environment, not the environment itself: a session + //! replaces it after the panel has been told the document exists. + using EnvironmentSource = std::function; + + RemoteFileSystemModel(EnvironmentSource env, QObject* parent); + ~RemoteFileSystemModel() override; + + //! Show this folder, and forget anything shown before. + void setRoot(const score::Uri& uri); + + //! What a row names, for whoever wants to open or drop it. + score::Uri uriAt(const QModelIndex& index) const; + bool isDirectory(const QModelIndex& index) const; + + QModelIndex + index(int row, int column, const QModelIndex& parent) const override; + QModelIndex parent(const QModelIndex& index) const override; + int rowCount(const QModelIndex& parent) const override; + int columnCount(const QModelIndex& parent) const override; + QVariant data(const QModelIndex& index, int role) const override; + QVariant + headerData(int section, Qt::Orientation orientation, int role) const override; + Qt::ItemFlags flags(const QModelIndex& index) const override; + + bool hasChildren(const QModelIndex& parent) const override; + bool canFetchMore(const QModelIndex& parent) const override; + void fetchMore(const QModelIndex& parent) override; + + QStringList mimeTypes() const override; + QMimeData* mimeData(const QModelIndexList& indexes) const override; + +private: + struct Entry + { + score::Uri uri; + QString name; + bool directory{}; + qint64 size{}; + + Entry* parent{}; + std::vector> children; + + //! Asked for, so that a folder that is genuinely empty is not asked about + //! again every time the view repaints. + bool requested{}; + bool listed{}; + }; + + Entry* entryOf(const QModelIndex& index) const; + QModelIndex indexOf(Entry& e) const; + + EnvironmentSource m_env; + std::unique_ptr m_root; +}; +} + +namespace Library +{ +/** + * @brief Browsing the library of the machine a score runs on. + * + * Deliberately not SystemLibraryWidget with a different model: that one is + * built around QFileSystemModel's filePath() and a proxy pinned to a root + * index, neither of which a remote listing has. Dragging out of it works, + * which is what the library is for. + */ +class SCORE_PLUGIN_LIBRARY_EXPORT RemoteLibraryWidget final : public QTreeView +{ +public: + RemoteLibraryWidget(QWidget* parent); + ~RemoteLibraryWidget() override; + + //! Show `root` as seen through `env`. Replaces whatever was shown. + void browse(RemoteFileSystemModel::EnvironmentSource env, const score::Uri& root); + + //! Nothing to show -- no document, or one whose files are on this machine. + void clear(); + +private: + RemoteFileSystemModel* m_model{}; +}; +} diff --git a/src/plugins/score-plugin-media/Media/AudioFileChooserWidget.hpp b/src/plugins/score-plugin-media/Media/AudioFileChooserWidget.hpp index b3d2d39daf..720a987bee 100644 --- a/src/plugins/score-plugin-media/Media/AudioFileChooserWidget.hpp +++ b/src/plugins/score-plugin-media/Media/AudioFileChooserWidget.hpp @@ -67,7 +67,7 @@ struct AudioFileChooser : WidgetFactory::FileChooser { auto bt = new score::QGraphicsWaveformButton{parent}; auto on_open = [&inlet, &ctx] { - score::openFileToImport(inlet.filters(), [&inlet, &ctx](const QString& filename) { + score::openFileToImport(ctx, inlet.filters(), [&inlet, &ctx](const QString& filename) { // On wasm `filename` is the staged MEMFS path; relativize so it is // stored consistently with drops. auto path = score::relativizeFilePath(filename, ctx); diff --git a/src/plugins/score-plugin-media/Media/Sound/SoundView.cpp b/src/plugins/score-plugin-media/Media/Sound/SoundView.cpp index d2da8d94f5..162a7057f1 100644 --- a/src/plugins/score-plugin-media/Media/Sound/SoundView.cpp +++ b/src/plugins/score-plugin-media/Media/Sound/SoundView.cpp @@ -24,7 +24,7 @@ LayerView::LayerView(const ProcessModel& m, QGraphicsItem* parent) setFlag(ItemClipsToShape, true); this->setAcceptDrops(true); - if(auto view = getView(*parent)) + if(auto view = parent ? getView(*parent) : nullptr) { connect( view->horizontalScrollBar(), &QScrollBar::valueChanged, this, @@ -93,6 +93,7 @@ void LayerView::setData(const std::shared_ptr& data) void LayerView::recompute() const { + m_recomputeCount++; if(Q_UNLIKELY( !m_data || width() < 2. || height() < 2. || m_zoom <= 0. || m_model.file()->sampleRate() < 1.)) @@ -163,8 +164,15 @@ void LayerView::paint_impl(QPainter* painter) const int channels = std::ssize(m_images); if(channels == 0.) { - if(!m_recomputed) + // recompute() gives up without marking itself done when it has no data, no + // size, no zoom or no view, and asking again on every paint made the two + // call each other for as long as the process existed -- which is what a + // file living on another machine does here. Of those, only the view has no + // signal to re-ask on, and painting is the proof that one exists: so paint + // asks once. Everything else already calls recompute() when it changes. + if(!m_recomputed && !m_askedWhilePainting) { + m_askedWhilePainting = true; m_renderAll = true; recompute(); } diff --git a/src/plugins/score-plugin-media/Media/Sound/SoundView.hpp b/src/plugins/score-plugin-media/Media/Sound/SoundView.hpp index e06283bc65..8f815f20d8 100644 --- a/src/plugins/score-plugin-media/Media/Sound/SoundView.hpp +++ b/src/plugins/score-plugin-media/Media/Sound/SoundView.hpp @@ -1,4 +1,5 @@ #pragma once +#include #include #include #include @@ -17,7 +18,7 @@ namespace Media namespace Sound { class ProcessModel; -class LayerView final +class SCORE_PLUGIN_MEDIA_EXPORT LayerView final : public Process::LayerView , public Nano::Observer { @@ -31,6 +32,10 @@ class LayerView final void recompute(ZoomRatio ratio); void recompute() const; + //! How many times a waveform was asked for. Painting asks, so a view that + //! cannot compute one must not let this grow without bound. + int recomputeCount() const noexcept { return m_recomputeCount; } + void on_finishedDecoding(); private: @@ -63,6 +68,8 @@ class LayerView final bool m_frontColors{true}; mutable bool m_recomputed{false}; + mutable bool m_askedWhilePainting{false}; + mutable int m_recomputeCount{}; mutable bool m_renderAll{true}; }; } diff --git a/src/plugins/score-plugin-protocols/Protocols/MIDI/MIDIDevice.cpp b/src/plugins/score-plugin-protocols/Protocols/MIDI/MIDIDevice.cpp index d066314d40..9491c6d3cd 100644 --- a/src/plugins/score-plugin-protocols/Protocols/MIDI/MIDIDevice.cpp +++ b/src/plugins/score-plugin-protocols/Protocols/MIDI/MIDIDevice.cpp @@ -196,6 +196,21 @@ makeOutputConfiguration(MIDIDevice& self, MIDISpecificSettings& set) return std::make_pair(conf, api_conf); } +Device::DeviceKinds MIDIDevice::kinds() const noexcept +{ + const auto& set + = settings().deviceSpecificSettings.value(); + switch(set.io) + { + case MIDISpecificSettings::IO::In: + return Device::DeviceKind::MidiIn; + case MIDISpecificSettings::IO::Out: + return Device::DeviceKind::MidiOut; + default: + return {}; + } +} + bool MIDIDevice::reconnect() { disconnect(); diff --git a/src/plugins/score-plugin-protocols/Protocols/MIDI/MIDIDevice.hpp b/src/plugins/score-plugin-protocols/Protocols/MIDI/MIDIDevice.hpp index 185ce7e6f4..e51c7695f2 100644 --- a/src/plugins/score-plugin-protocols/Protocols/MIDI/MIDIDevice.hpp +++ b/src/plugins/score-plugin-protocols/Protocols/MIDI/MIDIDevice.hpp @@ -13,6 +13,11 @@ class MidiKeyboardEventFilter; struct MIDISpecificSettings; class MIDIDevice final : public Device::OwningDeviceInterface { +public: + //! Which way round this port goes is in the protocol's own settings, so it + //! is the device that knows -- and now says so where anyone can read it. + Device::DeviceKinds kinds() const noexcept override; + public: MIDIDevice( const Device::DeviceSettings& settings, diff --git a/src/plugins/score-plugin-recording/Recording/Record/RecordMessagesManager.cpp b/src/plugins/score-plugin-recording/Recording/Record/RecordMessagesManager.cpp index 3d9e382be7..c4cdce9081 100644 --- a/src/plugins/score-plugin-recording/Recording/Record/RecordMessagesManager.cpp +++ b/src/plugins/score-plugin-recording/Recording/Record/RecordMessagesManager.cpp @@ -158,7 +158,10 @@ bool MessageRecorder::setup(const Box& box, const RecordListening& recordListeni //// Setup listening on the curves //// for(const auto& vec : recordListening) { - auto& dev = devicelist.device(*vec.front()); + auto* dev_p = devicelist.findDevice(Device::deviceName(*vec.front())); + if(!dev_p) + continue; + auto& dev = *dev_p; if(!dev.connected()) continue; diff --git a/src/plugins/score-plugin-remotecontrol/RemoteControl/Websockets/DocumentPlugin.cpp b/src/plugins/score-plugin-remotecontrol/RemoteControl/Websockets/DocumentPlugin.cpp index ba518d2580..a431b926d5 100644 --- a/src/plugins/score-plugin-remotecontrol/RemoteControl/Websockets/DocumentPlugin.cpp +++ b/src/plugins/score-plugin-remotecontrol/RemoteControl/Websockets/DocumentPlugin.cpp @@ -245,9 +245,16 @@ Receiver::Receiver(const score::DocumentContext& doc) auto it = obj.FindMember("Code"); if(it == obj.MemberEnd()) return; + if(!it->value.IsString()) + return; const auto& str = JsonValue{it->value}.toString(); - auto& console = doc.app.panel(); - console.engine().evaluate(str); + + // findPanel: there is no console without a GUI, and panel aborts + // rather than returning nothing. + auto* console = doc.app.findPanel(); + if(!console) + return; + console->engine().evaluate(str); })); m_answers.insert(std::make_pair( @@ -313,11 +320,8 @@ void Receiver::close() { m_server.close(); - // 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. + // Taken first: ~QWebSocket emits disconnected() synchronously, which erases + // from the container being walked. auto clients = std::exchange(m_clients, {}); m_listenedAddresses.clear(); @@ -342,9 +346,7 @@ quint16 Receiver::port() const noexcept 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. + // On the URL, not in a handshake: a browser cannot set WebSocket headers. const auto given = QUrlQuery{socket.requestUrl().query()}.queryItemValue(QStringLiteral("token")); return !m_settings.token.isEmpty() && given == m_settings.token; diff --git a/src/plugins/score-plugin-scenario/Scenario/Commands/Interval/AddOnlyProcessToInterval.cpp b/src/plugins/score-plugin-scenario/Scenario/Commands/Interval/AddOnlyProcessToInterval.cpp index 17690141a0..de713ad893 100644 --- a/src/plugins/score-plugin-scenario/Scenario/Commands/Interval/AddOnlyProcessToInterval.cpp +++ b/src/plugins/score-plugin-scenario/Scenario/Commands/Interval/AddOnlyProcessToInterval.cpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include #include #include @@ -75,11 +77,24 @@ Process::ProcessModel& AddOnlyProcessToInterval::redo( IntervalModel& interval, const score::DocumentContext& ctx) const { // Create process model - auto fac = ctx.app.interfaces().get(m_processName); - SCORE_ASSERT(fac); - auto proc = fac->make( - interval.duration.defaultDuration(), // TODO should maybe be max ? - m_data, m_createdProcessId, ctx, &interval); + auto& facs = ctx.app.interfaces(); + auto fac = facs.get(m_processName); + + // A peer runs its own build, so a command can name a process this one + // cannot make. A stand-in keeps the document in step, and says it is one. + Process::ProcessModel* proc + = fac ? fac->make( + interval.duration.defaultDuration(), // TODO should maybe be max ? + m_data, m_createdProcessId, ctx, &interval) + : facs.makeMissing( + m_processName, interval.duration.defaultDuration(), + m_createdProcessId, &interval); + SCORE_ASSERT(proc); + + // Creation data can name a file on the machine that sent the command, so + // even a factory we have produces an empty process. + if(fac && ctx.role() != score::DocumentRole::Local) + Process::awaitingRemoteState().push_back(proc); proc->setPosition(m_graphpos); AddProcess(interval, proc); @@ -127,11 +142,16 @@ Process::ProcessModel& LoadOnlyLayerInInterval::redo( const JsonValue obj{m_data.GetObject()}; auto key = obj[score::StringConstant().uuid].to>(); - auto fac = ctx.app.interfaces().get(key); - SCORE_ASSERT(fac); - // TODO handle missing process + auto& facs = ctx.app.interfaces(); + auto fac = facs.get(key); + + // Carries the process serialized, so loadMissing keeps it verbatim and + // rebuilds the ports. JSONObject::Deserializer des{obj}; - auto proc = fac->load(des.toVariant(), ctx, &interval); + Process::ProcessModel* proc + = fac ? fac->load(des.toVariant(), ctx, &interval) + : facs.loadMissing(key, des.toVariant(), ctx, &interval); + SCORE_ASSERT(proc); const auto ports = proc->findChildren(); for(Process::Port* port : ports) { diff --git a/tests/fixtures/score_test/AbsentFactory.hpp b/tests/fixtures/score_test/AbsentFactory.hpp new file mode 100644 index 0000000000..9f2cf0375f --- /dev/null +++ b/tests/fixtures/score_test/AbsentFactory.hpp @@ -0,0 +1,77 @@ +#pragma once +/** + * Hiding a factory from this build, for a scope. + * + * The session tests run both peers in one process, sharing one set of + * factories, so the case that matters most on a terminal cannot be written: a + * peer that *cannot construct* what the other side can. A browser has no evdev, + * no MIDI, no camera; it carries their settings without ever decoding them, and + * hands them back when it asks for a device to be made. + * + * Every device bug in that story was this asymmetry, and the suite caught none + * of them -- they were found by hand against a real wasm client, and one of them + * needed the wire hand-written byte by byte to reproduce at all. A test that + * passes with both sides symmetric proves the case that was never in doubt. + * + * So: take the factory out of the application's list, run the half of the + * exchange that must happen without it, and put it back. What the code does + * while it is gone is what a terminal does all the time. + * + * QByteArray wire; + * { + * score::test::absent_factory hidden{ctx, key}; + * REQUIRE(hidden.was_present()); // or the test proves nothing + * DataStream::Serializer s{&wire}; + * s.readFrom(settings); // as the machine without it writes + * } + * DataStream::Deserializer d{wire}; + * d.writeTo(received); // as the machine with it reads + */ +#include +#include +#include + +#include +#include + +namespace score::test +{ +template +class absent_factory +{ +public: + using key_type = typename FactoryList::key_type; + + absent_factory(const score::ApplicationContext& ctx, const key_type& key) + // The list is const through the context because nothing in score changes + // it after load. A test that puts back what it took is the exception. + : m_list{const_cast(ctx.interfaces())} + , m_key{key.impl()} + { + if(auto it = m_list.map.find(m_key); it != m_list.map.end()) + { + m_held = std::move(it->second); + m_list.map.erase(it); + } + } + + ~absent_factory() + { + if(m_held) + m_list.map.emplace(m_key, std::move(m_held)); + } + + absent_factory(const absent_factory&) = delete; + absent_factory& operator=(const absent_factory&) = delete; + + //! Whether there was anything to hide. A test asserting behaviour "without + //! the factory" says nothing if the factory was never in this build -- the + //! protocols are conditional, so this is worth checking rather than assuming. + bool was_present() const noexcept { return bool(m_held); } + +private: + FactoryList& m_list; + score::uuid_t m_key; + std::unique_ptr m_held; +}; +} diff --git a/tests/fixtures/score_test/ProbeProtocol.hpp b/tests/fixtures/score_test/ProbeProtocol.hpp new file mode 100644 index 0000000000..ad903ab007 --- /dev/null +++ b/tests/fixtures/score_test/ProbeProtocol.hpp @@ -0,0 +1,223 @@ +#pragma once + +// A protocol that exists but builds nothing, for tests about whether score +// *asks* for a device rather than what it gets back. +// +// Returning null is a case the explorer already handles -- it is what a +// protocol whose hardware is absent does -- so the request count is what is +// under test. A protocol the build genuinely has is the point: not building a +// device then reads as a decision rather than an absence. + +#include +#include +#include +#include +#include + +#include +#include + +namespace score::test +{ + +//! A device that is really there, whose state a test can drive. +//! +//! ProbeProtocolFactory deliberately builds none -- it is for tests about +//! whether score *asks*. But a host with no DeviceInterface at all reports +//! nothing about its devices, so everything that carries device state to a peer +//! can be deleted without a single test noticing. This protocol owns one. +struct ProbeDevice final : public Device::DeviceInterface +{ + using Device::DeviceInterface::DeviceInterface; + + bool reconnect() override { return m_connected; } + ossia::net::device_base* getDevice() const override { return nullptr; } + bool connected() const override { return m_connected; } + Device::DeviceKinds kinds() const noexcept override { return m_kinds; } + + void setConnected(bool b) + { + m_connected = b; + connectionChanged(b); + } + + bool m_connected{true}; + Device::DeviceKinds m_kinds{ + Device::DeviceKinds{Device::DeviceKind::MidiIn} | Device::DeviceKind::TextureOut}; +}; + +struct ConnectedProbeProtocolFactory final : public Device::ProtocolFactory +{ + SCORE_CONCRETE("2b7d6d05-4f1a-4c1e-9a2b-5e0f0d5b7c31") +public: + static inline ProbeDevice* last = nullptr; + + QString prettyName() const noexcept override + { + return QStringLiteral("Connected probe"); + } + QString category() const noexcept override { return StandardCategories::util; } + + Device::DeviceInterface* makeDevice( + const Device::DeviceSettings& s, const Explorer::DeviceDocumentPlugin&, + const score::DocumentContext&) override + { + last = new ProbeDevice{s}; + return last; + } + + const Device::DeviceSettings& defaultSettings() const noexcept override + { + static const Device::DeviceSettings s = [] { + Device::DeviceSettings d; + d.protocol = static_concreteKey(); + d.name = QStringLiteral("ConnectedProbe"); + return d; + }(); + return s; + } + + Device::AddressDialog* makeAddAddressDialog( + const Device::DeviceInterface&, const score::DocumentContext&, QWidget*) override + { + return nullptr; + } + Device::AddressDialog* makeEditAddressDialog( + const Device::AddressSettings&, const Device::DeviceInterface&, + const score::DocumentContext&, QWidget*) override + { + return nullptr; + } + Device::ProtocolSettingsWidget* makeSettingsWidget() override { return nullptr; } + + QVariant makeProtocolSpecificSettings(const VisitorVariant&) const override + { + return {}; + } + void serializeProtocolSpecificSettings(const QVariant&, const VisitorVariant&) + const override + { + } + bool checkCompatibility( + const Device::DeviceSettings&, const Device::DeviceSettings&) const noexcept override + { + return true; + } +}; + +//! Two devices under one heading, so that a test can tell a category from a +//! name without depending on what is plugged into the machine. +struct ProbeEnumerator final : public Device::DeviceEnumerator +{ + void enumerate(std::function f) + const override + { + for(const auto& name : {QStringLiteral("probe-one"), QStringLiteral("probe-two")}) + { + Device::DeviceSettings s; + s.name = name; + f(name, s); + } + } +}; + +struct ProbeProtocolFactory final : public Device::ProtocolFactory +{ + SCORE_CONCRETE("9f1c0f4a-1b2c-4d3e-8f70-0badc0ffee00") +public: + static inline int requests = 0; + + QString prettyName() const noexcept override { return QStringLiteral("Probe"); } + QString category() const noexcept override { return StandardCategories::util; } + + Device::DeviceInterface* makeDevice( + const Device::DeviceSettings&, const Explorer::DeviceDocumentPlugin&, + const score::DocumentContext&) override + { + ++requests; + return nullptr; + } + + const Device::DeviceSettings& defaultSettings() const noexcept override + { + static const Device::DeviceSettings s = [] { + Device::DeviceSettings d; + d.protocol = static_concreteKey(); + d.name = QStringLiteral("Probe"); + return d; + }(); + return s; + } + + Device::AddressDialog* makeAddAddressDialog( + const Device::DeviceInterface&, const score::DocumentContext&, QWidget*) override + { + return nullptr; + } + Device::AddressDialog* makeEditAddressDialog( + const Device::AddressSettings&, const Device::DeviceInterface&, + const score::DocumentContext&, QWidget*) override + { + return nullptr; + } + Device::ProtocolSettingsWidget* makeSettingsWidget() override { return nullptr; } + + static inline const QString enumeratorCategory{QStringLiteral("Probes")}; + Device::DeviceEnumerators + getEnumerators(const score::DocumentContext&) const override + { + return {{enumeratorCategory, new ProbeEnumerator}}; + } + + QVariant makeProtocolSpecificSettings(const VisitorVariant&) const override + { + return {}; + } + void serializeProtocolSpecificSettings(const QVariant&, const VisitorVariant&) + const override + { + } + bool checkCompatibility( + const Device::DeviceSettings&, const Device::DeviceSettings&) const noexcept override + { + return true; + } +}; + +//! Registered into the running application, so that the protocol is one this +//! build genuinely has. +inline void register_probe_protocol(const score::GUIApplicationContext& ctx) +{ + auto& list = ctx.interfaces(); + if(list.get(ProbeProtocolFactory::static_concreteKey())) + return; + const_cast(list).insert( + std::make_unique()); +} + +inline void register_connected_probe_protocol(const score::GUIApplicationContext& ctx) +{ + auto& list = ctx.interfaces(); + if(list.get(ConnectedProbeProtocolFactory::static_concreteKey())) + return; + const_cast(list).insert( + std::make_unique()); +} + +inline Device::Node connected_probe_device_node(const QString& name) +{ + Device::DeviceSettings s; + s.protocol = ConnectedProbeProtocolFactory::static_concreteKey(); + s.name = name; + return Device::Node{s, nullptr}; +} + +inline Device::Node probe_device_node(const QString& name) +{ + Device::DeviceSettings s; + s.protocol = ProbeProtocolFactory::static_concreteKey(); + s.name = name; + return Device::Node{s, nullptr}; +} + +} diff --git a/tests/integration/CMakeLists.txt b/tests/integration/CMakeLists.txt index f5ef942142..736755b9a2 100644 --- a/tests/integration/CMakeLists.txt +++ b/tests/integration/CMakeLists.txt @@ -164,6 +164,28 @@ score_add_test(test_regression_throwing_drop_handler # One harness per shader kind in the library: they are authored differently, # fail differently, and each keeps its own baseline. All need a GPU-capable # display, and all skip when no library is installed. +# The sweeps read a real shader library and diff against a checked-in baseline, +# so they need to be told where one is: the test fixture gives every test its own +# XDG_CONFIG_HOME and application name, on purpose, so the library root a user +# configured is deliberately not visible. Probed here, overridable, and skipped +# cleanly when there is none. +if(NOT DEFINED SCORE_SHADER_LIBRARY_DIR) + foreach(_candidate + "$ENV{HOME}/Documents/ossia/score/packages/default" + "$ENV{HOME}/Documents/ossia/score-test/packages/default") + if(EXISTS "${_candidate}") + set(SCORE_SHADER_LIBRARY_DIR "${_candidate}" CACHE PATH + "Shader library the sweeps run over; empty means skip them") + break() + endif() + endforeach() +endif() + +if(NOT SCORE_SHADER_LIBRARY_DIR) + message(STATUS "No shader library found: the shader sweeps will skip. " + "Set SCORE_SHADER_LIBRARY_DIR to run them.") +endif() + foreach(kind ISF VSA CSF) string(TOLOWER "${kind}" lc) score_add_test(test_integration_shader_sweep_${lc} @@ -189,6 +211,15 @@ foreach(kind ISF VSA CSF) target_compile_definitions(test_integration_shader_sweep_${lc} PRIVATE "SCORE_SHADER_SWEEP_BASELINE_${kind}=\"${_baseline}\"") + + # Catch2 exits 4 when every test skipped. Without this a skip is reported as a + # failure, which is how three sweeps sat "failing" while never running. + set_tests_properties(test_integration_shader_sweep_${lc} PROPERTIES + SKIP_RETURN_CODE 4) + if(SCORE_SHADER_LIBRARY_DIR) + set_tests_properties(test_integration_shader_sweep_${lc} PROPERTIES + ENVIRONMENT "SCORE_SHADER_LIBRARY_DIR=${SCORE_SHADER_LIBRARY_DIR}") + endif() endforeach() # Drags that never get their mouse release: QGraphicsScene drops the implicit @@ -212,15 +243,73 @@ score_add_test(test_integration_device_unimplemented APP PLUGINS score_plugin_scenario score_plugin_deviceexplorer) +# --- terminal role: a document that must not touch this machine ------------- +score_add_test(test_integration_terminal_role + SOURCES TerminalRoleTest.cpp + APP + PLUGINS score_plugin_scenario score_plugin_deviceexplorer score_plugin_engine) + +# What a port may be connected to, on a machine holding none of the devices. +score_add_test(test_integration_terminal_port_combo + SOURCES TerminalPortComboTest.cpp + APP + PLUGINS score_plugin_scenario score_plugin_deviceexplorer) + +# The panel only exists with the GUI stack, and one binary cannot mix the two +# application fixtures. +score_add_test(test_integration_terminal_library + SOURCES TerminalLibraryTest.cpp + GUI + PLUGINS score_plugin_scenario score_plugin_deviceexplorer score_plugin_library) + # --- remote control: who is allowed to drive this instance ------------------ # The WebSocket API sets device parameters, drives transport and can evaluate # JavaScript, so the access controls are worth asserting rather than assuming. if(TARGET score_plugin_remotecontrol) + # GUI: enforcing the scripting gate means seeing whether the console ran the + # code, and there is no console without the panel. score_add_test(test_integration_remote_control_auth SOURCES RemoteControlAuthTest.cpp - APP - PLUGINS score_plugin_remotecontrol score_plugin_deviceexplorer + GUI + PLUGINS score_plugin_remotecontrol score_plugin_deviceexplorer score_plugin_js LIBS ${QT_PREFIX}::WebSockets) target_include_directories(test_integration_remote_control_auth PRIVATE "${SCORE_ROOT_SOURCE_DIR}/src/plugins/score-plugin-remotecontrol") endif() + +# Whether the editing surface shows the same thing on both machines. +score_add_test(test_integration_terminal_slots + SOURCES TerminalSlotTest.cpp + GUI + PLUGINS score_plugin_scenario score_plugin_deviceexplorer score_plugin_automation + score_plugin_curve) + +# Dropping a file when the score runs on another machine. +score_add_test(test_integration_terminal_file_drop + SOURCES TerminalFileDropTest.cpp + APP + PLUGINS score_lib_process score_plugin_scenario score_plugin_media) + +# Settings a machine carried without understanding them, read by one that does. +score_add_test(test_integration_opaque_device_settings + SOURCES OpaqueDeviceSettingsTest.cpp + APP + PLUGINS score_plugin_deviceexplorer score_plugin_protocols score_plugin_scenario) + +# Adding a device whose protocol only the other machine has. +score_add_test(test_integration_terminal_device_add + SOURCES TerminalDeviceAddTest.cpp + GUI + PLUGINS score_plugin_deviceexplorer score_plugin_scenario) + +# Presets listed through the document's environment, not the local library. +score_add_test(test_integration_device_preset_source + SOURCES DevicePresetSourceTest.cpp + GUI + PLUGINS score_plugin_deviceexplorer score_plugin_scenario) + +# A waveform that cannot be computed must not be re-requested by painting. +score_add_test(test_integration_sound_view_spin + SOURCES SoundViewSpinTest.cpp + GUI + PLUGINS score_plugin_scenario score_plugin_media) diff --git a/tests/integration/DevicePresetSourceTest.cpp b/tests/integration/DevicePresetSourceTest.cpp new file mode 100644 index 0000000000..127bd74869 --- /dev/null +++ b/tests/integration/DevicePresetSourceTest.cpp @@ -0,0 +1,108 @@ +// Device presets are files, and on a terminal the files are on the other +// machine. The dialog used to scan the local library folder directly, so a +// terminal -- whose library folder is empty, or does not exist at all in a +// browser -- offered no presets. It asks the document's environment now. +// +// The environment here is scripted rather than local: what is under test is +// that the dialog takes what the environment gives it, which is the part that +// differs between a laptop and a browser. + +#include + +#include +#include + +#include +#include + +#include + +#include +#include + +#include +#include +#include +#include + +#include + +namespace +{ +//! A library that is not on this machine: nothing it names exists as a path. +struct ElsewhereEnvironment final : score::Environment +{ + int listed{}; + QString listedPath; + + bool isLocal() const noexcept override { return false; } + QString resolve(const score::Uri&) const override { return {}; } + + void list( + const score::Uri& uri, Callback> onListed, + Callback) override + { + listed++; + listedPath = uri.path; + + std::vector entries; + if(uri.path == "packages") + { + entries.push_back(score::DirEntry{ + score::Uri{score::UriScheme::Library, "packages/remote-osc.device"}, + "remote-osc.device", false, 12}); + } + if(onListed) + onListed(std::move(entries)); + } + + void read(const score::Uri&, Callback, Callback) override { } + void write(const score::Uri&, QByteArray, Done, Callback) override { } +}; +} + +TEST_CASE("Device presets come from the document's environment", "[devices]") +{ + score::test::run_in_gui_app([](const score::GUIApplicationContext& ctx) { + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + + auto env = std::make_unique(); + auto* envPtr = env.get(); + doc->setEnvironment(std::move(env)); + + auto& explorer = Explorer::deviceExplorerFromContext(doc->context()); + Explorer::DeviceEditDialog dial{ + explorer, ctx.interfaces(), + Explorer::DeviceEditDialog::Creating, nullptr}; + + // The walk yields to the event loop between directories, so that a library + // on a slow disk does not hold the dialog shut. Nothing here is allowed to + // depend on it having finished by accident. + auto* presets = dial.findChild("PresetList"); + REQUIRE(presets); + + QElapsedTimer t; + t.start(); + while(presets->topLevelItemCount() == 0 && t.elapsed() < 5000) + { + QApplication::processEvents(); + QThread::msleep(1); + } + REQUIRE(presets->topLevelItemCount() == 1); + + // The library it asked, not a folder on this machine. Checked after the + // wait: the walk is queued, so asking before it has run says nothing. + CHECK(envPtr->listed >= 1); + CHECK(envPtr->listedPath == "packages"); + + auto* item = presets->topLevelItem(0); + CHECK(item->text(0) == "remote-osc"); + + // The URI, not a path: reading it later has to go back through the + // environment, and a path from another machine names nothing here. + CHECK( + item->data(0, Qt::UserRole).toString() + == ":packages/remote-osc.device"); + }); +} diff --git a/tests/integration/OpaqueDeviceSettingsTest.cpp b/tests/integration/OpaqueDeviceSettingsTest.cpp new file mode 100644 index 0000000000..6e7337654d --- /dev/null +++ b/tests/integration/OpaqueDeviceSettingsTest.cpp @@ -0,0 +1,151 @@ +// Settings a machine carried without understanding them, read by one that does. +// +// A terminal has no factory for most protocols, so it never decodes their +// settings: it keeps the bytes and hands them back when adding the device. The +// host then reconstructs them with the real protocol. That is the only path by +// which a device gets added from a terminal, and it aborted. +// +// The reason is a name collision. The protocol writes its settings into the +// *same* JSON object as the device's own "Name" and "Protocol", and evdev calls +// one of its own settings "Name". Building the opaque payload strips the +// members score owns -- so that writing it back cannot duplicate them -- and +// that took the protocol's "Name" with it. rapidjson's operator[] on a missing +// member asserts, so the host died reading a device the terminal sent. + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +#include + +namespace +{ +//! One enumerated device per protocol that has any. Every protocol, not the +//! first with a "Name" in its JSON -- score writes a "Name" for the device +//! itself, so that test matches everything and proves nothing. Only some +//! protocols name a setting of their own the same way, and those are the ones +//! that broke. +std::vector> +enumeratedDevices(const score::GUIApplicationContext& ctx, score::Document& doc) +{ + std::vector> out; + for(auto& factory : ctx.interfaces()) + { + std::optional found; + for(auto [category, enumerator] : factory.getEnumerators(doc.context())) + { + std::unique_ptr owned{enumerator}; + if(owned && !found) + owned->enumerate([&](const QString&, const Device::DeviceSettings& s) { + if(!found) + found = s; + }); + } + if(found) + out.emplace_back(factory.prettyName(), *found); + } + return out; +} + +//! What a machine without the factory ends up holding: the object minus the +//! members score owns. Written by the real path, through a JSON deserializer +//! running while the factory is hidden -- exactly what a browser does with a +//! protocol it does not have. +QByteArray strippedPayload(const Device::DeviceSettings& s) +{ + JSONReader r; + r.readFrom(s); + const auto bytes = r.toByteArray(); + + rapidjson::Document doc; + doc.Parse(bytes.constData(), bytes.size()); + REQUIRE(!doc.HasParseError()); + + // The real stripping, not a hand-rolled RemoveMember: that drops only the + // first member of a name, and the protocol writes into the same object, so + // its own "Name" is a second one that survives. Skipping every member of the + // name -- what score actually does -- is what loses it. + const QStringList owned{ + QString::fromStdString(score::StringConstant().Name), + QString::fromStdString(score::StringConstant().Protocol)}; + return score::OpaquePayload::fromJson(doc, owned).toBlob(); +} +} + +TEST_CASE("Settings carried without being understood survive the trip", "[devices]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + + const auto devices = enumeratedDevices(ctx, *doc); + if(devices.empty()) + { + WARN("nothing is plugged into this machine; nothing to carry"); + return; + } + + for(const auto& [protocolName, device] : devices) + { + INFO("protocol: " << protocolName.toStdString()); + const auto* original = &device; + + // As a terminal holds it: the name and the protocol, and the rest as + // bytes it never looked inside. + Device::DeviceSettings carried; + carried.name = original->name; + carried.protocol = original->protocol; + carried.opaqueSettings = strippedPayload(*original); + REQUIRE(!carried.opaqueSettings.isEmpty()); + + // Sent to the machine that will make the device, which does have the + // protocol. This aborted: the protocol asked for a "Name" that stripping + // had removed. + // Written the way the machine without the factory writes it: with the + // factory hidden, so readFrom takes the branch that forwards the carried + // bytes instead of re-encoding from settings it never decoded. Hand- + // writing these bytes was the only way to reproduce this before. + QByteArray wire; + { + score::test::absent_factory hidden{ + ctx, device.protocol}; + REQUIRE(hidden.was_present()); + + DataStream::Serializer s{&wire}; + s.readFrom(carried); + } + + Device::DeviceSettings received; + { + DataStream::Deserializer d{wire}; + d.writeTo(received); + } + + CHECK(received.name == original->name); + CHECK(received.protocol == original->protocol); + REQUIRE(received.deviceSpecificSettings.isValid()); + + // The content, not merely that something came back. A missing member + // does not always abort -- with assertions compiled out, rapidjson hands + // back a garbage value and the device is made from nonsense instead. + // Comparing what the protocol writes for each is the only check that + // sees the difference. + JSONReader before, after; + before.readFrom(*original); + after.readFrom(received); + CHECK(before.toByteArray() == after.toByteArray()); + } + }); +} diff --git a/tests/integration/SoundViewSpinTest.cpp b/tests/integration/SoundViewSpinTest.cpp new file mode 100644 index 0000000000..5f59f2722f --- /dev/null +++ b/tests/integration/SoundViewSpinTest.cpp @@ -0,0 +1,88 @@ +// A waveform that cannot be computed must not be asked for again on every +// repaint. recompute() gives up without marking itself done when the file has +// no data, and paint_impl asked whenever there was nothing drawn: the two +// called each other for as long as the process existed, which pegs the thread +// that paints. In a browser that is the only thread, so the page dies -- and a +// terminal never has the file, since it is on the machine running the score. + +#include +#include +#include + +#include +#include +#include +#include + +#include + +#include +#include +#include + +#include + +#include +#include +#include +#include + +#include +#include + +#include + +TEST_CASE("A waveform that cannot be drawn is not asked for again", "[media]") +{ + qputenv("SCORE_DISABLE_LIBRARY", "1"); + + score::test::run_in_gui_app([](const score::GUIApplicationContext& ctx) { + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + + auto& itv = safe_cast(doc->model().modelDelegate()) + .baseScenario() + .interval(); + + // A path that names nothing here, which is what a terminal always has: the + // file is on the machine running the score. + doc->context().document.commandStack().redoAndPush( + new Scenario::Command::AddOnlyProcessToInterval{ + itv, Metadata::get(), + QStringLiteral("/nonexistent/not-here.wav"), QPointF{}}); + + Media::Sound::ProcessModel* model{}; + for(auto& p : itv.processes) + if(auto* snd = qobject_cast(&p)) + model = snd; + REQUIRE(model); + REQUIRE(model->file()); + REQUIRE(model->file()->sampleRate() < 1); + + // In a scene, as a real layer is: the view reads the scene's view for the + // device pixel ratio. + QGraphicsScene scene; + auto* root = new QGraphicsRectItem; + scene.addItem(root); + + Media::Sound::LayerView view{*model, root}; + view.setWidth(400.); + view.setHeight(100.); + view.recompute(1.); + view.setData(model->file()); + + QPixmap pm{400, 100}; + QPainter p{&pm}; + + const int before = view.recomputeCount(); + for(int i = 0; i < 50; i++) + view.paint(&p, nullptr, nullptr); + + // Painting is the only proof that a view exists, which recompute() needs + // and nothing else signals, so one ask is legitimate. What must be bounded + // is the total: nothing about the answer changes between two paints, and + // asking on each of them is a loop that outlives the process. When the data + // does arrive, on_newData asks on its own. + CHECK(view.recomputeCount() <= before + 1); + }); +} diff --git a/tests/integration/TerminalDeviceAddTest.cpp b/tests/integration/TerminalDeviceAddTest.cpp new file mode 100644 index 0000000000..7bc6293594 --- /dev/null +++ b/tests/integration/TerminalDeviceAddTest.cpp @@ -0,0 +1,330 @@ +// Adding a device whose protocol this build does not have. +// +// That is the ordinary case on a terminal: the score runs on the other machine, +// which has the evdev, the MIDI ports and the cameras. The dialog offers that +// machine's protocols and the hardware it enumerated, and the device is created +// over there. Nothing here can build a settings form for it -- the form is C++ +// in a plug-in we do not have -- so the settings that came across are the whole +// of what we know, and dropping them means such a device can never be added. + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include + +#include + +#include +#include + +#include + +#include + +namespace +{ +constexpr auto absent_uuid = "c0ffee00-1111-2222-3333-444455556666"; + +UuidKey absentProtocol() +{ + return UuidKey::fromString(QString{absent_uuid}); +} + +//! The other machine's protocols and hardware. "Absent" is one this build has +//! no factory for, which is the case under test; "Barren" is one that +//! enumerates nothing, like OSC or MQTT. +struct OtherMachine final : Device::DeviceCatalog +{ + QString enumeratedName{"Keyboard"}; + bool answerDevices{true}; + + std::vector protocols() const override + { + return { + Protocol{absentProtocol(), "Evdev-like", "Input", false}, + Protocol{ + UuidKey::fromString( + QString{"dead0000-1111-2222-3333-444455556666"}), + "Barren", "Network", false}}; + } + + void enumerate(const UuidKey& protocol, OnDevice onDevice) + override + { + if(!answerDevices || protocol != absentProtocol()) + return; + + Device::DeviceSettings s; + s.protocol = absentProtocol(); + s.name = enumeratedName; + onDevice("Devices", enumeratedName, s); + } +}; + +Device::DeviceSettings enumeratedSettings() +{ + Device::DeviceSettings s; + s.protocol = absentProtocol(); + s.name = "Keyboard"; + return s; +} +} + +TEST_CASE("A device whose protocol only the other machine has can be added", "[devices]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + + // Not something this build can make: that is the whole point. + REQUIRE(!ctx.interfaces().get(absentProtocol())); + + OtherMachine catalog; + auto& plug = doc->context().plugin(); + plug.setCatalog(&catalog); + + auto& explorer = Explorer::deviceExplorerFromContext(doc->context()); + + // Refusing here is what made every such device unaddable: the check asked + // this machine for a factory, and the device is made on the other one. + CHECK(explorer.checkDeviceInstantiatable(enumeratedSettings())); + + // A protocol nobody offers is still refused. + Device::DeviceSettings unknown; + unknown.name = "nope"; + unknown.protocol + = UuidKey::fromString(QString{"11112222-3333-4444-5555-666677778888"}); + CHECK_FALSE(explorer.checkDeviceInstantiatable(unknown)); + + plug.setCatalog(nullptr); + }); +} + +TEST_CASE("The settings the other machine sent are what gets added", "[devices]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + + OtherMachine catalog; + auto& plug = doc->context().plugin(); + plug.setCatalog(&catalog); + + auto& explorer = Explorer::deviceExplorerFromContext(doc->context()); + Explorer::DeviceEditDialog dial{ + explorer, ctx.interfaces(), + Explorer::DeviceEditDialog::Creating, nullptr}; + + dial.setSettings(enumeratedSettings()); + + // There is no widget to hold them -- the form lives in a plug-in this build + // does not have -- so the dialog itself has to remember them. Answering + // with an empty DeviceSettings is how the device got lost. + const auto out = dial.getSettings(); + CHECK(out.protocol == absentProtocol()); + CHECK(out.name == "Keyboard"); + + plug.setCatalog(nullptr); + }); +} + +TEST_CASE("A protocol that enumerates nothing shows no device list", "[devices]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + + OtherMachine catalog; + catalog.answerDevices = false; + auto& plug = doc->context().plugin(); + plug.setCatalog(&catalog); + + auto& explorer = Explorer::deviceExplorerFromContext(doc->context()); + Explorer::DeviceEditDialog dial{ + explorer, ctx.interfaces(), + Explorer::DeviceEditDialog::Creating, nullptr}; + + dial.setSettings(enumeratedSettings()); + + // Most protocols have nothing plugged into them. A column headed "Devices" + // that is permanently empty says the other machine has none, which is a + // different claim from not asking. + // + // isHidden, not isVisible: nothing is visible in a dialog that was never + // shown, so isVisible() would pass here whatever the code did. + auto* devices = dial.findChild("DeviceList"); + REQUIRE(devices); + CHECK(devices->isHidden()); + + plug.setCatalog(nullptr); + }); +} + +TEST_CASE("A protocol with hardware behind it shows the list", "[devices]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + + OtherMachine catalog; + auto& plug = doc->context().plugin(); + plug.setCatalog(&catalog); + + auto& explorer = Explorer::deviceExplorerFromContext(doc->context()); + Explorer::DeviceEditDialog dial{ + explorer, ctx.interfaces(), + Explorer::DeviceEditDialog::Creating, nullptr}; + + dial.setSettings(enumeratedSettings()); + + auto* devices = dial.findChild("DeviceList"); + REQUIRE(devices); + CHECK_FALSE(devices->isHidden()); + + // The category heading, and the keyboard under it. + REQUIRE(devices->topLevelItemCount() == 1); + REQUIRE(devices->topLevelItem(0)->childCount() == 1); + CHECK(devices->topLevelItem(0)->child(0)->text(0) == "Keyboard"); + + plug.setCatalog(nullptr); + }); +} + +TEST_CASE("Picking the other machine's hardware carries its settings", "[devices]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + + OtherMachine catalog; + auto& plug = doc->context().plugin(); + plug.setCatalog(&catalog); + + auto& explorer = Explorer::deviceExplorerFromContext(doc->context()); + Explorer::DeviceEditDialog dial{ + explorer, ctx.interfaces(), + Explorer::DeviceEditDialog::Creating, nullptr}; + + // The real path: choose the protocol, then click what is plugged into the + // other machine. Shown, because the dialog ignores clicks on a column it + // believes is not on screen. + dial.setSettings(enumeratedSettings()); + dial.show(); + + auto* devices = dial.findChild("DeviceList"); + REQUIRE(devices); + REQUIRE(devices->topLevelItemCount() == 1); + auto* item = devices->topLevelItem(0)->child(0); + REQUIRE(item); + + devices->setCurrentItem(item); + devices->activated(devices->currentIndex()); + + const auto out = dial.getSettings(); + CHECK(out.protocol == absentProtocol()); + CHECK(out.name == "Keyboard"); + + plug.setCatalog(nullptr); + }); +} + +TEST_CASE("The device handed to the add command is not empty", "[devices]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + + OtherMachine catalog; + auto& plug = doc->context().plugin(); + plug.setCatalog(&catalog); + + auto& explorer = Explorer::deviceExplorerFromContext(doc->context()); + Explorer::DeviceEditDialog dial{ + explorer, ctx.interfaces(), + Explorer::DeviceEditDialog::Creating, nullptr}; + + dial.setSettings(enumeratedSettings()); + + // What the add path actually reads -- not getSettings(). An empty node here + // is a null target, which the caller dereferenced on its way to adding + // nothing: Add was clickable and did nothing at all. + auto node = dial.getDevice(); + auto* settings = node.target(); + REQUIRE(settings); + CHECK(settings->protocol == absentProtocol()); + CHECK(settings->name == "Keyboard"); + + // And it must be something the model will accept, or it is dropped one + // step later. + CHECK(explorer.checkDeviceInstantiatable(*settings)); + + plug.setCatalog(nullptr); + }); +} + +TEST_CASE("Nothing chosen yields no device rather than a broken one", "[devices]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + + OtherMachine catalog; + auto& plug = doc->context().plugin(); + plug.setCatalog(&catalog); + + auto& explorer = Explorer::deviceExplorerFromContext(doc->context()); + Explorer::DeviceEditDialog dial{ + explorer, ctx.interfaces(), + Explorer::DeviceEditDialog::Creating, nullptr}; + + // Nothing selected: an empty node is right, and the caller has to cope + // with it rather than read through it. + auto node = dial.getDevice(); + CHECK(node.target() == nullptr); + + plug.setCatalog(nullptr); + }); +} + +TEST_CASE("A device arriving with a tree announces it", "[devices]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + + auto& plug = doc->context().plugin(); + auto& explorer = Explorer::deviceExplorerFromContext(doc->context()); + (void)explorer; + + QStringList announced; + QObject::connect( + &plug, &Explorer::DeviceDocumentPlugin::deviceTreeChanged, &plug, + [&](const QString& name) { announced.push_back(name); }); + + // What the machine running the score ends up with after opening a device: + // the node it was asked for, plus whatever the thing turned out to contain. + // The peer that asked sent none of this -- it cannot even make the device. + Device::DeviceSettings s; + s.protocol = absentProtocol(); + s.name = "Mouse"; + + Device::Node node{s, nullptr}; + Device::AddressSettings axis; + axis.name = "x"; + node.emplace_back(axis, &node); + + plug.updateProxy.addDevice(node); + + // Announced, because the command that created it carried a device with + // nothing inside: what is under it was discovered here, and a peer that + // never hears about it shows an empty device forever. + CHECK(announced.contains("Mouse")); + }); +} diff --git a/tests/integration/TerminalFileDropTest.cpp b/tests/integration/TerminalFileDropTest.cpp new file mode 100644 index 0000000000..4e39c7d150 --- /dev/null +++ b/tests/integration/TerminalFileDropTest.cpp @@ -0,0 +1,133 @@ +// Dropping a file when the score runs on another machine. +// +// The drop itself was never the problem: it went through, the command +// replicated, and the host created the process -- pointing at a path only the +// machine that did the dropping had. Nothing could open it, so the drop looked +// like it had done nothing. +// +// What is asserted here is the decision, not the copying (that is +// test_unit_import_file): a drop on a non-local environment must take the bytes +// and send them, and a drop on a local one must still leave the file alone. + +#include + +#include +#include +#include +#include +#include + +#include + +#include +#include + +#include +#include +#include + +#include + +namespace +{ +struct RecordingEnvironment final : score::Environment +{ + bool local{}; + std::vector written; + + explicit RecordingEnvironment(bool isLocal) + : local{isLocal} + { + } + + bool isLocal() const noexcept override { return local; } + QString resolve(const score::Uri& uri) const override + { + return local ? uri.path : QString{}; + } + void list(const score::Uri&, Callback>, Callback) + override + { + } + void read(const score::Uri&, Callback, Callback) override { } + void write(const score::Uri& uri, QByteArray, Done onWritten, Callback) override + { + written.push_back(uri); + if(onWritten) + onWritten(); + } +}; + +//! A real file on disk, as a drop names one. +QUrl writeTempWav(QTemporaryDir& dir) +{ + const QString path = dir.path() + "/dropped.wav"; + QFile f{path}; + SCORE_ASSERT(f.open(QIODevice::WriteOnly)); + // A header sndfile will not choke on is not needed: what is under test is + // which path comes out, and no decoder runs here. + f.write(QByteArray{"RIFF....WAVEfmt some bytes"}); + f.close(); + return QUrl::fromLocalFile(path); +} +} + +TEST_CASE("A file dropped for another machine is sent there", "[drop]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + + auto env = std::make_unique(false); + auto* envPtr = env.get(); + doc->setEnvironment(std::move(env)); + + QTemporaryDir dir; + REQUIRE(dir.isValid()); + + QMimeData mime; + mime.setUrls({writeTempWav(dir)}); + + const auto& handlers = ctx.interfaces(); + handlers.getDrop(mime, doc->context()); + + // The bytes went to the machine that will open them, addressed by the one + // scheme that means the same thing on both. + REQUIRE(envPtr->written.size() == 1); + CHECK(envPtr->written.front().scheme == score::UriScheme::Cache); + + // And what the document will store for it must be that same portable + // spelling: an empty or absolute path here is the whole bug. + const QString cached = score::importFile("x.wav", QByteArray{"bytes"}, *envPtr); + REQUIRE(!cached.isEmpty()); + const QString stored = score::relativizeFilePath(cached, doc->context()); + INFO("stored: " << stored.toStdString()); + CHECK(stored.startsWith(":")); + QFile::remove(cached); + }); +} + +TEST_CASE("A file dropped on the machine running the score is left alone", "[drop]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + auto* doc = score::test::new_document(ctx); + REQUIRE(doc); + + auto env = std::make_unique(true); + auto* envPtr = env.get(); + doc->setEnvironment(std::move(env)); + + QTemporaryDir dir; + REQUIRE(dir.isValid()); + + QMimeData mime; + mime.setUrls({writeTempWav(dir)}); + + const auto& handlers = ctx.interfaces(); + handlers.getDrop(mime, doc->context()); + + // Copying every dropped file into the cache would be a pointless copy of + // everything a user ever drags in. + CHECK(envPtr->written.empty()); + }); +} diff --git a/tests/integration/TerminalLibraryTest.cpp b/tests/integration/TerminalLibraryTest.cpp new file mode 100644 index 0000000000..f4bd403781 --- /dev/null +++ b/tests/integration/TerminalLibraryTest.cpp @@ -0,0 +1,101 @@ +// The library panel is one; documents are many. What is available to a score is +// a property of the document -- one that runs on another machine can only use +// that machine's processes -- so the panel has to follow whichever document is +// visible. Needs the GUI stack, since panels do not exist without it, and a +// binary cannot mix the two application fixtures. + + + +#include +#include +#include + +#include + +#include +#include + +#include +#include + +#include + +namespace +{ +QByteArray asJson(score::Document& doc) +{ + JSONObject::Serializer wr{}; + doc.saveAsJson(wr); + return wr.toByteArray(); +} + +//! No devices in it. A device that fails to instantiate calls score::warning, +//! and this fixture has a main window, so that is a modal nobody will dismiss. +//! What is under test here is the library panel, not devices. +QByteArray emptyDocument(const score::GUIApplicationContext& ctx) +{ + auto* doc = score::test::new_document(ctx); + SCORE_ASSERT(doc); + return asJson(*doc); +} + +score::Document* reload( + const score::GUIApplicationContext& ctx, const QByteArray& bytes, + score::DocumentRole role) +{ + auto& delegates = ctx.interfaces(); + SCORE_ASSERT(!delegates.empty()); + auto* doc = ctx.docManager.loadDocument( + ctx, QStringLiteral("terminal"), bytes, JSONObject::type(), *delegates.begin(), + role); + QApplication::processEvents(); + return doc; +} +} + +TEST_CASE("The library follows the document that is visible", "[terminal]") +{ + // No asynchronous file scan. rescan() posts one to the task pool, and that + // thread outlives the application: on the way out it calls back into library + // interfaces whose plug-ins have been unloaded. A real crash, reachable from + // Settings > rescan library and from quitting during the startup scan, but + // not this test's subject -- which is whether rescan is called at all, and + // the factory-derived entries it adds are there before the scan starts. + qputenv("SCORE_DISABLE_LIBRARY", "1"); + + score::test::run_in_gui_app([](const score::GUIApplicationContext& ctx) { + // Panels exist only with the GUI stack, so this case needs run_in_gui_app; + // finding none would make every assertion below vacuous. + auto* panel = ctx.findPanel(); + REQUIRE(panel); + + auto& model = panel->processWidget().processModel(); + const auto bytes = emptyDocument(ctx); + + auto* local = reload(ctx, bytes, score::DocumentRole::Local); + REQUIRE(local); + ctx.docManager.setCurrentDocument(ctx, local); + QApplication::processEvents(); + + // A document that runs here lists this build's processes. + REQUIRE(model.rootNode().childCount() > 0); + + // Emptied as whatever mirrors another machine would leave it. + model.clear(); + REQUIRE(model.rootNode().childCount() == 0); + + // Showing a terminal must not put this build's processes back: the score + // runs elsewhere and none of them can run for it. + auto* terminal = reload(ctx, bytes, score::DocumentRole::Terminal); + REQUIRE(terminal); + ctx.docManager.setCurrentDocument(ctx, terminal); + QApplication::processEvents(); + CHECK(model.rootNode().childCount() == 0); + + // Coming back to one that does run here restores them, which is what makes + // the panel usable with more than one document open. + ctx.docManager.setCurrentDocument(ctx, local); + QApplication::processEvents(); + CHECK(model.rootNode().childCount() > 0); + }); +} diff --git a/tests/integration/TerminalPortComboTest.cpp b/tests/integration/TerminalPortComboTest.cpp new file mode 100644 index 0000000000..868beed51b --- /dev/null +++ b/tests/integration/TerminalPortComboTest.cpp @@ -0,0 +1,136 @@ +// A terminal has no device objects, so the port inspector cannot find out what +// a device can be plugged into by casting it. It uses the kinds the machine +// running the score reported instead. + +#include + +#include + +#include +#include + +#include +#include + +#include +#include + +#include + +#include +#include + +#include + +namespace +{ +QByteArray emptyDocument(const score::GUIApplicationContext& ctx) +{ + auto* doc = score::test::new_document(ctx); + SCORE_ASSERT(doc); + JSONObject::Serializer wr{}; + doc->saveAsJson(wr); + return wr.toByteArray(); +} + +score::Document* reload( + const score::GUIApplicationContext& ctx, const QByteArray& bytes, + score::DocumentRole role) +{ + auto& delegates = ctx.interfaces(); + SCORE_ASSERT(!delegates.empty()); + auto* doc = ctx.docManager.loadDocument( + ctx, QStringLiteral("terminal"), bytes, JSONObject::type(), *delegates.begin(), + role); + QApplication::processEvents(); + return doc; +} + +std::vector entries(const QComboBox& box) +{ + std::vector res; + for(int i = 0; i < box.count(); i++) + res.push_back(box.itemText(i)); + return res; +} + +bool has(const QComboBox& box, const QString& name) +{ + const auto e = entries(box); + return std::find(e.begin(), e.end(), name) != e.end(); +} +} + +TEST_CASE("A terminal's port combo offers the devices the score's machine has", "[terminal]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + const auto bytes = emptyDocument(ctx); + + auto* doc = reload(ctx, bytes, score::DocumentRole::Terminal); + REQUIRE(doc); + auto& plug = doc->context().plugin(); + + // Nothing was instantiated here, which is the whole difficulty: the combo + // has no object to ask. + REQUIRE(plug.list().devices().empty()); + + plug.setRemoteKinds(QStringLiteral("stagewindow"), Device::DeviceKind::TextureOut); + plug.setRemoteKinds(QStringLiteral("webcam"), Device::DeviceKind::TextureIn); + plug.setRemoteKinds( + QStringLiteral("keyboard"), + Device::DeviceKinds{Device::DeviceKind::MidiIn} | Device::DeviceKind::MidiOut); + + QWidget parent; + Process::ValueInlet port{QStringLiteral("in"), Id{0}, &parent}; + + auto* out = Process::makeDeviceCombo( + Device::DeviceKind::TextureOut, plug.list(), port, doc->context(), &parent); + REQUIRE(out); + CHECK(has(*out, QStringLiteral("stagewindow"))); + + // Each kind offers only its own: a window is not somewhere to read a + // texture from, and offering it would produce a port that cannot bind. + CHECK_FALSE(has(*out, QStringLiteral("webcam"))); + CHECK_FALSE(has(*out, QStringLiteral("keyboard"))); + + auto* in = Process::makeDeviceCombo( + Device::DeviceKind::TextureIn, plug.list(), port, doc->context(), &parent); + REQUIRE(in); + CHECK(has(*in, QStringLiteral("webcam"))); + CHECK_FALSE(has(*in, QStringLiteral("stagewindow"))); + + // A device can be several things at once, and both directions must list it. + auto* midiIn = Process::makeDeviceCombo( + Device::DeviceKind::MidiIn, plug.list(), port, doc->context(), &parent); + auto* midiOut = Process::makeDeviceCombo( + Device::DeviceKind::MidiOut, plug.list(), port, doc->context(), &parent); + REQUIRE(midiIn); + REQUIRE(midiOut); + CHECK(has(*midiIn, QStringLiteral("keyboard"))); + CHECK(has(*midiOut, QStringLiteral("keyboard"))); + CHECK_FALSE(has(*midiIn, QStringLiteral("stagewindow"))); + }); +} + +TEST_CASE("A device reported after the combo was built still appears", "[terminal]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + const auto bytes = emptyDocument(ctx); + auto* doc = reload(ctx, bytes, score::DocumentRole::Terminal); + REQUIRE(doc); + auto& plug = doc->context().plugin(); + + QWidget parent; + Process::ValueInlet port{QStringLiteral("in"), Id{0}, &parent}; + auto* box = Process::makeDeviceCombo( + Device::DeviceKind::TextureOut, plug.list(), port, doc->context(), &parent); + REQUIRE(box); + REQUIRE_FALSE(has(*box, QStringLiteral("stagewindow"))); + + // Plugged in on the other machine while this inspector was open. + plug.setRemoteKinds(QStringLiteral("stagewindow"), Device::DeviceKind::TextureOut); + QApplication::processEvents(); + + CHECK(has(*box, QStringLiteral("stagewindow"))); + }); +} diff --git a/tests/integration/TerminalRoleTest.cpp b/tests/integration/TerminalRoleTest.cpp new file mode 100644 index 0000000000..f590cd9ec0 --- /dev/null +++ b/tests/integration/TerminalRoleTest.cpp @@ -0,0 +1,312 @@ +// A document opened as a terminal edits a score that runs on another machine. +// +// It must not claim this machine's hardware: no ports bound, no MIDI or camera +// taken, no render window opened on the wrong screen. All of that happens +// through device instantiation, which runs while the device plug-in is being +// deserialized -- so the role has to be known before the document is read, +// which is why it is fixed at construction rather than set afterwards. + +#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 +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +namespace +{ +using score::test::ProbeProtocolFactory; + +Device::Node probeNode(const QString& name = QStringLiteral("probe")) +{ + return score::test::probe_device_node(name); +} + +//! JSON, as a session sends it: RemoteClientBuilder writes saveAsJson and +//! ClientSessionBuilder reads it back with JSONObject::type(). The binary +//! format does not consult the device list on the way out, so saving that way +//! would exercise none of what a terminal does. +QByteArray asJson(score::Document& doc) +{ + JSONObject::Serializer wr{}; + doc.saveAsJson(wr); + return wr.toByteArray(); +} + +//! A saved document holding one device that uses the probe protocol, plus one +//! address under it. +QByteArray documentWithProbeDevice(const score::GUIApplicationContext& ctx) +{ + auto* doc = score::test::new_document(ctx); + SCORE_ASSERT(doc); + + auto& plug = doc->context().plugin(); + plug.explorer().addDevice(probeNode()); + + Device::AddressSettings addr; + addr.name = QStringLiteral("param"); + Device::NodePath devicePath; + devicePath.push_back(0); + plug.updateProxy.addAddress(devicePath, addr, 0); + + return asJson(*doc); +} + +score::Document* reload( + const score::GUIApplicationContext& ctx, const QByteArray& bytes, + score::DocumentRole role) +{ + auto& delegates = ctx.interfaces(); + SCORE_ASSERT(!delegates.empty()); + auto* doc = ctx.docManager.loadDocument( + ctx, QStringLiteral("terminal"), bytes, JSONObject::type(), *delegates.begin(), + role); + QApplication::processEvents(); + return doc; +} +} + +TEST_CASE("A local document builds the devices it names", "[terminal]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + score::test::register_probe_protocol(ctx); + const auto bytes = documentWithProbeDevice(ctx); + REQUIRE(bytes.size() > 0); + + ProbeProtocolFactory::requests = 0; + auto* doc = reload(ctx, bytes, score::DocumentRole::Local); + REQUIRE(doc); + CHECK(doc->role() == score::DocumentRole::Local); + + // The precondition for the next test: loading normally does ask. + CHECK(ProbeProtocolFactory::requests == 1); + }); +} + +TEST_CASE("A terminal document builds no devices at all", "[terminal]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + score::test::register_probe_protocol(ctx); + const auto bytes = documentWithProbeDevice(ctx); + REQUIRE(bytes.size() > 0); + + ProbeProtocolFactory::requests = 0; + auto* doc = reload(ctx, bytes, score::DocumentRole::Terminal); + REQUIRE(doc); + REQUIRE(doc->role() == score::DocumentRole::Terminal); + + CHECK(ProbeProtocolFactory::requests == 0); + + auto& plug = doc->context().plugin(); + CHECK(plug.list().findDevice(QStringLiteral("probe")) == nullptr); + }); +} + +TEST_CASE("A terminal still shows and keeps the score's devices", "[terminal]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + score::test::register_probe_protocol(ctx); + const auto bytes = documentWithProbeDevice(ctx); + + auto* doc = reload(ctx, bytes, score::DocumentRole::Terminal); + REQUIRE(doc); + + auto& plug = doc->context().plugin(); + + // The tree is what the person at the terminal is editing, so it has to be + // all there: the device, and the addresses under it. + REQUIRE(plug.rootNode().childCount() == 1); + const auto& device = plug.rootNode().childAt(0); + REQUIRE(device.is()); + CHECK(device.get().name == QStringLiteral("probe")); + REQUIRE(device.childCount() == 1); + CHECK(device.childAt(0).displayName() == QStringLiteral("param")); + + // And saving it again gives back a document naming the same device, so a + // terminal is not a way to quietly lose what it could not instantiate. + const auto resaved = asJson(*doc); + REQUIRE(resaved.size() > 0); + + auto* again = reload(ctx, resaved, score::DocumentRole::Terminal); + REQUIRE(again); + auto& plug2 = again->context().plugin(); + REQUIRE(plug2.rootNode().childCount() == 1); + CHECK( + plug2.rootNode().childAt(0).get().name + == QStringLiteral("probe")); + REQUIRE(plug2.rootNode().childAt(0).childCount() == 1); + }); +} + +TEST_CASE("A terminal document does not execute", "[terminal]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + score::test::register_probe_protocol(ctx); + const auto bytes = documentWithProbeDevice(ctx); + + auto* doc = reload(ctx, bytes, score::DocumentRole::Terminal); + REQUIRE(doc); + REQUIRE(doc->role() == score::DocumentRole::Terminal); + + // Play is a request to the host, never something this copy performs: it has + // no devices to play through. Asking anyway must be declined rather than + // start a graph against an empty device list. + ctx.docManager.setCurrentDocument(ctx, doc); + QApplication::processEvents(); + + auto& engine = ctx.guiApplicationPlugin(); + engine.execution().request_play_global(true); + QApplication::processEvents(); + QApplication::processEvents(); + + auto* exec = doc->context().findPlugin(); + if(exec) + CHECK_FALSE(exec->isPlaying()); + + // Not asserted here: that the transport buttons stay honest. They are set + // by TransportActions, which early-returns without the widgets a real + // window creates, so headless it does nothing either way and the check + // would pass with the guard removed. Verified in the GUI instead. + }); +} + +TEST_CASE("A terminal exposes no control surface of its own", "[terminal]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + score::test::register_probe_protocol(ctx); + const auto bytes = documentWithProbeDevice(ctx); + + // The local tree is score's own OSC/OSCQuery view of the document. On a + // terminal it would be a second control surface for a score executing + // somewhere else, bound to the same default ports as the machine actually + // running it -- which collide outright when that is this machine. + auto* terminal = reload(ctx, bytes, score::DocumentRole::Terminal); + REQUIRE(terminal); + auto& termDevices = terminal->context().plugin(); + CHECK(termDevices.list().localDevice() == nullptr); + + // The precondition: an ordinary document does have one. + auto* local = reload(ctx, bytes, score::DocumentRole::Local); + REQUIRE(local); + auto& localDevices = local->context().plugin(); + CHECK(localDevices.list().localDevice() != nullptr); + }); +} + +TEST_CASE("A terminal asks for the state of what it creates", "[terminal]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + score::test::register_probe_protocol(ctx); + const auto bytes = documentWithProbeDevice(ctx); + + auto& facs = ctx.interfaces(); + REQUIRE_FALSE(facs.empty()); + const auto key = facs.begin()->concreteKey(); + + auto makeProcess = [&](score::Document& doc) { + auto& itv = safe_cast(doc.model().modelDelegate()) + .baseScenario() + .interval(); + Process::awaitingRemoteState().clear(); + doc.context().document.commandStack().redoAndPush( + new Scenario::Command::AddOnlyProcessToInterval{ + itv, key, QStringLiteral("/on/the/other/machine.isf"), QPointF{}}); + return Process::awaitingRemoteState().size(); + }; + + // A document that runs here builds the process from the same data the + // command carries and has no reason to ask anyone. + auto* local = reload(ctx, bytes, score::DocumentRole::Local); + REQUIRE(local); + CHECK(makeProcess(*local) == 0); + + // A terminal cannot: the data describes the other machine -- a library + // entry carries the path of the file it was scanned from -- so the factory + // succeeds here and produces something empty. What it should contain is + // only known where the command came from. + auto* terminal = reload(ctx, bytes, score::DocumentRole::Terminal); + REQUIRE(terminal); + CHECK(makeProcess(*terminal) == 1); + + Process::awaitingRemoteState().clear(); + }); +} + +TEST_CASE("A score keeps playing until asked, whatever document is in front", + "[terminal]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + score::test::register_probe_protocol(ctx); + const auto bytes = documentWithProbeDevice(ctx); + + // Both up front: loading a document stops execution (prepareNewDocument), + // so creating the terminal later would stop the score for the wrong reason + // and the test would pass without the fix. + auto* local = reload(ctx, bytes, score::DocumentRole::Local); + REQUIRE(local); + auto* terminal = reload(ctx, bytes, score::DocumentRole::Terminal); + REQUIRE(terminal); + + ctx.docManager.setCurrentDocument(ctx, local); + QApplication::processEvents(); + + auto& engine = ctx.guiApplicationPlugin(); + engine.execution().request_play_global(true); + QApplication::processEvents(); + QApplication::processEvents(); + + auto* exec = local->context().findPlugin(); + REQUIRE(exec); + REQUIRE(exec->isPlaying()); + + // Selecting a terminal does not stop what is running -- it belongs to the + // document that started it, and there is only one execution controller. + ctx.docManager.setCurrentDocument(ctx, terminal); + QApplication::processEvents(); + CHECK(exec->isPlaying()); + + // And Stop must still reach it. Refusing because the document in front is + // a terminal leaves a score playing with no way to stop it. + engine.execution().request_stop(); + QApplication::processEvents(); + QApplication::processEvents(); + + CHECK_FALSE(exec->isPlaying()); + }); +} diff --git a/tests/integration/TerminalSlotTest.cpp b/tests/integration/TerminalSlotTest.cpp new file mode 100644 index 0000000000..5d054fa476 --- /dev/null +++ b/tests/integration/TerminalSlotTest.cpp @@ -0,0 +1,121 @@ +// What a terminal draws of the score's structure. +// +// The base interval's full view is the whole editing surface. If a process +// added on the machine running the score does not get a slot here, the person +// at the terminal cannot see or select it -- and there is then no inspector to +// set its ports from. + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +#include +#include + +#include + +namespace +{ +const UuidKey automationKey{ + score::uuids::string_generator::compute("d2a67bd8-5d3f-404e-b6e9-e350cf2a833f")}; + +Scenario::IntervalModel& baseInterval(score::Document& doc) +{ + return safe_cast(doc.model().modelDelegate()) + .baseScenario() + .interval(); +} + +//! The number of slots the full view actually built, which is what is drawn. +std::size_t displayedSlots(const score::GUIApplicationContext& ctx, score::Document& doc) +{ + ctx.docManager.setCurrentDocument(ctx, &doc); + QApplication::processEvents(); + + auto* pres + = safe_cast(doc.presenter()->presenterDelegate()); + SCORE_ASSERT(pres); + + // Loading builds the presenter but not the layers; this is what the + // application does on opening a score. + pres->setDisplayedInterval(&baseInterval(doc)); + QApplication::processEvents(); + + auto* full + = dynamic_cast(pres->displayedIntervalPresenter()); + SCORE_ASSERT(full); + return full->getSlots().size(); +} + +QByteArray asJson(score::Document& doc) +{ + JSONObject::Serializer wr{}; + doc.saveAsJson(wr); + return wr.toByteArray(); +} + +score::Document* reload( + const score::GUIApplicationContext& ctx, const QByteArray& bytes, + score::DocumentRole role) +{ + auto& delegates = ctx.interfaces(); + SCORE_ASSERT(!delegates.empty()); + auto* doc = ctx.docManager.loadDocument( + ctx, QStringLiteral("slots"), bytes, JSONObject::type(), *delegates.begin(), role); + QApplication::processEvents(); + return doc; +} +} + +TEST_CASE("A terminal draws the same slots as the machine running the score", "[terminal]") +{ + qputenv("SCORE_DISABLE_LIBRARY", "1"); + + score::test::run_in_gui_app([](const score::GUIApplicationContext& ctx) { + auto& facs = ctx.interfaces(); + REQUIRE(facs.get(automationKey)); + + auto* origin = score::test::new_document(ctx); + REQUIRE(origin); + + // A process on the base interval, put there the way the application and + // the scripting API both do it. + const auto before = displayedSlots(ctx, *origin); + { + Scenario::Command::Macro m{ + new Scenario::Command::AddProcessInNewBoxMacro, origin->context()}; + REQUIRE(m.createProcessInNewSlot(baseInterval(*origin), automationKey, {})); + m.commit(); + } + QApplication::processEvents(); + REQUIRE(baseInterval(*origin).processes.size() == 2); + + // The precondition: a document that runs here draws a slot for it. If this + // is one, the full view never shows base-interval processes and there is + // nothing for a terminal to be missing. + const auto local = displayedSlots(ctx, *origin); + CHECK(local == before + 1); + + // The same score, opened as a terminal: the processes belong to the other + // machine but the structure is what is being edited here. + const auto bytes = asJson(*origin); + auto* terminal = reload(ctx, bytes, score::DocumentRole::Terminal); + REQUIRE(terminal); + REQUIRE(baseInterval(*terminal).processes.size() == 2); + + CHECK(displayedSlots(ctx, *terminal) == local); + }); +} diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 48a80fd017..68d978f7f7 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -66,6 +66,16 @@ score_add_test(test_unit_device_explorer_node PLUGINS score_lib_device) # --- core data model (score-lib-state / score-lib-process, P3R2) ----------- +# Taking in a dropped file: into the cache here, and over to the machine that +# will actually open it. +score_add_test(test_unit_import_file + SOURCES ImportFileTest.cpp) + +# Walking a directory tree through the Environment interface: the remote one +# answers out of order and some directories cannot be listed at all. +score_add_test(test_unit_environment_walk + SOURCES EnvironmentWalkTest.cpp) + score_add_test(test_unit_state_serialization SOURCES StateSerializationTest.cpp PLUGINS score_lib_state) @@ -302,3 +312,20 @@ score_add_test(test_unit_heterogeneous_build # --- how a path is stored in a document ------------------------------------ score_add_test(test_unit_uri SOURCES UriTest.cpp) + +# Browsing the files of a machine that is not this one. +if(TARGET score_plugin_library) + score_add_test(test_unit_remote_filesystem + SOURCES RemoteFileSystemTest.cpp + APP + PLUGINS score_plugin_library) +endif() + +# Dropping a device from the explorer onto a port. +score_add_test(test_unit_port_drop + SOURCES PortDropTest.cpp + PLUGINS score_lib_process score_lib_state score_lib_device) + +# Thread creation is lazy: one acquired thread must not start the whole pool. +score_add_test(test_unit_thread_pool + SOURCES ThreadPoolTest.cpp) diff --git a/tests/unit/EnvironmentWalkTest.cpp b/tests/unit/EnvironmentWalkTest.cpp new file mode 100644 index 0000000000..ce287b6d53 --- /dev/null +++ b/tests/unit/EnvironmentWalkTest.cpp @@ -0,0 +1,178 @@ +// score::listRecursive walks a directory tree through the Environment +// interface. The interesting environment is the remote one, whose listings come +// back whenever they come back -- so the walk is tested against one that +// answers only when told to, and out of the order it was asked. + +#include + +#include + +#include +#include + +namespace +{ +//! A tree in memory. `deferred` holds the answers until they are released, the +//! way another machine does. +struct FakeEnvironment final : score::Environment +{ + std::map> tree; + std::vector unlistable; + std::vector> deferred; + bool defer{false}; + int listCalls{}; + + bool isLocal() const noexcept override { return false; } + QString resolve(const score::Uri&) const override { return {}; } + + void list( + const score::Uri& uri, Callback> onListed, + Callback onFailed) override + { + listCalls++; + const auto path = uri.path; + + auto answer = [this, path, onListed = std::move(onListed), + onFailed = std::move(onFailed)] { + if(std::find(unlistable.begin(), unlistable.end(), path) != unlistable.end()) + { + if(onFailed) + onFailed("nope"); + return; + } + auto it = tree.find(path); + if(onListed) + onListed(it != tree.end() ? it->second : std::vector{}); + }; + + if(defer) + deferred.push_back(std::move(answer)); + else + answer(); + } + + void read(const score::Uri&, Callback, Callback) override { } + void write(const score::Uri&, QByteArray, Done, Callback) override { } + + void dir(const QString& at, std::vector> children) + { + auto& entries = tree[at]; + for(auto& [name, isDir] : children) + entries.push_back(score::DirEntry{ + score::Uri{score::UriScheme::Library, at.isEmpty() ? name : at + '/' + name}, + name, isDir, 0}); + } +}; + +FakeEnvironment makeTree() +{ + FakeEnvironment env; + env.dir("packages", {{"a.device", false}, {"vendor", true}, {"notes.txt", false}}); + env.dir("packages/vendor", {{"b.device", false}, {"deep", true}}); + env.dir("packages/vendor/deep", {{"c.device", false}}); + return env; +} + +std::vector names(const std::vector& entries) +{ + std::vector out; + for(auto& e : entries) + out.push_back(e.name); + std::sort(out.begin(), out.end()); + return out; +} +} + +TEST_CASE("A recursive walk finds matching files at every depth", "[environment]") +{ + auto env = makeTree(); + + int called{}; + std::vector got; + score::listRecursive( + env, score::Uri{score::UriScheme::Library, "packages"}, ".device", + [&](std::vector r) { + called++; + got = std::move(r); + }); + + CHECK(called == 1); + CHECK(names(got) == std::vector{"a.device", "b.device", "c.device"}); +} + +TEST_CASE("A recursive walk reports once, however the answers arrive", "[environment]") +{ + auto env = makeTree(); + env.defer = true; + + int called{}; + std::vector got; + score::listRecursive( + env, score::Uri{score::UriScheme::Library, "packages"}, ".device", + [&](std::vector r) { + called++; + got = std::move(r); + }); + + // Nothing has answered yet: reporting here would report an empty library. + CHECK(called == 0); + + // Release them last-asked-first, which is what a walk over a network gets. + while(!env.deferred.empty()) + { + auto answer = env.deferred.back(); + env.deferred.pop_back(); + answer(); + } + + CHECK(called == 1); + CHECK(names(got) == std::vector{"a.device", "b.device", "c.device"}); +} + +TEST_CASE("A directory that cannot be listed does not lose the others", "[environment]") +{ + auto env = makeTree(); + env.unlistable.push_back("packages/vendor"); + + int called{}; + std::vector got; + score::listRecursive( + env, score::Uri{score::UriScheme::Library, "packages"}, ".device", + [&](std::vector r) { + called++; + got = std::move(r); + }); + + CHECK(called == 1); + CHECK(names(got) == std::vector{"a.device"}); +} + +TEST_CASE("A recursive walk stops at the depth it was given", "[environment]") +{ + auto env = makeTree(); + + std::vector got; + score::listRecursive( + env, score::Uri{score::UriScheme::Library, "packages"}, ".device", + [&](std::vector r) { got = std::move(r); }, 1); + + // One level below the root, so vendor is listed and deep is not. + CHECK(names(got) == std::vector{"a.device", "b.device"}); +} + +TEST_CASE("A walk that loops back on itself terminates", "[environment]") +{ + // What a symlink to a parent looks like from here. + FakeEnvironment env; + env.dir("packages", {{"self", true}, {"a.device", false}}); + env.tree["packages/self"] = env.tree["packages"]; + env.tree["packages/self"][0].uri = score::Uri{score::UriScheme::Library, "packages"}; + + int called{}; + score::listRecursive( + env, score::Uri{score::UriScheme::Library, "packages"}, ".device", + [&](std::vector) { called++; }, 4); + + CHECK(called == 1); + CHECK(env.listCalls <= 16); +} diff --git a/tests/unit/HeterogeneousBuildTest.cpp b/tests/unit/HeterogeneousBuildTest.cpp index ffa17fa89a..6e878ccdc5 100644 --- a/tests/unit/HeterogeneousBuildTest.cpp +++ b/tests/unit/HeterogeneousBuildTest.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include @@ -55,48 +56,65 @@ 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. +constexpr auto host_payload = "host-only protocol payload"; + +// Bytes as a build that *has* the protocol emits them: name, protocol key, the +// protocol's own settings in a blob of their own, then the trailing delimiter. QByteArray settingsFromRicherBuild(const QString& name) { + QByteArray inner; + { + DataStreamReader sub{&inner}; + sub.m_stream << QString{host_payload}; + } + QByteArray b; DataStreamReader r{&b}; r.m_stream << name << absentProtocol(); - r.m_stream << QStringLiteral("host-only protocol payload"); + r.m_stream << score::OpaquePayload{DataStream::type(), inner}.toBlob(); r.insertDelimiter(); return b; } } -TEST_CASE("DeviceSettings DataStream reports the missing protocol", "[heterogeneous]") +TEST_CASE("DeviceSettings DataStream keeps the settings of an absent 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. + // The protocol's settings are written in a blob of their own, as every + // other polymorphic kind already was, so a reader without the factory skips + // them by length and keeps them verbatim. Before, there was no length to + // skip by: the read landed mid-payload, and the only honest outcome was to + // refuse the document. const QByteArray bytes = settingsFromRicherBuild("syphon-in"); Device::DeviceSettings s; DataStreamWriter w{bytes}; - REQUIRE_THROWS_AS(w.writeTo(s), std::runtime_error); + REQUIRE_NOTHROW(w.writeTo(s)); + + CHECK(s.name == QStringLiteral("syphon-in")); + CHECK(s.protocol == absentProtocol()); + REQUIRE_FALSE(s.opaqueSettings.isEmpty()); - // The device and protocol are named, so the message can tell the user which - // machine to open the document on. - try + // And writing it back out gives the richer build its settings again -- + // byte for byte, since nothing here understood them well enough to change + // them. + QByteArray again; { - Device::DeviceSettings s2; - DataStreamWriter w2{bytes}; - w2.writeTo(s2); + DataStreamReader r{&again}; + r.readFrom(s); } - catch(const std::runtime_error& e) + CHECK(again == bytes); + + // What was kept really is the protocol's payload, not an empty husk. + const auto payload = score::OpaquePayload::fromBlob(s.opaqueSettings); + REQUIRE(payload.format == DataStream::type()); + QString roundtripped; { - const QString what = QString::fromStdString(e.what()); - CHECK(what.contains("syphon-in")); - CHECK(what.contains(absent_uuid)); - CHECK(what.contains(".score")); + DataStreamWriter sub{payload.bytes}; + sub.m_stream >> roundtripped; } + CHECK(roundtripped == QString{host_payload}); }); } @@ -401,6 +419,21 @@ TEST_CASE("A stand-in survives being written in the other format", "[heterogeneo REQUIRE(out.IsObject()); REQUIRE(out.HasMember("PluginState")); CHECK(out["PluginState"] == "must survive both"); + + // The ports too. Reading from JSON rebuilds them as real objects and + // removes them from the payload, so anything that writes only the payload + // drops them -- and the machine that does have the plug-in then receives a + // process with no ports and no cables, which is worse than not opening it. + const auto ports = [](Process::ProcessModel& p) { + return std::pair{p.inlets().size(), p.outlets().size()}; + }; + REQUIRE(ports(*fromJson) == ports(*original)); + CHECK(ports(*opaque) == ports(*original)); + + REQUIRE(out.HasMember("Inlets")); + REQUIRE(out.HasMember("Outlets")); + CHECK(out["Inlets"].Size() == original->inlets().size()); + CHECK(out["Outlets"].Size() == original->outlets().size()); }); } @@ -585,6 +618,19 @@ TEST_CASE("An unclaimed process still gets a layer", "[heterogeneous]") REQUIRE(plain); if(auto* f = layers.findDefaultFactory(*plain)) CHECK_FALSE(f->isFallback()); + + // And the case the whole thing exists for: a stand-in resolves to the + // fallback. Asserting only the two above passes with the routing deleted -- + // the lookup by key never reaches it, and a plain process must not. + auto* standIn = procs.makeMissing( + unknown, TimeVal::fromMsecs(1000), Id{12}, doc); + REQUIRE(standIn); + REQUIRE(dynamic_cast(standIn)); + + auto* resolved = layers.findDefaultFactory(*standIn); + REQUIRE(resolved); + CHECK(resolved->isFallback()); + CHECK(resolved == fallback); }); } @@ -723,3 +769,61 @@ TEST_CASE("A path does not resolve to an object of another type", "[heterogeneou CHECK_THROWS(wrong.find(dctx)); }); } + +TEST_CASE("An absent protocol's settings survive changing format", "[heterogeneous]") +{ + score::test::run_in_app([](const score::GUIApplicationContext&) { + // A document does not stay in the format it was authored in: one read from + // .score is written to the binary format on every autosave, and a device + // travels inside commands, which are binary too. A payload that could only + // be written back in the format it arrived in would be lost by saving. + const QByteArray json + = QStringLiteral(R"({"Name":"syphon-in","Protocol":"%1",)" + R"("ServerName":"Resolume","AppName":"Arena","Rate":60})") + .arg(absent_uuid) + .toUtf8(); + + Device::DeviceSettings fromJson; + { + rapidjson::Document doc; + doc.Parse(json.data(), json.size()); + REQUIRE_FALSE(doc.HasParseError()); + JSONObject::Deserializer w{doc}; + w.writeTo(fromJson); + } + REQUIRE_FALSE(fromJson.opaqueSettings.isEmpty()); + + // Out to binary and back, as an autosave does. + QByteArray binary; + { + DataStreamReader r{&binary}; + r.readFrom(fromJson); + } + + Device::DeviceSettings viaBinary; + { + DataStreamWriter w{binary}; + REQUIRE_NOTHROW(w.writeTo(viaBinary)); + } + CHECK(viaBinary.name == fromJson.name); + CHECK(viaBinary.protocol == fromJson.protocol); + CHECK(viaBinary.opaqueSettings == fromJson.opaqueSettings); + + // And back out to JSON, where the protocol's members must be at the top + // level again -- a build that *has* the protocol has to find them where it + // expects, not wrapped in whatever score used to carry them. + JSONReader r; + r.readFrom(viaBinary); + rapidjson::Document out; + out.Parse(r.toByteArray().data(), r.toByteArray().size()); + REQUIRE_FALSE(out.HasParseError()); + REQUIRE(out.IsObject()); + + REQUIRE(out.HasMember("ServerName")); + CHECK(std::string_view{out["ServerName"].GetString()} == "Resolume"); + REQUIRE(out.HasMember("AppName")); + CHECK(std::string_view{out["AppName"].GetString()} == "Arena"); + REQUIRE(out.HasMember("Rate")); + CHECK(out["Rate"].GetInt() == 60); + }); +} diff --git a/tests/unit/ImportFileTest.cpp b/tests/unit/ImportFileTest.cpp new file mode 100644 index 0000000000..2355769dfb --- /dev/null +++ b/tests/unit/ImportFileTest.cpp @@ -0,0 +1,184 @@ +// A dropped file is named by a path, and a path only means something on the +// machine holding it. While the score runs here that is fine; when it runs on +// another machine the process created there points at a file it does not have, +// which is what "dropping a file does nothing" turned out to be. +// +// score::importFile takes the bytes in: into the media cache here, named by +// content, and over to the other machine when there is one. + +#include +#include +#include + +#include +#include +#include + +#include + +namespace +{ +struct RecordingEnvironment final : score::Environment +{ + bool local{}; + std::vector> writes; + + explicit RecordingEnvironment(bool isLocal) + : local{isLocal} + { + } + + bool isLocal() const noexcept override { return local; } + QString resolve(const score::Uri&) const override { return {}; } + void list(const score::Uri&, Callback>, Callback) + override + { + } + void read(const score::Uri&, Callback, Callback) override { } + void write( + const score::Uri& uri, QByteArray data, Done onWritten, Callback) override + { + writes.emplace_back(uri, std::move(data)); + if(onWritten) + onWritten(); + } +}; + +//! Leaves the cache as it was found: this writes into the real one, since where +//! it is is exactly what is under test. +struct CacheEntry +{ + QString path; + ~CacheEntry() + { + if(!path.isEmpty()) + QFile::remove(path); + } +}; +} + +TEST_CASE("An imported file lands in the cache, named by content", "[import]") +{ + RecordingEnvironment env{true}; + const QByteArray data = "RIFF....some audio bytes"; + + CacheEntry staged{score::importFile("kick drum.wav", data, env)}; + REQUIRE(!staged.path.isEmpty()); + + // Under the cache, so relativizing gives ":" -- which is what makes + // the stored path mean the same thing on both machines. + CHECK(score::isUnder(staged.path, score::mediaCacheRoot())); + CHECK(QFile::exists(staged.path)); + + QFile f{staged.path}; + REQUIRE(f.open(QIODevice::ReadOnly)); + CHECK(f.readAll() == data); + + // The original name survives, so a process is not called after a hash. + CHECK(staged.path.contains("kick_drum.wav")); + + // Nothing sent: the score runs here, the file is already where it is needed. + CHECK(env.writes.empty()); +} + +TEST_CASE("Importing for another machine sends the bytes there", "[import]") +{ + RecordingEnvironment env{false}; + const QByteArray data = "RIFF....some audio bytes"; + + CacheEntry staged{score::importFile("kick.wav", data, env)}; + REQUIRE(!staged.path.isEmpty()); + + REQUIRE(env.writes.size() == 1); + const auto& [uri, sent] = env.writes.front(); + + // Addressed by the one scheme that means the same thing on both machines. + CHECK(uri.scheme == score::UriScheme::Cache); + CHECK(sent == data); + + // The very same entry the local copy went to, or the two machines disagree + // about what the document refers to. + CHECK(staged.path.endsWith(uri.path)); +} + +TEST_CASE("The same bytes are the same cache entry", "[import]") +{ + RecordingEnvironment env{true}; + const QByteArray data = "the same bytes"; + + CacheEntry first{score::importFile("a.wav", data, env)}; + CacheEntry second{score::importFile("a.wav", data, env)}; + REQUIRE(!first.path.isEmpty()); + CHECK(first.path == second.path); + + // Different bytes under the same name must not collide. + CacheEntry other{score::importFile("a.wav", QByteArray{"other bytes"}, env)}; + REQUIRE(!other.path.isEmpty()); + CHECK(other.path != first.path); +} + +TEST_CASE("A file too large to send is refused, not half-imported", "[import]") +{ + RecordingEnvironment env{false}; + const QByteArray huge(score::maxInlineTransferBytes() + 1, 'x'); + + // Nothing: a process naming a file the other machine will never have is + // worse than a drop that visibly does nothing. + CHECK(score::importFile("huge.wav", huge, env).isEmpty()); + CHECK(env.writes.empty()); + + // The same file is fine when the score runs here. + RecordingEnvironment localEnv{true}; + CacheEntry staged{score::importFile("huge.wav", huge, localEnv)}; + CHECK(!staged.path.isEmpty()); +} + +TEST_CASE("A picked file is left where it is when the score runs here", "[import]") +{ + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString chosen = dir.path() + "/song.wav"; + { + QFile f{chosen}; + REQUIRE(f.open(QIODevice::WriteOnly)); + f.write("some audio"); + } + + RecordingEnvironment env{true}; + + // Copying every file a user ever picks would be a copy for nothing. + CHECK(score::importPickedFile(chosen, env) == chosen); + CHECK(env.writes.empty()); +} + +TEST_CASE("A picked file follows the score to the other machine", "[import]") +{ + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString chosen = dir.path() + "/song.wav"; + const QByteArray data = "some audio"; + { + QFile f{chosen}; + REQUIRE(f.open(QIODevice::WriteOnly)); + f.write(data); + } + + RecordingEnvironment env{false}; + CacheEntry imported{score::importPickedFile(chosen, env)}; + + // Not the path the user picked: that one names nothing over there. + REQUIRE(!imported.path.isEmpty()); + CHECK(imported.path != chosen); + CHECK(score::isUnder(imported.path, score::mediaCacheRoot())); + + REQUIRE(env.writes.size() == 1); + CHECK(env.writes.front().first.scheme == score::UriScheme::Cache); + CHECK(env.writes.front().second == data); +} + +TEST_CASE("Cancelling the picker imports nothing", "[import]") +{ + RecordingEnvironment env{false}; + CHECK(score::importPickedFile({}, env).isEmpty()); + CHECK(env.writes.empty()); +} diff --git a/tests/unit/PortDropTest.cpp b/tests/unit/PortDropTest.cpp new file mode 100644 index 0000000000..351d6320ad --- /dev/null +++ b/tests/unit/PortDropTest.cpp @@ -0,0 +1,77 @@ +// Dragging a device from the explorer onto a port. +// +// The drop carries the tree node, and a device node holds DeviceSettings where +// a parameter holds AddressSettings. The handler only ever looked for the +// latter, so dropping a device -- the only thing that makes sense for a port +// addressed by device, and the gesture the device explorer offers -- did +// nothing at all, silently. + +#include +#include +#include + +#include +#include + +#include + +namespace +{ +Device::FreeNodeList deviceDrop(const QString& name) +{ + Device::DeviceSettings s; + s.name = name; + return {{State::Address{name, {}}, Device::Node{s, nullptr}}}; +} + +Device::FreeNodeList parameterDrop(const QString& device, const QString& param) +{ + Device::AddressSettings a; + a.name = param; + return {{State::Address{device, {param}}, Device::Node{a, nullptr}}}; +} +} + +TEST_CASE("A device dropped on a port addressed by device names that device", "[port]") +{ + using namespace Process; + for(auto type : {PortType::Audio, PortType::Midi, PortType::Texture, PortType::Geometry}) + { + const auto addr = droppedDeviceAddress(deviceDrop("stagewindow"), type); + REQUIRE(addr.has_value()); + CHECK(addr->device == QStringLiteral("stagewindow")); + + // Device and nothing else: this is exactly what makeDeviceCombo writes, so + // dropping and choosing from the list leave the port in the same state. + CHECK(addr->path.isEmpty()); + } +} + +TEST_CASE("A message port is not satisfied by a device", "[port]") +{ + // It needs a parameter to read or write. Naming a device alone would leave a + // port pointing at something with no value. + CHECK_FALSE( + Process::droppedDeviceAddress(deviceDrop("stagewindow"), Process::PortType::Message) + .has_value()); +} + +TEST_CASE("A parameter is left to the path that handles parameters", "[port]") +{ + // Dropping an address carries its AddressSettings, and the port takes those + // as well as the address -- domain, unit, type. That path is unaffected. + CHECK_FALSE(Process::droppedDeviceAddress( + parameterDrop("stagewindow", "size"), Process::PortType::Texture) + .has_value()); +} + +TEST_CASE("An empty drop names nothing", "[port]") +{ + CHECK_FALSE(Process::droppedDeviceAddress({}, Process::PortType::Texture).has_value()); + + // A node that claims to be a device but names none is not a device to point at. + Device::DeviceSettings s; + Device::FreeNodeList nameless{{State::Address{}, Device::Node{s, nullptr}}}; + CHECK_FALSE( + Process::droppedDeviceAddress(nameless, Process::PortType::Texture).has_value()); +} diff --git a/tests/unit/RemoteFileSystemTest.cpp b/tests/unit/RemoteFileSystemTest.cpp new file mode 100644 index 0000000000..a2ae8ef9a8 --- /dev/null +++ b/tests/unit/RemoteFileSystemTest.cpp @@ -0,0 +1,254 @@ +// Browsing files that are on another machine. +// +// QFileSystemModel cannot: it is built on paths this process can stat, and a +// listing that crosses a socket is neither synchronous nor local. So the model +// is driven by score::Environment, and what matters is that it copes with +// answers arriving late, out of order, or not at all. + +#include + +#include +#include + +#include +#include + +#include + +#include + +namespace +{ +//! An environment whose answers are given by the test, when the test says so. +struct ScriptedEnvironment final : public score::Environment +{ + std::map> contents; + + //! Listings asked for but not yet answered, so a test can decide when -- and + //! whether -- an answer arrives. + std::vector>>> pending; + int listCalls = 0; + + bool isLocal() const noexcept override { return false; } + QString resolve(const score::Uri&) const override { return {}; } + + void list( + const score::Uri& uri, Callback> onListed, + Callback) override + { + ++listCalls; + pending.emplace_back(uri.toString(), std::move(onListed)); + } + + void read(const score::Uri&, Callback, Callback) override { } + void write(const score::Uri&, QByteArray, Done, Callback) override { } + + //! Answer the oldest outstanding listing. + void answer() + { + REQUIRE_FALSE(pending.empty()); + auto [path, cb] = pending.front(); + pending.erase(pending.begin()); + if(cb) + cb(contents[path]); + } +}; + +score::DirEntry entry(const QString& name, bool dir) +{ + score::DirEntry e; + e.uri = score::Uri{score::UriScheme::Library, name}; + e.name = name; + e.directory = dir; + return e; +} +} + +TEST_CASE("A remote folder is listed when it is opened", "[library][remote]") +{ + ScriptedEnvironment env; + const auto root = score::Uri{score::UriScheme::Library, QString{}}; + env.contents[root.toString()] + = {entry("sounds", true), entry("a.wav", false), entry("b.wav", false)}; + + Library::RemoteFileSystemModel model{[&env] { return &env; }, nullptr}; + model.setRoot(root); + + // Nothing is fetched until something asks: a library can be large and it is + // on the other end of a socket. + CHECK(model.rowCount(QModelIndex{}) == 0); + REQUIRE(model.canFetchMore(QModelIndex{})); + + model.fetchMore(QModelIndex{}); + CHECK(env.listCalls == 1); + + // Still nothing: the answer has not come back. A model that had rows here + // would be inventing them. + CHECK(model.rowCount(QModelIndex{}) == 0); + + env.answer(); + REQUIRE(model.rowCount(QModelIndex{}) == 3); + + // Folders first, then by name, as a file browser shows them. + CHECK(model.data(model.index(0, 0, QModelIndex{}), Qt::DisplayRole).toString() + == QStringLiteral("sounds")); + CHECK(model.isDirectory(model.index(0, 0, QModelIndex{}))); + CHECK(model.data(model.index(1, 0, QModelIndex{}), Qt::DisplayRole).toString() + == QStringLiteral("a.wav")); + CHECK_FALSE(model.isDirectory(model.index(1, 0, QModelIndex{}))); +} + +TEST_CASE("A remote folder is asked about once", "[library][remote]") +{ + ScriptedEnvironment env; + const auto root = score::Uri{score::UriScheme::Library, QString{}}; + env.contents[root.toString()] = {entry("a.wav", false)}; + + Library::RemoteFileSystemModel model{[&env] { return &env; }, nullptr}; + model.setRoot(root); + + model.fetchMore(QModelIndex{}); + // A view calls canFetchMore constantly; each call must not be a request. + CHECK_FALSE(model.canFetchMore(QModelIndex{})); + model.fetchMore(QModelIndex{}); + CHECK(env.listCalls == 1); + + env.answer(); + CHECK(model.rowCount(QModelIndex{}) == 1); + CHECK_FALSE(model.canFetchMore(QModelIndex{})); +} + +TEST_CASE("An unanswered listing leaves the model usable", "[library][remote]") +{ + ScriptedEnvironment env; + const auto root = score::Uri{score::UriScheme::Library, QString{}}; + + Library::RemoteFileSystemModel model{[&env] { return &env; }, nullptr}; + model.setRoot(root); + model.fetchMore(QModelIndex{}); + + // The other machine never answers. Nothing must claim rows that are not + // there, and nothing must crash. + CHECK(model.rowCount(QModelIndex{}) == 0); + CHECK_FALSE(model.index(0, 0, QModelIndex{}).isValid()); + CHECK(model.data(model.index(0, 0, QModelIndex{}), Qt::DisplayRole).isNull()); +} + +TEST_CASE("A listing that arrives after the model is gone is dropped", + "[library][remote]") +{ + ScriptedEnvironment env; + const auto root = score::Uri{score::UriScheme::Library, QString{}}; + env.contents[root.toString()] = {entry("a.wav", false)}; + + { + Library::RemoteFileSystemModel model{[&env] { return &env; }, nullptr}; + model.setRoot(root); + model.fetchMore(QModelIndex{}); + } + + // The document was closed while a listing was in flight. Answering must not + // touch the model that asked. + REQUIRE_NOTHROW(env.answer()); +} + +TEST_CASE("A remote file can be dragged, a folder cannot", "[library][remote]") +{ + ScriptedEnvironment env; + const auto root = score::Uri{score::UriScheme::Library, QString{}}; + env.contents[root.toString()] = {entry("sounds", true), entry("a.wav", false)}; + + Library::RemoteFileSystemModel model{[&env] { return &env; }, nullptr}; + model.setRoot(root); + model.fetchMore(QModelIndex{}); + env.answer(); + + const auto folder = model.index(0, 0, QModelIndex{}); + const auto file = model.index(1, 0, QModelIndex{}); + + CHECK_FALSE(model.flags(folder) & Qt::ItemIsDragEnabled); + CHECK(model.flags(file) & Qt::ItemIsDragEnabled); + + // What travels is the uri, not a path, and under a type of its own: a + // text/uri-list would claim these are files this machine can open, and every + // existing drop handler would believe it. + std::unique_ptr mime{model.mimeData({file})}; + REQUIRE(mime); + CHECK_FALSE(mime->hasUrls()); + REQUIRE(mime->hasFormat(score::remoteUriMimeType())); + + const auto payload = QString::fromUtf8(mime->data(score::remoteUriMimeType())); + CHECK(payload.contains(QStringLiteral("a.wav"))); + CHECK(payload.startsWith(QStringLiteral(":"))); + + // A folder alone yields nothing rather than an empty drop. + CHECK(model.mimeData({folder}) == nullptr); +} + +TEST_CASE("The environment is asked for again each time", "[library][remote]") +{ + // Document::environment() is created lazily and replaced once a session says + // where the files are, and the panel is told the document exists before that + // happens. A model that captured the first one would be calling through an + // object the replacement destroyed -- which in the wasm build shows up as + // "function signature mismatch" from a dead vtable. + ScriptedEnvironment first, second; + const auto root = score::Uri{score::UriScheme::Library, QString{}}; + second.contents[root.toString()] = {entry("from-the-other-machine", false)}; + + score::Environment* current = &first; + Library::RemoteFileSystemModel model{[¤t] { return current; }, nullptr}; + model.setRoot(root); + + // Replaced before anything is listed, as a session does. + current = &second; + + model.fetchMore(QModelIndex{}); + CHECK(first.listCalls == 0); + REQUIRE(second.listCalls == 1); + + second.answer(); + REQUIRE(model.rowCount(QModelIndex{}) == 1); + CHECK(model.data(model.index(0, 0, QModelIndex{}), Qt::DisplayRole).toString() + == QStringLiteral("from-the-other-machine")); +} + +TEST_CASE("No environment at all is not a crash", "[library][remote]") +{ + Library::RemoteFileSystemModel model{[] { return nullptr; }, nullptr}; + model.setRoot(score::Uri{score::UriScheme::Library, QString{}}); + + REQUIRE_NOTHROW(model.fetchMore(QModelIndex{})); + CHECK(model.rowCount(QModelIndex{}) == 0); +} + +TEST_CASE("An empty remote folder is listed, not inserted", "[library][remote]") +{ + ScriptedEnvironment env; + const auto root = score::Uri{score::UriScheme::Library, QString{}}; + env.contents[root.toString()] = {}; + + Library::RemoteFileSystemModel model{[&env] { return &env; }, nullptr}; + model.setRoot(root); + + // A model contract violation rather than a wrong answer: inserting an empty + // range is beginInsertRows(0, -1), i.e. last < first, which asserts on a + // debug Qt and emits a nonsensical range otherwise. + bool badRange{}; + QObject::connect( + &model, &QAbstractItemModel::rowsAboutToBeInserted, &model, + [&](const QModelIndex&, int first, int last) { + if(last < first) + badRange = true; + }); + + model.fetchMore(QModelIndex{}); + env.answer(); + + CHECK_FALSE(badRange); + CHECK(model.rowCount(QModelIndex{}) == 0); + + // And it counts as answered: asking again would be a request per repaint. + CHECK_FALSE(model.canFetchMore(QModelIndex{})); + CHECK(env.listCalls == 1); +} diff --git a/tests/unit/ThreadPoolTest.cpp b/tests/unit/ThreadPoolTest.cpp new file mode 100644 index 0000000000..3b3c209411 --- /dev/null +++ b/tests/unit/ThreadPoolTest.cpp @@ -0,0 +1,97 @@ +// Asking the pool for one thread used to start every thread it would ever hand +// out -- hardware_concurrency()/2 of them, each with the process stack size. +// In a browser that exhausts the worker pool and deadlocks the main thread, +// which is what a media layer's waveform computer hit; on a desktop it is +// merely two dozen threads and a hundred megabytes of stacks for one waveform. + +#include + +#include +#include +#include + +#include + +#include + +TEST_CASE("The thread pool starts only what it hands out", "[threadpool]") +{ + int argc{}; + QCoreApplication app{argc, nullptr}; + + auto& pool = score::ThreadPool::instance(); + + auto* first = pool.acquireThread(); + REQUIRE(first); + CHECK(first->isRunning()); + + // The one just handed out, and nothing else: this is the whole point. The + // eager version started hardware_concurrency()/2 of them here. + CHECK(pool.startedThreadCount() == 1); + + auto* second = pool.acquireThread(); + REQUIRE(second); + CHECK(second->isRunning()); + CHECK(pool.startedThreadCount() == 2); + + // Distinct, so the pool is still round-robining rather than handing out one. + CHECK(first != second); + + pool.releaseThread(); + pool.releaseThread(); +} + +// Releasing the last thread used to stop the pool, which meant joining the +// workers from whoever released -- in practice the UI thread, in the destructor +// of a sound or video layer. It waited there for as long as the work took. A +// browser never gets that far: a worker only finishes starting once the main +// thread returns to the event loop, so the wait outlasted the tab. +TEST_CASE("Releasing a thread does not wait for its work", "[threadpool]") +{ + int argc{}; + QCoreApplication app{argc, nullptr}; + + auto& pool = score::ThreadPool::instance(); + + auto* thread = pool.acquireThread(); + REQUIRE(thread); + + // Lives on the pool thread, so it is deleted there too. + auto* worker = new QObject; + worker->moveToThread(thread); + + std::atomic_bool running{false}; + std::atomic_bool done{false}; + QMetaObject::invokeMethod( + worker, + [&] { + running = true; + QThread::msleep(1500); + done = true; + }, + Qt::QueuedConnection); + + while(!running) + QThread::msleep(1); + + QElapsedTimer timer; + timer.start(); + pool.releaseThread(); + const auto elapsed = timer.elapsed(); + + // The work is still going: releasing says "I am done with it", not "stop and + // let me watch". Joining here would have cost the remainder of the sleep. + CHECK(!done); + CHECK(elapsed < 200); + + while(!done) + QThread::msleep(1); + + // Still usable afterwards: the pool is not torn down under the survivors. + auto* again = pool.acquireThread(); + REQUIRE(again); + CHECK(again->isRunning()); + pool.releaseThread(); + + worker->deleteLater(); +}