Skip to content

Instantly share code, notes, and snippets.

@adamjs
Last active September 22, 2024 09:43
Show Gist options
  • Save adamjs/466361755a2285ac231c7ec242d4a8e3 to your computer and use it in GitHub Desktop.
Save adamjs/466361755a2285ac231c7ec242d4a8e3 to your computer and use it in GitHub Desktop.

Exposing Qt Classes to JavaScriptCore: A Dynamic Proxy Approach with Signal Support

When working with Ultralight, you may want to expose your C++ classes to JavaScript. If your C++ application is based on Qt, you may find yourself in the challenging position of exposing a vast number of Qt classes to JavaScript, which could involve writing tedious manual bindings for each class. However, using Qt's meta-object system (reflection), you can dynamically expose these classes with far less effort.

In this article, we'll walk through how to set up a dynamic proxy using the JSC C API and Qt's meta-object system. This proxy will automatically forward method calls and property accesses in JavaScript to the corresponding Qt object in C++, and also support Qt's signal-slot mechanism.

Overview of the Proxy Approach

Instead of manually defining JavaScript bindings for every Qt class and method, we create a generic proxy. This proxy will:

  1. Intercept method calls and property accesses made on JavaScript objects.
  2. Dynamically forward those calls and accesses to their corresponding QObject methods and properties in C++ using Qt's meta-object system (QMetaObject).
  3. Use Qt's QVariant and JavaScriptCore's JSValueRef to handle the type conversions between JavaScript and C++.
  4. Support connecting JavaScript functions to Qt signals.

Step 1: Define the Proxy JavaScript Class

First, we define the JavaScript class (JSClassDefinition) that represents our proxy object in JavaScript. This class will be named "Qt", and its callAsFunction and getProperty callbacks will dynamically forward method calls and property accesses to the underlying QObject.

#include <JavaScriptCore/JavaScript.h>
#include <QMetaObject>
#include <QMetaMethod>
#include <QObject>
#include <QString>
#include <QVariant>

// Function declarations
JSValueRef QtProxyGetProperty(JSContextRef ctx, JSObjectRef object, JSStringRef propertyName, JSValueRef *exception);
bool QtProxySetProperty(JSContextRef ctx, JSObjectRef object, JSStringRef propertyName, JSValueRef value, JSValueRef *exception);
void QtProxyFinalize(JSObjectRef object);

static JSClassDefinition qtProxyClassDefinition = {
    0,                          // version
    kJSClassAttributeNone,      // attributes
    "Qt",                       // className
    NULL,                       // parentClass
    NULL,                       // staticValues
    NULL,                       // staticFunctions
    NULL,                       // initialize
    QtProxyFinalize,            // finalize
    NULL,                       // hasProperty
    QtProxyGetProperty,         // getProperty
    QtProxySetProperty,         // setProperty
    NULL,                       // deleteProperty
    NULL,                       // getPropertyNames
    NULL,                       // callAsFunction
    NULL,                       // hasInstance
    NULL                        // convertToType
};

// Create the JSClassRef for later use
JSClassRef QtProxyClass = JSClassCreate(&qtProxyClassDefinition);

Step 2: Implementing the QtProxy Class

To support Qt signals and manage the relationship between Qt objects and their JavaScript representations, we implement a QtProxy class:

class QtProxy : public QObject {
    Q_OBJECT
public:
    QtProxy(QObject* wrappedObject, JSContextRef ctx)
        : m_wrappedObject(wrappedObject), m_context(ctx) {
        // Set up signal wrappers
        const QMetaObject* metaObject = wrappedObject->metaObject();
        for (int i = 0; i < metaObject->methodCount(); ++i) {
            QMetaMethod method = metaObject->method(i);
            if (method.methodType() == QMetaMethod::Signal) {
                m_signalConnections[method.name()] = QList<JSObjectRef>();
            }
        }
    }

    QObject* wrappedObject() const { return m_wrappedObject; }

    bool connectSignal(const QString& signalName, JSContextRef ctx, JSObjectRef callback) {
        if (!m_signalConnections.contains(signalName)) {
            return false;
        }
        
        m_signalConnections[signalName].append(callback);
        JSValueProtect(ctx, callback);

        // Connect the actual Qt signal to our slot
        const QMetaObject* metaObject = m_wrappedObject->metaObject();
        int signalIndex = metaObject->indexOfSignal(signalName.toUtf8().constData());
        if (signalIndex != -1) {
            QMetaObject::connect(m_wrappedObject, signalIndex, this, metaObject()->methodCount() + signalIndex);
        }

        return true;
    }

    JSValueRef callMethod(JSContextRef ctx, const QString& methodName, size_t argumentCount, const JSValueRef arguments[], JSValueRef* exception) {
        QVariantList qtArgs;
        for (size_t i = 0; i < argumentCount; ++i) {
            QVariant qtArg = ConvertJSToQVariant(ctx, arguments[i]);
            qtArgs.append(qtArg);
        }

        QVariant returnValue;
        bool success = QMetaObject::invokeMethod(m_wrappedObject, methodName.toUtf8().constData(), 
                                                 Qt::DirectConnection,
                                                 Q_RETURN_ARG(QVariant, returnValue),
                                                 Q_ARG(QVariantList, qtArgs));

        if (!success) {
            JSStringRef errorMsg = JSStringCreateWithUTF8CString("Failed to invoke Qt method");
            *exception = JSValueMakeString(ctx, errorMsg);
            JSStringRelease(errorMsg);
            return JSValueMakeUndefined(ctx);
        }

        return ConvertQVariantToJS(ctx, returnValue);
    }

protected:
    int qt_metacall(QMetaObject::Call call, int methodId, void **args) override {
        methodId = QObject::qt_metacall(call, methodId, args);
        if (methodId < 0)
            return methodId;

        if (call == QMetaObject::InvokeMetaMethod) {
            const QMetaObject* metaObject = m_wrappedObject->metaObject();
            QMetaMethod signal = metaObject->method(methodId);
            QString signalName = QString::fromLatin1(signal.name());

            // Convert args to JSValueRef
            QList<JSValueRef> jsArgs;
            for (int i = 0; i < signal.parameterCount(); ++i) {
                QVariant arg(QMetaType::type(signal.parameterType(i)), args[i+1]);
                jsArgs.append(ConvertQVariantToJS(m_context, arg));
            }

            // Call all connected callbacks
            for (JSObjectRef callback : m_signalConnections[signalName]) {
                JSValueRef exception = nullptr;
                JSObjectCallAsFunction(m_context, callback, nullptr, jsArgs.size(), jsArgs.constData(), &exception);
                if (exception) {
                    // Handle exception (e.g., log it)
                }
            }

            return -1;
        }
        return methodId;
    }

private:
    QObject* m_wrappedObject;
    JSContextRef m_context;
    QMap<QString, QList<JSObjectRef>> m_signalConnections;
};

void QtProxyFinalize(JSObjectRef object) {
    QtProxy* proxy = static_cast<QtProxy*>(JSObjectGetPrivate(object));
    delete proxy;
}

Step 3: Forwarding Property Access

Next, we implement the property access functions. These will use the QMetaObject::property() function to read or write the corresponding property on the QObject.

JSValueRef QtProxyGetProperty(JSContextRef ctx, JSObjectRef object, JSStringRef propertyName, JSValueRef *exception) {
    QtProxy* proxy = static_cast<QtProxy*>(JSObjectGetPrivate(object));
    QObject* qobject = proxy->wrappedObject();
    QString propName = QString::fromUtf8(JSStringGetCharactersPtr(propertyName), JSStringGetLength(propertyName));

    const QMetaObject* metaObject = qobject->metaObject();
    int propertyIndex = metaObject->indexOfProperty(propName.toUtf8().constData());
    
    if (propertyIndex < 0) {
        // Check if it's a method
        int methodIndex = metaObject->indexOfMethod(propName.toUtf8().constData());
        if (methodIndex >= 0) {
            // Return a function that will call the method
            return JSObjectMakeFunctionWithCallback(ctx, propertyName, 
                [](JSContextRef ctx, JSObjectRef function, JSObjectRef thisObject, size_t argumentCount, const JSValueRef arguments[], JSValueRef* exception) {
                    QtProxy* proxy = static_cast<QtProxy*>(JSObjectGetPrivate(thisObject));
                    JSStringRef propName = JSObjectGetPropertyName(ctx, function);
                    QString methodName = QString::fromUtf8(JSStringGetCharactersPtr(propName), JSStringGetLength(propName));
                    return proxy->callMethod(ctx, methodName, argumentCount, arguments, exception);
                });
        }

        JSStringRef errorMsg = JSStringCreateWithUTF8CString("Property not found");
        *exception = JSValueMakeString(ctx, errorMsg);
        JSStringRelease(errorMsg);
        return JSValueMakeUndefined(ctx);
    }

    QMetaProperty property = metaObject->property(propertyIndex);
    QVariant value = property.read(qobject);

    return ConvertQVariantToJS(ctx, value);
}

bool QtProxySetProperty(JSContextRef ctx, JSObjectRef object, JSStringRef propertyName, JSValueRef value, JSValueRef *exception) {
    QtProxy* proxy = static_cast<QtProxy*>(JSObjectGetPrivate(object));
    QObject* qobject = proxy->wrappedObject();
    QString propName = QString::fromUtf8(JSStringGetCharactersPtr(propertyName), JSStringGetLength(propertyName));

    const QMetaObject* metaObject = qobject->metaObject();
    int propertyIndex = metaObject->indexOfProperty(propName.toUtf8().constData());
    
    if (propertyIndex < 0) {
        JSStringRef errorMsg = JSStringCreateWithUTF8CString("Property not found");
        *exception = JSValueMakeString(ctx, errorMsg);
        JSStringRelease(errorMsg);
        return false;
    }

    QMetaProperty property = metaObject->property(propertyIndex);
    QVariant qtValue = ConvertJSToQVariant(ctx, value);
    
    return property.write(qobject, qtValue);
}

Step 4: Converting Between QVariant and JSValueRef

We need to handle the conversion between Qt's QVariant and JavaScript's JSValueRef:

JSValueRef ConvertQVariantToJS(JSContextRef ctx, const QVariant& value) {
    switch (value.type()) {
        case QVariant::String: {
            QString str = value.toString();
            JSStringRef jsStr = JSStringCreateWithUTF8CString(str.toUtf8().constData());
            JSValueRef result = JSValueMakeString(ctx, jsStr);
            JSStringRelease(jsStr);
            return result;
        }
        case QVariant::Int:
            return JSValueMakeNumber(ctx, value.toInt());
        case QVariant::Double:
            return JSValueMakeNumber(ctx, value.toDouble());
        case QVariant::Bool:
            return JSValueMakeBoolean(ctx, value.toBool());
        case QVariant::List: {
            QVariantList list = value.toList();
            JSValueRef* arrayElements = new JSValueRef[list.size()];
            for (int i = 0; i < list.size(); ++i) {
                arrayElements[i] = ConvertQVariantToJS(ctx, list[i]);
            }
            JSObjectRef jsArray = JSObjectMakeArray(ctx, list.size(), arrayElements, nullptr);
            delete[] arrayElements;
            return jsArray;
        }
        // Add more cases as needed
        default:
            // For unsupported types, you might want to add custom conversion logic
            // or return undefined
            return JSValueMakeUndefined(ctx);
    }
}

QVariant ConvertJSToQVariant(JSContextRef ctx, JSValueRef value) {
    if (JSValueIsString(ctx, value)) {
        JSStringRef jsString = JSValueToStringCopy(ctx, value, nullptr);
        size_t maxBufferSize = JSStringGetMaximumUTF8CStringSize(jsString);
        char* buffer = new char[maxBufferSize];
        JSStringGetUTF8CString(jsString, buffer, maxBufferSize);
        QString qString = QString::fromUtf8(buffer);
        delete[] buffer;
        JSStringRelease(jsString);
        return QVariant(qString);
    } else if (JSValueIsNumber(ctx, value)) {
        return QVariant(JSValueToNumber(ctx, value, nullptr));
    } else if (JSValueIsBoolean(ctx, value)) {
        return QVariant(JSValueToBoolean(ctx, value));
    } else if (JSValueIsArray(ctx, value)) {
        JSObjectRef jsArray = JSValueToObject(ctx, value, nullptr);
        JSPropertyNameArrayRef propertyNames = JSObjectCopyPropertyNames(ctx, jsArray);
        size_t count = JSPropertyNameArrayGetCount(propertyNames);
        QVariantList list;
        for (size_t i = 0; i < count; ++i) {
            JSValueRef element = JSObjectGetPropertyAtIndex(ctx, jsArray, i, nullptr);
            list.append(ConvertJSToQVariant(ctx, element));
        }
        JSPropertyNameArrayRelease(propertyNames);
        return QVariant(list);
    }
    // Add more type checks and conversions as needed
    return QVariant();
}

Additional Considerations

Memory Management

To ensure proper memory management, especially when Qt objects are exposed to JavaScript, we implement a tracking system:

class QtJSCBridge {
public:
    QtJSCBridge(JSContextRef ctx) : m_context(ctx) {}

    void registerObject(QObject* obj, JSObjectRef jsObj) {
        m_mutex.lock();
        m_objectMap[obj] = jsObj;
        JSValueProtect(m_context, jsObj);
        m_mutex.unlock();

        connect(obj, &QObject::destroyed, this, &QtJSCBridge::onObjectDestroyed);
    }

    void unregisterObject(QObject* obj) {
        m_mutex.lock();
        auto it = m_objectMap.find(obj);
        if (it != m_objectMap.end()) {
            JSValueUnprotect(m_context, it.value());
            m_objectMap.erase(it);
        }
        m_mutex.unlock();
    }

    void onObjectDestroyed(QObject* obj) {
        unregisterObject(obj);
    }

    void lockJSExecution() { m_mutex.lock(); }
    void unlockJSExecution() { m_mutex.unlock(); }

private:
    QMap<QObject*, JSObjectRef> m_objectMap;
    QMutex m_mutex;
    JSContextRef m_context;
};

Performance Optimization

For frequently accessed methods and properties, you might want to implement a caching mechanism:

class QtJSCCache {
public:
    QMetaMethod getCachedMethod(const QMetaObject* metaObj, const QString& methodName) {
        auto key = qMakePair(metaObj, methodName);
        if (!m_methodCache.contains(key)) {
            m_methodCache[key] = metaObj->method(metaObj->indexOfMethod(methodName.toUtf8().constData()));
        }
        return m_methodCache[key];
    }

    QMetaProperty getCachedProperty(const QMetaObject* metaObj, const QString& propertyName) {
        auto key = qMakePair(metaObj, propertyName);
        if (!m_propertyCache.contains(key)) {
            m_propertyCache[key] = metaObj->property(metaObj->indexOfProperty(propertyName.toUtf8().constData()));
        }
        return m_propertyCache[key];
    }

private:
    QHash<QPair<const QMetaObject*, QString>, QMetaMethod> m_methodCache;
    QHash<QPair<const QMetaObject*, QString>, QMetaProperty> m_propertyCache;
};

Example Binding Code (C++)

Here's an example of how to use the above code to expose Qt classes like QTimer, QPushButton, and QLineEdit to JavaScript:

#include <JavaScriptCore/JavaScript.h>
#include "QtJSCBridge.h" // Our QtJSCBridge class
#include "QtProxy.h" // Assume this contains our QtProxyClass definition

template<typename QtClass>
JSObjectRef createQtObject(JSContextRef ctx, JSObjectRef constructor, size_t argumentCount, const JSValueRef arguments[], JSValueRef* exception, QtJSCBridge* bridge) {
    QtClass* object = new QtClass();
    
    QtProxy* proxy = new QtProxy(object, ctx);
    
    JSObjectRef jsObject = JSObjectMake(ctx, QtProxyClass, proxy);
    
    bridge->registerObject(object, jsObject);
    
    return jsObject;
}

template<typename QtClass>
void SetupQtClass(JSContextRef ctx, JSObjectRef globalObject, QtJSCBridge* bridge, const char* className) {
    JSObjectRef classConstructor = JSObjectMakeConstructor(ctx, NULL, 
        [bridge](JSContextRef ctx, JSObjectRef constructor, size_t argumentCount, const JSValueRef arguments[], JSValueRef* exception) {
            return createQtObject<QtClass>(ctx, constructor, argumentCount, arguments, exception, bridge);
        });
    
    JSStringRef jsClassName = JSStringCreateWithUTF8CString(className);
    JSObjectSetProperty(ctx, globalObject, jsClassName, classConstructor, kJSPropertyAttributeNone, NULL);
    JSStringRelease(jsClassName);
}

// In your main application setup
void setupJSEnvironment() {
    JSContextRef ctx = /* initialize your JS context */;
    JSObjectRef globalObject = JSContextGetGlobalObject(ctx);
    
    QtJSCBridge* bridge = new QtJSCBridge(ctx);
    
    // Set up various Qt classes
    SetupQtClass<QTimer>(ctx, globalObject, bridge, "QTimer");
    SetupQtClass<QPushButton>(ctx, globalObject, bridge, "QPushButton");
    SetupQtClass<QLineEdit>(ctx, globalObject, bridge, "QLineEdit");
    // ... add more Qt classes as needed
    
    // ... set up other parts of your JS environment
}

Example Binding Code (JavaScript)

Here's how you can use the exposed Qt classes from JavaScript:

// Create and use a QTimer
let timer = new QTimer();
timer.timeout.connect(() => console.log("Timer triggered!"));
timer.start(1000);

// Create and use a QPushButton
let button = new QPushButton();
button.setText("Click me!");
button.clicked.connect(() => console.log("Button clicked!"));

// Create and use a QLineEdit
let lineEdit = new QLineEdit();
lineEdit.setText("Enter text here");
lineEdit.textChanged.connect((text) => console.log("Text changed:", text));

// Later, we can interact with these objects
timer.stop();
console.log("Button text:", button.text());
lineEdit.clear();

Conclusion

This implementation provides a robust framework for dynamically exposing Qt classes to JavaScript using the JavaScriptCore API. By leveraging Qt's powerful meta-object system and JavaScriptCore's C API, this dynamic proxy approach allows you to create a flexible, maintainable bridge between JavaScript and your C++ code in Ultralight applications.

Key features of this approach include:

  1. Dynamic forwarding of method calls and property accesses.
  2. Automatic type conversion between Qt and JavaScript types.
  3. Support for Qt's signal-slot mechanism, allowing JavaScript functions to connect to Qt signals.
  4. Error handling for method calls and property accesses.
  5. Memory management to prevent leaks and ensure proper object lifecycle.
  6. Thread safety considerations for multi-threaded applications.
  7. Performance optimization through caching of frequently accessed methods and properties.

While this implementation covers many bases, remember that depending on your specific use case, you may need to:

  1. Expand the type conversion functions to cover all types used in your application.
  2. Implement a more comprehensive memory management strategy, especially for complex object graphs.
  3. Add more robust error handling and reporting.
  4. Optimize performance for your specific usage patterns.
  5. Enhance the signal-slot connection mechanism to support disconnecting signals or connecting to specific overloads of signals.

By using this dynamic proxy approach, you can significantly reduce the need for manual bindings while providing a powerful interface for JavaScript to interact with your Qt objects. This can greatly simplify the process of exposing C++ functionality to web-based UIs in your Ultralight applications.

Remember that while this approach is powerful and flexible, it may not be suitable for all use cases. For more complex scenarios or when deeper integration between Qt and JavaScript is required, you might want to consider using Qt's own QML engine or other dedicated Qt/JavaScript bridge solutions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment