Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c72f4e2
Drop malformed OSC packets instead of unwinding to the event loop
aalex Jul 2, 2026
9db43f3
Harden the top-level exception guard in MainApplication::notify
aalex Jul 2, 2026
5038f05
Bind OSC to loopback by default; make network access opt-in
aalex Jul 2, 2026
5e18db7
Ignore the OSC quit command unless explicitly allowed
aalex Jul 2, 2026
6c06460
Gate per-message OSC receive logging behind verbose mode
aalex Jul 2, 2026
b759cef
Bump version to 1.0.0-alpha.1 from a single source of truth
aalex Jul 2, 2026
ac2a0bd
Add file-version compatibility tests (QUA-4 seed)
aalex Jul 2, 2026
c28a4f2
Check file-open results instead of discarding them
aalex Jul 2, 2026
7587b22
Emit the Syphon.framework bundle rule only once
aalex Jul 2, 2026
a398879
Complete the Spanish (es) translation
aalex Jul 2, 2026
76dd57b
Fail cleanly on unconstructable elements when loading a project
aalex Jul 2, 2026
609603f
Require loopback origin and a bearer token for the MCP server
aalex Jul 2, 2026
57e8bc6
Document the project format as JSON, not XML
aalex Jul 2, 2026
a17dd11
Disable the MCP server by default (opt-in)
aalex Jul 2, 2026
ca2e48d
Validate vertex and media fields when loading a project
aalex Jul 2, 2026
b06f4b4
Sync translation catalogues; complete French and Spanish
aalex Jul 2, 2026
3be0929
Modernize signal/slot connects in the dialogs and toolbar (QUA-1)
aalex Jul 2, 2026
3441c09
Migrate remaining signal/slot connects to pointer syntax (QUA-1)
aalex Jul 3, 2026
776ae1a
Use a consistent ellipsis (…) in action labels (UX-2)
aalex Jul 3, 2026
bb50ee1
Add a first-run welcome dialog (UX-1)
aalex Jul 3, 2026
f7209c1
Clarify the OSC media option and expose the welcome-dialog toggle
aalex Jul 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/datasheet.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ MapMap allows its users to:
* Create and destroy an unlimited amount of mappings. Each mapping is a shape on which a source paint is drawn.
* Color paints can be used as masks.
* Move layers.
* Save the project to a human-readable XML file.
* Load the project from a human-readable XML file.
* Save the project to a human-readable JSON file.
* Load the project from a human-readable JSON file.
* Turn any quad into a mesh.
* Inspect properties.

Expand Down
11 changes: 11 additions & 0 deletions docs/informations/osc.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,14 @@ Pause: `/mapmap/pause`
Play: `/mapmap/play`
Rewind/reset: `/mapmap/rewind`
Quit: `/mapmap/quit`

## Security

OSC has no authentication. By default MapMap listens on the loopback
interface only (`127.0.0.1`), so OSC is reachable solely from the same
computer. To control MapMap from another device, enable **Accept OSC from
the network** in *Preferences > OSC Setup* — only on a trusted show network.

The `/mapmap/quit` command is ignored unless **Allow OSC to quit the
application** is enabled in the same panel, so a remote sender cannot close
the app during a show.
2 changes: 1 addition & 1 deletion docs/manual/manual_en.rst
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Paints can be destroyed simply by selecting it in the paint list, and then choos

Save the project to a file
--------------------------
To save the current project, choose "File > Save as..." and then choose a file name. The extension file is ".mmp", but the file format is simply XML, a very common one.
To save the current project, choose "File > Save as..." and then choose a file name. The extension file is ".mmp", but the file format is simply JSON, a very common one.

Load a project from a file
--------------------------
Expand Down
9 changes: 7 additions & 2 deletions mapmap.pro
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,16 @@ CONFIG += c++17

TEMPLATE = app

# Always use major.minor.micro version number format
VERSION = 0.6.3
# Always use major.minor.micro version number format (kept numeric so bundle
# and library versioning stay valid).
VERSION = 1.0.0
# Human-facing version shown in-app (About box, window title, app version).
# Single source of truth for MM::VERSION, injected via DEFINES below.
MAPMAP_VERSION = 1.0.0-alpha.1
TARGET = mapmap

DEFINES += UNICODE QT_THREAD_SUPPORT QT_CORE_LIB QT_GUI_LIB QT_MESSAGELOGCONTEXT
DEFINES += MAPMAP_VERSION_STRING=\\\"$$MAPMAP_VERSION\\\"

include(src/core/core.pri)
include(src/shape/shape.pri)
Expand Down
15 changes: 12 additions & 3 deletions src/app/MainApplication.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,23 @@ MainApplication::~MainApplication()

bool MainApplication::notify(QObject *receiver, QEvent *event)
{
// Last-resort guard: a stray exception during event delivery must never take
// down a running show. Log loudly (qCritical survives in release builds,
// unlike qDebug) and drop the offending event instead of terminating.
const int eventType = event ? static_cast<int>(event->type()) : -1;
try
{
return QApplication::notify(receiver, event);
}
catch (std::exception &ex)
catch (const std::exception &ex)
{
qDebug() << "std::exception was caught: " << ex.what() << Qt::endl;
qDebug() << "event type: " << event->type() << Qt::endl;
qCritical() << "Unhandled std::exception during event delivery:" << ex.what()
<< "(event type" << eventType << ")";
}
catch (...)
{
qCritical() << "Unhandled non-standard exception during event delivery"
<< "(event type" << eventType << ")";
}

return false;
Expand Down
3 changes: 2 additions & 1 deletion src/app/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@ int main(int argc, char *argv[])

// Load stylesheet.
QFile stylesheet(":/stylesheet");
(void)stylesheet.open(QFile::ReadOnly);
if (!stylesheet.open(QFile::ReadOnly))
qWarning() << "Could not open embedded stylesheet" << stylesheet.fileName();
app.setStyleSheet(QLatin1String(stylesheet.readAll()));

// read positional argument:
Expand Down
46 changes: 46 additions & 0 deletions src/control/McpServer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
#include <QHttpServerRequest>
#include <QHttpServerResponse>
#include <QHostAddress>
#include <QUuid>
#include <QTcpServer>
#include <QJsonDocument>
#include <QMetaObject>
Expand All @@ -40,9 +41,38 @@

namespace mmp {

namespace {
// Host/Origin values that identify the local machine, used to reject
// DNS-rebinding and cross-origin requests to the control surface.
bool isLoopbackHost(QByteArray host)
{
host = host.trimmed();
if (host.startsWith('[')) // IPv6 literal, e.g. [::1]:port
{
const int end = host.indexOf(']');
if (end > 0) host = host.mid(1, end - 1);
}
else // host:port
{
const int colon = host.indexOf(':');
if (colon >= 0) host = host.left(colon);
}
return host == "localhost" || host == "127.0.0.1" || host == "::1";
}

bool isLoopbackOrigin(QByteArray origin)
{
const int scheme = origin.indexOf("://");
if (scheme >= 0) origin = origin.mid(scheme + 3);
return isLoopbackHost(origin);
}
} // namespace

McpServer::McpServer(MainWindow* mainWindow, QObject* parent)
: QObject(parent), _mainWindow(mainWindow), _httpServer(nullptr), _port(0)
{
// Per-process shared secret required on every request (see isAuthorized).
_token = QUuid::createUuid().toString(QUuid::WithoutBraces);
}

McpServer::~McpServer()
Expand All @@ -61,6 +91,9 @@ quint16 McpServer::start(quint16 port)
if (request.method() != QHttpServerRequest::Method::Post)
return QHttpServerResponse("text/plain", QByteArray("Method Not Allowed"),
QHttpServerResponse::StatusCode::MethodNotAllowed);
if (!isAuthorized(request))
return QHttpServerResponse("text/plain", QByteArray("Forbidden"),
QHttpServerResponse::StatusCode::Forbidden);
const QByteArray out = handleRpc(request.body());
if (out.isEmpty()) // notification: acknowledge with no body
return QHttpServerResponse(QHttpServerResponse::StatusCode::Accepted);
Expand All @@ -85,6 +118,19 @@ bool McpServer::isListening() const
return _httpServer != nullptr && _port != 0;
}

bool McpServer::isAuthorized(const QHttpServerRequest& request) const
{
// 1. DNS-rebinding guard: the Host header must name the loopback interface.
if (!isLoopbackHost(request.value("Host")))
return false;
// 2. If a browser sent an Origin, it must be loopback too ("null" is allowed).
const QByteArray origin = request.value("Origin");
if (!origin.isEmpty() && origin != "null" && !isLoopbackOrigin(origin))
return false;
// 3. Shared-secret bearer token generated at startup.
return request.value("Authorization") == QByteArray("Bearer ") + _token.toUtf8();
}

QByteArray McpServer::handleRpc(const QByteArray& body)
{
QJsonParseError parseError;
Expand Down
9 changes: 9 additions & 0 deletions src/control/McpServer.h
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

QT_BEGIN_NAMESPACE
class QHttpServer;
class QHttpServerRequest;
QT_END_NAMESPACE

namespace mmp {
Expand All @@ -56,12 +57,19 @@ class McpServer : public QObject {
/// Currently bound port (0 if not listening).
quint16 port() const { return _port; }

/// Bearer token that every request must present in its Authorization header.
QString token() const { return _token; }

private:
// --- JSON-RPC plumbing ---
// Returns the response body, or an empty array for notifications (no reply).
QByteArray handleRpc(const QByteArray& body);
QJsonObject dispatch(const QJsonObject& request);

// Rejects requests that are non-loopback (DNS-rebinding), cross-origin, or
// missing the bearer token.
bool isAuthorized(const QHttpServerRequest& request) const;

static QJsonObject makeResult(const QJsonValue& id, const QJsonValue& result);
static QJsonObject makeError(const QJsonValue& id, int code, const QString& message);

Expand All @@ -83,6 +91,7 @@ class McpServer : public QObject {
MainWindow* _mainWindow;
QHttpServer* _httpServer;
quint16 _port;
QString _token;
};

}
10 changes: 7 additions & 3 deletions src/control/OscInterface.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,8 @@ QVector<Layer::ptr> resolveLayers(MappingManager& manager, const OscAction& acti
} // namespace

OscInterface::OscInterface(
int listen_port) :
receiver_(listen_port),
int listen_port, bool acceptFromNetwork) :
receiver_(listen_port, acceptFromNetwork),
messaging_queue_() {
receiving_enabled_ = true;
if (receiving_enabled_) {
Expand Down Expand Up @@ -219,7 +219,11 @@ bool OscInterface::applyAction(MainWindow &main_window, const OscAction& action)
return false;

// Global transport.
case OscAction::Quit: main_window.close(); return true;
case OscAction::Quit:
if (main_window.isOscQuitAllowed()) { main_window.close(); return true; }
qWarning() << "Ignoring OSC /mapmap/quit: remote quit is disabled "
"(enable it in Preferences > OSC Setup).";
return false;
case OscAction::PlayAll: main_window.play(); return true;
case OscAction::PauseAll: main_window.pause(); return true;
case OscAction::RewindAll: main_window.rewind(); return true;
Expand Down
4 changes: 2 additions & 2 deletions src/control/OscInterface.h
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ class OscInterface {
public:
typedef QSharedPointer<OscInterface> ptr;

OscInterface(int listen_port);
OscInterface(int listen_port, bool acceptFromNetwork = false);
~OscInterface();

void setVerbose(bool verbose) { verbose_ = verbose; }
void setVerbose(bool verbose) { verbose_ = verbose; receiver_.setVerbose(verbose); }

/// Starts listening if receiving is enabled.
void start();
Expand Down
89 changes: 55 additions & 34 deletions src/control/qosc/oscreceiver.cpp
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
#include "oscreceiver.h"
#include "contrib/oscpack/OscTypes.h"
#include "contrib/oscpack/OscReceivedElements.h"
#include "contrib/oscpack/OscException.h"

OscReceiver::OscReceiver(quint16 receivePort, QObject* parent) :
OscReceiver::OscReceiver(quint16 receivePort, bool acceptFromNetwork, QObject* parent) :
QObject(parent)
{
m_udpSocket = new QUdpSocket(this);
// m_udpSocket->bind(QHostAddress::LocalHost, receivePort);
qDebug() << "Listening for OSC on " << receivePort;
m_udpSocket->bind(QHostAddress::Any, receivePort);
// OSC has no authentication, so bind to loopback by default: the control
// surface must not be reachable from the venue network unless the user
// explicitly opts in (Preferences > OSC Setup).
const QHostAddress bindAddress = acceptFromNetwork ? QHostAddress(QHostAddress::Any)
: QHostAddress(QHostAddress::LocalHost);
if (!m_udpSocket->bind(bindAddress, receivePort)) {
qWarning() << "OscReceiver: could not bind OSC port" << receivePort
<< "on" << bindAddress.toString();
} else {
qInfo() << "Listening for OSC on" << bindAddress.toString() << "port" << receivePort;
}
connect(m_udpSocket, &QUdpSocket::readyRead, this, &OscReceiver::readyReadCb);
}

Expand All @@ -19,39 +28,51 @@ void OscReceiver::readyReadCb() {
QByteArray data = datagram.data();
QVariantList arguments;
QString oscAddress;
this->byteArrayToVariantList(arguments, oscAddress, data);
emit messageReceived(oscAddress, arguments);
qDebug() << "C++OscReceiver Received: " << oscAddress << arguments;
// A malformed datagram must never take the app down: only forward the
// message when it parsed cleanly, and keep draining the queue otherwise.
if (this->byteArrayToVariantList(arguments, oscAddress, data)) {
emit messageReceived(oscAddress, arguments);
if (m_verbose)
qDebug() << "OscReceiver received:" << oscAddress << arguments;
}
}
}

void OscReceiver::byteArrayToVariantList(QVariantList& outputVariantList, QString& outputOscAddress, const QByteArray& inputByteArray) {
osc::ReceivedPacket packet(inputByteArray.data(), static_cast<std::size_t>(inputByteArray.size()));
// TODO: catch parsing exceptions
if (packet.IsMessage()) {
osc::ReceivedMessage message(packet);
// Get address pattern
QString address(message.AddressPattern());
outputOscAddress.replace(0, address.size(), address);
bool OscReceiver::byteArrayToVariantList(QVariantList& outputVariantList, QString& outputOscAddress, const QByteArray& inputByteArray) {
// oscpack throws osc::Exception (derived from std::exception) on any
// malformed input. Catch it here so a single bad datagram is dropped
// rather than unwinding through the Qt event loop.
try {
osc::ReceivedPacket packet(inputByteArray.data(), static_cast<std::size_t>(inputByteArray.size()));
if (packet.IsMessage()) {
osc::ReceivedMessage message(packet);
// Get address pattern
QString address(message.AddressPattern());
outputOscAddress.replace(0, address.size(), address);

for (auto iter = message.ArgumentsBegin(); iter != message.ArgumentsEnd(); ++ iter) {
osc::ReceivedMessageArgument argument = (*iter);
if (argument.IsBool()) {
outputVariantList.append(QVariant(argument.AsBool()));
} else if (argument.IsString()) {
outputVariantList.append(QVariant(argument.AsString()));
} else if (argument.IsInt32()) {
outputVariantList.append(QVariant::fromValue<qint32>(static_cast<qint32>(argument.AsInt32())));
} else if (argument.IsFloat()) {
outputVariantList.append(QVariant(argument.AsFloat()));
} else if (argument.IsChar()) {
outputVariantList.append(QVariant(argument.AsChar()));
//} else if (argument.IsInt64()) {
// outputVariantList.append(QVariant(argument.AsInt64()));
} else if (argument.IsDouble()) {
outputVariantList.append(QVariant(argument.AsDouble()));
for (auto iter = message.ArgumentsBegin(); iter != message.ArgumentsEnd(); ++ iter) {
osc::ReceivedMessageArgument argument = (*iter);
if (argument.IsBool()) {
outputVariantList.append(QVariant(argument.AsBool()));
} else if (argument.IsString()) {
outputVariantList.append(QVariant(argument.AsString()));
} else if (argument.IsInt32()) {
outputVariantList.append(QVariant::fromValue<qint32>(static_cast<qint32>(argument.AsInt32())));
} else if (argument.IsFloat()) {
outputVariantList.append(QVariant(argument.AsFloat()));
} else if (argument.IsChar()) {
outputVariantList.append(QVariant(argument.AsChar()));
//} else if (argument.IsInt64()) {
// outputVariantList.append(QVariant(argument.AsInt64()));
} else if (argument.IsDouble()) {
outputVariantList.append(QVariant(argument.AsDouble()));
}
// TODO: support Array, Midi, Blob, Symbol, TimeTag, RGBA, Nil
}
// TODO: support Array, Midi, Blob, Symbol, TimeTag, RGBA, Nil
}
} // TODO: also parse bundles
} // TODO: also parse bundles
} catch (const osc::Exception& e) {
qWarning() << "OscReceiver: dropped malformed OSC packet:" << e.what();
return false;
}
return true;
}
9 changes: 7 additions & 2 deletions src/control/qosc/oscreceiver.h
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ class QOSC_EXPORT OscReceiver : public QObject
* @brief Constructor.
* @param receivePort Port number to listen to.
*/
explicit OscReceiver(quint16 receivePort, QObject *parent = nullptr);
explicit OscReceiver(quint16 receivePort, bool acceptFromNetwork = false, QObject *parent = nullptr);

/// When false (default), per-message receive logging is suppressed to keep
/// the hot path quiet under high OSC rates.
void setVerbose(bool verbose) { m_verbose = verbose; }

signals:
/**
Expand All @@ -41,7 +45,8 @@ public slots:

private:
QUdpSocket* m_udpSocket;
void byteArrayToVariantList(QVariantList& outputVariantList, QString& outputOscAddress, const QByteArray& inputByteArray);
bool m_verbose = false;
bool byteArrayToVariantList(QVariantList& outputVariantList, QString& outputOscAddress, const QByteArray& inputByteArray);
};

#endif // OSCRECEIVER_H
12 changes: 10 additions & 2 deletions src/core/MM.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@
namespace mmp {

const QString MM::APPLICATION_NAME = "MapMap";
const QString MM::VERSION = "0.6.3";
// Single source of truth is MAPMAP_VERSION in mapmap.pro, injected via DEFINES.
// The literal fallback keeps direct compiles working and must match the .pro.
#ifdef MAPMAP_VERSION_STRING
const QString MM::VERSION = MAPMAP_VERSION_STRING;
#else
const QString MM::VERSION = "1.0.0-alpha.1";
#endif
const QString MM::COPYRIGHT_OWNERS = "Alexandre Quessy, Sofian Audry, Dame Diongue, Mike Latona, Vasilis Liaskovitis";
const QString MM::ORGANIZATION_NAME = "MapMap";
const QString MM::ORGANIZATION_DOMAIN = "artpluscode.com";
Expand All @@ -42,7 +48,9 @@ const QString MM::VIDEO_FILES_FILTER = "*.mov *.mp4 *.avi *.ogg *.ogv *.mpeg *.m
const QString MM::IMAGE_FILES_FILTER = "*.jpg *.jpeg *.gif *.png *.tiff *.tif *.bmp";
const QString MM::NAMESPACE_PREFIX = QString("%1::").arg(TOSTRING(MM_NAMESPACE));
const QString MM::SUPPORTED_LANGUAGES = "en, es, fr, zh_CN, zh_TW";
const QString MM::SUPPORTED_FILE_VERSIONS = "\\d+\\.\\d+\\.\\d+"; // regex
// x.y.z with an optional -prerelease / +build suffix (e.g. 1.0.0-alpha.1), so
// projects saved by pre-release builds remain loadable. Anchored to reject junk.
const QString MM::SUPPORTED_FILE_VERSIONS = "^\\d+\\.\\d+\\.\\d+([-+][0-9A-Za-z.-]+)?$"; // regex

const QColor MM::WHITE("#f6f5f5");
const QColor MM::BLUE_GRAY("#323541");
Expand Down
Loading
Loading