diff options
Diffstat (limited to 'dom/midi')
78 files changed, 6813 insertions, 0 deletions
diff --git a/dom/midi/AlsaCompatibility.cpp b/dom/midi/AlsaCompatibility.cpp new file mode 100644 index 0000000000..9e219d4999 --- /dev/null +++ b/dom/midi/AlsaCompatibility.cpp @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "mozilla/Assertions.h" + +// The code in this file is a workaround for building with ALSA versions prior +// to 1.1. Various functions that the alsa crate (a dependency of the midir +// crate) use are missing from those versions. The functions are not actually +// used so we provide dummy implementations that return an error. This file +// can be safely removed when the Linux sysroot will be updated to Debian 9 +// (or higher) +#include <alsa/asoundlib.h> + +extern "C" { + +#define ALSA_DIVERT(func) \ + int func(void) { \ + MOZ_CRASH(#func "should never be called."); \ + return -1; \ + } + +#if (SND_LIB_MAJOR == 1) && (SND_LIB_MINOR == 0) && (SND_LIB_SUBMINOR < 29) +ALSA_DIVERT(snd_pcm_sw_params_set_tstamp_type) +ALSA_DIVERT(snd_pcm_sw_params_get_tstamp_type) +#endif + +#if (SND_LIB_MAJOR == 1) && (SND_LIB_MINOR < 1) +ALSA_DIVERT(snd_pcm_hw_params_supports_audio_ts_type) +ALSA_DIVERT(snd_pcm_status_set_audio_htstamp_config) +#endif +} diff --git a/dom/midi/MIDIAccess.cpp b/dom/midi/MIDIAccess.cpp new file mode 100644 index 0000000000..fa8ae514c2 --- /dev/null +++ b/dom/midi/MIDIAccess.cpp @@ -0,0 +1,250 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "mozilla/dom/MIDIAccess.h" +#include "mozilla/dom/MIDIAccessManager.h" +#include "mozilla/dom/MIDIPort.h" +#include "mozilla/dom/MIDIAccessBinding.h" +#include "mozilla/dom/MIDIConnectionEvent.h" +#include "mozilla/dom/MIDIOptionsBinding.h" +#include "mozilla/dom/MIDIOutputMapBinding.h" +#include "mozilla/dom/MIDIInputMapBinding.h" +#include "mozilla/dom/MIDIOutputMap.h" +#include "mozilla/dom/MIDIInputMap.h" +#include "mozilla/dom/MIDIOutput.h" +#include "mozilla/dom/MIDIInput.h" +#include "mozilla/dom/MIDITypes.h" +#include "mozilla/dom/Promise.h" +#include "mozilla/dom/PContent.h" +#include "mozilla/dom/Document.h" +#include "nsPIDOMWindow.h" +#include "nsContentPermissionHelper.h" +#include "nsISupportsImpl.h" // for MOZ_COUNT_CTOR, MOZ_COUNT_DTOR +#include "ipc/IPCMessageUtils.h" +#include "MIDILog.h" + +namespace mozilla::dom { + +NS_IMPL_CYCLE_COLLECTION_CLASS(MIDIAccess) +NS_IMPL_CYCLE_COLLECTION_TRACE_BEGIN_INHERITED(MIDIAccess, DOMEventTargetHelper) +NS_IMPL_CYCLE_COLLECTION_TRACE_END + +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN_INHERITED(MIDIAccess, + DOMEventTargetHelper) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mInputMap) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mOutputMap) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mAccessPromise) +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN_INHERITED(MIDIAccess, + DOMEventTargetHelper) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mInputMap) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mOutputMap) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mAccessPromise) + tmp->Shutdown(); +NS_IMPL_CYCLE_COLLECTION_UNLINK_END + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(MIDIAccess) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY +NS_INTERFACE_MAP_END_INHERITING(DOMEventTargetHelper) + +NS_IMPL_ADDREF_INHERITED(MIDIAccess, DOMEventTargetHelper) +NS_IMPL_RELEASE_INHERITED(MIDIAccess, DOMEventTargetHelper) + +MIDIAccess::MIDIAccess(nsPIDOMWindowInner* aWindow, bool aSysexEnabled, + Promise* aAccessPromise) + : DOMEventTargetHelper(aWindow), + mInputMap(new MIDIInputMap(aWindow)), + mOutputMap(new MIDIOutputMap(aWindow)), + mSysexEnabled(aSysexEnabled), + mAccessPromise(aAccessPromise), + mHasShutdown(false) { + MOZ_ASSERT(aWindow); + MOZ_ASSERT(aAccessPromise); + KeepAliveIfHasListenersFor(nsGkAtoms::onstatechange); +} + +MIDIAccess::~MIDIAccess() { Shutdown(); } + +void MIDIAccess::Shutdown() { + LOG("MIDIAccess::Shutdown"); + if (mHasShutdown) { + return; + } + mDestructionObservers.Broadcast(void_t()); + if (MIDIAccessManager::IsRunning()) { + MIDIAccessManager::Get()->RemoveObserver(this); + } + mHasShutdown = true; +} + +void MIDIAccess::FireConnectionEvent(MIDIPort* aPort) { + MOZ_ASSERT(aPort); + MIDIConnectionEventInit init; + init.mPort = aPort; + nsAutoString id; + aPort->GetId(id); + ErrorResult rv; + if (aPort->State() == MIDIPortDeviceState::Disconnected) { + if (aPort->Type() == MIDIPortType::Input && mInputMap->Has(id)) { + MIDIInputMap_Binding::MaplikeHelpers::Delete(mInputMap, aPort->StableId(), + rv); + mInputMap->Remove(id); + } else if (aPort->Type() == MIDIPortType::Output && mOutputMap->Has(id)) { + MIDIOutputMap_Binding::MaplikeHelpers::Delete(mOutputMap, + aPort->StableId(), rv); + mOutputMap->Remove(id); + } + // Check to make sure Has()/Delete() calls haven't failed. + if (NS_WARN_IF(rv.Failed())) { + LOG("Inconsistency during FireConnectionEvent"); + return; + } + } else { + // If we receive an event from a port that is not in one of our port maps, + // this means a port that was disconnected has been reconnected, with the + // port owner holding the object during that time, and we should add that + // port object to our maps again. + if (aPort->Type() == MIDIPortType::Input && !mInputMap->Has(id)) { + if (NS_WARN_IF(rv.Failed())) { + LOG("Input port not found"); + return; + } + MIDIInputMap_Binding::MaplikeHelpers::Set( + mInputMap, aPort->StableId(), *(static_cast<MIDIInput*>(aPort)), rv); + if (NS_WARN_IF(rv.Failed())) { + LOG("Map Set failed for input port"); + return; + } + mInputMap->Insert(id, aPort); + } else if (aPort->Type() == MIDIPortType::Output && mOutputMap->Has(id)) { + if (NS_WARN_IF(rv.Failed())) { + LOG("Output port not found"); + return; + } + MIDIOutputMap_Binding::MaplikeHelpers::Set( + mOutputMap, aPort->StableId(), *(static_cast<MIDIOutput*>(aPort)), + rv); + if (NS_WARN_IF(rv.Failed())) { + LOG("Map set failed for output port"); + return; + } + mOutputMap->Insert(id, aPort); + } + } + RefPtr<MIDIConnectionEvent> event = + MIDIConnectionEvent::Constructor(this, u"statechange"_ns, init); + DispatchTrustedEvent(event); +} + +void MIDIAccess::MaybeCreateMIDIPort(const MIDIPortInfo& aInfo, + ErrorResult& aRv) { + nsAutoString id(aInfo.id()); + MIDIPortType type = static_cast<MIDIPortType>(aInfo.type()); + RefPtr<MIDIPort> port; + if (type == MIDIPortType::Input) { + if (mInputMap->Has(id) || NS_WARN_IF(aRv.Failed())) { + // We already have the port in our map. + return; + } + port = MIDIInput::Create(GetOwner(), this, aInfo, mSysexEnabled); + if (NS_WARN_IF(!port)) { + LOG("Couldn't create input port"); + aRv.Throw(NS_ERROR_FAILURE); + return; + } + MIDIInputMap_Binding::MaplikeHelpers::Set( + mInputMap, port->StableId(), *(static_cast<MIDIInput*>(port.get())), + aRv); + if (NS_WARN_IF(aRv.Failed())) { + LOG("Coudld't set input port in map"); + return; + } + mInputMap->Insert(id, port); + } else if (type == MIDIPortType::Output) { + if (mOutputMap->Has(id) || NS_WARN_IF(aRv.Failed())) { + // We already have the port in our map. + return; + } + port = MIDIOutput::Create(GetOwner(), this, aInfo, mSysexEnabled); + if (NS_WARN_IF(!port)) { + LOG("Couldn't create output port"); + aRv.Throw(NS_ERROR_FAILURE); + return; + } + MIDIOutputMap_Binding::MaplikeHelpers::Set( + mOutputMap, port->StableId(), *(static_cast<MIDIOutput*>(port.get())), + aRv); + if (NS_WARN_IF(aRv.Failed())) { + LOG("Coudld't set output port in map"); + return; + } + mOutputMap->Insert(id, port); + } else { + // If we hit this, then we have some port that is neither input nor output. + // That is bad. + MOZ_CRASH("We shouldn't be here!"); + } + // Set up port to listen for destruction of this access object. + mDestructionObservers.AddObserver(port); + + // If we haven't resolved the promise for handing the MIDIAccess object to + // content, this means we're still populating the list of already connected + // devices. Don't fire events yet. + if (!mAccessPromise) { + FireConnectionEvent(port); + } +} + +// For the MIDIAccess object, only worry about new connections, where we create +// MIDIPort objects. When a port is removed and the MIDIPortRemove event is +// received, that will be handled by the MIDIPort object itself, and it will +// request removal from MIDIAccess's maps. +void MIDIAccess::Notify(const MIDIPortList& aEvent) { + LOG("MIDIAcess::Notify"); + if (!GetOwner()) { + // Do nothing if we've already been disconnected from the document. + return; + } + + for (const auto& port : aEvent.ports()) { + // Something went very wrong. Warn and return. + ErrorResult rv; + MaybeCreateMIDIPort(port, rv); + if (rv.Failed()) { + if (!mAccessPromise) { + // We can't reject the promise so let's suppress the error instead + rv.SuppressException(); + return; + } + mAccessPromise->MaybeReject(std::move(rv)); + mAccessPromise = nullptr; + } + } + if (!mAccessPromise) { + return; + } + mAccessPromise->MaybeResolve(this); + mAccessPromise = nullptr; +} + +JSObject* MIDIAccess::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return MIDIAccess_Binding::Wrap(aCx, this, aGivenProto); +} + +void MIDIAccess::RemovePortListener(MIDIAccessDestructionObserver* aObs) { + mDestructionObservers.RemoveObserver(aObs); +} + +void MIDIAccess::DisconnectFromOwner() { + IgnoreKeepAliveIfHasListenersFor(nsGkAtoms::onstatechange); + + DOMEventTargetHelper::DisconnectFromOwner(); + MIDIAccessManager::Get()->SendRefresh(); +} + +} // namespace mozilla::dom diff --git a/dom/midi/MIDIAccess.h b/dom/midi/MIDIAccess.h new file mode 100644 index 0000000000..9b3a860f02 --- /dev/null +++ b/dom/midi/MIDIAccess.h @@ -0,0 +1,118 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_MIDIAccess_h +#define mozilla_dom_MIDIAccess_h + +#include "mozilla/Attributes.h" +#include "mozilla/DOMEventTargetHelper.h" +#include "mozilla/Observer.h" +#include "nsCycleCollectionParticipant.h" +#include "nsWrapperCache.h" + +struct JSContext; + +namespace mozilla { +class ErrorResult; + +// Predeclare void_t here, as including IPCMessageUtils brings in windows.h and +// causes binding compilation problems. +struct void_t; + +namespace dom { + +class MIDIAccessManager; +class MIDIInputMap; +struct MIDIOptions; +class MIDIOutputMap; +class MIDIPermissionRequest; +class MIDIPort; +class MIDIPortChangeEvent; +class MIDIPortInfo; +class MIDIPortList; +class Promise; + +using MIDIAccessDestructionObserver = Observer<void_t>; + +/** + * MIDIAccess is the DOM object that is handed to the user upon MIDI permissions + * being successfully granted. It manages access to MIDI ports, and fires events + * for device connection and disconnection. + * + * New MIDIAccess objects are created every time RequestMIDIAccess is called. + * MIDIAccess objects are managed via MIDIAccessManager. + */ +class MIDIAccess final : public DOMEventTargetHelper, + public Observer<MIDIPortList> { + // Use the Permission Request class in MIDIAccessManager for creating + // MIDIAccess objects. + friend class MIDIPermissionRequest; + friend class MIDIAccessManager; + + public: + NS_DECL_ISUPPORTS_INHERITED + NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS_INHERITED(MIDIAccess, + DOMEventTargetHelper) + public: + virtual JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + // Return map of MIDI Input Ports + MIDIInputMap* Inputs() const { return mInputMap; } + + // Return map of MIDI Output Ports + MIDIOutputMap* Outputs() const { return mOutputMap; } + + // Returns true if sysex permissions were given + bool SysexEnabled() const { return mSysexEnabled; } + + // Observer implementation for receiving port connection updates + void Notify(const MIDIPortList& aEvent) override; + + // All MIDIPort objects observe destruction of the MIDIAccess object that + // created them, as the port object receives disconnection events which then + // must be passed up to the MIDIAccess object. If the Port object dies before + // the MIDIAccess object, it needs to be removed from the observer list. + void RemovePortListener(MIDIAccessDestructionObserver* aObs); + + // Fires DOM event on port connection/disconnection + void FireConnectionEvent(MIDIPort* aPort); + + // Notify all MIDIPorts that were created by this MIDIAccess and are still + // alive, and detach from the MIDIAccessManager. + void Shutdown(); + IMPL_EVENT_HANDLER(statechange); + + void DisconnectFromOwner() override; + + private: + MIDIAccess(nsPIDOMWindowInner* aWindow, bool aSysexEnabled, + Promise* aAccessPromise); + ~MIDIAccess(); + + // On receiving a connection event from MIDIAccessManager, create a + // corresponding MIDIPort object if we don't already have one. + void MaybeCreateMIDIPort(const MIDIPortInfo& aInfo, ErrorResult& aRv); + + // Stores all known MIDIInput Ports + RefPtr<MIDIInputMap> mInputMap; + // Stores all known MIDIOutput Ports + RefPtr<MIDIOutputMap> mOutputMap; + // List of MIDIPort observers that need to be updated on destruction. + ObserverList<void_t> mDestructionObservers; + // True if user gave permissions for sysex usage to this object. + bool mSysexEnabled; + // Promise created by RequestMIDIAccess call, to be resolved after port + // populating is finished. + RefPtr<Promise> mAccessPromise; + // True if shutdown process has started, so we don't try to add more ports. + bool mHasShutdown; +}; + +} // namespace dom +} // namespace mozilla + +#endif // mozilla_dom_MIDIAccess_h diff --git a/dom/midi/MIDIAccessManager.cpp b/dom/midi/MIDIAccessManager.cpp new file mode 100644 index 0000000000..c0556f1ffd --- /dev/null +++ b/dom/midi/MIDIAccessManager.cpp @@ -0,0 +1,176 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "mozilla/dom/MIDIAccessManager.h" +#include "mozilla/dom/Document.h" +#include "mozilla/dom/MIDIAccess.h" +#include "mozilla/dom/MIDIManagerChild.h" +#include "mozilla/dom/MIDIPermissionRequest.h" +#include "mozilla/dom/FeaturePolicyUtils.h" +#include "mozilla/dom/Promise.h" +#include "nsIGlobalObject.h" +#include "mozilla/ClearOnShutdown.h" +#include "mozilla/ipc/Endpoint.h" +#include "mozilla/ipc/PBackgroundChild.h" +#include "mozilla/ipc/BackgroundChild.h" +#include "mozilla/StaticPrefs_midi.h" + +using namespace mozilla::ipc; + +namespace mozilla::dom { + +namespace { +// Singleton object for MIDIAccessManager +StaticRefPtr<MIDIAccessManager> gMIDIAccessManager; +} // namespace + +MIDIAccessManager::MIDIAccessManager() : mHasPortList(false), mChild(nullptr) {} + +MIDIAccessManager::~MIDIAccessManager() = default; + +// static +MIDIAccessManager* MIDIAccessManager::Get() { + if (!gMIDIAccessManager) { + gMIDIAccessManager = new MIDIAccessManager(); + ClearOnShutdown(&gMIDIAccessManager); + } + return gMIDIAccessManager; +} + +// static +bool MIDIAccessManager::IsRunning() { return !!gMIDIAccessManager; } + +already_AddRefed<Promise> MIDIAccessManager::RequestMIDIAccess( + nsPIDOMWindowInner* aWindow, const MIDIOptions& aOptions, + ErrorResult& aRv) { + MOZ_ASSERT(NS_IsMainThread()); + MOZ_ASSERT(aWindow); + nsCOMPtr<nsIGlobalObject> go = do_QueryInterface(aWindow); + RefPtr<Promise> p = Promise::Create(go, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return nullptr; + } + nsCOMPtr<Document> doc = aWindow->GetDoc(); + if (NS_WARN_IF(!doc)) { + aRv.Throw(NS_ERROR_FAILURE); + return nullptr; + } + +#ifndef MOZ_WEBMIDI_MIDIR_IMPL + if (!StaticPrefs::midi_testing()) { + // If we don't have a MIDI implementation and testing is disabled we can't + // allow accessing WebMIDI. However we don't want to return something + // different from a normal rejection because we don't want websites to use + // the error as a way to fingerprint users, so we throw a security error + // as if the request had been rejected by the user. + aRv.ThrowSecurityError("Access not allowed"); + return nullptr; + } +#endif + + if (!FeaturePolicyUtils::IsFeatureAllowed(doc, u"midi"_ns)) { + aRv.Throw(NS_ERROR_DOM_SECURITY_ERR); + return nullptr; + } + + nsCOMPtr<nsIRunnable> permRunnable = + new MIDIPermissionRequest(aWindow, p, aOptions); + aRv = NS_DispatchToMainThread(permRunnable); + if (NS_WARN_IF(aRv.Failed())) { + return nullptr; + } + return p.forget(); +} + +bool MIDIAccessManager::AddObserver(Observer<MIDIPortList>* aObserver) { + // Add observer before we start the service, otherwise we can end up with + // device lists being received before we have observers to send them to. + mChangeObservers.AddObserver(aObserver); + + if (!mChild) { + StartActor(); + } else { + mChild->SendRefresh(); + } + + return true; +} + +// Sets up the actor to talk to the parent. +// +// We Bootstrap the actor manually rather than using a constructor so that +// we can bind the parent endpoint to a dedicated task queue. +void MIDIAccessManager::StartActor() { + MOZ_ASSERT(NS_IsMainThread()); + MOZ_ASSERT(!mChild); + + // Grab PBackground. + ::mozilla::ipc::PBackgroundChild* pBackground = + BackgroundChild::GetOrCreateForCurrentThread(); + + // Create the endpoints and bind the one on the child side. + Endpoint<PMIDIManagerParent> parentEndpoint; + Endpoint<PMIDIManagerChild> childEndpoint; + MOZ_ALWAYS_SUCCEEDS( + PMIDIManager::CreateEndpoints(&parentEndpoint, &childEndpoint)); + mChild = new MIDIManagerChild(); + MOZ_ALWAYS_TRUE(childEndpoint.Bind(mChild)); + + // Kick over to the parent to connect things over there. + pBackground->SendCreateMIDIManager(std::move(parentEndpoint)); +} + +void MIDIAccessManager::RemoveObserver(Observer<MIDIPortList>* aObserver) { + mChangeObservers.RemoveObserver(aObserver); + if (mChangeObservers.Length() == 0) { + // If we're out of listeners, go ahead and shut down. Make sure to cleanup + // the IPDL protocol also. + if (mChild) { + mChild->Shutdown(); + mChild = nullptr; + } + gMIDIAccessManager = nullptr; + } +} + +void MIDIAccessManager::SendRefresh() { + if (mChild) { + mChild->SendRefresh(); + } +} + +void MIDIAccessManager::CreateMIDIAccess(nsPIDOMWindowInner* aWindow, + bool aNeedsSysex, Promise* aPromise) { + MOZ_ASSERT(aWindow); + MOZ_ASSERT(aPromise); + RefPtr<MIDIAccess> a(new MIDIAccess(aWindow, aNeedsSysex, aPromise)); + if (NS_WARN_IF(!AddObserver(a))) { + aPromise->MaybeReject(NS_ERROR_FAILURE); + return; + } + if (!mHasPortList) { + // Hold the access object until we get a connected device list. + mAccessHolder.AppendElement(a); + } else { + // If we already have a port list, just send it to the MIDIAccess object now + // so it can prepopulate its device list and resolve the promise. + a->Notify(mPortList); + } +} + +void MIDIAccessManager::Update(const MIDIPortList& aPortList) { + mPortList = aPortList; + mChangeObservers.Broadcast(aPortList); + if (!mHasPortList) { + mHasPortList = true; + // Now that we've broadcast the already-connected port list, content + // should manage the lifetime of the MIDIAccess object, so we can clear the + // keep-alive array. + mAccessHolder.Clear(); + } +} + +} // namespace mozilla::dom diff --git a/dom/midi/MIDIAccessManager.h b/dom/midi/MIDIAccessManager.h new file mode 100644 index 0000000000..606c73d268 --- /dev/null +++ b/dom/midi/MIDIAccessManager.h @@ -0,0 +1,76 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_MIDIAccessManager_h +#define mozilla_dom_MIDIAccessManager_h + +#include "nsPIDOMWindow.h" +#include "mozilla/dom/MIDITypes.h" +#include "mozilla/Observer.h" + +namespace mozilla::dom { + +class MIDIAccess; +class MIDIManagerChild; +struct MIDIOptions; +class MIDIPortChangeEvent; +class MIDIPortInfo; +class Promise; + +/** + * MIDIAccessManager manages creation and lifetime of MIDIAccess objects for the + * process it lives in. It is in charge of dealing with permission requests, + * creating new MIDIAccess objects, and updating live MIDIAccess objects with + * new device listings. + * + * While a process/window can have many MIDIAccess objects, there is only one + * MIDIAccessManager for any one process. + */ +class MIDIAccessManager final { + public: + NS_INLINE_DECL_REFCOUNTING(MIDIAccessManager); + // Handles requests from Navigator for MIDI permissions and MIDIAccess + // creation. + already_AddRefed<Promise> RequestMIDIAccess(nsPIDOMWindowInner* aWindow, + const MIDIOptions& aOptions, + ErrorResult& aRv); + // Creates a new MIDIAccess object + void CreateMIDIAccess(nsPIDOMWindowInner* aWindow, bool aNeedsSysex, + Promise* aPromise); + // Getter for manager singleton + static MIDIAccessManager* Get(); + // True if manager singleton has been created + static bool IsRunning(); + // Send device connection updates to all known MIDIAccess objects. + void Update(const MIDIPortList& aPortList); + // Adds a device update observer (usually a MIDIAccess object) + bool AddObserver(Observer<MIDIPortList>* aObserver); + // Removes a device update observer (usually a MIDIAccess object) + void RemoveObserver(Observer<MIDIPortList>* aObserver); + // Requests the service to update the list of devices + void SendRefresh(); + + private: + MIDIAccessManager(); + ~MIDIAccessManager(); + void StartActor(); + // True if object has received a device list from the MIDI platform service. + bool mHasPortList; + // List of known ports for the system. + MIDIPortList mPortList; + // Holds MIDIAccess objects until we've received the first list of devices + // from the MIDI Service. + nsTArray<RefPtr<MIDIAccess>> mAccessHolder; + // Device state update observers (usually MIDIAccess objects) + ObserverList<MIDIPortList> mChangeObservers; + // IPC Object for MIDIManager. Created on first MIDIAccess object creation, + // destroyed on last MIDIAccess object destruction. + RefPtr<MIDIManagerChild> mChild; +}; + +} // namespace mozilla::dom + +#endif // mozilla_dom_MIDIAccessManager_h diff --git a/dom/midi/MIDIInput.cpp b/dom/midi/MIDIInput.cpp new file mode 100644 index 0000000000..b9202ac578 --- /dev/null +++ b/dom/midi/MIDIInput.cpp @@ -0,0 +1,101 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "mozilla/dom/MIDIInput.h" +#include "mozilla/dom/Document.h" +#include "mozilla/dom/MIDIPortChild.h" +#include "mozilla/dom/MIDIInputBinding.h" +#include "mozilla/dom/MIDIMessageEvent.h" +#include "mozilla/dom/MIDIMessageEventBinding.h" + +#include "MIDILog.h" + +namespace mozilla::dom { + +MIDIInput::MIDIInput(nsPIDOMWindowInner* aWindow) + : MIDIPort(aWindow), mKeepAlive(false) {} + +// static +RefPtr<MIDIInput> MIDIInput::Create(nsPIDOMWindowInner* aWindow, + MIDIAccess* aMIDIAccessParent, + const MIDIPortInfo& aPortInfo, + const bool aSysexEnabled) { + MOZ_ASSERT(static_cast<MIDIPortType>(aPortInfo.type()) == + MIDIPortType::Input); + RefPtr<MIDIInput> port = new MIDIInput(aWindow); + if (!port->Initialize(aPortInfo, aSysexEnabled, aMIDIAccessParent)) { + return nullptr; + } + return port; +} + +JSObject* MIDIInput::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return MIDIInput_Binding::Wrap(aCx, this, aGivenProto); +} + +void MIDIInput::Receive(const nsTArray<MIDIMessage>& aMsgs) { + if (!GetOwner()) { + return; // Ignore messages once we've been disconnected from the owner + } + + nsCOMPtr<Document> doc = GetOwner()->GetDoc(); + if (!doc) { + NS_WARNING("No document available to send MIDIMessageEvent to!"); + return; + } + for (const auto& msg : aMsgs) { + RefPtr<MIDIMessageEvent> event( + MIDIMessageEvent::Constructor(this, msg.timestamp(), msg.data())); + DispatchTrustedEvent(event); + } +} + +void MIDIInput::StateChange() { + if (Port()->ConnectionState() == MIDIPortConnectionState::Open || + (Port()->DeviceState() == MIDIPortDeviceState::Connected && + Port()->ConnectionState() == MIDIPortConnectionState::Pending)) { + KeepAliveOnMidimessage(); + } else { + DontKeepAliveOnMidimessage(); + } +} + +void MIDIInput::EventListenerAdded(nsAtom* aType) { + if (aType == nsGkAtoms::onmidimessage) { + // HACK: the Web MIDI spec states that we should open a port only when + // setting the midimessage event handler but Chrome does it even when + // adding event listeners hence this. + if (Port()->ConnectionState() != MIDIPortConnectionState::Open) { + LOG("onmidimessage event listener added, sending implicit Open"); + Port()->SendOpen(); + } + } + + DOMEventTargetHelper::EventListenerAdded(aType); +} + +void MIDIInput::DisconnectFromOwner() { + DontKeepAliveOnMidimessage(); + + MIDIPort::DisconnectFromOwner(); +} + +void MIDIInput::KeepAliveOnMidimessage() { + if (!mKeepAlive) { + mKeepAlive = true; + KeepAliveIfHasListenersFor(nsGkAtoms::onmidimessage); + } +} + +void MIDIInput::DontKeepAliveOnMidimessage() { + if (mKeepAlive) { + IgnoreKeepAliveIfHasListenersFor(nsGkAtoms::onmidimessage); + mKeepAlive = false; + } +} + +} // namespace mozilla::dom diff --git a/dom/midi/MIDIInput.h b/dom/midi/MIDIInput.h new file mode 100644 index 0000000000..72458457a0 --- /dev/null +++ b/dom/midi/MIDIInput.h @@ -0,0 +1,54 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_MIDIInput_h +#define mozilla_dom_MIDIInput_h + +#include "mozilla/dom/MIDIPort.h" + +struct JSContext; + +namespace mozilla::dom { + +class MIDIPortInfo; + +/** + * Represents a MIDI Input Port, handles generating incoming message events. + * + */ +class MIDIInput final : public MIDIPort { + public: + static RefPtr<MIDIInput> Create(nsPIDOMWindowInner* aWindow, + MIDIAccess* aMIDIAccessParent, + const MIDIPortInfo& aPortInfo, + const bool aSysexEnabled); + ~MIDIInput() = default; + + JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + IMPL_EVENT_HANDLER(midimessage); + + void StateChange() override; + void EventListenerAdded(nsAtom* aType) override; + void DisconnectFromOwner() override; + + private: + explicit MIDIInput(nsPIDOMWindowInner* aWindow); + // Takes an array of IPC MIDIMessage objects and turns them into + // MIDIMessageEvents, which it then fires. + void Receive(const nsTArray<MIDIMessage>& aMsgs) override; + + void KeepAliveOnMidimessage(); + void DontKeepAliveOnMidimessage(); + + // If true this object will be kept alive even without direct JS references + bool mKeepAlive; +}; + +} // namespace mozilla::dom + +#endif // mozilla_dom_MIDIInput_h diff --git a/dom/midi/MIDIInputMap.cpp b/dom/midi/MIDIInputMap.cpp new file mode 100644 index 0000000000..aa4f57a7d3 --- /dev/null +++ b/dom/midi/MIDIInputMap.cpp @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "mozilla/dom/MIDIInputMap.h" +#include "mozilla/dom/MIDIInputMapBinding.h" +#include "nsPIDOMWindow.h" +#include "mozilla/dom/BindingUtils.h" + +namespace mozilla::dom { + +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(MIDIInputMap, mParent) + +NS_IMPL_CYCLE_COLLECTING_ADDREF(MIDIInputMap) +NS_IMPL_CYCLE_COLLECTING_RELEASE(MIDIInputMap) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(MIDIInputMap) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +MIDIInputMap::MIDIInputMap(nsPIDOMWindowInner* aParent) : mParent(aParent) {} + +JSObject* MIDIInputMap::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return MIDIInputMap_Binding::Wrap(aCx, this, aGivenProto); +} + +} // namespace mozilla::dom diff --git a/dom/midi/MIDIInputMap.h b/dom/midi/MIDIInputMap.h new file mode 100644 index 0000000000..5826290dbc --- /dev/null +++ b/dom/midi/MIDIInputMap.h @@ -0,0 +1,46 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_MIDIInputMap_h +#define mozilla_dom_MIDIInputMap_h + +#include "mozilla/dom/MIDIPort.h" +#include "nsCOMPtr.h" +#include "nsTHashMap.h" +#include "nsWrapperCache.h" + +class nsPIDOMWindowInner; + +namespace mozilla::dom { + +/** + * Maplike DOM object that holds a list of all MIDI input ports available for + * access. Almost all functions are implemented automatically by WebIDL. + */ +class MIDIInputMap final : public nsISupports, public nsWrapperCache { + public: + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(MIDIInputMap) + nsPIDOMWindowInner* GetParentObject() const { return mParent; } + + explicit MIDIInputMap(nsPIDOMWindowInner* aParent); + JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + bool Has(nsAString& aId) { return mPorts.Get(aId) != nullptr; } + void Insert(nsAString& aId, RefPtr<MIDIPort> aPort) { + mPorts.InsertOrUpdate(aId, aPort); + } + void Remove(nsAString& aId) { mPorts.Remove(aId); } + + private: + ~MIDIInputMap() = default; + nsTHashMap<nsString, RefPtr<MIDIPort>> mPorts; + nsCOMPtr<nsPIDOMWindowInner> mParent; +}; + +} // namespace mozilla::dom + +#endif // mozilla_dom_MIDIInputMap_h diff --git a/dom/midi/MIDILog.cpp b/dom/midi/MIDILog.cpp new file mode 100644 index 0000000000..cc9945ed58 --- /dev/null +++ b/dom/midi/MIDILog.cpp @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "MIDILog.h" + +#include "mozilla/dom/MIDITypes.h" +#include "mozilla/dom/MIDIPortBinding.h" + +mozilla::LazyLogModule gWebMIDILog("WebMIDI"); + +void LogMIDIMessage(const mozilla::dom::MIDIMessage& aMessage, + const nsAString& aPortId, + mozilla::dom::MIDIPortType aDirection) { + if (MOZ_LOG_TEST(gWebMIDILog, mozilla::LogLevel::Debug)) { + if (MOZ_LOG_TEST(gWebMIDILog, mozilla::LogLevel::Verbose)) { + uint32_t byteCount = aMessage.data().Length(); + nsAutoCString logMessage; + // Log long messages inline with the timestamp and the length, log + // longer messages a bit like xxd + logMessage.AppendPrintf( + "%s %s length=%u", NS_ConvertUTF16toUTF8(aPortId).get(), + aDirection == mozilla::dom::MIDIPortType::Input ? "->" : "<-", + byteCount); + + if (byteCount <= 3) { + logMessage.AppendPrintf(" ["); + // Regular messages + for (uint32_t i = 0; i < byteCount - 1; i++) { + logMessage.AppendPrintf("%x ", aMessage.data()[i]); + } + logMessage.AppendPrintf("%x]", aMessage.data()[byteCount - 1]); + } else { + // Longer messages + for (uint32_t i = 0; i < byteCount; i++) { + if (!(i % 8)) { + logMessage.AppendPrintf("\n%08u:\t", i); + } + logMessage.AppendPrintf("%x ", aMessage.data()[i]); + } + } + MOZ_LOG(gWebMIDILog, mozilla::LogLevel::Verbose, + ("%s", logMessage.get())); + } + } +} diff --git a/dom/midi/MIDILog.h b/dom/midi/MIDILog.h new file mode 100644 index 0000000000..ee2cae3dfc --- /dev/null +++ b/dom/midi/MIDILog.h @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_MIDILog_h +#define mozilla_dom_MIDILog_h + +#include <mozilla/Logging.h> +#include <nsStringFwd.h> + +namespace mozilla::dom { +class MIDIMessage; +enum class MIDIPortType : uint8_t; +} // namespace mozilla::dom + +extern mozilla::LazyLogModule gWebMIDILog; + +#define LOG(...) MOZ_LOG(gWebMIDILog, mozilla::LogLevel::Debug, (__VA_ARGS__)); +#define LOGV(x, ...) \ + MOZ_LOG(gWebMIDILog, mozilla::LogLevel::Verbose, (__VA_ARGS__)); + +void LogMIDIMessage(const mozilla::dom::MIDIMessage& aMessage, + const nsAString& aPortId, + mozilla::dom::MIDIPortType aDirection); + +#endif // mozilla_dom_MIDILog_h diff --git a/dom/midi/MIDIManagerChild.cpp b/dom/midi/MIDIManagerChild.cpp new file mode 100644 index 0000000000..cb0dd6a793 --- /dev/null +++ b/dom/midi/MIDIManagerChild.cpp @@ -0,0 +1,29 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "MIDIManagerChild.h" +#include "mozilla/dom/MIDIAccessManager.h" + +using namespace mozilla::dom; + +MIDIManagerChild::MIDIManagerChild() : mShutdown(false) {} + +mozilla::ipc::IPCResult MIDIManagerChild::RecvMIDIPortListUpdate( + const MIDIPortList& aPortList) { + MOZ_ASSERT(NS_IsMainThread()); + if (mShutdown) { + return IPC_OK(); + } + MOZ_ASSERT(MIDIAccessManager::IsRunning()); + MIDIAccessManager::Get()->Update(aPortList); + return IPC_OK(); +} + +void MIDIManagerChild::Shutdown() { + MOZ_ASSERT(!mShutdown); + mShutdown = true; + SendShutdown(); +} diff --git a/dom/midi/MIDIManagerChild.h b/dom/midi/MIDIManagerChild.h new file mode 100644 index 0000000000..610105de9c --- /dev/null +++ b/dom/midi/MIDIManagerChild.h @@ -0,0 +1,36 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_MIDIManagerChild_h +#define mozilla_dom_MIDIManagerChild_h + +#include "mozilla/dom/PMIDIManagerChild.h" + +namespace mozilla::dom { + +/** + * Actor implementation for the Child side of MIDIManager (represented in DOM by + * MIDIAccess). Manages actor lifetime so that we know to shut down services + * when all MIDIManagers are gone. Also receives port list update on MIDIAccess + * object creation. + * + */ +class MIDIManagerChild final : public PMIDIManagerChild { + public: + NS_INLINE_DECL_REFCOUNTING(MIDIManagerChild) + + MIDIManagerChild(); + mozilla::ipc::IPCResult RecvMIDIPortListUpdate(const MIDIPortList& aPortList); + void Shutdown(); + + private: + ~MIDIManagerChild() = default; + bool mShutdown; +}; + +} // namespace mozilla::dom + +#endif // mozilla_dom_MIDIManagerChild_h diff --git a/dom/midi/MIDIManagerParent.cpp b/dom/midi/MIDIManagerParent.cpp new file mode 100644 index 0000000000..ef574f5b04 --- /dev/null +++ b/dom/midi/MIDIManagerParent.cpp @@ -0,0 +1,34 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "mozilla/dom/MIDIManagerParent.h" +#include "mozilla/dom/MIDIPlatformService.h" + +namespace mozilla::dom { + +void MIDIManagerParent::ActorDestroy(ActorDestroyReason aWhy) { + if (MIDIPlatformService::IsRunning()) { + MIDIPlatformService::Get()->RemoveManager(this); + } +} + +mozilla::ipc::IPCResult MIDIManagerParent::RecvRefresh() { + MIDIPlatformService::Get()->Refresh(); + return IPC_OK(); +} + +mozilla::ipc::IPCResult MIDIManagerParent::RecvShutdown() { + // The two-step shutdown process here is the standard way to ensure that the + // child receives any messages sent by the server (since either sending or + // receiving __delete__ prevents any further messages from being received). + // This was necessary before bug 1547085 when discarded messages would + // trigger a crash, and is probably unnecessary now, but we leave it in place + // just in case. + Close(); + return IPC_OK(); +} + +} // namespace mozilla::dom diff --git a/dom/midi/MIDIManagerParent.h b/dom/midi/MIDIManagerParent.h new file mode 100644 index 0000000000..a8ff0648da --- /dev/null +++ b/dom/midi/MIDIManagerParent.h @@ -0,0 +1,34 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_MIDIManagerParent_h +#define mozilla_dom_MIDIManagerParent_h + +#include "mozilla/dom/PMIDIManagerParent.h" + +namespace mozilla::dom { + +/** + * Actor implementation for the Parent (PBackground thread) side of MIDIManager + * (represented in DOM by MIDIAccess). Manages actor lifetime so that we know + * to shut down services when all MIDIManagers are gone. + * + */ +class MIDIManagerParent final : public PMIDIManagerParent { + public: + NS_INLINE_DECL_THREADSAFE_REFCOUNTING(MIDIManagerParent, override) + MIDIManagerParent() = default; + mozilla::ipc::IPCResult RecvRefresh(); + mozilla::ipc::IPCResult RecvShutdown(); + void ActorDestroy(ActorDestroyReason aWhy) override; + + private: + ~MIDIManagerParent() = default; +}; + +} // namespace mozilla::dom + +#endif // mozilla_dom_MIDIManagerParent_h diff --git a/dom/midi/MIDIMessageEvent.cpp b/dom/midi/MIDIMessageEvent.cpp new file mode 100644 index 0000000000..793cb4eeca --- /dev/null +++ b/dom/midi/MIDIMessageEvent.cpp @@ -0,0 +1,95 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "js/Realm.h" +#include "mozilla/dom/EventBinding.h" +#include "mozilla/dom/MIDIMessageEvent.h" +#include "mozilla/dom/MIDIMessageEventBinding.h" +#include "js/GCAPI.h" +#include "jsfriendapi.h" +#include "mozilla/FloatingPoint.h" +#include "mozilla/HoldDropJSObjects.h" +#include "mozilla/dom/Nullable.h" +#include "mozilla/dom/PrimitiveConversions.h" +#include "mozilla/dom/TypedArray.h" +#include "mozilla/dom/Performance.h" + +namespace mozilla::dom { + +NS_IMPL_CYCLE_COLLECTION_INHERITED_WITH_JS_MEMBERS(MIDIMessageEvent, Event, (), + (mData)) + +NS_IMPL_ADDREF_INHERITED(MIDIMessageEvent, Event) +NS_IMPL_RELEASE_INHERITED(MIDIMessageEvent, Event) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(MIDIMessageEvent) +NS_INTERFACE_MAP_END_INHERITING(Event) + +MIDIMessageEvent::MIDIMessageEvent(mozilla::dom::EventTarget* aOwner) + : Event(aOwner, nullptr, nullptr) { + mozilla::HoldJSObjects(this); +} + +MIDIMessageEvent::~MIDIMessageEvent() { mozilla::DropJSObjects(this); } + +JSObject* MIDIMessageEvent::WrapObjectInternal( + JSContext* aCx, JS::Handle<JSObject*> aGivenProto) { + return MIDIMessageEvent_Binding::Wrap(aCx, this, aGivenProto); +} + +MIDIMessageEvent* MIDIMessageEvent::AsMIDIMessageEvent() { return this; } + +already_AddRefed<MIDIMessageEvent> MIDIMessageEvent::Constructor( + EventTarget* aOwner, const class TimeStamp& aReceivedTime, + const nsTArray<uint8_t>& aData) { + MOZ_ASSERT(aOwner); + RefPtr<MIDIMessageEvent> e = new MIDIMessageEvent(aOwner); + e->InitEvent(u"midimessage"_ns, false, false); + e->mEvent->mTimeStamp = aReceivedTime; + e->mRawData = aData.Clone(); + e->SetTrusted(true); + return e.forget(); +} + +already_AddRefed<MIDIMessageEvent> MIDIMessageEvent::Constructor( + const GlobalObject& aGlobal, const nsAString& aType, + const MIDIMessageEventInit& aEventInitDict, ErrorResult& aRv) { + nsCOMPtr<EventTarget> owner = do_QueryInterface(aGlobal.GetAsSupports()); + RefPtr<MIDIMessageEvent> e = new MIDIMessageEvent(owner); + bool trusted = e->Init(owner); + e->InitEvent(aType, aEventInitDict.mBubbles, aEventInitDict.mCancelable); + // Set data for event. Timestamp will always be set to Now() (default for + // event) using this constructor. + if (aEventInitDict.mData.WasPassed()) { + JSAutoRealm ar(aGlobal.Context(), aGlobal.Get()); + JS::Rooted<JSObject*> data(aGlobal.Context(), + aEventInitDict.mData.Value().Obj()); + e->mData = JS_NewUint8ArrayFromArray(aGlobal.Context(), data); + if (NS_WARN_IF(!e->mData)) { + aRv.Throw(NS_ERROR_OUT_OF_MEMORY); + return nullptr; + } + } + + e->SetTrusted(trusted); + mozilla::HoldJSObjects(e.get()); + return e.forget(); +} + +void MIDIMessageEvent::GetData(JSContext* cx, + JS::MutableHandle<JSObject*> aData, + ErrorResult& aRv) { + if (!mData) { + mData = Uint8Array::Create(cx, this, mRawData, aRv); + if (aRv.Failed()) { + return; + } + mRawData.Clear(); + } + aData.set(mData); +} + +} // namespace mozilla::dom diff --git a/dom/midi/MIDIMessageEvent.h b/dom/midi/MIDIMessageEvent.h new file mode 100644 index 0000000000..d5bc0b710a --- /dev/null +++ b/dom/midi/MIDIMessageEvent.h @@ -0,0 +1,62 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_MIDIMessageEvent_h +#define mozilla_dom_MIDIMessageEvent_h + +#include <cstdint> +#include "js/RootingAPI.h" +#include "mozilla/AlreadyAddRefed.h" +#include "mozilla/Assertions.h" +#include "mozilla/TimeStamp.h" +#include "mozilla/dom/Event.h" +#include "nsCycleCollectionParticipant.h" +#include "nsISupports.h" +#include "nsStringFwd.h" +#include "nsTArray.h" + +struct JSContext; +namespace mozilla::dom { +struct MIDIMessageEventInit; + +/** + * Event that fires whenever a MIDI message is received by the MIDIInput object. + * + */ +class MIDIMessageEvent final : public Event { + public: + NS_DECL_ISUPPORTS_INHERITED + NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS_INHERITED(MIDIMessageEvent, + Event) + protected: + explicit MIDIMessageEvent(mozilla::dom::EventTarget* aOwner); + + JS::Heap<JSObject*> mData; + + public: + virtual MIDIMessageEvent* AsMIDIMessageEvent(); + virtual JSObject* WrapObjectInternal( + JSContext* aCx, JS::Handle<JSObject*> aGivenProto) override; + static already_AddRefed<MIDIMessageEvent> Constructor( + EventTarget* aOwner, const class TimeStamp& aReceivedTime, + const nsTArray<uint8_t>& aData); + + static already_AddRefed<MIDIMessageEvent> Constructor( + const GlobalObject& aGlobal, const nsAString& aType, + const MIDIMessageEventInit& aEventInitDict, ErrorResult& aRv); + + // Getter for message data + void GetData(JSContext* cx, JS::MutableHandle<JSObject*> aData, + ErrorResult& aRv); + + private: + ~MIDIMessageEvent(); + nsTArray<uint8_t> mRawData; +}; + +} // namespace mozilla::dom + +#endif // mozilla_dom_MIDIMessageEvent_h diff --git a/dom/midi/MIDIMessageQueue.cpp b/dom/midi/MIDIMessageQueue.cpp new file mode 100644 index 0000000000..3006b225c0 --- /dev/null +++ b/dom/midi/MIDIMessageQueue.cpp @@ -0,0 +1,73 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "MIDIMessageQueue.h" +#include "mozilla/dom/MIDITypes.h" + +namespace mozilla::dom { + +MIDIMessageQueue::MIDIMessageQueue() : mMutex("MIDIMessageQueue::mMutex") {} + +class MIDIMessageTimestampComparator { + public: + bool Equals(const MIDIMessage& a, const MIDIMessage& b) const { + return a.timestamp() == b.timestamp(); + } + bool LessThan(const MIDIMessage& a, const MIDIMessage& b) const { + return a.timestamp() < b.timestamp(); + } +}; + +void MIDIMessageQueue::Add(nsTArray<MIDIMessage>& aMsg) { + MutexAutoLock lock(mMutex); + for (auto msg : aMsg) { + mMessageQueue.InsertElementSorted(msg, MIDIMessageTimestampComparator()); + } +} + +void MIDIMessageQueue::GetMessagesBefore(TimeStamp aTimestamp, + nsTArray<MIDIMessage>& aMsgQueue) { + MutexAutoLock lock(mMutex); + int i = 0; + for (auto msg : mMessageQueue) { + if (aTimestamp < msg.timestamp()) { + break; + } + aMsgQueue.AppendElement(msg); + i++; + } + if (i > 0) { + mMessageQueue.RemoveElementsAt(0, i); + } +} + +void MIDIMessageQueue::GetMessages(nsTArray<MIDIMessage>& aMsgQueue) { + MutexAutoLock lock(mMutex); + aMsgQueue.AppendElements(mMessageQueue); + mMessageQueue.Clear(); +} + +void MIDIMessageQueue::Clear() { + MutexAutoLock lock(mMutex); + mMessageQueue.Clear(); +} + +void MIDIMessageQueue::ClearAfterNow() { + MutexAutoLock lock(mMutex); + TimeStamp now = TimeStamp::Now(); + int i = 0; + for (auto msg : mMessageQueue) { + if (now < msg.timestamp()) { + break; + } + i++; + } + if (i > 0) { + mMessageQueue.RemoveElementsAt(0, i); + } +} + +} // namespace mozilla::dom diff --git a/dom/midi/MIDIMessageQueue.h b/dom/midi/MIDIMessageQueue.h new file mode 100644 index 0000000000..cadfc1b249 --- /dev/null +++ b/dom/midi/MIDIMessageQueue.h @@ -0,0 +1,58 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_MIDIMessageQueue_h +#define mozilla_dom_MIDIMessageQueue_h + +#include "mozilla/Mutex.h" +#include "nsTArray.h" + +// XXX Avoid including this here by moving function implementations to the cpp +// file. +#include "mozilla/dom/MIDITypes.h" + +namespace mozilla { + +class TimeStamp; + +namespace dom { + +class MIDIMessage; + +/** + * Since some MIDI Messages can be scheduled to be sent in the future, the + * MIDIMessageQueue is responsible for making sure all MIDI messages are + * scheduled and sent in order. + */ +class MIDIMessageQueue { + public: + MIDIMessageQueue(); + ~MIDIMessageQueue() = default; + // Adds an array of possibly out-of-order messages to our queue. + void Add(nsTArray<MIDIMessage>& aMsg); + // Retrieve all pending messages before the time specified. + void GetMessagesBefore(TimeStamp aTimestamp, + nsTArray<MIDIMessage>& aMsgQueue); + // Get all pending messages. + void GetMessages(nsTArray<MIDIMessage>& aMsgQueue); + // Erase all pending messages. + void Clear(); + // Erase all pending messages that would be sent in the future. Useful for + // when ports are closed, as we must send all messages with timestamps in the + // past. + void ClearAfterNow(); + + private: + // Array of messages to be sent. + nsTArray<MIDIMessage> mMessageQueue; + // Mutex for coordinating cross thread array access. + Mutex mMutex MOZ_UNANNOTATED; +}; + +} // namespace dom +} // namespace mozilla + +#endif // mozilla_dom_MIDIMessageQueue_h diff --git a/dom/midi/MIDIOutput.cpp b/dom/midi/MIDIOutput.cpp new file mode 100644 index 0000000000..3cdd7d308d --- /dev/null +++ b/dom/midi/MIDIOutput.cpp @@ -0,0 +1,102 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "mozilla/dom/MIDIOutput.h" +#include "mozilla/dom/MIDIPortChild.h" +#include "mozilla/dom/MIDITypes.h" +#include "mozilla/dom/MIDIOutputBinding.h" +#include "mozilla/dom/MIDIUtils.h" +#include "nsDOMNavigationTiming.h" +#include "mozilla/ErrorResult.h" +#include "mozilla/TimeStamp.h" +#include "mozilla/dom/Document.h" +#include "mozilla/dom/Performance.h" + +using namespace mozilla; +using namespace mozilla::dom; + +MIDIOutput::MIDIOutput(nsPIDOMWindowInner* aWindow) : MIDIPort(aWindow) {} + +// static +RefPtr<MIDIOutput> MIDIOutput::Create(nsPIDOMWindowInner* aWindow, + MIDIAccess* aMIDIAccessParent, + const MIDIPortInfo& aPortInfo, + const bool aSysexEnabled) { + MOZ_ASSERT(static_cast<MIDIPortType>(aPortInfo.type()) == + MIDIPortType::Output); + RefPtr<MIDIOutput> port = new MIDIOutput(aWindow); + if (NS_WARN_IF( + !port->Initialize(aPortInfo, aSysexEnabled, aMIDIAccessParent))) { + return nullptr; + } + return port; +} + +JSObject* MIDIOutput::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return MIDIOutput_Binding::Wrap(aCx, this, aGivenProto); +} + +void MIDIOutput::Send(const Sequence<uint8_t>& aData, + const Optional<double>& aTimestamp, ErrorResult& aRv) { + if (Port()->DeviceState() == MIDIPortDeviceState::Disconnected) { + aRv.Throw(NS_ERROR_DOM_INVALID_STATE_ERR); + return; + } + // The timestamp passed to us is a DOMHighResTimestamp, which is in relation + // to the start of navigation timing. This needs to be turned into a + // TimeStamp before it hits the platform specific MIDI service. + // + // If timestamp is either not set or zero, set timestamp to now and send the + // message ASAP. + TimeStamp timestamp; + if (aTimestamp.WasPassed() && aTimestamp.Value() != 0) { + nsCOMPtr<Document> doc = GetOwner()->GetDoc(); + if (!doc) { + aRv.Throw(NS_ERROR_DOM_INVALID_STATE_ERR); + return; + } + TimeDuration ts_diff = TimeDuration::FromMilliseconds(aTimestamp.Value()); + timestamp = GetOwner() + ->GetPerformance() + ->GetDOMTiming() + ->GetNavigationStartTimeStamp() + + ts_diff; + } else { + timestamp = TimeStamp::Now(); + } + + nsTArray<MIDIMessage> msgArray; + bool ret = MIDIUtils::ParseMessages(aData, timestamp, msgArray); + if (!ret) { + aRv.ThrowTypeError("Invalid MIDI message"); + return; + } + + if (msgArray.IsEmpty()) { + aRv.ThrowTypeError("Empty message array"); + return; + } + + // TODO Move this check back to parse message so we don't have to iterate + // twice. + if (!SysexEnabled()) { + for (auto& msg : msgArray) { + if (MIDIUtils::IsSysexMessage(msg)) { + aRv.Throw(NS_ERROR_DOM_INVALID_ACCESS_ERR); + return; + } + } + } + Port()->SendSend(msgArray); +} + +void MIDIOutput::Clear() { + if (Port()->ConnectionState() == MIDIPortConnectionState::Closed) { + return; + } + Port()->SendClear(); +} diff --git a/dom/midi/MIDIOutput.h b/dom/midi/MIDIOutput.h new file mode 100644 index 0000000000..7c39d7a4a9 --- /dev/null +++ b/dom/midi/MIDIOutput.h @@ -0,0 +1,50 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_MIDIOutput_h +#define mozilla_dom_MIDIOutput_h + +#include "mozilla/dom/MIDIPort.h" + +struct JSContext; + +namespace mozilla { +class ErrorResult; + +namespace dom { + +class MIDIPortInfo; +class MIDIMessage; + +/** + * Represents a MIDI Output Port, handles sending message to devices. + * + */ +class MIDIOutput final : public MIDIPort { + public: + static RefPtr<MIDIOutput> Create(nsPIDOMWindowInner* aWindow, + MIDIAccess* aMIDIAccessParent, + const MIDIPortInfo& aPortInfo, + const bool aSysexEnabled); + ~MIDIOutput() = default; + + JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + // Send a message to an output port + void Send(const Sequence<uint8_t>& aData, const Optional<double>& aTimestamp, + ErrorResult& aRv); + // Clear any partially sent messages from the send queue + void Clear(); + + private: + explicit MIDIOutput(nsPIDOMWindowInner* aWindow); +}; + +} // namespace dom +} // namespace mozilla + +#endif // mozilla_dom_MIDIOutput_h diff --git a/dom/midi/MIDIOutputMap.cpp b/dom/midi/MIDIOutputMap.cpp new file mode 100644 index 0000000000..d7840827f2 --- /dev/null +++ b/dom/midi/MIDIOutputMap.cpp @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "mozilla/dom/MIDIOutputMap.h" +#include "mozilla/dom/MIDIOutputMapBinding.h" +#include "nsPIDOMWindow.h" +#include "mozilla/dom/BindingUtils.h" + +namespace mozilla::dom { + +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(MIDIOutputMap, mParent) + +NS_IMPL_CYCLE_COLLECTING_ADDREF(MIDIOutputMap) +NS_IMPL_CYCLE_COLLECTING_RELEASE(MIDIOutputMap) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(MIDIOutputMap) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +MIDIOutputMap::MIDIOutputMap(nsPIDOMWindowInner* aParent) : mParent(aParent) {} + +JSObject* MIDIOutputMap::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return MIDIOutputMap_Binding::Wrap(aCx, this, aGivenProto); +} + +} // namespace mozilla::dom diff --git a/dom/midi/MIDIOutputMap.h b/dom/midi/MIDIOutputMap.h new file mode 100644 index 0000000000..2e33e8b2a7 --- /dev/null +++ b/dom/midi/MIDIOutputMap.h @@ -0,0 +1,49 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_MIDIOutputMap_h +#define mozilla_dom_MIDIOutputMap_h + +#include "mozilla/dom/MIDIPort.h" +#include "nsCOMPtr.h" +#include "nsTHashMap.h" +#include "nsWrapperCache.h" + +class nsPIDOMWindowInner; + +namespace mozilla::dom { + +/** + * Maplike DOM object that holds a list of all MIDI output ports available for + * access. Almost all functions are implemented automatically by WebIDL. + * + */ +class MIDIOutputMap final : public nsISupports, public nsWrapperCache { + public: + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(MIDIOutputMap) + + explicit MIDIOutputMap(nsPIDOMWindowInner* aParent); + + nsPIDOMWindowInner* GetParentObject() const { return mParent; } + + JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + bool Has(nsAString& aId) { return mPorts.Get(aId) != nullptr; } + void Insert(nsAString& aId, RefPtr<MIDIPort> aPort) { + mPorts.InsertOrUpdate(aId, aPort); + } + void Remove(nsAString& aId) { mPorts.Remove(aId); } + + private: + ~MIDIOutputMap() = default; + nsTHashMap<nsString, RefPtr<MIDIPort>> mPorts; + nsCOMPtr<nsPIDOMWindowInner> mParent; +}; + +} // namespace mozilla::dom + +#endif // mozilla_dom_MIDIOutputMap_h diff --git a/dom/midi/MIDIPermissionRequest.cpp b/dom/midi/MIDIPermissionRequest.cpp new file mode 100644 index 0000000000..1eed95f177 --- /dev/null +++ b/dom/midi/MIDIPermissionRequest.cpp @@ -0,0 +1,202 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "mozilla/dom/MIDIPermissionRequest.h" +#include "mozilla/dom/Document.h" +#include "mozilla/dom/MIDIAccessManager.h" +#include "mozilla/dom/MIDIOptionsBinding.h" +#include "mozilla/ipc/BackgroundChild.h" +#include "mozilla/ipc/PBackgroundChild.h" +#include "mozilla/BasePrincipal.h" +#include "mozilla/RandomNum.h" +#include "mozilla/StaticPrefs_dom.h" +#include "nsIGlobalObject.h" +#include "mozilla/Preferences.h" +#include "nsContentUtils.h" + +//------------------------------------------------- +// MIDI Permission Requests +//------------------------------------------------- + +using namespace mozilla::dom; + +NS_IMPL_CYCLE_COLLECTION_INHERITED(MIDIPermissionRequest, + ContentPermissionRequestBase, mPromise) + +NS_IMPL_QUERY_INTERFACE_CYCLE_COLLECTION_INHERITED(MIDIPermissionRequest, + ContentPermissionRequestBase, + nsIRunnable) + +NS_IMPL_ADDREF_INHERITED(MIDIPermissionRequest, ContentPermissionRequestBase) +NS_IMPL_RELEASE_INHERITED(MIDIPermissionRequest, ContentPermissionRequestBase) + +MIDIPermissionRequest::MIDIPermissionRequest(nsPIDOMWindowInner* aWindow, + Promise* aPromise, + const MIDIOptions& aOptions) + : ContentPermissionRequestBase( + aWindow->GetDoc()->NodePrincipal(), aWindow, + ""_ns, // We check prefs in a custom way here + "midi"_ns), + mPromise(aPromise), + mNeedsSysex(aOptions.mSysex) { + MOZ_ASSERT(aWindow); + MOZ_ASSERT(aPromise, "aPromise should not be null!"); + MOZ_ASSERT(aWindow->GetDoc()); + mPrincipal = aWindow->GetDoc()->NodePrincipal(); + MOZ_ASSERT(mPrincipal); +} + +NS_IMETHODIMP +MIDIPermissionRequest::GetTypes(nsIArray** aTypes) { + NS_ENSURE_ARG_POINTER(aTypes); + nsTArray<nsString> options; + + // The previous implementation made no differences between midi and + // midi-sysex. The check on the SitePermsAddonProvider pref should be removed + // at the same time as the old implementation. + if (mNeedsSysex || !StaticPrefs::dom_sitepermsaddon_provider_enabled()) { + options.AppendElement(u"sysex"_ns); + } + return nsContentPermissionUtils::CreatePermissionArray(mType, options, + aTypes); +} + +NS_IMETHODIMP +MIDIPermissionRequest::Cancel() { + mCancelTimer = nullptr; + + if (StaticPrefs::dom_sitepermsaddon_provider_enabled()) { + mPromise->MaybeRejectWithSecurityError( + "WebMIDI requires a site permission add-on to activate"); + } else { + // This message is used for the initial XPIProvider-based implementation + // of Site Permissions. + // It should be removed as part of Bug 1789718. + mPromise->MaybeRejectWithSecurityError( + "WebMIDI requires a site permission add-on to activate — see " + "https://extensionworkshop.com/documentation/publish/" + "site-permission-add-on/ for details."); + } + return NS_OK; +} + +NS_IMETHODIMP +MIDIPermissionRequest::Allow(JS::Handle<JS::Value> aChoices) { + MOZ_ASSERT(aChoices.isUndefined()); + MIDIAccessManager* mgr = MIDIAccessManager::Get(); + mgr->CreateMIDIAccess(mWindow, mNeedsSysex, mPromise); + return NS_OK; +} + +NS_IMETHODIMP +MIDIPermissionRequest::Run() { + // If the testing flag is true, skip dialog + if (Preferences::GetBool("midi.prompt.testing", false)) { + bool allow = + Preferences::GetBool("media.navigator.permission.disabled", false); + if (allow) { + Allow(JS::UndefinedHandleValue); + } else { + Cancel(); + } + return NS_OK; + } + + nsCString permName = "midi"_ns; + // The previous implementation made no differences between midi and + // midi-sysex. The check on the SitePermsAddonProvider pref should be removed + // at the same time as the old implementation. + if (mNeedsSysex || !StaticPrefs::dom_sitepermsaddon_provider_enabled()) { + permName.Append("-sysex"); + } + + // First, check for an explicit allow/deny. Note that we want to support + // granting a permission on the base domain and then using it on a subdomain, + // which is why we use the non-"Exact" variants of these APIs. See bug + // 1757218. + if (nsContentUtils::IsSitePermAllow(mPrincipal, permName)) { + Allow(JS::UndefinedHandleValue); + return NS_OK; + } + + if (nsContentUtils::IsSitePermDeny(mPrincipal, permName)) { + CancelWithRandomizedDelay(); + return NS_OK; + } + + // If the add-on is not installed, and sitepermsaddon provider not enabled, + // auto-deny (except for localhost). + if (StaticPrefs::dom_webmidi_gated() && + !StaticPrefs::dom_sitepermsaddon_provider_enabled() && + !nsContentUtils::HasSitePerm(mPrincipal, permName) && + !mPrincipal->GetIsLoopbackHost()) { + CancelWithRandomizedDelay(); + return NS_OK; + } + + // If sitepermsaddon provider is enabled and user denied install, + // auto-deny (except for localhost, where we use a regular permission flow). + if (StaticPrefs::dom_sitepermsaddon_provider_enabled() && + nsContentUtils::IsSitePermDeny(mPrincipal, "install"_ns) && + !mPrincipal->GetIsLoopbackHost()) { + CancelWithRandomizedDelay(); + return NS_OK; + } + + // Before we bother the user with a prompt, see if they have any devices. If + // they don't, just report denial. + MOZ_ASSERT(NS_IsMainThread()); + mozilla::ipc::PBackgroundChild* actor = + mozilla::ipc::BackgroundChild::GetOrCreateForCurrentThread(); + if (NS_WARN_IF(!actor)) { + return NS_ERROR_FAILURE; + } + RefPtr<MIDIPermissionRequest> self = this; + actor->SendHasMIDIDevice( + [=](bool aHasDevices) { + MOZ_ASSERT(NS_IsMainThread()); + + if (aHasDevices) { + self->DoPrompt(); + } else { + nsContentUtils::ReportToConsoleNonLocalized( + u"Silently denying site request for MIDI access because no devices were detected. You may need to restart your browser after connecting a new device."_ns, + nsIScriptError::infoFlag, "WebMIDI"_ns, mWindow->GetDoc()); + self->CancelWithRandomizedDelay(); + } + }, + [=](auto) { self->CancelWithRandomizedDelay(); }); + + return NS_OK; +} + +// If the user has no MIDI devices, we automatically deny the request. To +// prevent sites from using timing attack to discern the existence of MIDI +// devices, we instrument silent denials with a randomized delay between 3 +// and 13 seconds, which is intended to model the time the user might spend +// considering a prompt before denying it. +// +// Note that we set the random component of the delay to zero in automation +// to avoid unnecessarily increasing test end-to-end time. +void MIDIPermissionRequest::CancelWithRandomizedDelay() { + MOZ_ASSERT(NS_IsMainThread()); + uint32_t baseDelayMS = 3 * 1000; + uint32_t randomDelayMS = + xpc::IsInAutomation() ? 0 : RandomUint64OrDie() % (10 * 1000); + auto delay = TimeDuration::FromMilliseconds(baseDelayMS + randomDelayMS); + RefPtr<MIDIPermissionRequest> self = this; + NS_NewTimerWithCallback( + getter_AddRefs(mCancelTimer), [=](auto) { self->Cancel(); }, delay, + nsITimer::TYPE_ONE_SHOT, __func__); +} + +nsresult MIDIPermissionRequest::DoPrompt() { + if (NS_FAILED(nsContentPermissionUtils::AskPermission(this, mWindow))) { + Cancel(); + return NS_ERROR_FAILURE; + } + return NS_OK; +} diff --git a/dom/midi/MIDIPermissionRequest.h b/dom/midi/MIDIPermissionRequest.h new file mode 100644 index 0000000000..935abf580a --- /dev/null +++ b/dom/midi/MIDIPermissionRequest.h @@ -0,0 +1,52 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_MIDIPermissionRequest_h +#define mozilla_dom_MIDIPermissionRequest_h + +#include "mozilla/dom/Promise.h" +#include "nsContentPermissionHelper.h" + +namespace mozilla::dom { + +struct MIDIOptions; + +/** + * Handles permission dialog management when requesting MIDI permissions. + */ +class MIDIPermissionRequest final : public ContentPermissionRequestBase, + public nsIRunnable { + public: + MIDIPermissionRequest(nsPIDOMWindowInner* aWindow, Promise* aPromise, + const MIDIOptions& aOptions); + + NS_DECL_ISUPPORTS_INHERITED + NS_DECL_NSIRUNNABLE + NS_DECL_CYCLE_COLLECTION_CLASS_INHERITED(MIDIPermissionRequest, + ContentPermissionRequestBase) + // nsIContentPermissionRequest + NS_IMETHOD Cancel(void) override; + NS_IMETHOD Allow(JS::Handle<JS::Value> choices) override; + NS_IMETHOD GetTypes(nsIArray** aTypes) override; + + private: + ~MIDIPermissionRequest() = default; + nsresult DoPrompt(); + void CancelWithRandomizedDelay(); + + // If we're canceling on a timer, we need to hold a strong ref while it's + // outstanding. + nsCOMPtr<nsITimer> mCancelTimer; + + // Promise for returning MIDIAccess on request success + RefPtr<Promise> mPromise; + // True if sysex permissions should be requested + bool mNeedsSysex; +}; + +} // namespace mozilla::dom + +#endif // mozilla_dom_MIDIPermissionRequest_h diff --git a/dom/midi/MIDIPlatformRunnables.cpp b/dom/midi/MIDIPlatformRunnables.cpp new file mode 100644 index 0000000000..355f90b9f3 --- /dev/null +++ b/dom/midi/MIDIPlatformRunnables.cpp @@ -0,0 +1,49 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "mozilla/dom/MIDIPlatformRunnables.h" +#include "mozilla/dom/MIDIPlatformService.h" +#include "mozilla/dom/MIDIPortParent.h" +#include "mozilla/ipc/BackgroundParent.h" + +namespace mozilla::dom { + +NS_IMETHODIMP +MIDIBackgroundRunnable::Run() { + MIDIPlatformService::AssertThread(); + if (!MIDIPlatformService::IsRunning()) { + return NS_OK; + } + RunInternal(); + return NS_OK; +} + +void ReceiveRunnable::RunInternal() { + MIDIPlatformService::Get()->CheckAndReceive(mPortId, mMsgs); +} + +void AddPortRunnable::RunInternal() { + MIDIPlatformService::Get()->AddPortInfo(mPortInfo); +} + +void RemovePortRunnable::RunInternal() { + MIDIPlatformService::Get()->RemovePortInfo(mPortInfo); +} + +void SetStatusRunnable::RunInternal() { + MIDIPlatformService::Get()->UpdateStatus(mPort, mState, mConnection); +} + +void SendPortListRunnable::RunInternal() { + // Unlike other runnables, SendPortListRunnable should just exit quietly if + // the service has died. + if (!MIDIPlatformService::IsRunning()) { + return; + } + MIDIPlatformService::Get()->SendPortList(); +} + +} // namespace mozilla::dom diff --git a/dom/midi/MIDIPlatformRunnables.h b/dom/midi/MIDIPlatformRunnables.h new file mode 100644 index 0000000000..15727a99cc --- /dev/null +++ b/dom/midi/MIDIPlatformRunnables.h @@ -0,0 +1,125 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_MIDIPlatformRunnables_h +#define mozilla_dom_MIDIPlatformRunnables_h + +#include "mozilla/dom/MIDITypes.h" + +namespace mozilla::dom { + +enum class MIDIPortConnectionState : uint8_t; +enum class MIDIPortDeviceState : uint8_t; + +class MIDIPortParent; +class MIDIMessage; +class MIDIPortInfo; + +/** + * Base class for runnables to be fired to the platform-specific MIDI service + * thread in PBackground. + */ +class MIDIBackgroundRunnable : public Runnable { + public: + MIDIBackgroundRunnable(const char* aName) : Runnable(aName) {} + virtual ~MIDIBackgroundRunnable() = default; + NS_IMETHOD Run() override; + virtual void RunInternal() = 0; +}; + +/** + * Runnable fired from platform-specific MIDI service thread to PBackground + * Thread whenever messages need to be sent to a MIDI device. + * + */ +class ReceiveRunnable final : public MIDIBackgroundRunnable { + public: + ReceiveRunnable(const nsAString& aPortId, const nsTArray<MIDIMessage>& aMsgs) + : MIDIBackgroundRunnable("ReceiveRunnable"), + mMsgs(aMsgs.Clone()), + mPortId(aPortId) {} + // Used in tests + ReceiveRunnable(const nsAString& aPortId, const MIDIMessage& aMsgs) + : MIDIBackgroundRunnable("ReceiveRunnable"), mPortId(aPortId) { + mMsgs.AppendElement(aMsgs); + } + ~ReceiveRunnable() = default; + void RunInternal() override; + + private: + nsTArray<MIDIMessage> mMsgs; + nsString mPortId; +}; + +/** + * Runnable fired from platform-specific MIDI service thread to PBackground + * Thread whenever a device is connected. + * + */ +class AddPortRunnable final : public MIDIBackgroundRunnable { + public: + explicit AddPortRunnable(const MIDIPortInfo& aPortInfo) + : MIDIBackgroundRunnable("AddPortRunnable"), mPortInfo(aPortInfo) {} + ~AddPortRunnable() = default; + void RunInternal() override; + + private: + MIDIPortInfo mPortInfo; +}; + +/** + * Runnable fired from platform-specific MIDI service thread to PBackground + * Thread whenever a device is disconnected. + * + */ +class RemovePortRunnable final : public MIDIBackgroundRunnable { + public: + explicit RemovePortRunnable(const MIDIPortInfo& aPortInfo) + : MIDIBackgroundRunnable("RemovePortRunnable"), mPortInfo(aPortInfo) {} + ~RemovePortRunnable() = default; + void RunInternal() override; + + private: + MIDIPortInfo mPortInfo; +}; + +/** + * Runnable used to delay calls to SendPortList, which is requires to make sure + * MIDIManager actor initialization happens correctly. Also used for testing. + * + */ +class SendPortListRunnable final : public MIDIBackgroundRunnable { + public: + SendPortListRunnable() : MIDIBackgroundRunnable("SendPortListRunnable") {} + ~SendPortListRunnable() = default; + void RunInternal() override; +}; + +/** + * Runnable fired from platform-specific MIDI service thread to PBackground + * Thread whenever a device is disconnected. + * + */ +class SetStatusRunnable final : public MIDIBackgroundRunnable { + public: + SetStatusRunnable(MIDIPortParent* aPort, MIDIPortDeviceState aState, + MIDIPortConnectionState aConnection) + : MIDIBackgroundRunnable("SetStatusRunnable"), + mPort(aPort), + mState(aState), + mConnection(aConnection) {} + ~SetStatusRunnable() = default; + void RunInternal() override; + + private: + RefPtr<MIDIPortParent> mPort; + MIDIPortDeviceState mState; + MIDIPortConnectionState mConnection; +}; + +} // namespace mozilla::dom + +#endif // mozilla_dom_MIDIPlatformRunnables_h diff --git a/dom/midi/MIDIPlatformService.cpp b/dom/midi/MIDIPlatformService.cpp new file mode 100644 index 0000000000..a3ef5911a7 --- /dev/null +++ b/dom/midi/MIDIPlatformService.cpp @@ -0,0 +1,279 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "MIDIPlatformService.h" +#include "MIDIMessageQueue.h" +#include "TestMIDIPlatformService.h" +#ifdef MOZ_WEBMIDI_MIDIR_IMPL +# include "midirMIDIPlatformService.h" +#endif // MOZ_WEBMIDI_MIDIR_IMPL +#include "mozilla/ErrorResult.h" +#include "mozilla/StaticPrefs_midi.h" +#include "mozilla/StaticPtr.h" +#include "mozilla/Unused.h" +#include "mozilla/dom/MIDIManagerParent.h" +#include "mozilla/dom/MIDIPlatformRunnables.h" +#include "mozilla/dom/MIDIUtils.h" +#include "mozilla/dom/PMIDIManagerParent.h" +#include "mozilla/ipc/BackgroundParent.h" +#include "mozilla/dom/MIDIPortParent.h" + +using namespace mozilla; +using namespace mozilla::dom; + +MIDIPlatformService::MIDIPlatformService() + : mHasSentPortList(false), + mMessageQueueMutex("MIDIPlatformServce::mMessageQueueMutex") {} + +MIDIPlatformService::~MIDIPlatformService() = default; + +void MIDIPlatformService::CheckAndReceive(const nsAString& aPortId, + const nsTArray<MIDIMessage>& aMsgs) { + AssertThread(); + for (auto& port : mPorts) { + // TODO Clean this up when we split input/output port arrays + if (port->MIDIPortInterface::Id() != aPortId || + port->Type() != MIDIPortType::Input || + port->ConnectionState() != MIDIPortConnectionState::Open) { + continue; + } + if (!port->SysexEnabled()) { + nsTArray<MIDIMessage> msgs; + for (const auto& msg : aMsgs) { + if (!MIDIUtils::IsSysexMessage(msg)) { + msgs.AppendElement(msg); + } + } + Unused << port->SendReceive(msgs); + } else { + Unused << port->SendReceive(aMsgs); + } + } +} + +void MIDIPlatformService::AddPort(MIDIPortParent* aPort) { + MOZ_ASSERT(aPort); + AssertThread(); + mPorts.AppendElement(aPort); +} + +void MIDIPlatformService::RemovePort(MIDIPortParent* aPort) { + // This should only be called from the background thread, when a MIDIPort + // actor has been destroyed. + AssertThread(); + MOZ_ASSERT(aPort); + mPorts.RemoveElement(aPort); + MaybeStop(); +} + +void MIDIPlatformService::BroadcastState(const MIDIPortInfo& aPortInfo, + const MIDIPortDeviceState& aState) { + AssertThread(); + for (auto& p : mPorts) { + if (p->MIDIPortInterface::Id() == aPortInfo.id() && + p->DeviceState() != aState) { + p->SendUpdateStatus(aState, p->ConnectionState()); + } + } +} + +void MIDIPlatformService::QueueMessages(const nsAString& aId, + nsTArray<MIDIMessage>& aMsgs) { + AssertThread(); + { + MutexAutoLock lock(mMessageQueueMutex); + MIDIMessageQueue* msgQueue = mMessageQueues.GetOrInsertNew(aId); + msgQueue->Add(aMsgs); + } + + ScheduleSend(aId); +} + +void MIDIPlatformService::SendPortList() { + AssertThread(); + mHasSentPortList = true; + MIDIPortList l; + for (auto& el : mPortInfo) { + l.ports().AppendElement(el); + } + for (auto& mgr : mManagers) { + Unused << mgr->SendMIDIPortListUpdate(l); + } +} + +void MIDIPlatformService::Clear(MIDIPortParent* aPort) { + AssertThread(); + MOZ_ASSERT(aPort); + { + MutexAutoLock lock(mMessageQueueMutex); + MIDIMessageQueue* msgQueue = + mMessageQueues.Get(aPort->MIDIPortInterface::Id()); + if (msgQueue) { + msgQueue->Clear(); + } + } +} + +void MIDIPlatformService::AddPortInfo(MIDIPortInfo& aPortInfo) { + AssertThread(); + MOZ_ASSERT(XRE_IsParentProcess()); + + mPortInfo.AppendElement(aPortInfo); + + // ORDER MATTERS HERE. + // + // When MIDI hardware is disconnected, all open MIDIPort objects revert to a + // "pending" state, and they are removed from the port maps of MIDIAccess + // objects. We need to send connection updates to all living ports first, THEN + // we can send port list updates to all of the live MIDIAccess objects. We + // have to go in this order because if a port object is still held live but is + // disconnected, it needs to readd itself to its originating MIDIAccess + // object. Running SendPortList first would cause MIDIAccess to create a new + // MIDIPort object, which would conflict (i.e. old disconnected object != new + // object in port map, which is against spec). + for (auto& port : mPorts) { + if (port->MIDIPortInterface::Id() == aPortInfo.id()) { + port->SendUpdateStatus(MIDIPortDeviceState::Connected, + port->ConnectionState()); + } + } + if (mHasSentPortList) { + SendPortList(); + } +} + +void MIDIPlatformService::RemovePortInfo(MIDIPortInfo& aPortInfo) { + AssertThread(); + mPortInfo.RemoveElement(aPortInfo); + BroadcastState(aPortInfo, MIDIPortDeviceState::Disconnected); + if (mHasSentPortList) { + SendPortList(); + } +} + +StaticRefPtr<nsISerialEventTarget> gMIDITaskQueue; + +// static +void MIDIPlatformService::InitStatics() { + nsCOMPtr<nsISerialEventTarget> queue; + MOZ_ALWAYS_SUCCEEDS( + NS_CreateBackgroundTaskQueue("MIDITaskQueue", getter_AddRefs(queue))); + gMIDITaskQueue = queue.forget(); + ClearOnShutdown(&gMIDITaskQueue); +} + +// static +nsISerialEventTarget* MIDIPlatformService::OwnerThread() { + return gMIDITaskQueue; +} + +StaticRefPtr<MIDIPlatformService> gMIDIPlatformService; + +// static +bool MIDIPlatformService::IsRunning() { + return gMIDIPlatformService != nullptr; +} + +void MIDIPlatformService::Close(mozilla::dom::MIDIPortParent* aPort) { + AssertThread(); + { + MutexAutoLock lock(mMessageQueueMutex); + MIDIMessageQueue* msgQueue = + mMessageQueues.Get(aPort->MIDIPortInterface::Id()); + if (msgQueue) { + msgQueue->ClearAfterNow(); + } + } + // Send all messages before sending a close request + ScheduleSend(aPort->MIDIPortInterface::Id()); + // TODO We should probably have the send function schedule closing + ScheduleClose(aPort); +} + +// static +MIDIPlatformService* MIDIPlatformService::Get() { + // We should never touch the platform service in a child process. + MOZ_ASSERT(XRE_IsParentProcess()); + AssertThread(); + if (!IsRunning()) { + if (StaticPrefs::midi_testing()) { + gMIDIPlatformService = new TestMIDIPlatformService(); + } +#ifdef MOZ_WEBMIDI_MIDIR_IMPL + else { + gMIDIPlatformService = new midirMIDIPlatformService(); + } +#endif // MOZ_WEBMIDI_MIDIR_IMPL + gMIDIPlatformService->Init(); + } + return gMIDIPlatformService; +} + +void MIDIPlatformService::MaybeStop() { + AssertThread(); + if (!IsRunning()) { + // Service already stopped or never started. Exit. + return; + } + // If we have any ports or managers left, we should still be alive. + if (!mPorts.IsEmpty() || !mManagers.IsEmpty()) { + return; + } + Stop(); + gMIDIPlatformService = nullptr; +} + +void MIDIPlatformService::AddManager(MIDIManagerParent* aManager) { + AssertThread(); + mManagers.AppendElement(aManager); + // Managers add themselves during construction. We have to wait for the + // protocol construction to finish before we send them a port list. The + // runnable calls SendPortList, which iterates through the live manager list, + // so this saves us from having to worry about Manager pointer validity at + // time of runnable execution. + nsCOMPtr<nsIRunnable> r(new SendPortListRunnable()); + OwnerThread()->Dispatch(r.forget()); +} + +void MIDIPlatformService::RemoveManager(MIDIManagerParent* aManager) { + AssertThread(); + mManagers.RemoveElement(aManager); + MaybeStop(); +} + +void MIDIPlatformService::UpdateStatus( + MIDIPortParent* aPort, const MIDIPortDeviceState& aDeviceState, + const MIDIPortConnectionState& aConnectionState) { + AssertThread(); + aPort->SendUpdateStatus(aDeviceState, aConnectionState); +} + +void MIDIPlatformService::GetMessages(const nsAString& aPortId, + nsTArray<MIDIMessage>& aMsgs) { + // Can run on either background thread or platform specific IO Thread. + { + MutexAutoLock lock(mMessageQueueMutex); + MIDIMessageQueue* msgQueue; + if (!mMessageQueues.Get(aPortId, &msgQueue)) { + return; + } + msgQueue->GetMessages(aMsgs); + } +} + +void MIDIPlatformService::GetMessagesBefore(const nsAString& aPortId, + const TimeStamp& aTimeStamp, + nsTArray<MIDIMessage>& aMsgs) { + // Can run on either background thread or platform specific IO Thread. + { + MutexAutoLock lock(mMessageQueueMutex); + MIDIMessageQueue* msgQueue; + if (!mMessageQueues.Get(aPortId, &msgQueue)) { + return; + } + msgQueue->GetMessagesBefore(aTimeStamp, aMsgs); + } +} diff --git a/dom/midi/MIDIPlatformService.h b/dom/midi/MIDIPlatformService.h new file mode 100644 index 0000000000..36a1ea1296 --- /dev/null +++ b/dom/midi/MIDIPlatformService.h @@ -0,0 +1,174 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_MIDIPlatformService_h +#define mozilla_dom_MIDIPlatformService_h + +#include "nsClassHashtable.h" +#include "mozilla/Mutex.h" +#include "mozilla/dom/MIDIPortBinding.h" +#include "nsHashKeys.h" + +// XXX Avoid including this here by moving function implementations to the cpp +// file. +#include "mozilla/dom/MIDIMessageQueue.h" + +namespace mozilla::dom { + +class MIDIManagerParent; +class MIDIPortParent; +class MIDIMessage; +class MIDIMessageQueue; +class MIDIPortInfo; + +/** + * Base class for platform specific MIDI implementations. Handles aggregation of + * IPC service objects, as well as sending/receiving updates about port + * connection events and messages. + * + */ +class MIDIPlatformService { + public: + NS_INLINE_DECL_THREADSAFE_REFCOUNTING(MIDIPlatformService); + // Adds info about MIDI Port that has been connected. + void AddPortInfo(MIDIPortInfo& aPortInfo); + + // Removes info of MIDI Port that has been disconnected. + void RemovePortInfo(MIDIPortInfo& aPortInfo); + + // Adds a newly created manager protocol object to manager array. + void AddManager(MIDIManagerParent* aManager); + + // Removes a deleted manager protocol object from manager array. + void RemoveManager(MIDIManagerParent* aManager); + + // Adds a newly created port protocol object to port array. + void AddPort(MIDIPortParent* aPort); + + // Removes a deleted port protocol object from port array. + void RemovePort(MIDIPortParent* aPort); + + // Platform specific init function. + virtual void Init() = 0; + + // Forces the implementation to refresh the port list. + virtual void Refresh() = 0; + + // Platform specific MIDI port opening function. + virtual void Open(MIDIPortParent* aPort) = 0; + + // Clears all queued MIDI messages for a port. + void Clear(MIDIPortParent* aPort); + + // Puts in a request to destroy the singleton MIDIPlatformService object. + // Object will only be destroyed if there are no more MIDIManager and MIDIPort + // protocols left to communicate with. + void MaybeStop(); + + // Initializes statics on startup. + static void InitStatics(); + + // Returns the MIDI Task Queue. + static nsISerialEventTarget* OwnerThread(); + + // Asserts that we're on the above task queue. + static void AssertThread() { + MOZ_DIAGNOSTIC_ASSERT(OwnerThread()->IsOnCurrentThread()); + } + + // True if service is live. + static bool IsRunning(); + + // Returns a pointer to the MIDIPlatformService object, creating it and + // starting the platform specific service if it is not currently running. + static MIDIPlatformService* Get(); + + // Sends a list of all currently connected ports in order to populate a new + // MIDIAccess object. + void SendPortList(); + + // Receives a new set of messages from an MIDI Input Port, and checks their + // validity. + void CheckAndReceive(const nsAString& aPortID, + const nsTArray<MIDIMessage>& aMsgs); + + // Sends connection/disconnect/open/closed/etc status updates about a MIDI + // Port to all port listeners. + void UpdateStatus(MIDIPortParent* aPort, + const MIDIPortDeviceState& aDeviceState, + const MIDIPortConnectionState& aConnectionState); + + // Adds outgoing messages to the sorted message queue, for sending later. + void QueueMessages(const nsAString& aId, nsTArray<MIDIMessage>& aMsgs); + + // Clears all messages later than now, sends all outgoing message scheduled + // before/at now, and schedules MIDI Port connection closing. + void Close(MIDIPortParent* aPort); + + // Returns whether there are currently any MIDI devices. + bool HasDevice() { return !mPortInfo.IsEmpty(); } + + protected: + MIDIPlatformService(); + virtual ~MIDIPlatformService(); + // Platform specific MIDI service shutdown method. + virtual void Stop() = 0; + + // When device state of a MIDI Port changes, broadcast to all IPC port + // objects. + void BroadcastState(const MIDIPortInfo& aPortInfo, + const MIDIPortDeviceState& aState); + + // Platform specific MIDI port closing function. Named "Schedule" due to the + // fact that it needs to happen in the context of the I/O thread for the + // platform MIDI implementation, and therefore will happen async. + virtual void ScheduleClose(MIDIPortParent* aPort) = 0; + + // Platform specific MIDI message sending function. Named "Schedule" due to + // the fact that it needs to happen in the context of the I/O thread for the + // platform MIDI implementation, and therefore will happen async. + virtual void ScheduleSend(const nsAString& aPortId) = 0; + + // Allows platform specific IO Threads to retrieve all messages to be sent. + // Handles mutex locking. + void GetMessages(const nsAString& aPortId, nsTArray<MIDIMessage>& aMsgs); + + // Allows platform specific IO Threads to retrieve all messages to be sent + // before a certain timestamp. Handles mutex locking. + void GetMessagesBefore(const nsAString& aPortId, const TimeStamp& aTimeStamp, + nsTArray<MIDIMessage>& aMsgs); + + private: + // When the MIDIPlatformService is created, we need to know whether or not the + // corresponding IPC MIDIManager objects have received the MIDIPort list after + // it is populated. This is set to True when that is done, so we don't + // constantly spam MIDIManagers with port lists. + bool mHasSentPortList; + + // Array of MIDIManager IPC objects. This array manages the lifetime of + // MIDIManager objects in the parent process, and IPC will call + // RemoveManager() end lifetime when IPC channel is destroyed. + nsTArray<RefPtr<MIDIManagerParent>> mManagers; + + // Array of information for currently connected Ports + nsTArray<MIDIPortInfo> mPortInfo; + + // Array of MIDIPort IPC objects. May contain ports not listed in mPortInfo, + // as we can hold port objects even after they are disconnected. + // + // TODO Split this into input and output ports. Will make life easier. + nsTArray<RefPtr<MIDIPortParent>> mPorts; + + // Per-port message queue hashtable. Handles scheduling messages for sending. + nsClassHashtable<nsStringHashKey, MIDIMessageQueue> mMessageQueues; + + // Mutex for managing access to message queue objects. + Mutex mMessageQueueMutex MOZ_UNANNOTATED; +}; + +} // namespace mozilla::dom + +#endif // mozilla_dom_MIDIPlatformService_h diff --git a/dom/midi/MIDIPort.cpp b/dom/midi/MIDIPort.cpp new file mode 100644 index 0000000000..3d75bf2d63 --- /dev/null +++ b/dom/midi/MIDIPort.cpp @@ -0,0 +1,283 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "mozilla/dom/MIDIPort.h" +#include "mozilla/dom/MIDIConnectionEvent.h" +#include "mozilla/dom/MIDIPortChild.h" +#include "mozilla/dom/MIDIAccess.h" +#include "mozilla/dom/MIDITypes.h" +#include "mozilla/ipc/Endpoint.h" +#include "mozilla/ipc/PBackgroundChild.h" +#include "mozilla/ipc/BackgroundChild.h" +#include "mozilla/dom/Document.h" +#include "mozilla/dom/Promise.h" +#include "nsContentUtils.h" +#include "nsISupportsImpl.h" // for MOZ_COUNT_CTOR, MOZ_COUNT_DTOR +#include "MIDILog.h" + +using namespace mozilla::ipc; + +namespace mozilla::dom { + +NS_IMPL_CYCLE_COLLECTION_INHERITED(MIDIPort, DOMEventTargetHelper, + mOpeningPromise, mClosingPromise) + +NS_IMPL_CYCLE_COLLECTION_TRACE_BEGIN_INHERITED(MIDIPort, DOMEventTargetHelper) +NS_IMPL_CYCLE_COLLECTION_TRACE_END + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(MIDIPort) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY +NS_INTERFACE_MAP_END_INHERITING(DOMEventTargetHelper) + +NS_IMPL_ADDREF_INHERITED(MIDIPort, DOMEventTargetHelper) +NS_IMPL_RELEASE_INHERITED(MIDIPort, DOMEventTargetHelper) + +MIDIPort::MIDIPort(nsPIDOMWindowInner* aWindow) + : DOMEventTargetHelper(aWindow), + mMIDIAccessParent(nullptr), + mKeepAlive(false) { + MOZ_ASSERT(aWindow); + + Document* aDoc = GetOwner()->GetExtantDoc(); + if (aDoc) { + aDoc->DisallowBFCaching(); + } +} + +MIDIPort::~MIDIPort() { + if (mMIDIAccessParent) { + mMIDIAccessParent->RemovePortListener(this); + mMIDIAccessParent = nullptr; + } + if (Port()) { + // If the IPC port channel is still alive at this point, it means we're + // probably CC'ing this port object. Send the shutdown message to also clean + // up the IPC channel. + Port()->SendShutdown(); + } +} + +bool MIDIPort::Initialize(const MIDIPortInfo& aPortInfo, bool aSysexEnabled, + MIDIAccess* aMIDIAccessParent) { + MOZ_ASSERT(aMIDIAccessParent); + nsCOMPtr<Document> document = GetDocumentIfCurrent(); + if (!document) { + return false; + } + + nsCOMPtr<nsIURI> uri = document->GetDocumentURI(); + if (!uri) { + return false; + } + + nsAutoCString origin; + nsresult rv = nsContentUtils::GetWebExposedOriginSerialization(uri, origin); + if (NS_FAILED(rv)) { + return false; + } + RefPtr<MIDIPortChild> port = + new MIDIPortChild(aPortInfo, aSysexEnabled, this); + if (NS_FAILED(port->GenerateStableId(origin))) { + return false; + } + PBackgroundChild* b = BackgroundChild::GetForCurrentThread(); + MOZ_ASSERT(b, + "Should always have a valid BackgroundChild when creating a port " + "object!"); + + // Create the endpoints and bind the one on the child side. + Endpoint<PMIDIPortParent> parentEndpoint; + Endpoint<PMIDIPortChild> childEndpoint; + MOZ_ALWAYS_SUCCEEDS( + PMIDIPort::CreateEndpoints(&parentEndpoint, &childEndpoint)); + MOZ_ALWAYS_TRUE(childEndpoint.Bind(port)); + + if (!b->SendCreateMIDIPort(std::move(parentEndpoint), aPortInfo, + aSysexEnabled)) { + return false; + } + + mMIDIAccessParent = aMIDIAccessParent; + mPortHolder.Init(port.forget()); + LOG("MIDIPort::Initialize (%s, %s)", + NS_ConvertUTF16toUTF8(Port()->Name()).get(), + MIDIPortTypeValues::strings[uint32_t(Port()->Type())].value); + return true; +} + +void MIDIPort::UnsetIPCPort() { + LOG("MIDIPort::UnsetIPCPort (%s, %s)", + NS_ConvertUTF16toUTF8(Port()->Name()).get(), + MIDIPortTypeValues::strings[uint32_t(Port()->Type())].value); + mPortHolder.Clear(); +} + +void MIDIPort::GetId(nsString& aRetVal) const { + MOZ_ASSERT(Port()); + aRetVal = Port()->StableId(); +} + +void MIDIPort::GetManufacturer(nsString& aRetVal) const { + MOZ_ASSERT(Port()); + aRetVal = Port()->Manufacturer(); +} + +void MIDIPort::GetName(nsString& aRetVal) const { + MOZ_ASSERT(Port()); + aRetVal = Port()->Name(); +} + +void MIDIPort::GetVersion(nsString& aRetVal) const { + MOZ_ASSERT(Port()); + aRetVal = Port()->Version(); +} + +MIDIPortType MIDIPort::Type() const { + MOZ_ASSERT(Port()); + return Port()->Type(); +} + +MIDIPortConnectionState MIDIPort::Connection() const { + MOZ_ASSERT(Port()); + return Port()->ConnectionState(); +} + +MIDIPortDeviceState MIDIPort::State() const { + MOZ_ASSERT(Port()); + return Port()->DeviceState(); +} + +bool MIDIPort::SysexEnabled() const { + MOZ_ASSERT(Port()); + return Port()->SysexEnabled(); +} + +already_AddRefed<Promise> MIDIPort::Open(ErrorResult& aError) { + LOG("MIDIPort::Open"); + MOZ_ASSERT(Port()); + RefPtr<Promise> p; + if (mOpeningPromise) { + p = mOpeningPromise; + return p.forget(); + } + nsCOMPtr<nsIGlobalObject> go = do_QueryInterface(GetOwner()); + p = Promise::Create(go, aError); + if (aError.Failed()) { + return nullptr; + } + mOpeningPromise = p; + Port()->SendOpen(); + return p.forget(); +} + +already_AddRefed<Promise> MIDIPort::Close(ErrorResult& aError) { + LOG("MIDIPort::Close"); + MOZ_ASSERT(Port()); + RefPtr<Promise> p; + if (mClosingPromise) { + p = mClosingPromise; + return p.forget(); + } + nsCOMPtr<nsIGlobalObject> go = do_QueryInterface(GetOwner()); + p = Promise::Create(go, aError); + if (aError.Failed()) { + return nullptr; + } + mClosingPromise = p; + Port()->SendClose(); + return p.forget(); +} + +void MIDIPort::Notify(const void_t& aVoid) { + LOG("MIDIPort::notify MIDIAccess shutting down, dropping reference."); + // If we're getting notified, it means the MIDIAccess parent object is dead. + // Nullify our copy. + mMIDIAccessParent = nullptr; +} + +void MIDIPort::FireStateChangeEvent() { + if (!GetOwner()) { + return; // Ignore changes once we've been disconnected from the owner + } + + StateChange(); + + MOZ_ASSERT(Port()); + if (Port()->ConnectionState() == MIDIPortConnectionState::Open || + Port()->ConnectionState() == MIDIPortConnectionState::Pending) { + if (mOpeningPromise) { + mOpeningPromise->MaybeResolve(this); + mOpeningPromise = nullptr; + } + } else if (Port()->ConnectionState() == MIDIPortConnectionState::Closed) { + if (mOpeningPromise) { + mOpeningPromise->MaybeReject(NS_ERROR_DOM_INVALID_ACCESS_ERR); + mOpeningPromise = nullptr; + } + if (mClosingPromise) { + mClosingPromise->MaybeResolve(this); + mClosingPromise = nullptr; + } + } + + if (Port()->DeviceState() == MIDIPortDeviceState::Connected && + Port()->ConnectionState() == MIDIPortConnectionState::Pending) { + Port()->SendOpen(); + } + + if (Port()->ConnectionState() == MIDIPortConnectionState::Open || + (Port()->DeviceState() == MIDIPortDeviceState::Connected && + Port()->ConnectionState() == MIDIPortConnectionState::Pending)) { + KeepAliveOnStatechange(); + } else { + DontKeepAliveOnStatechange(); + } + + // Fire MIDIAccess events first so that the port is no longer in the port + // maps. + if (mMIDIAccessParent) { + mMIDIAccessParent->FireConnectionEvent(this); + } + + MIDIConnectionEventInit init; + init.mPort = this; + RefPtr<MIDIConnectionEvent> event( + MIDIConnectionEvent::Constructor(this, u"statechange"_ns, init)); + DispatchTrustedEvent(event); +} + +void MIDIPort::StateChange() {} + +void MIDIPort::Receive(const nsTArray<MIDIMessage>& aMsg) { + MOZ_CRASH("We should never get here!"); +} + +void MIDIPort::DisconnectFromOwner() { + if (Port()) { + Port()->SendClose(); + } + DontKeepAliveOnStatechange(); + + DOMEventTargetHelper::DisconnectFromOwner(); +} + +void MIDIPort::KeepAliveOnStatechange() { + if (!mKeepAlive) { + mKeepAlive = true; + KeepAliveIfHasListenersFor(nsGkAtoms::onstatechange); + } +} + +void MIDIPort::DontKeepAliveOnStatechange() { + if (mKeepAlive) { + IgnoreKeepAliveIfHasListenersFor(nsGkAtoms::onstatechange); + mKeepAlive = false; + } +} + +const nsString& MIDIPort::StableId() { return Port()->StableId(); } + +} // namespace mozilla::dom diff --git a/dom/midi/MIDIPort.h b/dom/midi/MIDIPort.h new file mode 100644 index 0000000000..76e039ab65 --- /dev/null +++ b/dom/midi/MIDIPort.h @@ -0,0 +1,126 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_MIDIPort_h +#define mozilla_dom_MIDIPort_h + +#include "mozilla/DOMEventTargetHelper.h" +#include "mozilla/dom/MIDIAccess.h" +#include "mozilla/dom/MIDIPortChild.h" +#include "mozilla/dom/MIDIPortInterface.h" + +struct JSContext; + +namespace mozilla::dom { + +class Promise; +class MIDIPortInfo; +class MIDIAccess; +class MIDIPortChangeEvent; +class MIDIPortChild; +class MIDIMessage; + +/** + * Implementation of WebIDL DOM MIDIPort class. Handles all port representation + * and communication. + * + */ +class MIDIPort : public DOMEventTargetHelper, + public MIDIAccessDestructionObserver { + public: + NS_DECL_ISUPPORTS_INHERITED + NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS_INHERITED(MIDIPort, + DOMEventTargetHelper) + protected: + explicit MIDIPort(nsPIDOMWindowInner* aWindow); + bool Initialize(const MIDIPortInfo& aPortInfo, bool aSysexEnabled, + MIDIAccess* aMIDIAccessParent); + virtual ~MIDIPort(); + + public: + nsPIDOMWindowInner* GetParentObject() const { return GetOwner(); } + + // Getters + void GetId(nsString& aRetVal) const; + void GetManufacturer(nsString& aRetVal) const; + void GetName(nsString& aRetVal) const; + void GetVersion(nsString& aRetVal) const; + MIDIPortType Type() const; + MIDIPortConnectionState Connection() const; + MIDIPortDeviceState State() const; + bool SysexEnabled() const; + + already_AddRefed<Promise> Open(ErrorResult& aError); + already_AddRefed<Promise> Close(ErrorResult& aError); + + // MIDIPorts observe the death of their parent MIDIAccess object, and delete + // their reference accordingly. + virtual void Notify(const void_t& aVoid) override; + + void FireStateChangeEvent(); + + virtual void StateChange(); + virtual void Receive(const nsTArray<MIDIMessage>& aMsg); + + // This object holds a pointer to its corresponding IPC MIDIPortChild actor. + // If the IPC actor is deleted, it cleans itself up via this method. + void UnsetIPCPort(); + + IMPL_EVENT_HANDLER(statechange) + + void DisconnectFromOwner() override; + const nsString& StableId(); + + protected: + // Helper class to ensure we always call DetachOwner when we drop the + // reference to the the port. + class PortHolder { + public: + void Init(already_AddRefed<MIDIPortChild> aArg) { + MOZ_ASSERT(!mInner); + mInner = aArg; + } + void Clear() { + if (mInner) { + mInner->DetachOwner(); + mInner = nullptr; + } + } + ~PortHolder() { Clear(); } + MIDIPortChild* Get() const { return mInner; } + + private: + RefPtr<MIDIPortChild> mInner; + }; + + // IPC Actor corresponding to this class. + PortHolder mPortHolder; + MIDIPortChild* Port() const { return mPortHolder.Get(); } + + private: + void KeepAliveOnStatechange(); + void DontKeepAliveOnStatechange(); + + // MIDIAccess object that created this MIDIPort object, which we need for + // firing port connection events. There is a chance this MIDIPort object can + // outlive its parent MIDIAccess object, so this is a weak reference that must + // be handled properly. It is set on construction of the MIDIPort object, and + // set to null when the parent MIDIAccess object is destroyed, which fires an + // notification we observe. + MIDIAccess* mMIDIAccessParent; + // Promise object generated on Open() call, that needs to be resolved once the + // platform specific Open() function has completed. + RefPtr<Promise> mOpeningPromise; + // Promise object generated on Close() call, that needs to be resolved once + // the platform specific Close() function has completed. + RefPtr<Promise> mClosingPromise; + // If true this object will be kept alive even without direct JS references + bool mKeepAlive; +}; + +} // namespace mozilla::dom + +#endif // mozilla_dom_MIDIPort_h diff --git a/dom/midi/MIDIPortChild.cpp b/dom/midi/MIDIPortChild.cpp new file mode 100644 index 0000000000..d649d5540e --- /dev/null +++ b/dom/midi/MIDIPortChild.cpp @@ -0,0 +1,59 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "mozilla/dom/MIDIPortChild.h" +#include "mozilla/dom/MIDIPort.h" +#include "mozilla/dom/MIDIPortInterface.h" +#include "nsContentUtils.h" + +using namespace mozilla; +using namespace mozilla::dom; + +MIDIPortChild::MIDIPortChild(const MIDIPortInfo& aPortInfo, bool aSysexEnabled, + MIDIPort* aPort) + : MIDIPortInterface(aPortInfo, aSysexEnabled), mDOMPort(aPort) {} + +void MIDIPortChild::ActorDestroy(ActorDestroyReason aWhy) { + if (mDOMPort) { + mDOMPort->UnsetIPCPort(); + MOZ_ASSERT(!mDOMPort); + } + MIDIPortInterface::Shutdown(); +} + +mozilla::ipc::IPCResult MIDIPortChild::RecvReceive( + nsTArray<MIDIMessage>&& aMsgs) { + if (mDOMPort) { + mDOMPort->Receive(aMsgs); + } + return IPC_OK(); +} + +mozilla::ipc::IPCResult MIDIPortChild::RecvUpdateStatus( + const uint32_t& aDeviceState, const uint32_t& aConnectionState) { + // Either a device is connected, and can have any connection state, or a + // device is disconnected, and can only be closed or pending. + MOZ_ASSERT(mDeviceState == MIDIPortDeviceState::Connected || + (mConnectionState == MIDIPortConnectionState::Closed || + mConnectionState == MIDIPortConnectionState::Pending)); + mDeviceState = static_cast<MIDIPortDeviceState>(aDeviceState); + mConnectionState = static_cast<MIDIPortConnectionState>(aConnectionState); + if (mDOMPort) { + mDOMPort->FireStateChangeEvent(); + } + return IPC_OK(); +} + +nsresult MIDIPortChild::GenerateStableId(const nsACString& aOrigin) { + const size_t kIdLength = 64; + mStableId.SetCapacity(kIdLength); + mStableId.Append(Name()); + mStableId.Append(Manufacturer()); + mStableId.Append(Version()); + nsContentUtils::AnonymizeId(mStableId, aOrigin, + nsContentUtils::OriginFormat::Plain); + return NS_OK; +} diff --git a/dom/midi/MIDIPortChild.h b/dom/midi/MIDIPortChild.h new file mode 100644 index 0000000000..2ef1059c78 --- /dev/null +++ b/dom/midi/MIDIPortChild.h @@ -0,0 +1,50 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_MIDIPortChild_h +#define mozilla_dom_MIDIPortChild_h + +#include "mozilla/dom/MIDIPortInterface.h" +#include "mozilla/dom/PMIDIPortChild.h" + +namespace mozilla::dom { + +class MIDIPort; +class MIDIPortInfo; + +/** + * Child actor for a MIDIPort object. Each MIDIPort DOM object in JS has a its + * own child actor. The lifetime of the actor object is dependent on the + * lifetime of the JS object. + * + */ +class MIDIPortChild final : public PMIDIPortChild, public MIDIPortInterface { + public: + NS_INLINE_DECL_REFCOUNTING(MIDIPortChild, override); + mozilla::ipc::IPCResult RecvReceive(nsTArray<MIDIMessage>&& aMsgs); + + virtual void ActorDestroy(ActorDestroyReason aWhy) override; + + mozilla::ipc::IPCResult RecvUpdateStatus(const uint32_t& aDeviceState, + const uint32_t& aConnectionState); + + MIDIPortChild(const MIDIPortInfo& aPortInfo, bool aSysexEnabled, + MIDIPort* aPort); + nsresult GenerateStableId(const nsACString& aOrigin); + const nsString& StableId() { return mStableId; }; + + void DetachOwner() { mDOMPort = nullptr; } + + private: + ~MIDIPortChild() = default; + // Pointer to the DOM object this actor represents. The actor cannot outlive + // the DOM object. + MIDIPort* mDOMPort; + nsString mStableId; +}; +} // namespace mozilla::dom + +#endif diff --git a/dom/midi/MIDIPortInterface.cpp b/dom/midi/MIDIPortInterface.cpp new file mode 100644 index 0000000000..9d186fa3f4 --- /dev/null +++ b/dom/midi/MIDIPortInterface.cpp @@ -0,0 +1,26 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "mozilla/dom/MIDIPortInterface.h" +#include "mozilla/dom/MIDIPlatformService.h" +#include "mozilla/dom/MIDITypes.h" + +mozilla::dom::MIDIPortInterface::MIDIPortInterface( + const MIDIPortInfo& aPortInfo, bool aSysexEnabled) + : mId(aPortInfo.id()), + mName(aPortInfo.name()), + mManufacturer(aPortInfo.manufacturer()), + mVersion(aPortInfo.version()), + mSysexEnabled(aSysexEnabled), + mType((MIDIPortType)aPortInfo.type()), + // We'll never initialize a port object that's not connected + mDeviceState(MIDIPortDeviceState::Connected), + mConnectionState(MIDIPortConnectionState::Closed), + mShuttingDown(false) {} + +mozilla::dom::MIDIPortInterface::~MIDIPortInterface() { Shutdown(); } + +void mozilla::dom::MIDIPortInterface::Shutdown() { mShuttingDown = true; } diff --git a/dom/midi/MIDIPortInterface.h b/dom/midi/MIDIPortInterface.h new file mode 100644 index 0000000000..360750e6e5 --- /dev/null +++ b/dom/midi/MIDIPortInterface.h @@ -0,0 +1,48 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_MIDIPortInterface_h +#define mozilla_dom_MIDIPortInterface_h + +#include "mozilla/dom/MIDIPortBinding.h" + +namespace mozilla::dom { +class MIDIPortInfo; +/** + * Base class for MIDIPort Parent/Child Actors. Makes sure both sides of the + * MIDIPort IPC connection need to a synchronized set of info/state. + * + */ +class MIDIPortInterface { + public: + MIDIPortInterface(const MIDIPortInfo& aPortInfo, bool aSysexEnabled); + const nsString& Id() const { return mId; } + const nsString& Name() const { return mName; } + const nsString& Manufacturer() const { return mManufacturer; } + const nsString& Version() const { return mVersion; } + bool SysexEnabled() const { return mSysexEnabled; } + MIDIPortType Type() const { return mType; } + MIDIPortDeviceState DeviceState() const { return mDeviceState; } + MIDIPortConnectionState ConnectionState() const { return mConnectionState; } + bool IsShutdown() const { return mShuttingDown; } + virtual void Shutdown(); + + protected: + virtual ~MIDIPortInterface(); + nsString mId; + nsString mName; + nsString mManufacturer; + nsString mVersion; + bool mSysexEnabled; + MIDIPortType mType; + MIDIPortDeviceState mDeviceState; + MIDIPortConnectionState mConnectionState; + bool mShuttingDown; +}; + +} // namespace mozilla::dom + +#endif // mozilla_dom_MIDIPortInterface_h diff --git a/dom/midi/MIDIPortParent.cpp b/dom/midi/MIDIPortParent.cpp new file mode 100644 index 0000000000..0a01784945 --- /dev/null +++ b/dom/midi/MIDIPortParent.cpp @@ -0,0 +1,106 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "mozilla/dom/MIDIPortParent.h" +#include "mozilla/dom/MIDIPlatformService.h" +#include "nsContentUtils.h" + +// C++ file contents +namespace mozilla::dom { + +// Keep an internal ID that we can use for passing information about specific +// MIDI ports back and forth to the Rust libraries. +static uint32_t gId = 0; + +mozilla::ipc::IPCResult MIDIPortParent::RecvSend( + nsTArray<MIDIMessage>&& aMsgs) { + if (mConnectionState != MIDIPortConnectionState::Open) { + mMessageQueue.AppendElements(aMsgs); + if (MIDIPlatformService::IsRunning()) { + MIDIPlatformService::Get()->Open(this); + } + return IPC_OK(); + } + if (MIDIPlatformService::IsRunning()) { + MIDIPlatformService::Get()->QueueMessages(MIDIPortInterface::mId, aMsgs); + } + return IPC_OK(); +} + +mozilla::ipc::IPCResult MIDIPortParent::RecvOpen() { + if (MIDIPlatformService::IsRunning() && + mConnectionState == MIDIPortConnectionState::Closed) { + MIDIPlatformService::Get()->Open(this); + } + return IPC_OK(); +} + +mozilla::ipc::IPCResult MIDIPortParent::RecvClose() { + if (mConnectionState != MIDIPortConnectionState::Closed) { + if (MIDIPlatformService::IsRunning()) { + MIDIPlatformService::Get()->Close(this); + } + } + return IPC_OK(); +} + +mozilla::ipc::IPCResult MIDIPortParent::RecvClear() { + if (MIDIPlatformService::IsRunning()) { + MIDIPlatformService::Get()->Clear(this); + } + return IPC_OK(); +} + +mozilla::ipc::IPCResult MIDIPortParent::RecvShutdown() { + if (mShuttingDown) { + return IPC_OK(); + } + Close(); + return IPC_OK(); +} + +void MIDIPortParent::ActorDestroy(ActorDestroyReason) { + mMessageQueue.Clear(); + MIDIPortInterface::Shutdown(); + if (MIDIPlatformService::IsRunning()) { + MIDIPlatformService::Get()->RemovePort(this); + } +} + +bool MIDIPortParent::SendUpdateStatus( + const MIDIPortDeviceState& aDeviceState, + const MIDIPortConnectionState& aConnectionState) { + if (mShuttingDown) { + return true; + } + mDeviceState = aDeviceState; + mConnectionState = aConnectionState; + if (aConnectionState == MIDIPortConnectionState::Open && + aDeviceState == MIDIPortDeviceState::Disconnected) { + mConnectionState = MIDIPortConnectionState::Pending; + } else if (aConnectionState == MIDIPortConnectionState::Open && + aDeviceState == MIDIPortDeviceState::Connected && + !mMessageQueue.IsEmpty()) { + if (MIDIPlatformService::IsRunning()) { + MIDIPlatformService::Get()->QueueMessages(MIDIPortInterface::mId, + mMessageQueue); + } + mMessageQueue.Clear(); + } + return PMIDIPortParent::SendUpdateStatus( + static_cast<uint32_t>(mDeviceState), + static_cast<uint32_t>(mConnectionState)); +} + +MIDIPortParent::MIDIPortParent(const MIDIPortInfo& aPortInfo, + const bool aSysexEnabled) + : MIDIPortInterface(aPortInfo, aSysexEnabled), mInternalId(++gId) { + MOZ_ASSERT(MIDIPlatformService::IsRunning(), + "Shouldn't be able to add MIDI port without MIDI service!"); + MIDIPlatformService::Get()->AddPort(this); +} + +} // namespace mozilla::dom diff --git a/dom/midi/MIDIPortParent.h b/dom/midi/MIDIPortParent.h new file mode 100644 index 0000000000..527abddd00 --- /dev/null +++ b/dom/midi/MIDIPortParent.h @@ -0,0 +1,49 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_MIDIPortParent_h +#define mozilla_dom_MIDIPortParent_h + +#include "mozilla/dom/PMIDIPortParent.h" +#include "mozilla/dom/MIDIPortBinding.h" +#include "mozilla/dom/MIDIPortInterface.h" + +// Header file contents +namespace mozilla::dom { + +/** + * Actor representing the parent (PBackground thread) side of a MIDIPort object. + * + */ +class MIDIPortParent final : public PMIDIPortParent, public MIDIPortInterface { + public: + NS_INLINE_DECL_THREADSAFE_REFCOUNTING(MIDIPortParent, override); + void ActorDestroy(ActorDestroyReason) override; + mozilla::ipc::IPCResult RecvSend(nsTArray<MIDIMessage>&& aMsg); + mozilla::ipc::IPCResult RecvOpen(); + mozilla::ipc::IPCResult RecvClose(); + mozilla::ipc::IPCResult RecvClear(); + mozilla::ipc::IPCResult RecvShutdown(); + MOZ_IMPLICIT MIDIPortParent(const MIDIPortInfo& aPortInfo, + const bool aSysexEnabled); + // Sends the current port status to the child actor. May also send message + // buffer if required. + bool SendUpdateStatus(const MIDIPortDeviceState& aDeviceState, + const MIDIPortConnectionState& aConnectionState); + uint32_t GetInternalId() const { return mInternalId; } + + protected: + ~MIDIPortParent() = default; + // Queue of messages that needs to be sent. Since sending a message on a + // closed port opens it, we sometimes have to buffer messages from the time + // Send() is called until the time we get a device state change to Opened. + nsTArray<MIDIMessage> mMessageQueue; + const uint32_t mInternalId; +}; + +} // namespace mozilla::dom + +#endif diff --git a/dom/midi/MIDITypes.ipdlh b/dom/midi/MIDITypes.ipdlh new file mode 100644 index 0000000000..0508f171a8 --- /dev/null +++ b/dom/midi/MIDITypes.ipdlh @@ -0,0 +1,31 @@ +/* -*- Mode: C++; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 8 -*- */ +/* vim: set sw=4 ts=8 et tw=80 ft=cpp : */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +using mozilla::TimeStamp from "mozilla/TimeStamp.h"; + +namespace mozilla { +namespace dom { + +[Comparable] struct MIDIPortInfo { + nsString id; + nsString name; + nsString manufacturer; + nsString version; + //Actually a MIDIPortType enum + uint32_t type; +}; + +struct MIDIMessage { + uint8_t[] data; + TimeStamp timestamp; +}; + +struct MIDIPortList { + MIDIPortInfo[] ports; +}; + +} // namespace dom +} // namespace mozilla diff --git a/dom/midi/MIDIUtils.cpp b/dom/midi/MIDIUtils.cpp new file mode 100644 index 0000000000..645889a167 --- /dev/null +++ b/dom/midi/MIDIUtils.cpp @@ -0,0 +1,172 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "mozilla/dom/MIDITypes.h" +#include "mozilla/dom/MIDIUtils.h" +#include "mozilla/UniquePtr.h" + +// Taken from MIDI IMPLEMENTATION CHART INSTRUCTIONS, MIDI Spec v1.0, Pg. 97 +static const uint8_t kCommandByte = 0x80; +static const uint8_t kSysexMessageStart = 0xF0; +static const uint8_t kSystemMessage = 0xF0; +static const uint8_t kSysexMessageEnd = 0xF7; +static const uint8_t kSystemRealtimeMessage = 0xF8; +// Represents the length of all possible command messages. +// Taken from MIDI Spec, Pg. 101v 1.0, Table 2 +static const uint8_t kCommandLengths[] = {3, 3, 3, 3, 2, 2, 3}; +// Represents the length of all possible system messages. The length of sysex +// messages is variable, so we just put zero since it won't be checked anyways. +// Taken from MIDI Spec v1.0, Pg. 105, Table 5 +static const uint8_t kSystemLengths[] = {0, 2, 3, 2, 1, 1, 1, 1}; +static const uint8_t kReservedStatuses[] = {0xf4, 0xf5, 0xf9, 0xfd}; + +namespace mozilla::dom::MIDIUtils { + +static bool IsSystemRealtimeMessage(uint8_t aByte) { + return (aByte & kSystemRealtimeMessage) == kSystemRealtimeMessage; +} + +static bool IsCommandByte(uint8_t aByte) { + return (aByte & kCommandByte) == kCommandByte; +} + +static bool IsReservedStatus(uint8_t aByte) { + for (const auto& msg : kReservedStatuses) { + if (aByte == msg) { + return true; + } + } + + return false; +} + +// Checks validity of MIDIMessage passed to it. Throws debug warnings and +// returns false if message is not valid. +bool IsValidMessage(const MIDIMessage* aMsg) { + if (aMsg->data().Length() == 0) { + return false; + } + + uint8_t cmd = aMsg->data()[0]; + // If first byte isn't a command, something is definitely wrong. + if (!IsCommandByte(cmd)) { + NS_WARNING("Constructed a MIDI packet where first byte is not command!"); + return false; + } + + if (IsReservedStatus(cmd)) { + NS_WARNING("Using a reserved message"); + return false; + } + + if (cmd == kSysexMessageStart) { + // All we can do with sysex is make sure it starts and ends with the + // correct command bytes and that it does not contain other command bytes. + if (aMsg->data()[aMsg->data().Length() - 1] != kSysexMessageEnd) { + NS_WARNING("Last byte of Sysex Message not 0xF7!"); + return false; + } + + for (size_t i = 1; i < aMsg->data().Length() - 2; i++) { + if (IsCommandByte(aMsg->data()[i])) { + return false; + } + } + + return true; + } + // For system realtime messages, the length should always be 1. + if (IsSystemRealtimeMessage(cmd)) { + return aMsg->data().Length() == 1; + } + // Otherwise, just use the correct array for testing lengths. We can't tell + // much about message validity other than that. + if ((cmd & kSystemMessage) == kSystemMessage) { + if (cmd - kSystemMessage >= + static_cast<uint8_t>(ArrayLength(kSystemLengths))) { + NS_WARNING("System Message Command byte not valid!"); + return false; + } + return aMsg->data().Length() == kSystemLengths[cmd - kSystemMessage]; + } + // For non system commands, we only care about differences in the high nibble + // of the first byte. Shift this down to give the index of the expected packet + // length. + uint8_t cmdIndex = (cmd - kCommandByte) >> 4; + if (cmdIndex >= ArrayLength(kCommandLengths)) { + // If our index is bigger than our array length, command byte is unknown; + NS_WARNING("Unknown MIDI command!"); + return false; + } + return aMsg->data().Length() == kCommandLengths[cmdIndex]; +} + +bool ParseMessages(const nsTArray<uint8_t>& aByteBuffer, + const TimeStamp& aTimestamp, + nsTArray<MIDIMessage>& aMsgArray) { + bool inSysexMessage = false; + UniquePtr<MIDIMessage> currentMsg = nullptr; + for (const auto& byte : aByteBuffer) { + if (IsSystemRealtimeMessage(byte)) { + MIDIMessage rt_msg; + rt_msg.data().AppendElement(byte); + rt_msg.timestamp() = aTimestamp; + if (!IsValidMessage(&rt_msg)) { + return false; + } + aMsgArray.AppendElement(rt_msg); + continue; + } + + if (byte == kSysexMessageEnd) { + if (!inSysexMessage) { + NS_WARNING( + "Got sysex message end with no sysex message being processed!"); + return false; + } + inSysexMessage = false; + } else if (IsCommandByte(byte)) { + if (currentMsg) { + if (!IsValidMessage(currentMsg.get())) { + return false; + } + + aMsgArray.AppendElement(*currentMsg); + } + + currentMsg = MakeUnique<MIDIMessage>(); + currentMsg->timestamp() = aTimestamp; + } + + if (!currentMsg) { + NS_WARNING("No command byte has been encountered yet!"); + return false; + } + + currentMsg->data().AppendElement(byte); + + if (byte == kSysexMessageStart) { + inSysexMessage = true; + } + } + + if (currentMsg) { + if (!IsValidMessage(currentMsg.get())) { + return false; + } + aMsgArray.AppendElement(*currentMsg); + } + + return true; +} + +bool IsSysexMessage(const MIDIMessage& aMsg) { + if (aMsg.data().Length() == 0) { + return false; + } + return aMsg.data()[0] == kSysexMessageStart; +} +} // namespace mozilla::dom::MIDIUtils diff --git a/dom/midi/MIDIUtils.h b/dom/midi/MIDIUtils.h new file mode 100644 index 0000000000..c5559dce7e --- /dev/null +++ b/dom/midi/MIDIUtils.h @@ -0,0 +1,27 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "nsTArray.h" +#include "mozilla/TimeStamp.h" + +namespace mozilla::dom { +class MIDIMessage; + +/** + * Set of utility functions for dealing with MIDI Messages. + * + */ +namespace MIDIUtils { + +// Takes a nsTArray of bytes and parses it into zero or more MIDI messages. +// Returns true if no errors were encountered, false otherwise. +bool ParseMessages(const nsTArray<uint8_t>& aByteBuffer, + const TimeStamp& aTimestamp, + nsTArray<MIDIMessage>& aMsgArray); +// Returns true if a message is a sysex message. +bool IsSysexMessage(const MIDIMessage& a); +} // namespace MIDIUtils +} // namespace mozilla::dom diff --git a/dom/midi/PMIDIManager.ipdl b/dom/midi/PMIDIManager.ipdl new file mode 100644 index 0000000000..42698a9189 --- /dev/null +++ b/dom/midi/PMIDIManager.ipdl @@ -0,0 +1,24 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +include MIDITypes; + +namespace mozilla { +namespace dom { + +[ChildProc=anydom] +async protocol PMIDIManager +{ +parent: + async Refresh(); + async Shutdown(); +child: + /* + * Send an updated list of MIDI ports to the child + */ + async MIDIPortListUpdate(MIDIPortList aPortList); +}; + +} // namespace ipc +} // namespace mozilla diff --git a/dom/midi/PMIDIPort.ipdl b/dom/midi/PMIDIPort.ipdl new file mode 100644 index 0000000000..917518e919 --- /dev/null +++ b/dom/midi/PMIDIPort.ipdl @@ -0,0 +1,29 @@ +/* -*- Mode: c++; c-basic-offset: 2; indent-tabs-mode: nil; tab-width: 40 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +include MIDITypes; + +namespace mozilla { +namespace dom { + +[ChildProc=anydom] +async protocol PMIDIPort +{ +parent: + async Shutdown(); + async Send(MIDIMessage[] msg); + async Open(); + async Close(); + async Clear(); +child: + async Receive(MIDIMessage[] msg); + // Actually takes a MIDIDeviceConnectionState and MIDIPortConnectionState + // respectively. + async UpdateStatus(uint32_t deviceState, uint32_t connectionState); +}; + +} +} diff --git a/dom/midi/TestMIDIPlatformService.cpp b/dom/midi/TestMIDIPlatformService.cpp new file mode 100644 index 0000000000..cbc11a7474 --- /dev/null +++ b/dom/midi/TestMIDIPlatformService.cpp @@ -0,0 +1,258 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "TestMIDIPlatformService.h" +#include "mozilla/dom/MIDIPort.h" +#include "mozilla/dom/MIDITypes.h" +#include "mozilla/dom/MIDIPortInterface.h" +#include "mozilla/dom/MIDIPortParent.h" +#include "mozilla/dom/MIDIPlatformRunnables.h" +#include "mozilla/dom/MIDIUtils.h" +#include "mozilla/ipc/BackgroundParent.h" +#include "mozilla/Unused.h" +#include "nsIThread.h" + +using namespace mozilla; +using namespace mozilla::dom; +using namespace mozilla::ipc; + +/** + * Runnable used for making sure ProcessMessages only happens on the IO thread. + * + */ +class ProcessMessagesRunnable : public mozilla::Runnable { + public: + explicit ProcessMessagesRunnable(const nsAString& aPortID) + : Runnable("ProcessMessagesRunnable"), mPortID(aPortID) {} + ~ProcessMessagesRunnable() = default; + NS_IMETHOD Run() override { + // If service is no longer running, just exist without processing. + if (!MIDIPlatformService::IsRunning()) { + return NS_OK; + } + TestMIDIPlatformService* srv = + static_cast<TestMIDIPlatformService*>(MIDIPlatformService::Get()); + srv->ProcessMessages(mPortID); + return NS_OK; + } + + private: + nsString mPortID; +}; + +/** + * Runnable used for allowing IO thread to queue more messages for processing, + * since it can't access the service object directly. + * + */ +class QueueMessagesRunnable : public MIDIBackgroundRunnable { + public: + QueueMessagesRunnable(const nsAString& aPortID, + const nsTArray<MIDIMessage>& aMsgs) + : MIDIBackgroundRunnable("QueueMessagesRunnable"), + mPortID(aPortID), + mMsgs(aMsgs.Clone()) {} + ~QueueMessagesRunnable() = default; + virtual void RunInternal() { + MIDIPlatformService::AssertThread(); + MIDIPlatformService::Get()->QueueMessages(mPortID, mMsgs); + } + + private: + nsString mPortID; + nsTArray<MIDIMessage> mMsgs; +}; + +TestMIDIPlatformService::TestMIDIPlatformService() + : mControlInputPort(u"b744eebe-f7d8-499b-872b-958f63c8f522"_ns, + u"Test Control MIDI Device Input Port"_ns, + u"Test Manufacturer"_ns, u"1.0.0"_ns, + static_cast<uint32_t>(MIDIPortType::Input)), + mControlOutputPort(u"ab8e7fe8-c4de-436a-a960-30898a7c9a3d"_ns, + u"Test Control MIDI Device Output Port"_ns, + u"Test Manufacturer"_ns, u"1.0.0"_ns, + static_cast<uint32_t>(MIDIPortType::Output)), + mStateTestInputPort(u"a9329677-8588-4460-a091-9d4a7f629a48"_ns, + u"Test State MIDI Device Input Port"_ns, + u"Test Manufacturer"_ns, u"1.0.0"_ns, + static_cast<uint32_t>(MIDIPortType::Input)), + mStateTestOutputPort(u"478fa225-b5fc-4fa6-a543-d32d9cb651e7"_ns, + u"Test State MIDI Device Output Port"_ns, + u"Test Manufacturer"_ns, u"1.0.0"_ns, + static_cast<uint32_t>(MIDIPortType::Output)), + mAlwaysClosedTestOutputPort(u"f87d0c76-3c68-49a9-a44f-700f1125c07a"_ns, + u"Always Closed MIDI Device Output Port"_ns, + u"Test Manufacturer"_ns, u"1.0.0"_ns, + static_cast<uint32_t>(MIDIPortType::Output)), + mDoRefresh(false), + mIsInitialized(false) { + MIDIPlatformService::AssertThread(); +} + +TestMIDIPlatformService::~TestMIDIPlatformService() { + MIDIPlatformService::AssertThread(); +} + +void TestMIDIPlatformService::Init() { + MIDIPlatformService::AssertThread(); + + if (mIsInitialized) { + return; + } + mIsInitialized = true; + + // Treat all of our special ports as always connected. When the service comes + // up, prepopulate the port list with them. + MIDIPlatformService::Get()->AddPortInfo(mControlInputPort); + MIDIPlatformService::Get()->AddPortInfo(mControlOutputPort); + MIDIPlatformService::Get()->AddPortInfo(mAlwaysClosedTestOutputPort); + MIDIPlatformService::Get()->AddPortInfo(mStateTestOutputPort); + nsCOMPtr<nsIRunnable> r(new SendPortListRunnable()); + + // Start the IO Thread. + OwnerThread()->Dispatch(r.forget()); +} + +void TestMIDIPlatformService::Refresh() { + if (mDoRefresh) { + AddPortInfo(mStateTestInputPort); + mDoRefresh = false; + } +} + +void TestMIDIPlatformService::Open(MIDIPortParent* aPort) { + MOZ_ASSERT(aPort); + MIDIPortConnectionState s = MIDIPortConnectionState::Open; + if (aPort->MIDIPortInterface::Id() == mAlwaysClosedTestOutputPort.id()) { + // If it's the always closed testing port, act like it's already opened + // exclusively elsewhere. + s = MIDIPortConnectionState::Closed; + } + // Connection events are just simulated on the background thread, no need to + // push to IO thread. + nsCOMPtr<nsIRunnable> r( + new SetStatusRunnable(aPort, aPort->DeviceState(), s)); + OwnerThread()->Dispatch(r.forget()); +} + +void TestMIDIPlatformService::ScheduleClose(MIDIPortParent* aPort) { + AssertThread(); + MOZ_ASSERT(aPort); + if (aPort->ConnectionState() == MIDIPortConnectionState::Open) { + // Connection events are just simulated on the background thread, no need to + // push to IO thread. + nsCOMPtr<nsIRunnable> r(new SetStatusRunnable( + aPort, aPort->DeviceState(), MIDIPortConnectionState::Closed)); + OwnerThread()->Dispatch(r.forget()); + } +} + +void TestMIDIPlatformService::Stop() { MIDIPlatformService::AssertThread(); } + +void TestMIDIPlatformService::ScheduleSend(const nsAString& aPortId) { + AssertThread(); + nsCOMPtr<nsIRunnable> r(new ProcessMessagesRunnable(aPortId)); + OwnerThread()->Dispatch(r.forget()); +} + +void TestMIDIPlatformService::ProcessMessages(const nsAString& aPortId) { + nsTArray<MIDIMessage> msgs; + GetMessagesBefore(aPortId, TimeStamp::Now(), msgs); + + for (MIDIMessage msg : msgs) { + // receiving message from test control port + if (aPortId == mControlOutputPort.id()) { + switch (msg.data()[0]) { + // Hit a note, get a test! + case 0x90: + switch (msg.data()[1]) { + // Echo data/timestamp back through output port + case 0x00: { + nsCOMPtr<nsIRunnable> r( + new ReceiveRunnable(mControlInputPort.id(), msg)); + OwnerThread()->Dispatch(r, NS_DISPATCH_NORMAL); + break; + } + // Cause control test ports to connect + case 0x01: { + nsCOMPtr<nsIRunnable> r1( + new AddPortRunnable(mStateTestInputPort)); + OwnerThread()->Dispatch(r1, NS_DISPATCH_NORMAL); + break; + } + // Cause control test ports to disconnect + case 0x02: { + nsCOMPtr<nsIRunnable> r1( + new RemovePortRunnable(mStateTestInputPort)); + OwnerThread()->Dispatch(r1, NS_DISPATCH_NORMAL); + break; + } + // Test for packet timing + case 0x03: { + // Append a few echo command packets in reverse timing order, + // should come out in correct order on other end. + nsTArray<MIDIMessage> newMsgs; + nsTArray<uint8_t> msg; + msg.AppendElement(0x90); + msg.AppendElement(0x00); + msg.AppendElement(0x00); + // PR_Now() returns nanosecods, and we need a double with + // fractional milliseconds. + TimeStamp currentTime = TimeStamp::Now(); + for (int i = 0; i <= 5; ++i) { + // Insert messages with timestamps in reverse order, to make + // sure we're sorting correctly. + newMsgs.AppendElement(MIDIMessage( + msg, currentTime - TimeDuration::FromMilliseconds(i * 2))); + } + nsCOMPtr<nsIRunnable> r( + new QueueMessagesRunnable(aPortId, newMsgs)); + OwnerThread()->Dispatch(r, NS_DISPATCH_NORMAL); + break; + } + // Causes the next refresh to add new ports to the list + case 0x04: { + mDoRefresh = true; + break; + } + default: + NS_WARNING("Unknown Test MIDI message received!"); + } + break; + // Sysex tests + case 0xF0: + switch (msg.data()[1]) { + // Echo data/timestamp back through output port + case 0x00: { + nsCOMPtr<nsIRunnable> r( + new ReceiveRunnable(mControlInputPort.id(), msg)); + OwnerThread()->Dispatch(r, NS_DISPATCH_NORMAL); + break; + } + // Test for system real time messages in the middle of sysex + // messages. + case 0x01: { + nsTArray<uint8_t> msgs; + const uint8_t msg[] = {0xF0, 0x01, 0xFA, 0x02, 0x03, + 0x04, 0xF8, 0x05, 0xF7}; + // Can't use AppendElements on an array here, so just do range + // based loading. + for (const auto& s : msg) { + msgs.AppendElement(s); + } + nsTArray<MIDIMessage> newMsgs; + MIDIUtils::ParseMessages(msgs, TimeStamp::Now(), newMsgs); + nsCOMPtr<nsIRunnable> r( + new ReceiveRunnable(mControlInputPort.id(), newMsgs)); + OwnerThread()->Dispatch(r, NS_DISPATCH_NORMAL); + break; + } + default: + NS_WARNING("Unknown Test Sysex MIDI message received!"); + } + break; + } + } + } +} diff --git a/dom/midi/TestMIDIPlatformService.h b/dom/midi/TestMIDIPlatformService.h new file mode 100644 index 0000000000..29423a02a4 --- /dev/null +++ b/dom/midi/TestMIDIPlatformService.h @@ -0,0 +1,61 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_TestMIDIPlatformService_h +#define mozilla_dom_TestMIDIPlatformService_h + +#include "mozilla/dom/MIDIPlatformService.h" +#include "mozilla/dom/MIDITypes.h" + +class nsIThread; + +namespace mozilla::dom { + +class MIDIPortInterface; + +/** + * Platform service implementation used for mochitests. Emulates what a real + * platform service should look like, including using an internal IO thread for + * message IO. + * + */ +class TestMIDIPlatformService : public MIDIPlatformService { + public: + TestMIDIPlatformService(); + virtual void Init() override; + virtual void Refresh() override; + virtual void Open(MIDIPortParent* aPort) override; + virtual void Stop() override; + virtual void ScheduleSend(const nsAString& aPort) override; + virtual void ScheduleClose(MIDIPortParent* aPort) override; + // MIDI Service simulation function. Can take specially formed sysex messages + // in order to trigger device connection events and state changes, + // interrupting messages for high priority sysex sends, etc... + void ProcessMessages(const nsAString& aPort); + + private: + virtual ~TestMIDIPlatformService(); + // Port that takes test control messages + MIDIPortInfo mControlInputPort; + // Port that returns test status messages + MIDIPortInfo mControlOutputPort; + // Used for testing input connection/disconnection + MIDIPortInfo mStateTestInputPort; + // Used for testing output connection/disconnection + MIDIPortInfo mStateTestOutputPort; + // Used for testing open() call failures + MIDIPortInfo mAlwaysClosedTestOutputPort; + // IO Simulation thread. Runs all instances of ProcessMessages(). + nsCOMPtr<nsIThread> mClientThread; + // When true calling Refresh() will add new ports. + bool mDoRefresh; + // True if server has been brought up already. + bool mIsInitialized; +}; + +} // namespace mozilla::dom + +#endif // mozilla_dom_TestMIDIPlatformService_h diff --git a/dom/midi/crashtests/1851829.html b/dom/midi/crashtests/1851829.html new file mode 100644 index 0000000000..f14d22e42f --- /dev/null +++ b/dom/midi/crashtests/1851829.html @@ -0,0 +1,31 @@ +<!DOCTYPE html> +<html class="reftest-wait"> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=1851829 +--> +<head> + <meta charset="utf-8"> + <title>Bug 1851829</title> + <script> + async function timeout (cmd) { + const timer = new Promise((resolve, reject) => { + const id = setTimeout(() => { + clearTimeout(id) + reject(new Error('Promise timed out!')) + }, 750) + }) + return Promise.race([cmd, timer]) + } + + window.addEventListener('load', async () => { + // <script>window.close()<\/script> + const tab = window.open('data:text/plain;charset=utf-8;base64,PHNjcmlwdD53aW5kb3cuY2xvc2UoKTwvc2NyaXB0Pg==') + setTimeout(async () => { + try { await timeout(tab.navigator.requestMIDIAccess({})) } catch (e) {} + window.close() + document.documentElement.classList.remove("reftest-wait"); + }, 400) + }) + </script> +</head> +</html> diff --git a/dom/midi/crashtests/crashtests.list b/dom/midi/crashtests/crashtests.list new file mode 100644 index 0000000000..6b22018eb6 --- /dev/null +++ b/dom/midi/crashtests/crashtests.list @@ -0,0 +1 @@ +skip-if(Android) test-pref(app.normandy.enabled,false) test-pref(app.update.auto,false) test-pref(app.update.staging.enabled,false) test-pref(app.update.url.android,'') test-pref(apz.wr.activate_all_scroll_frames,true) test-pref(browser.EULA.override,true) test-pref(browser.cache.disk.enable,false) test-pref(browser.cache.disk_cache_ssl,false) test-pref(browser.cache.memory.enable,false) test-pref(browser.cache.offline.enable,false) test-pref(browser.chrome.site_icons,false) test-pref(browser.chrome.toolbar_tips,false) test-pref(browser.dom.window.dump.enabled,true) test-pref(browser.newtabpage.enabled,false) test-pref(browser.pagethumbnails.capturing_disabled,true) test-pref(browser.reader.detectedFirstArticle,true) test-pref(browser.safebrowsing.blockedURIs.enabled,false) test-pref(browser.safebrowsing.downloads.enabled,false) test-pref(browser.safebrowsing.downloads.remote.enabled,false) test-pref(browser.safebrowsing.enabled,false) test-pref(browser.safebrowsing.malware.enabled,false) test-pref(browser.safebrowsing.phishing.enabled,false) test-pref(browser.search.geoip.url,'') test-pref(browser.search.region,'US') test-pref(browser.search.suggest.enabled,false) test-pref(browser.search.suggest.prompted,true) test-pref(browser.search.update,false) test-pref(browser.sessionstore.resume_from_crash,false) test-pref(browser.shell.checkDefaultBrowser,false) test-pref(browser.ssl_override_behavior,1) test-pref(browser.startup.homepage,'about:blank') test-pref(browser.startup.homepage_override.mstone,'ignore') test-pref(browser.startup.page,0) test-pref(browser.tabs.warnOnClose,false) test-pref(browser.tabs.warnOnCloseOtherTabs,false) test-pref(browser.warnOnQuit,false) test-pref(canvas.hitregions.enabled,true) test-pref(captivedetect.canonicalURL,'') test-pref(clipboard.autocopy,true) test-pref(csp.skip_about_page_has_csp_assert,true) test-pref(datareporting.healthreport.uploadEnabled,false) test-pref(datareporting.policy.dataSubmissionEnabled,false) test-pref(datareporting.policy.dataSubmissionPolicyAcceptedVersion,2) test-pref(datareporting.policy.dataSubmissionPolicyBypassNotification,true) test-pref(datareporting.policy.firstRunURL,'') test-pref(device.sensors.ambientLight.enabled,true) test-pref(device.sensors.proximity.enabled,true) test-pref(devtools.selfxss.count,999) test-pref(dom.allow_scripts_to_close_windows,true) test-pref(dom.always_stop_slow_scripts,true) test-pref(dom.caches.testing.enabled,true) test-pref(dom.css_pseudo_element.enabled,true) test-pref(dom.dialog_element.enabled,true) test-pref(dom.disable_open_during_load,false) test-pref(dom.disable_window_flip,false) test-pref(dom.element.popover.enabled,true) test-pref(dom.fetchObserver.enabled,true) test-pref(dom.forms.datetime.others,true) test-pref(dom.gamepad.extensions.lightindicator,false) test-pref(dom.gamepad.extensions.multitouch,false) test-pref(dom.gamepad.test.enabled,false) test-pref(dom.image-lazy-loading.enabled,false) test-pref(dom.indexedDB.experimental,true) test-pref(dom.input.dirpicker,true) test-pref(dom.input_events.beforeinput.enabled,true) test-pref(dom.max_chrome_script_run_time,0) test-pref(dom.max_script_run_time,0) test-pref(dom.payments.request.enabled,true) test-pref(dom.presentation.controller.enabled,true) test-pref(dom.presentation.enabled,true) test-pref(dom.presentation.receiver.enabled,true) test-pref(dom.push.testing.ignorePermission,true) test-pref(dom.security.featurePolicy.webidl.enabled,true) test-pref(dom.security.sanitizer.enabled,true) test-pref(dom.security.setHTML.enabled,true) test-pref(dom.send_after_paint_to_content,true) test-pref(dom.serviceWorkers.testing.enabled,true) test-pref(dom.successive_dialog_time_limit,0) test-pref(dom.textMetrics.baselines.enabled,true) test-pref(dom.textMetrics.emHeight.enabled,true) test-pref(dom.textMetrics.fontBoundingBox.enabled,true) test-pref(dom.visualviewport.enabled,true) test-pref(dom.vr.external.notdetected.timeout,0) test-pref(dom.vr.external.quit.timeout,0) test-pref(dom.vr.poseprediction.enabled,false) test-pref(dom.vr.puppet.enabled,false) test-pref(dom.vr.require-gesture,false) test-pref(dom.vr.webxr.enabled,false) test-pref(dom.webgpu.enabled,true) test-pref(dom.weblocks.enabled,true) test-pref(dom.webmidi.enabled,true) test-pref(dom.window_print.fuzzing.block_while_printing,true) test-pref(extensions.autoDisableScopes,0) test-pref(extensions.blocklist.enabled,false) test-pref(extensions.enabledScopes,5) test-pref(extensions.getAddons.cache.enabled,false) test-pref(extensions.installDistroAddons,false) test-pref(extensions.showMismatchUI,false) test-pref(extensions.update.enabled,false) test-pref(extensions.update.notifyUser,false) test-pref(full-screen-api.allow-trusted-requests-only,false) test-pref(full-screen-api.warning.timeout,500) test-pref(fuzzing.enabled,true) test-pref(general.useragent.updates.enabled,false) test-pref(general.warnOnAboutConfig,false) test-pref(geo.enabled,false) test-pref(gfx.color_management.mode,1) test-pref(gfx.downloadable_fonts.disable_cache,true) test-pref(gfx.downloadable_fonts.otl_validation,false) test-pref(gfx.downloadable_fonts.sanitize_omt,false) test-pref(gfx.downloadable_fonts.validate_variation_tables,false) test-pref(gfx.offscreencanvas.enabled,true) test-pref(gfx.webrender.all,true) test-pref(gfx.webrender.debug.restrict-blob-size,true) test-pref(image.animated.decode-on-demand.batch-size,1) test-pref(image.animated.decode-on-demand.threshold-kb,0) test-pref(image.avif.sequence.enabled,true) test-pref(image.multithreaded_decoding.limit,1) test-pref(layout.accessiblecaret.enabled,true) test-pref(layout.css.backdrop-filter.enabled,true) test-pref(layout.css.constructable-stylesheets.enabled,true) test-pref(layout.css.container-queries.enabled,true) test-pref(layout.css.content-visibility.enabled,true) test-pref(layout.css.initial-letter.enabled,true) test-pref(layout.css.moz-control-character-visibility.enabled,true) test-pref(layout.css.moz-document.content.enabled,true) test-pref(layout.css.overflow-clip-box.enabled,true) test-pref(layout.css.scroll-linked-animations.enabled,true) test-pref(layout.css.zoom-transform-hack.enabled,true) test-pref(media.autoplay.default,0) test-pref(media.autoplay.enabled.user-gestures-needed,false) test-pref(media.eme.enabled,true) test-pref(media.eme.hdcp-policy-check.enabled,true) test-pref(media.gmp-manager.url.override,'http://127.0.0.1:6/dummy-gmp-manager.xml') test-pref(media.mediasource.webm.enabled,true) test-pref(media.navigator.permission.disabled,true) test-pref(media.navigator.video.red_ulpfec_enabled,true) test-pref(media.setsinkid.enabled,true) test-pref(midi.prompt.testing,true) test-pref(midi.testing,true) test-pref(network.captive-portal-service.enabled,false) test-pref(network.connectivity-service.enabled,false) test-pref(network.http.response.timeout,1) test-pref(network.http.spdy.enabled,false) test-pref(network.manage-offline-status,false) test-pref(network.prefetch-next,false) test-pref(network.protocol-handler.external.mailto,false) test-pref(network.proxy.allow_bypass,false) test-pref(network.proxy.autoconfig_url,"data:text/base64,ZnVuY3Rpb24gRmluZFByb3h5Rm9yVVJMKHVybCwgaG9zdCkgeyBpZiAoaG9zdCA9PSAnbG9jYWxob3N0JyB8fCBob3N0ID09ICcxMjcuMC4wLjEnKSB7IHJldHVybiAnRElSRUNUJzsgfSBlbHNlIHsgcmV0dXJuICdQUk9YWSAxMjcuMC4wLjE6Nic7IH0gfQ==") test-pref(network.proxy.failover_direct,false) test-pref(network.proxy.share_proxy_settings,true) test-pref(network.proxy.type,2) test-pref(network.websocket.allowInsecureFromHTTPS,true) test-pref(network.websocket.delay-failed-reconnects,false) test-pref(nglayout.debug.disable_xul_cache,false) test-pref(notification.prompt.testing,true) test-pref(notification.prompt.testing.allow,true) test-pref(pdfjs.firstRun,false) test-pref(pdfjs.previousHandler.alwaysAskBeforeHandling,true) test-pref(pdfjs.previousHandler.preferredAction,4) test-pref(permissions.default.camera,1) test-pref(permissions.default.geo,1) test-pref(permissions.default.microphone,1) test-pref(plugin.disable,true) test-pref(print.always_print_silent,true) test-pref(print.print_to_file,true) test-pref(print.show_print_progress,true) test-pref(security.OCSP.enabled,0) test-pref(security.data_uri.unique_opaque_origin,false) test-pref(security.default_personal_cert,'Select\u0020Automatically') test-pref(security.fileuri.strict_origin_policy,false) test-pref(security.webauth.webauthn_enable_softtoken,true) test-pref(security.webauth.webauthn_enable_usbtoken,false) test-pref(svg.context-properties.content.enabled,true) test-pref(toolkit.cosmeticAnimations.enabled,false) test-pref(toolkit.startup.max_resumed_crashes,-1) test-pref(toolkit.telemetry.enabled,false) test-pref(toolkit.telemetry.server,'') test-pref(webgl.enable-privileged-extensions,true) test-pref(webgl.max-warnings-per-context,0) test-pref(webgl.prefer-native-gl,false) load 1851829.html # WebMIDI is not supported on Android diff --git a/dom/midi/midirMIDIPlatformService.cpp b/dom/midi/midirMIDIPlatformService.cpp new file mode 100644 index 0000000000..842eb51dcd --- /dev/null +++ b/dom/midi/midirMIDIPlatformService.cpp @@ -0,0 +1,193 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "midirMIDIPlatformService.h" +#include "mozilla/StaticMutex.h" +#include "mozilla/TimeStamp.h" +#include "mozilla/dom/MIDIPort.h" +#include "mozilla/dom/MIDITypes.h" +#include "mozilla/dom/MIDIPortInterface.h" +#include "mozilla/dom/MIDIPortParent.h" +#include "mozilla/dom/MIDIPlatformRunnables.h" +#include "mozilla/dom/MIDIUtils.h" +#include "mozilla/dom/midi/midir_impl_ffi_generated.h" +#include "mozilla/ipc/BackgroundParent.h" +#include "mozilla/Unused.h" +#include "nsIThread.h" +#include "mozilla/Logging.h" +#include "MIDILog.h" + +using namespace mozilla; +using namespace mozilla::dom; +using namespace mozilla::ipc; + +static_assert(sizeof(TimeStamp) == sizeof(GeckoTimeStamp)); + +/** + * Runnable used for to send messages asynchronously on the I/O thread. + */ +class SendRunnable : public MIDIBackgroundRunnable { + public: + explicit SendRunnable(const nsAString& aPortID, const MIDIMessage& aMessage) + : MIDIBackgroundRunnable("SendRunnable"), + mPortID(aPortID), + mMessage(aMessage) {} + ~SendRunnable() = default; + virtual void RunInternal() { + MIDIPlatformService::AssertThread(); + if (!MIDIPlatformService::IsRunning()) { + // Some send operations might outlive the service, bail out and do nothing + return; + } + midirMIDIPlatformService* srv = + static_cast<midirMIDIPlatformService*>(MIDIPlatformService::Get()); + srv->SendMessage(mPortID, mMessage); + } + + private: + nsString mPortID; + MIDIMessage mMessage; +}; + +// static +StaticMutex midirMIDIPlatformService::gOwnerThreadMutex; + +// static +nsCOMPtr<nsISerialEventTarget> midirMIDIPlatformService::gOwnerThread; + +midirMIDIPlatformService::midirMIDIPlatformService() + : mImplementation(nullptr) { + StaticMutexAutoLock lock(gOwnerThreadMutex); + gOwnerThread = OwnerThread(); +} + +midirMIDIPlatformService::~midirMIDIPlatformService() { + LOG("midir_impl_shutdown"); + if (mImplementation) { + midir_impl_shutdown(mImplementation); + } + StaticMutexAutoLock lock(gOwnerThreadMutex); + gOwnerThread = nullptr; +} + +// static +void midirMIDIPlatformService::AddPort(const nsString* aId, + const nsString* aName, bool aInput) { + MIDIPortType type = aInput ? MIDIPortType::Input : MIDIPortType::Output; + MIDIPortInfo port(*aId, *aName, u""_ns, u""_ns, static_cast<uint32_t>(type)); + MIDIPlatformService::Get()->AddPortInfo(port); +} + +// static +void midirMIDIPlatformService::RemovePort(const nsString* aId, + const nsString* aName, bool aInput) { + MIDIPortType type = aInput ? MIDIPortType::Input : MIDIPortType::Output; + MIDIPortInfo port(*aId, *aName, u""_ns, u""_ns, static_cast<uint32_t>(type)); + MIDIPlatformService::Get()->RemovePortInfo(port); +} + +void midirMIDIPlatformService::Init() { + if (mImplementation) { + return; + } + + mImplementation = midir_impl_init(AddPort); + + if (mImplementation) { + MIDIPlatformService::Get()->SendPortList(); + } else { + LOG("midir_impl_init failure"); + } +} + +// static +void midirMIDIPlatformService::CheckAndReceive(const nsString* aId, + const uint8_t* aData, + size_t aLength, + const GeckoTimeStamp* aTimeStamp, + uint64_t aMicros) { + nsTArray<uint8_t> data; + data.AppendElements(aData, aLength); + const TimeStamp* openTime = reinterpret_cast<const TimeStamp*>(aTimeStamp); + TimeStamp timestamp = + *openTime + TimeDuration::FromMicroseconds(static_cast<double>(aMicros)); + MIDIMessage message(data, timestamp); + LogMIDIMessage(message, *aId, MIDIPortType::Input); + nsTArray<MIDIMessage> messages; + messages.AppendElement(message); + + nsCOMPtr<nsIRunnable> r(new ReceiveRunnable(*aId, messages)); + StaticMutexAutoLock lock(gOwnerThreadMutex); + if (gOwnerThread) { + gOwnerThread->Dispatch(r, NS_DISPATCH_NORMAL); + } +} + +void midirMIDIPlatformService::Refresh() { + midir_impl_refresh(mImplementation, AddPort, RemovePort); +} + +void midirMIDIPlatformService::Open(MIDIPortParent* aPort) { + AssertThread(); + MOZ_ASSERT(aPort); + nsString id = aPort->MIDIPortInterface::Id(); + TimeStamp openTimeStamp = TimeStamp::Now(); + if (midir_impl_open_port(mImplementation, &id, + reinterpret_cast<GeckoTimeStamp*>(&openTimeStamp), + CheckAndReceive)) { + LOG("MIDI port open: %s at t=%lf", NS_ConvertUTF16toUTF8(id).get(), + (openTimeStamp - TimeStamp::ProcessCreation()).ToSeconds()); + nsCOMPtr<nsIRunnable> r(new SetStatusRunnable( + aPort, aPort->DeviceState(), MIDIPortConnectionState::Open)); + OwnerThread()->Dispatch(r.forget()); + } else { + LOG("MIDI port open failed: %s", NS_ConvertUTF16toUTF8(id).get()); + } +} + +void midirMIDIPlatformService::Stop() { + // Nothing to do here AFAIK +} + +void midirMIDIPlatformService::ScheduleSend(const nsAString& aPortId) { + AssertThread(); + LOG("MIDI port schedule send %s", NS_ConvertUTF16toUTF8(aPortId).get()); + nsTArray<MIDIMessage> messages; + GetMessages(aPortId, messages); + TimeStamp now = TimeStamp::Now(); + for (const auto& message : messages) { + if (message.timestamp().IsNull()) { + SendMessage(aPortId, message); + } else { + double delay = (message.timestamp() - now).ToMilliseconds(); + if (delay < 1.0) { + SendMessage(aPortId, message); + } else { + nsCOMPtr<nsIRunnable> r(new SendRunnable(aPortId, message)); + OwnerThread()->DelayedDispatch(r.forget(), + static_cast<uint32_t>(delay)); + } + } + } +} + +void midirMIDIPlatformService::ScheduleClose(MIDIPortParent* aPort) { + AssertThread(); + MOZ_ASSERT(aPort); + nsString id = aPort->MIDIPortInterface::Id(); + LOG("MIDI port schedule close %s", NS_ConvertUTF16toUTF8(id).get()); + if (aPort->ConnectionState() == MIDIPortConnectionState::Open) { + midir_impl_close_port(mImplementation, &id); + nsCOMPtr<nsIRunnable> r(new SetStatusRunnable( + aPort, aPort->DeviceState(), MIDIPortConnectionState::Closed)); + OwnerThread()->Dispatch(r.forget()); + } +} + +void midirMIDIPlatformService::SendMessage(const nsAString& aPortId, + const MIDIMessage& aMessage) { + LOG("MIDI send message on %s", NS_ConvertUTF16toUTF8(aPortId).get()); + LogMIDIMessage(aMessage, aPortId, MIDIPortType::Output); + midir_impl_send(mImplementation, &aPortId, &aMessage.data()); +} diff --git a/dom/midi/midirMIDIPlatformService.h b/dom/midi/midirMIDIPlatformService.h new file mode 100644 index 0000000000..e204b5576a --- /dev/null +++ b/dom/midi/midirMIDIPlatformService.h @@ -0,0 +1,64 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_midirMIDIPlatformService_h +#define mozilla_dom_midirMIDIPlatformService_h + +#include "mozilla/StaticMutex.h" +#include "mozilla/dom/MIDIPlatformService.h" +#include "mozilla/dom/MIDITypes.h" +#include "mozilla/dom/midi/midir_impl_ffi_generated.h" + +class nsIThread; +struct MidirWrapper; + +namespace mozilla::dom { + +class MIDIPortInterface; + +/** + * Platform service implementation using the midir crate. + */ +class midirMIDIPlatformService : public MIDIPlatformService { + public: + midirMIDIPlatformService(); + virtual void Init() override; + virtual void Refresh() override; + virtual void Open(MIDIPortParent* aPort) override; + virtual void Stop() override; + virtual void ScheduleSend(const nsAString& aPort) override; + virtual void ScheduleClose(MIDIPortParent* aPort) override; + + void SendMessage(const nsAString& aPort, const MIDIMessage& aMessage); + + private: + virtual ~midirMIDIPlatformService(); + + static void AddPort(const nsString* aId, const nsString* aName, bool aInput); + static void RemovePort(const nsString* aId, const nsString* aName, + bool aInput); + static void CheckAndReceive(const nsString* aId, const uint8_t* aData, + size_t aLength, const GeckoTimeStamp* aTimeStamp, + uint64_t aMicros); + + // Wrapper around the midir Rust implementation. + MidirWrapper* mImplementation; + + // The midir backends can invoke CheckAndReceive on arbitrary background + // threads, and so we dispatch events from there to the owner task queue. + // It's a little ambiguous whether midir can ever invoke CheckAndReceive + // on one of its platform-specific background threads after we've dropped + // the main instance. Just in case, we use a static mutex to avoid a potential + // race with dropping the primary reference to the task queue via + // ClearOnShutdown. + static StaticMutex gOwnerThreadMutex; + static nsCOMPtr<nsISerialEventTarget> gOwnerThread + MOZ_GUARDED_BY(gOwnerThreadMutex); +}; + +} // namespace mozilla::dom + +#endif // mozilla_dom_midirMIDIPlatformService_h diff --git a/dom/midi/midir_impl/Cargo.toml b/dom/midi/midir_impl/Cargo.toml new file mode 100644 index 0000000000..1e6d4030bf --- /dev/null +++ b/dom/midi/midir_impl/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "midir_impl" +version = "0.1.0" +authors = ["Gabriele Svelto"] +edition = "2018" +license = "MPL-2.0" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +midir = "0.7.0" +nsstring = { path = "../../../xpcom/rust/nsstring/" } +uuid = { version = "1.0", features = ["v4"] } +thin-vec = { version = "0.2.1", features = ["gecko-ffi"] } diff --git a/dom/midi/midir_impl/cbindgen.toml b/dom/midi/midir_impl/cbindgen.toml new file mode 100644 index 0000000000..1b80a85e45 --- /dev/null +++ b/dom/midi/midir_impl/cbindgen.toml @@ -0,0 +1,18 @@ +header = """/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */""" +autogen_warning = """/* DO NOT MODIFY THIS MANUALLY! This file was generated using cbindgen. See RunCbindgen.py */ +""" +include_version = true +braces = "SameLine" +line_length = 100 +tab_width = 2 +language = "C++" +include_guard = "midir_impl_ffi_generated_h" +includes = ["nsStringFwd.h", "nsTArrayForwardDeclare.h"] + +[defines] +"target_os = windows" = "XP_WIN" + +[export.rename] +"ThinVec" = "nsTArray" diff --git a/dom/midi/midir_impl/moz.build b/dom/midi/midir_impl/moz.build new file mode 100644 index 0000000000..f079022906 --- /dev/null +++ b/dom/midi/midir_impl/moz.build @@ -0,0 +1,17 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +if CONFIG["COMPILE_ENVIRONMENT"]: + # This tells mach to run cbindgen and that this header-file should be created + CbindgenHeader( + "midir_impl_ffi_generated.h", + inputs=["/dom/midi/midir_impl"], + ) + + # This tells mach to copy that generated file to obj/dist/includes/mozilla/dom/midi + EXPORTS.mozilla.dom.midi += [ + "!midir_impl_ffi_generated.h", + ] diff --git a/dom/midi/midir_impl/src/lib.rs b/dom/midi/midir_impl/src/lib.rs new file mode 100644 index 0000000000..de47fbeb11 --- /dev/null +++ b/dom/midi/midir_impl/src/lib.rs @@ -0,0 +1,418 @@ +extern crate thin_vec; + +use midir::{ + InitError, MidiInput, MidiInputConnection, MidiInputPort, MidiOutput, MidiOutputConnection, + MidiOutputPort, +}; +use nsstring::{nsAString, nsString}; +use std::boxed::Box; +use std::ptr; +use thin_vec::ThinVec; +use uuid::Uuid; + +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +extern crate midir; + +#[cfg(target_os = "windows")] +#[repr(C)] +#[derive(Clone, Copy)] +pub struct GeckoTimeStamp { + gtc: u64, + qpc: u64, + + is_null: u8, + has_qpc: u8, +} + +#[cfg(not(target_os = "windows"))] +#[repr(C)] +#[derive(Clone, Copy)] +pub struct GeckoTimeStamp { + value: u64, +} + +enum MidiConnection { + Input(MidiInputConnection<CallbackData>), + Output(MidiOutputConnection), +} + +struct MidiConnectionWrapper { + id: String, + connection: MidiConnection, +} + +enum MidiPort { + Input(MidiInputPort), + Output(MidiOutputPort), +} + +struct MidiPortWrapper { + id: String, + name: String, + port: MidiPort, + open_count: u32, +} + +impl MidiPortWrapper { + fn input(self: &MidiPortWrapper) -> bool { + match self.port { + MidiPort::Input(_) => true, + MidiPort::Output(_) => false, + } + } +} + +pub struct MidirWrapper { + ports: Vec<MidiPortWrapper>, + connections: Vec<MidiConnectionWrapper>, +} + +struct CallbackData { + nsid: nsString, + open_timestamp: GeckoTimeStamp, +} + +type AddCallback = unsafe extern "C" fn(id: &nsString, name: &nsString, input: bool); +type RemoveCallback = AddCallback; + +impl MidirWrapper { + fn refresh( + self: &mut MidirWrapper, + add_callback: AddCallback, + remove_callback: Option<RemoveCallback>, + ) { + if let Ok(ports) = collect_ports() { + if let Some(remove_callback) = remove_callback { + self.remove_missing_ports(&ports, remove_callback); + } + + self.add_new_ports(ports, add_callback); + } + } + + fn remove_missing_ports( + self: &mut MidirWrapper, + ports: &Vec<MidiPortWrapper>, + remove_callback: RemoveCallback, + ) { + let old_ports = &mut self.ports; + let mut i = 0; + while i < old_ports.len() { + if !ports + .iter() + .any(|p| p.name == old_ports[i].name && p.input() == old_ports[i].input()) + { + let port = old_ports.remove(i); + let id = nsString::from(&port.id); + let name = nsString::from(&port.name); + unsafe { remove_callback(&id, &name, port.input()) }; + } else { + i += 1; + } + } + } + + fn add_new_ports( + self: &mut MidirWrapper, + ports: Vec<MidiPortWrapper>, + add_callback: AddCallback, + ) { + for port in ports { + if !self.is_port_present(&port) && !Self::is_microsoft_synth_output(&port) { + let id = nsString::from(&port.id); + let name = nsString::from(&port.name); + unsafe { add_callback(&id, &name, port.input()) }; + self.ports.push(port); + } + } + } + + fn is_port_present(self: &MidirWrapper, port: &MidiPortWrapper) -> bool { + self.ports + .iter() + .any(|p| p.name == port.name && p.input() == port.input()) + } + + // We explicitly disable Microsoft's soft synthesizer, see bug 1798097 + fn is_microsoft_synth_output(port: &MidiPortWrapper) -> bool { + (port.input() == false) && (port.name == "Microsoft GS Wavetable Synth") + } + + fn open_port( + self: &mut MidirWrapper, + nsid: &nsString, + timestamp: GeckoTimeStamp, + callback: unsafe extern "C" fn( + id: &nsString, + data: *const u8, + length: usize, + timestamp: &GeckoTimeStamp, + micros: u64, + ), + ) -> Result<(), ()> { + let id = nsid.to_string(); + let connections = &mut self.connections; + let port = self.ports.iter_mut().find(|e| e.id.eq(&id)); + if let Some(port) = port { + if port.open_count == 0 { + let connection = match &port.port { + MidiPort::Input(port) => { + let input = MidiInput::new("WebMIDI input").map_err(|_err| ())?; + let data = CallbackData { + nsid: nsid.clone(), + open_timestamp: timestamp, + }; + let connection = input + .connect( + port, + "Input connection", + move |stamp, message, data| unsafe { + callback( + &data.nsid, + message.as_ptr(), + message.len(), + &data.open_timestamp, + stamp, + ); + }, + data, + ) + .map_err(|_err| ())?; + MidiConnectionWrapper { + id: id.clone(), + connection: MidiConnection::Input(connection), + } + } + MidiPort::Output(port) => { + let output = MidiOutput::new("WebMIDI output").map_err(|_err| ())?; + let connection = output + .connect(port, "Output connection") + .map_err(|_err| ())?; + MidiConnectionWrapper { + connection: MidiConnection::Output(connection), + id: id.clone(), + } + } + }; + + connections.push(connection); + } + + port.open_count += 1; + return Ok(()); + } + + Err(()) + } + + fn close_port(self: &mut MidirWrapper, id: &str) { + let port = self.ports.iter_mut().find(|e| e.id.eq(&id)).unwrap(); + port.open_count -= 1; + + if port.open_count > 0 { + return; + } + + let connections = &mut self.connections; + let index = connections.iter().position(|e| e.id.eq(id)).unwrap(); + let connection_wrapper = connections.remove(index); + + match connection_wrapper.connection { + MidiConnection::Input(connection) => { + connection.close(); + } + MidiConnection::Output(connection) => { + connection.close(); + } + } + } + + fn send(self: &mut MidirWrapper, id: &str, data: &[u8]) -> Result<(), ()> { + let connections = &mut self.connections; + let index = connections.iter().position(|e| e.id.eq(id)).ok_or(())?; + let connection_wrapper = connections.get_mut(index).unwrap(); + + match &mut connection_wrapper.connection { + MidiConnection::Output(connection) => { + connection.send(data).map_err(|_err| ())?; + } + _ => { + panic!("Sending on an input port!"); + } + } + + Ok(()) + } +} + +fn collect_ports() -> Result<Vec<MidiPortWrapper>, InitError> { + let input = MidiInput::new("WebMIDI input")?; + let output = MidiOutput::new("WebMIDI output")?; + let mut ports = Vec::<MidiPortWrapper>::new(); + collect_input_ports(&input, &mut ports); + collect_output_ports(&output, &mut ports); + Ok(ports) +} + +impl MidirWrapper { + fn new() -> Result<MidirWrapper, InitError> { + let ports = Vec::new(); + let connections: Vec<MidiConnectionWrapper> = Vec::new(); + Ok(MidirWrapper { ports, connections }) + } +} + +/// Create the C++ wrapper that will be used to talk with midir. +/// +/// This function will be exposed to C++ +/// +/// # Safety +/// +/// This function deliberately leaks the wrapper because ownership is +/// transfered to the C++ code. Use [midir_impl_shutdown()] to free it. +#[no_mangle] +pub unsafe extern "C" fn midir_impl_init(callback: AddCallback) -> *mut MidirWrapper { + if let Ok(mut midir_impl) = MidirWrapper::new() { + midir_impl.refresh(callback, None); + + // Gecko invokes this initialization on a separate thread from all the + // other operations, so make it clear to Rust this needs to be Send. + fn assert_send<T: Send>(_: &T) {} + assert_send(&midir_impl); + + let midir_box = Box::new(midir_impl); + // Leak the object as it will be owned by the C++ code from now on + Box::leak(midir_box) as *mut _ + } else { + ptr::null_mut() + } +} + +/// Refresh the list of ports. +/// +/// This function will be exposed to C++ +/// +/// # Safety +/// +/// `wrapper` must be the pointer returned by [midir_impl_init()]. +#[no_mangle] +pub unsafe extern "C" fn midir_impl_refresh( + wrapper: *mut MidirWrapper, + add_callback: AddCallback, + remove_callback: RemoveCallback, +) { + (*wrapper).refresh(add_callback, Some(remove_callback)) +} + +/// Shutdown midir and free the C++ wrapper. +/// +/// This function will be exposed to C++ +/// +/// # Safety +/// +/// `wrapper` must be the pointer returned by [midir_impl_init()]. After this +/// has been called the wrapper object will be destoyed and cannot be accessed +/// anymore. +#[no_mangle] +pub unsafe extern "C" fn midir_impl_shutdown(wrapper: *mut MidirWrapper) { + // The MidirImpl object will be automatically destroyed when the contents + // of this box are automatically dropped at the end of the function + let _midir_box = Box::from_raw(wrapper); +} + +/// Open a MIDI port. +/// +/// This function will be exposed to C++ +/// +/// # Safety +/// +/// `wrapper` must be the pointer returned by [midir_impl_init()]. +#[no_mangle] +pub unsafe extern "C" fn midir_impl_open_port( + wrapper: *mut MidirWrapper, + nsid: *mut nsString, + timestamp: *mut GeckoTimeStamp, + callback: unsafe extern "C" fn( + id: &nsString, + data: *const u8, + length: usize, + timestamp: &GeckoTimeStamp, + micros: u64, + ), +) -> bool { + (*wrapper) + .open_port(nsid.as_ref().unwrap(), *timestamp, callback) + .is_ok() +} + +/// Close a MIDI port. +/// +/// This function will be exposed to C++ +/// +/// # Safety +/// +/// `wrapper` must be the pointer returned by [midir_impl_init()]. +#[no_mangle] +pub unsafe extern "C" fn midir_impl_close_port(wrapper: *mut MidirWrapper, id: *mut nsString) { + (*wrapper).close_port(&(*id).to_string()); +} + +/// Send a message over a MIDI output port. +/// +/// This function will be exposed to C++ +/// +/// # Safety +/// +/// `wrapper` must be the pointer returned by [midir_impl_init()]. +#[no_mangle] +pub unsafe extern "C" fn midir_impl_send( + wrapper: *mut MidirWrapper, + id: *const nsAString, + data: *const ThinVec<u8>, +) -> bool { + (*wrapper) + .send(&(*id).to_string(), (*data).as_slice()) + .is_ok() +} + +fn collect_input_ports(input: &MidiInput, wrappers: &mut Vec<MidiPortWrapper>) { + let ports = input.ports(); + for port in ports { + let id = Uuid::new_v4() + .as_hyphenated() + .encode_lower(&mut Uuid::encode_buffer()) + .to_owned(); + let name = input + .port_name(&port) + .unwrap_or_else(|_| "unknown input port".to_string()); + let port = MidiPortWrapper { + id, + name, + port: MidiPort::Input(port), + open_count: 0, + }; + wrappers.push(port); + } +} + +fn collect_output_ports(output: &MidiOutput, wrappers: &mut Vec<MidiPortWrapper>) { + let ports = output.ports(); + for port in ports { + let id = Uuid::new_v4() + .as_hyphenated() + .encode_lower(&mut Uuid::encode_buffer()) + .to_owned(); + let name = output + .port_name(&port) + .unwrap_or_else(|_| "unknown input port".to_string()); + let port = MidiPortWrapper { + id, + name, + port: MidiPort::Output(port), + open_count: 0, + }; + wrappers.push(port); + } +} diff --git a/dom/midi/moz.build b/dom/midi/moz.build new file mode 100644 index 0000000000..80ec11b68c --- /dev/null +++ b/dom/midi/moz.build @@ -0,0 +1,76 @@ +# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +IPDL_SOURCES += [ + "MIDITypes.ipdlh", + "PMIDIManager.ipdl", + "PMIDIPort.ipdl", +] + +EXPORTS.mozilla.dom += [ + "MIDIAccess.h", + "MIDIAccessManager.h", + "MIDIInput.h", + "MIDIInputMap.h", + "MIDIManagerChild.h", + "MIDIManagerParent.h", + "MIDIMessageEvent.h", + "MIDIMessageQueue.h", + "MIDIOutput.h", + "MIDIOutputMap.h", + "MIDIPermissionRequest.h", + "MIDIPlatformRunnables.h", + "MIDIPlatformService.h", + "MIDIPort.h", + "MIDIPortChild.h", + "MIDIPortInterface.h", + "MIDIPortParent.h", + "MIDIUtils.h", +] + +UNIFIED_SOURCES += [ + "MIDIAccess.cpp", + "MIDIAccessManager.cpp", + "MIDIInput.cpp", + "MIDIInputMap.cpp", + "MIDILog.cpp", + "MIDIManagerChild.cpp", + "MIDIManagerParent.cpp", + "MIDIMessageEvent.cpp", + "MIDIMessageQueue.cpp", + "MIDIOutput.cpp", + "MIDIOutputMap.cpp", + "MIDIPermissionRequest.cpp", + "MIDIPlatformRunnables.cpp", + "MIDIPlatformService.cpp", + "MIDIPort.cpp", + "MIDIPortChild.cpp", + "MIDIPortInterface.cpp", + "MIDIPortParent.cpp", + "MIDIUtils.cpp", + "TestMIDIPlatformService.cpp", +] + +include("/ipc/chromium/chromium-config.mozbuild") + +if CONFIG["MOZ_WEBMIDI_MIDIR_IMPL"]: + DEFINES["MOZ_WEBMIDI_MIDIR_IMPL"] = True + DIRS += ["midir_impl"] + UNIFIED_SOURCES += [ + "midirMIDIPlatformService.cpp", + ] + + if CONFIG["OS_TARGET"] == "Linux": + OS_LIBS += ["asound"] # Required by midir + UNIFIED_SOURCES += ["AlsaCompatibility.cpp"] + +FINAL_LIBRARY = "xul" +LOCAL_INCLUDES += [ + "/dom/base", +] + +MOCHITEST_MANIFESTS += ["tests/mochitest.toml"] +BROWSER_CHROME_MANIFESTS += ["tests/browser.toml"] diff --git a/dom/midi/tests/MIDITestUtils.js b/dom/midi/tests/MIDITestUtils.js new file mode 100644 index 0000000000..779a961991 --- /dev/null +++ b/dom/midi/tests/MIDITestUtils.js @@ -0,0 +1,94 @@ +var MIDITestUtils = { + permissionSetup: allow => { + let permPromiseRes; + let permPromise = new Promise((res, rej) => { + permPromiseRes = res; + }); + SpecialPowers.pushPrefEnv( + { + set: [ + ["dom.webmidi.enabled", true], + ["midi.testing", true], + ["midi.prompt.testing", true], + ["media.navigator.permission.disabled", allow], + ], + }, + () => { + permPromiseRes(); + } + ); + return permPromise; + }, + // This list needs to stay synced with the ports in + // dom/midi/TestMIDIPlatformService. + inputInfo: { + get id() { + return MIDITestUtils.stableId(this); + }, + name: "Test Control MIDI Device Input Port", + manufacturer: "Test Manufacturer", + version: "1.0.0", + }, + outputInfo: { + get id() { + return MIDITestUtils.stableId(this); + }, + name: "Test Control MIDI Device Output Port", + manufacturer: "Test Manufacturer", + version: "1.0.0", + }, + stateTestInputInfo: { + get id() { + return MIDITestUtils.stableId(this); + }, + name: "Test State MIDI Device Input Port", + manufacturer: "Test Manufacturer", + version: "1.0.0", + }, + stateTestOutputInfo: { + get id() { + return MIDITestUtils.stableId(this); + }, + name: "Test State MIDI Device Output Port", + manufacturer: "Test Manufacturer", + version: "1.0.0", + }, + alwaysClosedTestOutputInfo: { + get id() { + return MIDITestUtils.stableId(this); + }, + name: "Always Closed MIDI Device Output Port", + manufacturer: "Test Manufacturer", + version: "1.0.0", + }, + checkPacket: (expected, actual) => { + if (expected.length != actual.length) { + ok(false, "Packet " + expected + " length not same as packet " + actual); + } + for (var i = 0; i < expected.length; ++i) { + is(expected[i], actual[i], "Packet value " + expected[i] + " matches."); + } + }, + stableId: async info => { + // This computes the stable ID of a MIDI port according to the logic we + // use in the Web MIDI implementation. See MIDIPortChild::GenerateStableId() + // and nsContentUtils::AnonymizeId(). + const id = info.name + info.manufacturer + info.version; + const encoder = new TextEncoder(); + const data = encoder.encode(id); + const keyBytes = encoder.encode(self.origin); + const key = await crypto.subtle.importKey( + "raw", + keyBytes, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const result = new Uint8Array(await crypto.subtle.sign("HMAC", key, data)); + let resultString = ""; + for (let i = 0; i < result.length; i++) { + resultString += String.fromCharCode(result[i]); + } + return btoa(resultString); + }, +}; diff --git a/dom/midi/tests/blank.html b/dom/midi/tests/blank.html new file mode 100644 index 0000000000..7fdd0621f6 --- /dev/null +++ b/dom/midi/tests/blank.html @@ -0,0 +1,5 @@ +<!DOCTYPE html> +<meta charset="utf8"> +<html> +<body></body> +</html> diff --git a/dom/midi/tests/browser.toml b/dom/midi/tests/browser.toml new file mode 100644 index 0000000000..1741a9e25f --- /dev/null +++ b/dom/midi/tests/browser.toml @@ -0,0 +1,23 @@ +[DEFAULT] +prefs = [ + "dom.webmidi.enabled=true", + "midi.testing=true", + "midi.prompt.testing=true", + "media.navigator.permission.disabled=true", + "dom.sitepermsaddon-provider.enabled=true", +] + +["browser_midi_permission_gated.js"] +support-files = ["blank.html"] +skip-if = ["a11y_checks"] # Bug 1858041 clicked popup-notification-primary-button may not be focusable, intermittent results (passes on Try, fails on Autoland) + +["browser_refresh_port_list.js"] +run-if = ["os != 'android'"] +support-files = ["refresh_port_list.html"] + +["browser_stable_midi_port_ids.js"] +run-if = ["os != 'android'"] +support-files = [ + "port_ids_page_1.html", + "port_ids_page_2.html", +] diff --git a/dom/midi/tests/browser_midi_permission_gated.js b/dom/midi/tests/browser_midi_permission_gated.js new file mode 100644 index 0000000000..2bdce51a2d --- /dev/null +++ b/dom/midi/tests/browser_midi_permission_gated.js @@ -0,0 +1,839 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const EXAMPLE_COM_URL = + "https://example.com/document-builder.sjs?html=<h1>Test midi permission with synthetic site permission addon</h1>"; +const PAGE_WITH_IFRAMES_URL = `https://example.org/document-builder.sjs?html= + <h1>Test midi permission with synthetic site permission addon in iframes</h1> + <iframe id=sameOrigin src="${encodeURIComponent( + 'https://example.org/document-builder.sjs?html=SameOrigin"' + )}"></iframe> + <iframe id=crossOrigin src="${encodeURIComponent( + 'https://example.net/document-builder.sjs?html=CrossOrigin"' + )}"></iframe>`; + +const l10n = new Localization( + [ + "browser/addonNotifications.ftl", + "toolkit/global/extensions.ftl", + "toolkit/global/extensionPermissions.ftl", + "branding/brand.ftl", + ], + true +); + +const { HttpServer } = ChromeUtils.importESModule( + "resource://testing-common/httpd.sys.mjs" +); +ChromeUtils.defineESModuleGetters(this, { + AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs", +}); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["midi.prompt.testing", false]], + }); + + AddonTestUtils.initMochitest(this); + AddonTestUtils.hookAMTelemetryEvents(); + + // Once the addon is installed, a dialog is displayed as a confirmation. + // This could interfere with tests running after this one, so we set up a listener + // that will always accept post install dialogs so we don't have to deal with them in + // the test. + alwaysAcceptAddonPostInstallDialogs(); + + registerCleanupFunction(async () => { + // Remove the permission. + await SpecialPowers.removePermission("midi-sysex", { + url: EXAMPLE_COM_URL, + }); + await SpecialPowers.removePermission("midi-sysex", { + url: PAGE_WITH_IFRAMES_URL, + }); + await SpecialPowers.removePermission("midi", { + url: EXAMPLE_COM_URL, + }); + await SpecialPowers.removePermission("midi", { + url: PAGE_WITH_IFRAMES_URL, + }); + await SpecialPowers.removePermission("install", { + url: EXAMPLE_COM_URL, + }); + + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + } + }); +}); + +add_task(async function testRequestMIDIAccess() { + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, EXAMPLE_COM_URL); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + const testPageHost = gBrowser.selectedTab.linkedBrowser.documentURI.host; + Services.fog.testResetFOG(); + + info("Check that midi-sysex isn't set"); + ok( + await SpecialPowers.testPermission( + "midi-sysex", + SpecialPowers.Services.perms.UNKNOWN_ACTION, + { url: EXAMPLE_COM_URL } + ), + "midi-sysex value should have UNKNOWN permission" + ); + + info("Request midi-sysex access"); + let onAddonInstallBlockedNotification = waitForNotification( + "addon-install-blocked" + ); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.midiAccessRequestPromise = content.navigator.requestMIDIAccess({ + sysex: true, + }); + }); + + info("Deny site permission addon install in first popup"); + let addonInstallPanel = await onAddonInstallBlockedNotification; + const [installPopupHeader, installPopupMessage] = + addonInstallPanel.querySelectorAll( + "description.popup-notification-description" + ); + is( + installPopupHeader.textContent, + l10n.formatValueSync("site-permission-install-first-prompt-midi-header"), + "First popup has expected header text" + ); + is( + installPopupMessage.textContent, + l10n.formatValueSync("site-permission-install-first-prompt-midi-message"), + "First popup has expected message" + ); + + let notification = addonInstallPanel.childNodes[0]; + // secondaryButton is the "Don't allow" button + notification.secondaryButton.click(); + + let rejectionMessage = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + async () => { + let errorMessage; + try { + await content.midiAccessRequestPromise; + } catch (e) { + errorMessage = `${e.name}: ${e.message}`; + } + + delete content.midiAccessRequestPromise; + return errorMessage; + } + ); + is( + rejectionMessage, + "SecurityError: WebMIDI requires a site permission add-on to activate" + ); + + assertSitePermissionInstallTelemetryEvents(["site_warning", "cancelled"]); + + info("Deny site permission addon install in second popup"); + onAddonInstallBlockedNotification = waitForNotification( + "addon-install-blocked" + ); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.midiAccessRequestPromise = content.navigator.requestMIDIAccess({ + sysex: true, + }); + }); + addonInstallPanel = await onAddonInstallBlockedNotification; + notification = addonInstallPanel.childNodes[0]; + let dialogPromise = waitForInstallDialog(); + notification.button.click(); + let installDialog = await dialogPromise; + is( + installDialog.querySelector(".popup-notification-description").textContent, + l10n.formatValueSync( + "webext-site-perms-header-with-gated-perms-midi-sysex", + { hostname: testPageHost } + ), + "Install dialog has expected header text" + ); + is( + installDialog.querySelector("popupnotificationcontent description") + .textContent, + l10n.formatValueSync("webext-site-perms-description-gated-perms-midi"), + "Install dialog has expected description" + ); + + // secondaryButton is the "Cancel" button + installDialog.secondaryButton.click(); + + rejectionMessage = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + async () => { + let errorMessage; + try { + await content.midiAccessRequestPromise; + } catch (e) { + errorMessage = `${e.name}: ${e.message}`; + } + + delete content.midiAccessRequestPromise; + return errorMessage; + } + ); + is( + rejectionMessage, + "SecurityError: WebMIDI requires a site permission add-on to activate" + ); + + assertSitePermissionInstallTelemetryEvents([ + "site_warning", + "permissions_prompt", + "cancelled", + ]); + + info("Request midi-sysex access again"); + onAddonInstallBlockedNotification = waitForNotification( + "addon-install-blocked" + ); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.midiAccessRequestPromise = content.navigator.requestMIDIAccess({ + sysex: true, + }); + }); + + info("Accept site permission addon install"); + addonInstallPanel = await onAddonInstallBlockedNotification; + notification = addonInstallPanel.childNodes[0]; + dialogPromise = waitForInstallDialog(); + notification.button.click(); + installDialog = await dialogPromise; + installDialog.button.click(); + + info("Wait for the midi-sysex access request promise to resolve"); + let accessGranted = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + async () => { + try { + await content.midiAccessRequestPromise; + return true; + } catch (e) {} + + delete content.midiAccessRequestPromise; + return false; + } + ); + ok(accessGranted, "requestMIDIAccess resolved"); + + info("Check that midi-sysex is now set"); + ok( + await SpecialPowers.testPermission( + "midi-sysex", + SpecialPowers.Services.perms.ALLOW_ACTION, + { url: EXAMPLE_COM_URL } + ), + "midi-sysex value should have ALLOW permission" + ); + ok( + await SpecialPowers.testPermission( + "midi", + SpecialPowers.Services.perms.UNKNOWN_ACTION, + { url: EXAMPLE_COM_URL } + ), + "but midi should have UNKNOWN permission" + ); + + info("Check that we don't prompt user again once they installed the addon"); + const accessPromiseState = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + async () => { + return content.navigator + .requestMIDIAccess({ sysex: true }) + .then(() => "resolved"); + } + ); + is( + accessPromiseState, + "resolved", + "requestMIDIAccess resolved without user prompt" + ); + + assertSitePermissionInstallTelemetryEvents([ + "site_warning", + "permissions_prompt", + "completed", + ]); + + info("Request midi access without sysex"); + onAddonInstallBlockedNotification = waitForNotification( + "addon-install-blocked" + ); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.midiNoSysexAccessRequestPromise = + content.navigator.requestMIDIAccess(); + }); + + info("Accept site permission addon install"); + addonInstallPanel = await onAddonInstallBlockedNotification; + notification = addonInstallPanel.childNodes[0]; + + is( + notification + .querySelector("#addon-install-blocked-info") + .getAttribute("href"), + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "site-permission-addons", + "Got the expected SUMO page as a learn more link in the addon-install-blocked panel" + ); + + dialogPromise = waitForInstallDialog(); + notification.button.click(); + installDialog = await dialogPromise; + + is( + installDialog.querySelector(".popup-notification-description").textContent, + l10n.formatValueSync("webext-site-perms-header-with-gated-perms-midi", { + hostname: testPageHost, + }), + "Install dialog has expected header text" + ); + is( + installDialog.querySelector("popupnotificationcontent description") + .textContent, + l10n.formatValueSync("webext-site-perms-description-gated-perms-midi"), + "Install dialog has expected description" + ); + + installDialog.button.click(); + + info("Wait for the midi access request promise to resolve"); + accessGranted = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + async () => { + try { + await content.midiNoSysexAccessRequestPromise; + return true; + } catch (e) {} + + delete content.midiNoSysexAccessRequestPromise; + return false; + } + ); + ok(accessGranted, "requestMIDIAccess resolved"); + + info("Check that both midi-sysex and midi are now set"); + ok( + await SpecialPowers.testPermission( + "midi-sysex", + SpecialPowers.Services.perms.ALLOW_ACTION, + { url: EXAMPLE_COM_URL } + ), + "midi-sysex value should have ALLOW permission" + ); + ok( + await SpecialPowers.testPermission( + "midi", + SpecialPowers.Services.perms.ALLOW_ACTION, + { url: EXAMPLE_COM_URL } + ), + "and midi value should also have ALLOW permission" + ); + + assertSitePermissionInstallTelemetryEvents([ + "site_warning", + "permissions_prompt", + "completed", + ]); + + info("Check that we don't prompt user again when they perm denied"); + // remove permission to have a clean state + await SpecialPowers.removePermission("midi-sysex", { + url: EXAMPLE_COM_URL, + }); + + onAddonInstallBlockedNotification = waitForNotification( + "addon-install-blocked" + ); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.midiAccessRequestPromise = content.navigator.requestMIDIAccess({ + sysex: true, + }); + }); + + info("Perm-deny site permission addon install"); + addonInstallPanel = await onAddonInstallBlockedNotification; + // Click the "Report Suspicious Site" menuitem, which has the same effect as + // "Never Allow" and also submits a telemetry event (which we check below). + notification.menupopup.querySelectorAll("menuitem")[1].click(); + + rejectionMessage = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + async () => { + let errorMessage; + try { + await content.midiAccessRequestPromise; + } catch (e) { + errorMessage = e.name; + } + + delete content.midiAccessRequestPromise; + return errorMessage; + } + ); + is(rejectionMessage, "SecurityError", "requestMIDIAccess was rejected"); + + info("Request midi-sysex access again"); + let denyIntervalStart = performance.now(); + rejectionMessage = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + async () => { + let errorMessage; + try { + await content.navigator.requestMIDIAccess({ + sysex: true, + }); + } catch (e) { + errorMessage = e.name; + } + return errorMessage; + } + ); + is( + rejectionMessage, + "SecurityError", + "requestMIDIAccess was rejected without user prompt" + ); + let denyIntervalElapsed = performance.now() - denyIntervalStart; + Assert.greaterOrEqual( + denyIntervalElapsed, + 3000, + `Rejection should be delayed by a randomized interval no less than 3 seconds (got ${ + denyIntervalElapsed / 1000 + } seconds)` + ); + + Assert.deepEqual( + [{ suspicious_site: "example.com" }], + AddonTestUtils.getAMGleanEvents("reportSuspiciousSite"), + "Expected Glean event recorded." + ); + + // Invoking getAMTelemetryEvents resets the mocked event array, and we want + // to test two different things here, so we cache it. + let events = AddonTestUtils.getAMTelemetryEvents(); + Assert.deepEqual( + events.filter(evt => evt.method == "reportSuspiciousSite")[0], + { + method: "reportSuspiciousSite", + object: "suspiciousSite", + value: "example.com", + extra: undefined, + } + ); + assertSitePermissionInstallTelemetryEvents( + ["site_warning", "cancelled"], + events + ); +}); + +add_task(async function testIframeRequestMIDIAccess() { + gBrowser.selectedTab = BrowserTestUtils.addTab( + gBrowser, + PAGE_WITH_IFRAMES_URL + ); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + info("Check that midi-sysex isn't set"); + ok( + await SpecialPowers.testPermission( + "midi-sysex", + SpecialPowers.Services.perms.UNKNOWN_ACTION, + { url: PAGE_WITH_IFRAMES_URL } + ), + "midi-sysex value should have UNKNOWN permission" + ); + + info("Request midi-sysex access from the same-origin iframe"); + const sameOriginIframeBrowsingContext = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + async () => { + return content.document.getElementById("sameOrigin").browsingContext; + } + ); + + let onAddonInstallBlockedNotification = waitForNotification( + "addon-install-blocked" + ); + await SpecialPowers.spawn(sameOriginIframeBrowsingContext, [], () => { + content.midiAccessRequestPromise = content.navigator.requestMIDIAccess({ + sysex: true, + }); + }); + + info("Accept site permission addon install"); + const addonInstallPanel = await onAddonInstallBlockedNotification; + const notification = addonInstallPanel.childNodes[0]; + const dialogPromise = waitForInstallDialog(); + notification.button.click(); + let installDialog = await dialogPromise; + installDialog.button.click(); + + info("Wait for the midi-sysex access request promise to resolve"); + const accessGranted = await SpecialPowers.spawn( + sameOriginIframeBrowsingContext, + [], + async () => { + try { + await content.midiAccessRequestPromise; + return true; + } catch (e) {} + + delete content.midiAccessRequestPromise; + return false; + } + ); + ok(accessGranted, "requestMIDIAccess resolved"); + + info("Check that midi-sysex is now set"); + ok( + await SpecialPowers.testPermission( + "midi-sysex", + SpecialPowers.Services.perms.ALLOW_ACTION, + { url: PAGE_WITH_IFRAMES_URL } + ), + "midi-sysex value should have ALLOW permission" + ); + + info( + "Check that we don't prompt user again once they installed the addon from the same-origin iframe" + ); + const accessPromiseState = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + async () => { + return content.navigator + .requestMIDIAccess({ sysex: true }) + .then(() => "resolved"); + } + ); + is( + accessPromiseState, + "resolved", + "requestMIDIAccess resolved without user prompt" + ); + + assertSitePermissionInstallTelemetryEvents([ + "site_warning", + "permissions_prompt", + "completed", + ]); + + info("Check that request is rejected when done from a cross-origin iframe"); + const crossOriginIframeBrowsingContext = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + async () => { + return content.document.getElementById("crossOrigin").browsingContext; + } + ); + + const onConsoleErrorMessage = new Promise(resolve => { + const errorListener = { + observe(error) { + if (error.message.includes("WebMIDI access request was denied")) { + resolve(error); + Services.console.unregisterListener(errorListener); + } + }, + }; + Services.console.registerListener(errorListener); + }); + + const rejectionMessage = await SpecialPowers.spawn( + crossOriginIframeBrowsingContext, + [], + async () => { + let errorName; + try { + await content.navigator.requestMIDIAccess({ + sysex: true, + }); + } catch (e) { + errorName = e.name; + } + return errorName; + } + ); + + is( + rejectionMessage, + "SecurityError", + "requestMIDIAccess from the remote iframe was rejected" + ); + + const consoleErrorMessage = await onConsoleErrorMessage; + ok( + consoleErrorMessage.message.includes( + `WebMIDI access request was denied: ❝SitePermsAddons can't be installed from cross origin subframes❞`, + "an error message is sent to the console" + ) + ); + assertSitePermissionInstallTelemetryEvents([]); +}); + +add_task(async function testRequestMIDIAccessLocalhost() { + const httpServer = new HttpServer(); + httpServer.start(-1); + httpServer.registerPathHandler(`/test`, function (request, response) { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write(` + <!DOCTYPE html> + <meta charset=utf8> + <h1>Test requestMIDIAccess on lcoalhost</h1>`); + }); + const localHostTestUrl = `http://localhost:${httpServer.identity.primaryPort}/test`; + + registerCleanupFunction(async function cleanup() { + await new Promise(resolve => httpServer.stop(resolve)); + }); + + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, localHostTestUrl); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + info("Check that midi-sysex isn't set"); + ok( + await SpecialPowers.testPermission( + "midi-sysex", + SpecialPowers.Services.perms.UNKNOWN_ACTION, + { url: localHostTestUrl } + ), + "midi-sysex value should have UNKNOWN permission" + ); + + info( + "Request midi-sysex access should not prompt for addon install on locahost, but for permission" + ); + let popupShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.midiAccessRequestPromise = content.navigator.requestMIDIAccess({ + sysex: true, + }); + }); + await popupShown; + is( + PopupNotifications.panel.querySelector("popupnotification").id, + "midi-notification", + "midi notification was displayed" + ); + + info("Accept permission"); + PopupNotifications.panel + .querySelector(".popup-notification-primary-button") + .click(); + + info("Wait for the midi-sysex access request promise to resolve"); + const accessGranted = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + async () => { + try { + await content.midiAccessRequestPromise; + return true; + } catch (e) {} + + delete content.midiAccessRequestPromise; + return false; + } + ); + ok(accessGranted, "requestMIDIAccess resolved"); + + info("Check that we prompt user again even if they accepted before"); + popupShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + content.navigator.requestMIDIAccess({ sysex: true }); + }); + await popupShown; + is( + PopupNotifications.panel.querySelector("popupnotification").id, + "midi-notification", + "midi notification was displayed again" + ); + + assertSitePermissionInstallTelemetryEvents([]); +}); + +add_task(async function testDisabledRequestMIDIAccessFile() { + let dir = getChromeDir(getResolvedURI(gTestPath)); + dir.append("blank.html"); + const fileSchemeTestUri = Services.io.newFileURI(dir).spec; + + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, fileSchemeTestUri); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + info("Check that requestMIDIAccess isn't set on navigator on file scheme"); + const isRequestMIDIAccessDefined = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => { + return "requestMIDIAccess" in content.wrappedJSObject.navigator; + } + ); + is( + isRequestMIDIAccessDefined, + false, + "navigator.requestMIDIAccess is not defined on file scheme" + ); +}); + +// Ignore any additional telemetry events collected in this file. +// Unfortunately it doesn't work to have this in a cleanup function. +// Keep this as the last task done. +add_task(function teardown_telemetry_events() { + AddonTestUtils.getAMTelemetryEvents(); +}); + +/** + * Check that the expected sitepermission install events are recorded. + * + * @param {Array<String>} expectedSteps: An array of the expected extra.step values recorded. + */ +function assertSitePermissionInstallTelemetryEvents( + expectedSteps, + events = null +) { + let amInstallEvents = (events ?? AddonTestUtils.getAMTelemetryEvents()) + .filter(evt => evt.method === "install" && evt.object === "sitepermission") + .map(evt => evt.extra.step); + + Assert.deepEqual(amInstallEvents, expectedSteps); +} + +async function waitForInstallDialog(id = "addon-webext-permissions") { + let panel = await waitForNotification(id); + return panel.childNodes[0]; +} + +/** + * Adds an event listener that will listen for post-install dialog event and automatically + * close the dialogs. + */ +function alwaysAcceptAddonPostInstallDialogs() { + // Once the addon is installed, a dialog is displayed as a confirmation. + // This could interfere with tests running after this one, so we set up a listener + // that will always accept post install dialogs so we don't have to deal with them in + // the test. + const abortController = new AbortController(); + + const { AppMenuNotifications } = ChromeUtils.importESModule( + "resource://gre/modules/AppMenuNotifications.sys.mjs" + ); + info("Start listening and accept addon post-install notifications"); + PanelUI.notificationPanel.addEventListener( + "popupshown", + async function popupshown() { + let notification = AppMenuNotifications.activeNotification; + if (!notification || notification.id !== "addon-installed") { + return; + } + + let popupnotificationID = PanelUI._getPopupId(notification); + if (popupnotificationID) { + info("Accept post-install dialog"); + let popupnotification = document.getElementById(popupnotificationID); + popupnotification?.button.click(); + } + }, + { + signal: abortController.signal, + } + ); + + registerCleanupFunction(async () => { + // Clear the listener at the end of the test file, to prevent it to stay + // around when the same browser instance may be running other unrelated + // test files. + abortController.abort(); + }); +} + +const PROGRESS_NOTIFICATION = "addon-progress"; +async function waitForNotification(notificationId) { + info(`Waiting for ${notificationId} notification`); + + let topic = getObserverTopic(notificationId); + + let observerPromise; + if (notificationId !== "addon-webext-permissions") { + observerPromise = new Promise(resolve => { + Services.obs.addObserver(function observer(aSubject, aTopic, aData) { + // Ignore the progress notification unless that is the notification we want + if ( + notificationId != PROGRESS_NOTIFICATION && + aTopic == getObserverTopic(PROGRESS_NOTIFICATION) + ) { + return; + } + Services.obs.removeObserver(observer, topic); + resolve(); + }, topic); + }); + } + + let panelEventPromise = new Promise(resolve => { + window.PopupNotifications.panel.addEventListener( + "PanelUpdated", + function eventListener(e) { + // Skip notifications that are not the one that we are supposed to be looking for + if (!e.detail.includes(notificationId)) { + return; + } + window.PopupNotifications.panel.removeEventListener( + "PanelUpdated", + eventListener + ); + resolve(); + } + ); + }); + + await observerPromise; + await panelEventPromise; + await waitForTick(); + + info(`Saw a ${notificationId} notification`); + await SimpleTest.promiseFocus(window.PopupNotifications.window); + return window.PopupNotifications.panel; +} + +// This function is similar to the one in +// toolkit/mozapps/extensions/test/xpinstall/browser_doorhanger_installs.js, +// please keep both in sync! +function getObserverTopic(aNotificationId) { + let topic = aNotificationId; + if (topic == "xpinstall-disabled") { + topic = "addon-install-disabled"; + } else if (topic == "addon-progress") { + topic = "addon-install-started"; + } else if (topic == "addon-installed") { + topic = "webextension-install-notify"; + } + return topic; +} + +function waitForTick() { + return new Promise(resolve => executeSoon(resolve)); +} diff --git a/dom/midi/tests/browser_refresh_port_list.js b/dom/midi/tests/browser_refresh_port_list.js new file mode 100644 index 0000000000..152b067254 --- /dev/null +++ b/dom/midi/tests/browser_refresh_port_list.js @@ -0,0 +1,71 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const EXAMPLE_ORG_URL = "https://example.org/browser/dom/midi/tests/"; +const PAGE = "refresh_port_list.html"; + +async function get_access(browser) { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + return content.wrappedJSObject.get_access(); + }); +} + +async function reset_access(browser) { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + return content.wrappedJSObject.reset_access(); + }); +} + +async function get_num_ports(browser) { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + return content.wrappedJSObject.get_num_ports(); + }); +} + +async function add_port(browser) { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + return content.wrappedJSObject.add_port(); + }); +} + +async function remove_port(browser) { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + return content.wrappedJSObject.remove_port(); + }); +} + +async function force_refresh(browser) { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + return content.wrappedJSObject.force_refresh(); + }); +} + +add_task(async function () { + gBrowser.selectedTab = BrowserTestUtils.addTab( + gBrowser, + EXAMPLE_ORG_URL + PAGE + ); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + await get_access(gBrowser.selectedBrowser); + let ports_num = await get_num_ports(gBrowser.selectedBrowser); + Assert.equal(ports_num, 4, "We start with four ports"); + await add_port(gBrowser.selectedBrowser); + ports_num = await get_num_ports(gBrowser.selectedBrowser); + Assert.equal(ports_num, 5, "One port is added manually"); + // This causes the test service to refresh the ports the next time a refresh + // is requested, it will happen after we reload the tab later on and will add + // back the port that we're removing on the next line. + await force_refresh(gBrowser.selectedBrowser); + await remove_port(gBrowser.selectedBrowser); + ports_num = await get_num_ports(gBrowser.selectedBrowser); + Assert.equal(ports_num, 4, "One port is removed manually"); + + await BrowserTestUtils.reloadTab(gBrowser.selectedTab); + + await get_access(gBrowser.selectedBrowser); + let refreshed_ports_num = await get_num_ports(gBrowser.selectedBrowser); + Assert.equal(refreshed_ports_num, 5, "One port is added by the refresh"); + + gBrowser.removeTab(gBrowser.selectedTab); +}); diff --git a/dom/midi/tests/browser_stable_midi_port_ids.js b/dom/midi/tests/browser_stable_midi_port_ids.js new file mode 100644 index 0000000000..e7d3056160 --- /dev/null +++ b/dom/midi/tests/browser_stable_midi_port_ids.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const EXAMPLE_COM_URL = "https://example.com/browser/dom/midi/tests/"; +const EXAMPLE_ORG_URL = "https://example.org/browser/dom/midi/tests/"; +const PAGE1 = "port_ids_page_1.html"; +const PAGE2 = "port_ids_page_2.html"; + +// Return the MIDI port id of the first input port for the given URL and page +function id_for_tab(url, page) { + return BrowserTestUtils.withNewTab( + { + gBrowser, + url: url + page, + waitForLoad: true, + }, + async function (browser) { + return SpecialPowers.spawn(browser, [""], function () { + return content.wrappedJSObject.get_first_input_id(); + }); + } + ); +} + +add_task(async function () { + let com_page1; + let com_page1_reload; + let org_page1; + let org_page2; + + [com_page1, com_page1_reload, org_page1, org_page2] = await Promise.all([ + id_for_tab(EXAMPLE_COM_URL, PAGE1), + id_for_tab(EXAMPLE_COM_URL, PAGE1), + id_for_tab(EXAMPLE_ORG_URL, PAGE1), + id_for_tab(EXAMPLE_ORG_URL, PAGE2), + ]); + Assert.equal( + com_page1, + com_page1_reload, + "MIDI port ids should be the same when reloading the same page" + ); + Assert.notEqual( + com_page1, + org_page1, + "MIDI port ids should be different in different origins" + ); + Assert.equal( + org_page1, + org_page2, + "MIDI port ids should be the same in the same origin" + ); +}); diff --git a/dom/midi/tests/file_midi_permission_gated.html b/dom/midi/tests/file_midi_permission_gated.html new file mode 100644 index 0000000000..8e3ed4d625 --- /dev/null +++ b/dom/midi/tests/file_midi_permission_gated.html @@ -0,0 +1,15 @@ +<!DOCTYPE html> +<html> +<head> +<script> + window.addEventListener("message", async (evt) => { + try { + await navigator.requestMIDIAccess({sysex: evt.data}); + parent.postMessage("succeeded", "*"); + } catch (ex) { + parent.postMessage("failed", "*"); + } + }); +</script> +<body></body> +</html> diff --git a/dom/midi/tests/mochitest.toml b/dom/midi/tests/mochitest.toml new file mode 100644 index 0000000000..93a34003af --- /dev/null +++ b/dom/midi/tests/mochitest.toml @@ -0,0 +1,41 @@ +[DEFAULT] +support-files = [ + "MIDITestUtils.js", + "file_midi_permission_gated.html", +] +scheme = "https" + +["test_midi_device_connect_disconnect.html"] +disabled = "Bug 1437204" + +["test_midi_device_enumeration.html"] + +["test_midi_device_explicit_open_close.html"] + +["test_midi_device_implicit_open_close.html"] + +["test_midi_device_pending.html"] +disabled = "Bug 1437204" + +["test_midi_device_sysex.html"] + +["test_midi_device_system_rt.html"] + +["test_midi_message_event.html"] + +["test_midi_packet_timing_sorting.html"] + +["test_midi_permission_allow.html"] + +["test_midi_permission_deny.html"] + +["test_midi_permission_gated.html"] +skip-if = [ + "os == 'android'", #Bug 1747637 + "http3", + "http2", +] + +["test_midi_permission_prompt.html"] + +["test_midi_send_messages.html"] diff --git a/dom/midi/tests/port_ids_page_1.html b/dom/midi/tests/port_ids_page_1.html new file mode 100644 index 0000000000..31dadad4a5 --- /dev/null +++ b/dom/midi/tests/port_ids_page_1.html @@ -0,0 +1,17 @@ +<!DOCTYPE html> +<html> +<head> +<title>Stable MIDI port id test</title> +<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta> +</head> +<body> +<script> + async function get_first_input_id() { + let access = await navigator.requestMIDIAccess({ sysex: false }); + const inputs = access.inputs.values(); + const input = inputs.next(); + return input.value.id; + } +</script> +</body> +</html> diff --git a/dom/midi/tests/port_ids_page_2.html b/dom/midi/tests/port_ids_page_2.html new file mode 100644 index 0000000000..8c313b04da --- /dev/null +++ b/dom/midi/tests/port_ids_page_2.html @@ -0,0 +1,17 @@ +<!DOCTYPE html> +<html> +<head> +<title>Stable MIDI port id test</title> +<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta> +</head> +<body> +<script> + async function get_first_input_id() { + let access = await navigator.requestMIDIAccess({ sysex: false }); + const inputs = access.inputs.values(); + const input = inputs.next(); + return input.value.id; +} +</script> +</body> +</html> diff --git a/dom/midi/tests/refresh_port_list.html b/dom/midi/tests/refresh_port_list.html new file mode 100644 index 0000000000..96e4a7a309 --- /dev/null +++ b/dom/midi/tests/refresh_port_list.html @@ -0,0 +1,49 @@ +<!DOCTYPE html> +<html> +<head> +<title>Refresh MIDI port list test</title> +<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta> +</head> +<body> +<script> + var access = null; + async function get_access() { + access = await navigator.requestMIDIAccess({ sysex: true }); + } + + async function reset_access() { + access = null; + } + + async function get_num_ports() { + return access.inputs.size + access.outputs.size; + } + + async function add_port() { + let addPortPromise = new Promise(resolve => { + access.addEventListener("statechange", (event) => { dump("***** 1 event.port.name = " + event.port.name + "event.connection = " + event.port.connection + "\n"); if (event.port.connection != "open") { resolve(); } }); + }); + const outputs = access.outputs.values(); + const output = outputs.next().value; + output.send([0x90, 0x01, 0x00]); + await addPortPromise; + } + + async function remove_port() { + let removePortPromise = new Promise(resolve => { + access.addEventListener("statechange", (event) => { dump("***** 2 event.port.name = " + event.port.name + "event.connection = " + event.port.connection + "\n"); if (event.port.connection != "open") { resolve(); } }); + }); + const outputs = access.outputs.values(); + const output = outputs.next().value; + output.send([0x90, 0x02, 0x00]); + await removePortPromise; + } + + async function force_refresh() { + const outputs = access.outputs.values(); + const output = outputs.next().value; + output.send([0x90, 0x04, 0x00]); + } +</script> +</body> +</html> diff --git a/dom/midi/tests/test_midi_device_connect_disconnect.html b/dom/midi/tests/test_midi_device_connect_disconnect.html new file mode 100644 index 0000000000..338d1de55d --- /dev/null +++ b/dom/midi/tests/test_midi_device_connect_disconnect.html @@ -0,0 +1,54 @@ +<html> + <head> + <title>WebMIDI Listener Test</title> + <script type="text/javascript" src="/MochiKit/MochiKit.js"></script> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> + <script type="application/javascript" src="MIDITestUtils.js"></script> + </head> + + <body onload="runTests()"> + <script class="testbody" type="application/javascript"> + SimpleTest.waitForExplicitFinish(); + + async function runTests() { + await MIDITestUtils.permissionSetup(true); + let output; + + let midi_access; + try { + midi_access = await navigator.requestMIDIAccess({ "sysex": false }); + ok(true, "MIDI Access Request successful"); + } catch (e) { + ok(false, "MIDI Access Request failed!"); + SimpleTest.finish(); + return; + } + is(midi_access.sysexEnabled, false, "Sysex should be false"); + output = midi_access.outputs.get(await MIDITestUtils.outputInfo.id); + let statePromiseRes; + let statePromise = new Promise((res) => { statePromiseRes = res; }); + await output.open(); + let stateChangeHandler = (event) => { + if (event.port == output) { + return; + } + statePromiseRes(event.port); + }; + midi_access.addEventListener("statechange", stateChangeHandler); + // Send command to connect new port. + output.send([0x90, 0x01, 0x00]); + let p = await statePromise; + is(p.state, "connected", "Device " + p.name + " connected"); + + // Rebuild our promise, we'll need to await another one. + statePromise = new Promise((res) => { statePromiseRes = res; }); + output.send([0x90, 0x02, 0x00]); + p = await statePromise; + is(p.state, "disconnected", "Device " + p.name + " disconnected"); + midi_access.removeEventListener("statechange", stateChangeHandler); + SimpleTest.finish(); + } + </script> + </body> +</html> diff --git a/dom/midi/tests/test_midi_device_enumeration.html b/dom/midi/tests/test_midi_device_enumeration.html new file mode 100644 index 0000000000..1dab1c8cf7 --- /dev/null +++ b/dom/midi/tests/test_midi_device_enumeration.html @@ -0,0 +1,46 @@ +<html> + <head> + <title>WebMIDI Listener Test</title> + <script type="text/javascript" src="/MochiKit/MochiKit.js"></script> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> + <script type="application/javascript" src="MIDITestUtils.js"></script> + </head> + + <body onload="runTests()"> + <script class="testbody" type="application/javascript"> + SimpleTest.waitForExplicitFinish(); + + let objectCompare = async (type, props, obj) => { + for (var prop in props) { + is(await props[prop], obj[prop], type + " property value " + prop + " is " + props[prop]); + } + }; + let failOnCall = (event) => { + ok(false, "No connect/state events should be received on startup!"); + }; + async function runTests () { + await MIDITestUtils.permissionSetup(true); + // Request access without sysex. + let access = await navigator.requestMIDIAccess({ "sysex": false }); + ok(true, "MIDI Access Request successful"); + is(access.sysexEnabled, false, "Sysex should be false"); + access.addEventListener("statechange", failOnCall); + var input_id = await MIDITestUtils.inputInfo.id; + var output_id = await MIDITestUtils.outputInfo.id; + var inputs = access.inputs; + var outputs = access.outputs; + is(inputs.size, 1, "Should have one input"); + is(outputs.size, 3, "Should have three outputs"); + ok(inputs.has(input_id), "input list should contain input id"); + ok(outputs.has(output_id), "output list should contain output id"); + var input = access.inputs.get(input_id); + var output = access.outputs.get(output_id); + await objectCompare("input", MIDITestUtils.inputInfo, input); + await objectCompare("output", MIDITestUtils.outputInfo, output); + access.removeEventListener("statechange", failOnCall); + SimpleTest.finish(); + }; + </script> + </body> +</html> diff --git a/dom/midi/tests/test_midi_device_explicit_open_close.html b/dom/midi/tests/test_midi_device_explicit_open_close.html new file mode 100644 index 0000000000..d3ed910a55 --- /dev/null +++ b/dom/midi/tests/test_midi_device_explicit_open_close.html @@ -0,0 +1,94 @@ +<html> + <head> + <title>WebMIDI Device Open/Close Test</title> + <script type="text/javascript" src="/MochiKit/MochiKit.js"></script> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> + <script type="application/javascript" src="MIDITestUtils.js"></script> + </head> + + <body onload="runTests()"> + <script class="testbody" type="application/javascript"> + SimpleTest.waitForExplicitFinish(); + + async function runTests() { + await MIDITestUtils.permissionSetup(true); + + let access; + try { + access = await navigator.requestMIDIAccess({ "sysex": false }) + } catch (e) { + ok(false, "MIDI Access Request Failed!"); + SimpleTest.finish(); + } + + ok(true, "MIDI Access Request successful"); + let input = access.inputs.get(await MIDITestUtils.inputInfo.id); + let portEventRes; + let accessEventRes; + let portEventPromise = new Promise((resolve, reject) => { portEventRes = resolve; }); + let accessEventPromise = new Promise((resolve, reject) => { accessEventRes = resolve; }); + let shouldClose = false; + let checkPort = (event) => { + ok(input === event.port, "input port object and event port object are same object"); + ok(true, "port connection event fired"); + ok(event.port.connection === (!shouldClose ? "open" : "closed"), "connection registered correctly"); + }; + let inputEventHandler = (event) => { + checkPort(event); + portEventRes(); + }; + let accessEventHandler = (event) => { + checkPort(event); + accessEventRes(); + }; + input.addEventListener("statechange", inputEventHandler); + access.addEventListener("statechange", accessEventHandler); + await input.open(); + ok(true, "connection successful"); + ok(input.connection === "open", "connection registered as open"); + await Promise.all([portEventPromise, accessEventPromise]); + input.removeEventListener("statechange", inputEventHandler); + access.removeEventListener("statechange", accessEventHandler); + ok(true, "MIDI Port Open Test finished."); + ok(true, "Testing open failure"); + let out_access; + try { + out_access = await navigator.requestMIDIAccess({ "sysex": false }); + } catch (e) { + ok(false, "MIDI Access Request Failed!"); + SimpleTest.finish(); + } + let outputEventHandler = (event) => { + ok(output_opened === event.port, "output port object and event port object are same object"); + ok(true, "access connection event fired"); + ok(event.port.connection === "closed", "connection registered as closed"); + }; + out_access.addEventListener("statechange", outputEventHandler); + let output_opened = out_access.outputs.get(await MIDITestUtils.alwaysClosedTestOutputInfo.id); + try { + await output_opened.open(); + ok(false, "Should've failed to open port!"); + } catch(err) { + is(err.name, "InvalidAccessError", "error name " + err.name + " should be InvalidAccessError"); + ok(output_opened.connection == "closed", "connection registered as closed"); + ok(true, "Port not opened, test succeeded"); + } finally { + out_access.removeEventListener("statechange", outputEventHandler); + } + ok(true, "Starting MIDI port closing test"); + portEventPromise = new Promise((resolve, reject) => { portEventRes = resolve; }); + accessEventPromise = new Promise((resolve, reject) => { accessEventRes = resolve; }); + input.addEventListener("statechange", inputEventHandler); + access.addEventListener("statechange", accessEventHandler); + shouldClose = true; + await input.close(); + ok(input.connection === "closed", "connection registered as closed"); + await Promise.all([portEventPromise, accessEventPromise]); + input.removeEventListener("statechange", inputEventHandler); + access.removeEventListener("statechange", accessEventHandler); + SimpleTest.finish(); + } + </script> + </body> +</html> diff --git a/dom/midi/tests/test_midi_device_implicit_open_close.html b/dom/midi/tests/test_midi_device_implicit_open_close.html new file mode 100644 index 0000000000..cddbaf26c2 --- /dev/null +++ b/dom/midi/tests/test_midi_device_implicit_open_close.html @@ -0,0 +1,54 @@ +<html> + <head> + <title>WebMIDI Listener Test</title> + <script type="text/javascript" src="/MochiKit/MochiKit.js"></script> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> + <script type="application/javascript" src="MIDITestUtils.js"></script> + </head> + + <body onload="runTests()"> + <script class="testbody" type="application/javascript"> + SimpleTest.waitForExplicitFinish(); + + async function runTests() { + await MIDITestUtils.permissionSetup(true); + let access = await navigator.requestMIDIAccess({ "sysex": false }); + ok(true, "MIDI Access Request successful"); + is(access.sysexEnabled, false, "Sysex should be false"); + + var checkCount = 0; + var input; + var output; + function checkCallbacks(port) { + if (checkCount < 2) { + ok(port.connection === "open", "Got port " + port.connection + " for " + port.name); + } else { + ok(port.connection === "closed", "Got port " + port.connection + " for " + port.name); + } + + checkCount++; + if (checkCount == 4) { + input.onstatechange = undefined; + output.onstatechange = undefined; + SimpleTest.finish(); + } + } + function checkReturn(event) { + ok(true, "Got echo message back"); + MIDITestUtils.checkPacket(event.data, [0x90, 0x00, 0x7f]); + input.close(); + output.close(); + } + + input = access.inputs.get(await MIDITestUtils.inputInfo.id); + output = access.outputs.get(await MIDITestUtils.outputInfo.id); + input.onstatechange = (event) => { checkCallbacks(event.port); }; + output.onstatechange = (event) => { checkCallbacks(event.port); }; + // Ports are closed. Fire rest of tests. + input.onmidimessage = checkReturn; + output.send([0x90, 0x00, 0x7F]); + } + </script> + </body> +</html> diff --git a/dom/midi/tests/test_midi_device_pending.html b/dom/midi/tests/test_midi_device_pending.html new file mode 100644 index 0000000000..2e6bd08420 --- /dev/null +++ b/dom/midi/tests/test_midi_device_pending.html @@ -0,0 +1,118 @@ +<html> + <head> + <title>WebMIDI Listener Test</title> + <script type="text/javascript" src="/MochiKit/MochiKit.js"></script> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> + <script type="application/javascript" src="MIDITestUtils.js"></script> + </head> + + <body onload="runTests()"> + <script class="testbody" type="application/javascript"> + SimpleTest.waitForExplicitFinish(); + + async function runTests() { + await MIDITestUtils.permissionSetup(true); + + + var output; + var test_ports = []; + let access; + + let accessRes; + let accessPromise; + let portRes; + let portPromise; + + function resetPromises() { + accessPromise = new Promise((res, rej) => { accessRes = res; }); + portPromise = new Promise((res, rej) => { portRes = res; }); + } + + function accessStateChangeHandler(event) { + var p = event.port; + // We'll get an open event for the output control port. Ignore it. + if (p.name == MIDITestUtils.outputInfo.name) { + return; + } + accessRes(event); + } + + function portStateChangeHandler(event) { + var p = event.port; + // We'll get an open event for the output control port. Ignore it. + if (p.name == MIDITestUtils.outputInfo.name) { + return; + } + portRes(event); + } + + // Part 1: Create MIDIAccess object, attach state change listener to list for new connections + access = await navigator.requestMIDIAccess({ "sysex": false }); + ok(true, "MIDI Access Request successful"); + is(access.sysexEnabled, false, "Sysex should be false"); + access.addEventListener("statechange", accessStateChangeHandler); + + // Part 2: open test device, make sure it connects, attach event handler to device object + output = access.outputs.get(await MIDITestUtils.outputInfo.id); + resetPromises(); + output.send([0x90, 0x01, 0x00]); + let accessEvent = await accessPromise; + let testPort = accessEvent.port; + test_ports.push(testPort); + testPort.addEventListener("statechange", portStateChangeHandler); + is(testPort.state, "connected", "Device " + testPort.name + " connected"); + + // Part 3: Listen for port status change on open as both an access event + // and a port event. + resetPromises(); + testPort.open(); + accessEvent = await accessPromise; + is(testPort.connection, "open", "Connection " + testPort.name + " opened"); + let portEvent = await portPromise; + is(testPort.connection, "open", "Connection " + testPort.name + " opened"); + + // Part 4: Disconnect port but don't close, check status to make sure we're pending. + resetPromises(); + output.send([0x90, 0x02, 0x00]); + accessEvent = await accessPromise; + is(testPort.connection, "pending", "Connection " + testPort.name + " pending"); + is(access.inputs.has(testPort.id), false, "port removed from input map while pending"); + portEvent = await portPromise; + is(testPort.connection, "pending", "Connection " + testPort.name + " pending"); + + // Part 5: Connect ports again, make sure we return to the right status. The events will + // fire because the device has been readded to the device maps in the access object. + resetPromises(); + output.send([0x90, 0x01, 0x00]); + accessEvent = await accessPromise; + var port = access.inputs.get(testPort.id); + is(port, accessEvent.port, "port in map and port in event should be the same"); + is(testPort.connection, "pending", "Connection " + testPort.name + " pending"); + portEvent = await portPromise; + is(testPort.connection, "pending", "Connection " + testPort.name + " pending"); + + // Part 6: Close out everything and clean up. + resetPromises(); + accessEvent = await accessPromise; + is(accessEvent.port.connection, "open", "Connection " + testPort.name + " opened"); + portEvent = await portPromise; + is(portEvent.port.connection, "open", "Connection " + testPort.name + " opened"); + + /* for (let port of test_ports) { + * port.removeEventListener("statechange", checkDevices); + * } + * access.removeEventListener("statechange", checkDevices);*/ + output.send([0x90, 0x02, 0x00]); + testPort.removeEventListener("statechange", portStateChangeHandler); + access.removeEventListener("statechange", accessStateChangeHandler); + access = undefined; + output = undefined; + testPort = undefined; + accessEvent = undefined; + portEvent = undefined; + SimpleTest.finish(); + } + </script> + </body> +</html> diff --git a/dom/midi/tests/test_midi_device_sysex.html b/dom/midi/tests/test_midi_device_sysex.html new file mode 100644 index 0000000000..618f54ac8a --- /dev/null +++ b/dom/midi/tests/test_midi_device_sysex.html @@ -0,0 +1,57 @@ +<html> + <head> + <title>WebMIDI Listener Test</title> + <script type="text/javascript" src="/MochiKit/MochiKit.js"></script> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> + <script type="application/javascript" src="MIDITestUtils.js"></script> + </head> + + <body onload="runTests()"> + <script class="testbody" type="application/javascript"> + SimpleTest.waitForExplicitFinish(); + + async function runTests() { + await MIDITestUtils.permissionSetup(true); + var sysexCheckCount = 0; + var checkCount = 0; + var input; + var output; + function checkSysexReceive(event) { + checkCount++; + sysexCheckCount++; + if (sysexCheckCount == 1) { + is(event.data[0], 0xF0, "Echoed sysex message via sysex port"); + } else { + is(event.data[0], 0x90, "Echoed regular message via sysex port"); + } + if (checkCount == 5) { + SimpleTest.finish(); + } + } + + function checkNoSysexReceive(event) { + checkCount++; + is(event.data[0], 0x90, "Echoed regular message via non-sysex port"); + if (checkCount == 5) { + SimpleTest.finish() + } + } + + // Request access without sysex. + let access_regular = await navigator.requestMIDIAccess({ "sysex": false }); + let access_sysex = await navigator.requestMIDIAccess({ "sysex": true }); + ok(true, "MIDI Access Request successful"); + ok(true, "Check for sysex message drop"); + input = access_regular.inputs.get(await MIDITestUtils.inputInfo.id); + output = access_sysex.outputs.get(await MIDITestUtils.outputInfo.id); + let input_sysex = access_sysex.inputs.get(await MIDITestUtils.inputInfo.id); + input_sysex.onmidimessage = checkSysexReceive; + input.onmidimessage = checkNoSysexReceive; + output.send([0xF0, 0x00, 0xF7]); + output.send([0x90, 0x00, 0x01]); + output.send([0x90, 0x00, 0x01]); + } + </script> + </body> +</html> diff --git a/dom/midi/tests/test_midi_device_system_rt.html b/dom/midi/tests/test_midi_device_system_rt.html new file mode 100644 index 0000000000..81de0c3a94 --- /dev/null +++ b/dom/midi/tests/test_midi_device_system_rt.html @@ -0,0 +1,39 @@ +<html> + <head> + <title>WebMIDI Listener Test</title> + <script type="text/javascript" src="/MochiKit/MochiKit.js"></script> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> + <script type="application/javascript" src="MIDITestUtils.js"></script> + </head> + + <body onload="runTests()"> + <script class="testbody" type="application/javascript"> + SimpleTest.waitForExplicitFinish(); + + async function runTests() { + await MIDITestUtils.permissionSetup(true); + var checkCount = 0; + + function checkReturn(msg) { + checkCount++; + if (checkCount == 1) { + MIDITestUtils.checkPacket(msg.data, [0xFA]); + } else if (checkCount == 2) { + MIDITestUtils.checkPacket(msg.data, [0xF8]); + } else if (checkCount == 3) { + MIDITestUtils.checkPacket(msg.data, [0xF0, 0x01, 0x02, 0x03, 0x04, 0x05, 0xF7]); + SimpleTest.finish(); + } + } + + // Request access without sysex. + let access_sysex = await navigator.requestMIDIAccess({ "sysex": true }); + let input_sysex = access_sysex.inputs.get(await MIDITestUtils.inputInfo.id); + input_sysex.onmidimessage = checkReturn; + let output_sysex = access_sysex.outputs.get(await MIDITestUtils.outputInfo.id); + output_sysex.send([0xF0, 0x01, 0xF7]); + } + </script> + </body> +</html> diff --git a/dom/midi/tests/test_midi_message_event.html b/dom/midi/tests/test_midi_message_event.html new file mode 100644 index 0000000000..098b033008 --- /dev/null +++ b/dom/midi/tests/test_midi_message_event.html @@ -0,0 +1,45 @@ +<html> + +<head> + <title>WebMIDI MIDIMessageEvent Test</title> + <script type="text/javascript" src="/MochiKit/MochiKit.js"></script> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> + <script type="application/javascript" src="MIDITestUtils.js"></script> +</head> + +<body> + <script class="testbody" type="application/javascript"> + add_task(async () => { + await MIDITestUtils.permissionSetup(true); + + is(new MIDIMessageEvent('eventType').bubbles, false, "bubbles field is false by default"); + is(new MIDIMessageEvent('eventType').cancelable, false, "cancelable field is false by default"); + isDeeply(new MIDIMessageEvent('eventType').data, [], "The default message is empty"); + + is(new MIDIMessageEvent('eventType', { bubbles: false }).bubbles, false, "bubbles is passed"); + is(new MIDIMessageEvent('eventType', { bubbles: true }).bubbles, true, "bubbles is passed"); + + is(new MIDIMessageEvent('eventType', { cancelable: false }).cancelable, false, "cancelable is passed"); + is(new MIDIMessageEvent('eventType', { cancelable: true }).cancelable, true, "cancelable is passed"); + + var data = new Uint8Array(16); + isDeeply(new MIDIMessageEvent('eventType', { data }).data, data, "data is passed"); + + // All initializers are passed. + data = new Uint8Array(3); + is(new MIDIMessageEvent('eventType', { bubbles: true, cancelable: true, data }).bubbles, true, "all initializers are passed"); + is(new MIDIMessageEvent('eventType', { bubbles: true, cancelable: true, data }).cancelable, true, "all initializers are passed"); + isDeeply(new MIDIMessageEvent('eventType', { bubbles: true, cancelable: true, data }).data, data, "all initializers are passed"); + + if (window.SharedArrayBuffer) { + data = new Uint8Array(new SharedArrayBuffer(3)); + SimpleTest.doesThrow(() => { new MIDIMessageEvent('eventType', { data }); }, "shared array buffers are rejected"); + } else { + todo(false, 'SharedArrayBuffer is unavailable.'); + } + }); + </script> +</body> + +</html> diff --git a/dom/midi/tests/test_midi_packet_timing_sorting.html b/dom/midi/tests/test_midi_packet_timing_sorting.html new file mode 100644 index 0000000000..3c344066ac --- /dev/null +++ b/dom/midi/tests/test_midi_packet_timing_sorting.html @@ -0,0 +1,47 @@ +<html> + <head> + <title>WebMIDI Listener Test</title> + <script type="text/javascript" src="/MochiKit/MochiKit.js"></script> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> + <script type="application/javascript" src="MIDITestUtils.js"></script> + </head> + + <body onload="runTests()"> + <script class="testbody" type="application/javascript"> + SimpleTest.waitForExplicitFinish(); + + async function runTests() { + await MIDITestUtils.permissionSetup(true); + await SpecialPowers.pushPrefEnv({"set": [["privacy.resistFingerprinting.reduceTimerPrecision.jitter", false]]}); + var checkCount = 0; + var lastTime = 0; + var input; + var output; + function checkReturn(event) { + ok(event.timeStamp > lastTime, "Received timestamp " + event.timeStamp + " should be greater than " + lastTime); + lastTime = event.timeStamp; + checkCount++; + + if (checkCount == 6) { + input.close(); + output.close(); + SimpleTest.finish(); + } + } + ok("Testing MIDI packet reordering based on timestamps"); + // Request access without sysex. + let access = await navigator.requestMIDIAccess({ "sysex": false }); + ok(true, "MIDI Access Request successful"); + is(access.sysexEnabled, false, "Sysex should be false"); + + input = access.inputs.get(await MIDITestUtils.inputInfo.id); + output = access.outputs.get(await MIDITestUtils.outputInfo.id); + input.onmidimessage = checkReturn; + // trigger the packet timing sorting tests + output.send([0x90, 0x03, 0x00], 0); + ok(true, "Waiting on packets"); + } + </script> + </body> +</html> diff --git a/dom/midi/tests/test_midi_permission_allow.html b/dom/midi/tests/test_midi_permission_allow.html new file mode 100644 index 0000000000..84578cfeae --- /dev/null +++ b/dom/midi/tests/test_midi_permission_allow.html @@ -0,0 +1,26 @@ +<html> + <head> + <title>WebMIDI Listener Test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="application/javascript" src="MIDITestUtils.js"></script> + </head> + + <body onload="runTests()"> + <script class="testbody" type="application/javascript"> + SimpleTest.waitForExplicitFinish(); + + async function runTests() { + await MIDITestUtils.permissionSetup(true); + // Request access without sysex. + try { + await navigator.requestMIDIAccess({ "sysex": false }) + ok(true, "MIDI Access Request successful"); + SimpleTest.finish(); + } catch (ex) { + ok(false, "MIDI Access Request Failed!"); + SimpleTest.finish(); + } + } + </script> + </body> +</html> diff --git a/dom/midi/tests/test_midi_permission_deny.html b/dom/midi/tests/test_midi_permission_deny.html new file mode 100644 index 0000000000..8e3043a49a --- /dev/null +++ b/dom/midi/tests/test_midi_permission_deny.html @@ -0,0 +1,26 @@ +<html> + <head> + <title>WebMIDI Listener Test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="application/javascript" src="MIDITestUtils.js"></script> + </head> + + <body onload="runTests()"> + <script class="testbody" type="application/javascript"> + SimpleTest.waitForExplicitFinish(); + + async function runTests() { + await MIDITestUtils.permissionSetup(false); + // Request access without sysex. + try { + await navigator.requestMIDIAccess({ "sysex": false }); + ok(false, "MIDI Access Request Deny failed"); + SimpleTest.finish(); + } catch (ex) { + ok(true, "MIDI Access Request Deny successful!"); + SimpleTest.finish(); + } + } + </script> + </body> +</html> diff --git a/dom/midi/tests/test_midi_permission_gated.html b/dom/midi/tests/test_midi_permission_gated.html new file mode 100644 index 0000000000..0e85e99e9c --- /dev/null +++ b/dom/midi/tests/test_midi_permission_gated.html @@ -0,0 +1,181 @@ +<html> + <head> + <title>WebMIDI Listener Test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="application/javascript" src="MIDITestUtils.js"></script> + </head> + + <body onload="runTests()"> + <iframe id="subdomain"></iframe> + <iframe id="localhost"></iframe> + <script class="testbody" type="application/javascript"> + SimpleTest.waitForExplicitFinish(); + const filePath = "/tests/dom/midi/tests/file_midi_permission_gated.html"; + // Generally this runs on example.com but with --enable-xorigin-tests it runs + // on example.org. + let subdomainURL = "https://test1." + location.host + filePath; + $("subdomain").src = subdomainURL; + // For some reason the mochitest server returns "Bad request" with localhost, + // but permits the loopback address. That's good enough for testing purposes. + $("localhost").src = "http://127.0.0.1:8888" + filePath; + + function waitForMessage() { + return new Promise((resolve) => { + window.addEventListener("message", (e) => resolve(e.data), {once: true}); + }); + } + + async function runTests() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["dom.webmidi.enabled", true], + ["midi.testing", true], + ], + }); + ok( + await SpecialPowers.testPermission( + "midi-sysex", + SpecialPowers.Services.perms.UNKNOWN_ACTION, + document + ), + "midi-sysex value should have UNKNOWN permission" + ); + ok( + await SpecialPowers.testPermission( + "midi-sysex", + SpecialPowers.Services.perms.UNKNOWN_ACTION, + subdomainURL + ), + "permission should also not be set for subdomain" + ); + + let onChangeCalled = 0; + let onChangeCalledWithSysex = 0; + // We expect the same states with and without sysex support. + const expectedChangedStates = ["denied", "granted", "prompt"]; + + const results = []; + for (let sysex of [false, true]) { + let result = await navigator.permissions.query({ name: "midi", sysex }); + is(result?.state, "prompt", "expected 'prompt' permission status"); + // Register two unique listeners that should be invoked every time we + // change permissions in the rest of this test case: one with sysex + // support, and the other one without. + if (sysex) { + result.onchange = () => { + is( + result.state, + expectedChangedStates[onChangeCalledWithSysex++], + "expected change event with sysex support" + ); + }; + results.push(result); + } else { + result.onchange = () => { + is( + result.state, + expectedChangedStates[onChangeCalled++], + "expected change event" + ); + }; + results.push(result); + } + } + + // Explicitly set the permission as blocked, and expect the + // `requestMIDIAccess` call to be automatically rejected (not having any + // permission set would trigger the synthetic addon install provided by + // AddonManager and SitePermsAddonProvider). + await SpecialPowers.addPermission( + "midi-sysex", + SpecialPowers.Services.perms.DENY_ACTION, + document + ); + await SpecialPowers.addPermission( + "midi", + SpecialPowers.Services.perms.DENY_ACTION, + document + ); + for (let sysex of [false, true]) { + try { + await navigator.requestMIDIAccess({ sysex }); + ok(false, "MIDI Access Request gate allowed but expected to be denied"); + } catch (ex) { + ok(true, "MIDI Access Request denied by default"); + } + + let result = await navigator.permissions.query({ name: "midi", sysex }); + // We expect "denied" because that's what has been set above (with + // `SpecialPowers.addPermission()`). In practice, this state should + // never be returned since explicit rejection is handled at the add-on + // installation level. + is(result?.state, "denied", "expected 'denied' permission status"); + } + + // Gated permission should prompt for localhost. + // + // Note: We don't appear to have good test machinery anymore for + // navigating prompts from a plain mochitest. If you uncomment the lines + // below and run the test interactively, it should pass. Given that this + // is a niche feature that's unlikely to break, it doesn't seem worth + // investing in complicated test infrastructure to check it in automation. + // for (let sysex of [false, true]) { + // $("localhost").contentWindow.postMessage(sysex, "*"); + // let response = await waitForMessage(); + // is(response, "succeeded", "MIDI Access Request allowed for localhost"); + // } + + // When an addon is installed, the permission is inserted. Test + // that the request succeeds after we insert the permission. + await SpecialPowers.addPermission( + "midi-sysex", + SpecialPowers.Services.perms.ALLOW_ACTION, + document + ); + await SpecialPowers.addPermission( + "midi", + SpecialPowers.Services.perms.ALLOW_ACTION, + document + ); + // Gated permission should allow access after addon inserted permission. + for (let sysex of [false, true]) { + try { + await navigator.requestMIDIAccess({ sysex }); + ok(true, "MIDI Access Request allowed"); + } catch (ex) { + ok(false, "MIDI Access Request failed"); + } + + let result = await navigator.permissions.query({ name: "midi", sysex }); + is(result?.state, "granted", "expected 'granted' permission status"); + } + + // Gated permission should also apply to subdomains. + for (let sysex of [false, true]) { + $("subdomain").contentWindow.postMessage(sysex, "*"); + let response = await waitForMessage(); + is(response, "succeeded", "MIDI Access Request allowed for subdomain"); + } + + is( + onChangeCalled, + expectedChangedStates.length - 1, + `expected onchange listener to have been called ${expectedChangedStates.length - 1} times` + ); + is( + onChangeCalledWithSysex, + expectedChangedStates.length - 1, + `expected onchange listener to have been called ${expectedChangedStates.length - 1} times (sysex)` + ); + + // Remove the permission. + await SpecialPowers.removePermission("midi-sysex", document); + await SpecialPowers.removePermission("midi", document); + + results.forEach(result => result.onchange = null); + + SimpleTest.finish(); + } + </script> + </body> +</html> diff --git a/dom/midi/tests/test_midi_permission_prompt.html b/dom/midi/tests/test_midi_permission_prompt.html new file mode 100644 index 0000000000..26a6b3d789 --- /dev/null +++ b/dom/midi/tests/test_midi_permission_prompt.html @@ -0,0 +1,24 @@ +<html> + <head> + <title>WebMIDI Listener Test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="application/javascript" src="MIDITestUtils.js"></script> + </head> + + <body onload="runTests()"> + <script class="testbody" type="application/javascript"> + SimpleTest.waitForExplicitFinish(); + + async function runTests() { + await MIDITestUtils.permissionSetup(true); + try { + await navigator.requestMIDIAccess({ "sysex": false }); + ok(true, "Prompting for permissions succeeded!"); + } catch (e) { + ok(false, "Prompting for permissions failed!"); + } + SimpleTest.finish(); + } + </script> + </body> +</html> diff --git a/dom/midi/tests/test_midi_send_messages.html b/dom/midi/tests/test_midi_send_messages.html new file mode 100644 index 0000000000..1d78709877 --- /dev/null +++ b/dom/midi/tests/test_midi_send_messages.html @@ -0,0 +1,112 @@ +<html> + +<head> + <title>WebMIDI Send Test</title> + <script type="text/javascript" src="/MochiKit/MochiKit.js"></script> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> + <script type="application/javascript" src="MIDITestUtils.js"></script> +</head> + +<body onload="runTests()"> + <script class="testbody" type="application/javascript"> + SimpleTest.waitForExplicitFinish(); + + async function runTests() { + await MIDITestUtils.permissionSetup(true); + const access = await navigator.requestMIDIAccess({ sysex: true }); + const output = access.outputs.get(await MIDITestUtils.stateTestOutputInfo.id); + + + // Note on(off). + output.send([0xff, 0x90, 0x00, 0x00, 0x90, 0x07, 0x00]); + + try { + output.send([0x00, 0x01]) + } catch (ex) { + ok(true, "Caught exception"); + } + + // Running status is not allowed in Web MIDI API. + SimpleTest.doesThrow(() => output.send([0x00, 0x01]), "Running status is not allowed in Web MIDI API."); + + // Unexpected End of Sysex. + SimpleTest.doesThrow(() => output.send([0xf7]), "Unexpected End of Sysex."); + + // Unexpected reserved status bytes. + SimpleTest.doesThrow(() => output.send([0xf4]), "Unexpected reserved status byte 0xf4."); + SimpleTest.doesThrow(() => output.send([0xf5]), "Unexpected reserved status byte 0xf5."); + SimpleTest.doesThrow(() => output.send([0xf9]), "Unexpected reserved status byte 0xf9."); + SimpleTest.doesThrow(() => output.send([0xfd]), "Unexpected reserved status byte 0xfd."); + + // Incomplete channel messages. + SimpleTest.doesThrow(() => output.send([0x80]), "Incomplete channel message."); + SimpleTest.doesThrow(() => output.send([0x80, 0x00]), "Incomplete channel message."); + SimpleTest.doesThrow(() => output.send([0x90]), "Incomplete channel message."); + SimpleTest.doesThrow(() => output.send([0x90, 0x00]), "Incomplete channel message."); + SimpleTest.doesThrow(() => output.send([0xa0]), "Incomplete channel message."); + SimpleTest.doesThrow(() => output.send([0xa0, 0x00]), "Incomplete channel message."); + SimpleTest.doesThrow(() => output.send([0xb0]), "Incomplete channel message."); + SimpleTest.doesThrow(() => output.send([0xb0, 0x00]), "Incomplete channel message."); + SimpleTest.doesThrow(() => output.send([0xc0]), "Incomplete channel message."); + SimpleTest.doesThrow(() => output.send([0xd0]), "Incomplete channel message."); + SimpleTest.doesThrow(() => output.send([0xe0]), "Incomplete channel message."); + SimpleTest.doesThrow(() => output.send([0xe0, 0x00]), "Incomplete channel message."); + + // Incomplete system messages. + SimpleTest.doesThrow(() => output.send([0xf1]), "Incomplete system message."); + SimpleTest.doesThrow(() => output.send([0xf2]), "Incomplete system message."); + SimpleTest.doesThrow(() => output.send([0xf2, 0x00]), "Incomplete system message."); + SimpleTest.doesThrow(() => output.send([0xf3]), "Incomplete system message."); + + // Invalid data bytes. + SimpleTest.doesThrow(() => output.send([0x80, 0x80, 0x00]), "Incomplete system message."); + SimpleTest.doesThrow(() => output.send([0x80, 0x00, 0x80]), "Incomplete system message."); + + // Complete messages. + output.send([0x80, 0x00, 0x00]); + output.send([0x90, 0x00, 0x00]); + output.send([0xa0, 0x00, 0x00]); + output.send([0xb0, 0x00, 0x00]); + output.send([0xc0, 0x00]); + output.send([0xd0, 0x00]); + output.send([0xe0, 0x00, 0x00]); + + // Real-Time messages. + output.send([0xf8]); + output.send([0xfa]); + output.send([0xfb]); + output.send([0xfc]); + output.send([0xfe]); + output.send([0xff]); + + // Valid messages with Real-Time messages. + output.send([0x90, 0xff, 0xff, 0x00, 0xff, 0x01, 0xff, 0x80, 0xff, 0x00, + 0xff, 0xff, 0x00, 0xff, 0xff]); + + // Sysex messages. + output.send([0xf0, 0x00, 0x01, 0x02, 0x03, 0xf7]); + output.send([0xf0, 0xf8, 0xf7, 0xff]); + SimpleTest.doesThrow(() => output.send([0xf0, 0x80, 0xf7]), "Invalid sysex message."); + SimpleTest.doesThrow(() => output.send([0xf0, 0xf0, 0xf7]), "Double begin sysex message."); + SimpleTest.doesThrow(() => output.send([0xf0, 0xff, 0xf7, 0xf7]), "Double end sysex message."); + + // Reserved status bytes. + SimpleTest.doesThrow(() => output.send([0xf4, 0x80, 0x00, 0x00]), "Reserved status byte."); + SimpleTest.doesThrow(() => output.send([0x80, 0xf4, 0x00, 0x00]), "Reserved status byte."); + SimpleTest.doesThrow(() => output.send([0x80, 0x00, 0xf4, 0x00]), "Reserved status byte."); + SimpleTest.doesThrow(() => output.send([0x80, 0x00, 0x00, 0xf4]), "Reserved status byte."); + SimpleTest.doesThrow(() => output.send([0xf0, 0xff, 0xf4, 0xf7]), "Reserved status byte."); + + // Invalid timestamps. + SimpleTest.doesThrow(() => output.send([], NaN), "NaN timestamp."); + SimpleTest.doesThrow(() => output.send([], Infinity), "Infinity timestamp."); + SimpleTest.doesThrow(() => output.send(new Uint8Array(), NaN), "NaN timestamp."); + SimpleTest.doesThrow(() => output.send(new Uint8Array(), Infinity), "Infinity timestamp."); + + SimpleTest.finish(); + } + </script> +</body> + +</html> |