Mudlet's C API: Decoupling Backend And Frontend

by Alex Johnson 48 views

Hey there, Mudlet enthusiasts! Ever thought about what makes Mudlet tick under the hood? Well, the Mudlet development team has been cooking up an exciting plan to give our favorite MUD client even more flexibility and power. This involves a clever architectural shift: separating the backend (affectionately called libmudlet) from the Qt frontend. The main goal? To pave the way for a future Mudlet mobile app with a frontend that isn't tied to Qt, potentially using modern tech like React Native or Flutter. Intrigued? Let's dive into how they plan to achieve this using a C API.

The Core Idea: A Universal C API Bridge

The heart of this architectural change lies in creating a C API that will act as the communication layer between the backend and the frontend. Think of it like a universal translator, allowing different frontends to talk to the same robust Mudlet engine. This approach is inspired by the successful architecture of libVLC, the powerful multimedia framework behind VLC media player. By exposing core functionalities through a C interface, Mudlet can become more modular and adaptable.

Why a C API?

One of the most compelling reasons for choosing C is its near-universal compatibility. Almost every programming language out there can interact with C functions, usually through mechanisms like Foreign Function Interface (FFI) bindings. This means that if Mudlet wants to support a new frontend technology, say, a mobile app built with Flutter using Dart, or a web-based interface, the existing C API will be the key. The example provided shows how a Dart function might call a future C API function like mudlet_api_package_manager_install_package_from_repository(), demonstrating this interoperability.

Here's a peek at what the C API for the package manager might look like:

#ifndef MUDLET_PACKAGE_MANAGER_API_H
#define MUDLET_PACKAGE_MANAGER_API_H

#ifdef __cplusplus
extern "C" {
#endif

void mudlet_api_package_manager_install_package_from_file(void* backend, const char* fileName);
void mudlet_api_package_manager_cancel_downloads(void* backend);
const char* mudlet_api_package_manager_find_package(void* backend, const char* packageName);
void mudlet_api_package_manager_install_package_from_repository(void* backend, const char* packageName, void (*callbackDispatcher)(void*), void* onError, void* onSuccess);
void mudlet_api_package_manager_set_remaining_downloads(void* backend, int remainingDownloads);

void* mudlet_api_package_manager_create_backend();
void mudlet_api_delete_backend(void* backend);

#ifdef __cplusplus
}
#endif

#endif

This C code defines the functions that the frontend will use to interact with the backend. For the short term, the plan is to mirror each frontend class with a corresponding backend class. This ensures that as the separation happens, there's a clear one-to-one mapping, making the transition smoother. Eventually, as the architecture matures, these mappings can become more refined and logical.

Binaries: A Clearer Separation

In terms of build output, this separation means the frontend code will reside directly within the Mudlet executable, while the backend code will be packaged into a static library named libmudlet. The executable will then link against this library. While this might sound like a minor change, it's crucial for modularity. The core Mudlet engine (libmudlet) can, in theory, be compiled and used independently of the Qt frontend. This structure is similar to how many complex applications are built, ensuring that the core logic is contained and reusable.

Currently, the diagram shows a clear structure:

+---------------------------+
|      Mudlet Executable    |
|---------------------------|
|      Frontend code        |
|---------------------------|
|  Links against libmudlet  |
+---------------------------+
                |
                v
        +---------------------+
        |  libmudlet (static) |
        |---------------------|
        |    Backend code     |
        +---------------------+

It's worth noting that VLC uses a dynamic library for its core. While a static library has its own pros and cons, it's perfectly feasible for platforms like Android and iOS to link executables with static C++ libraries, so this approach should pose no significant issues for cross-platform development. This binary structure is fundamental to achieving the goal of a decoupled system.

A Small Working Example: Installing Packages

To make this concept more concrete, let's look at a simplified code example involving package management. Imagine you're in the Mudlet package manager and decide to install a package directly from a file.

Previously, a function like slot_installPackageFromFile() in the frontend (e.g., dlgPackageManager.cpp) would directly call a method on a host object:

// Before the change
// mpHost->installPackage(fileName, enums::PackageModuleType::Package);

With the new C API in place, this call would be redirected:

// After the change
mudlet_api_package_manager_install_package_from_file(backend, fileName.toUtf8().constData());

This C function mudlet_api_package_manager_install_package_from_file would then be implemented in the backend, likely in a new backend class designed to handle package management tasks. Here’s how that might look:

// In mudlet_api_package_manager.cpp (backend code)
extern "C" {

void mudlet_api_package_manager_install_package_from_file(void* backend, const char* fileName)
{
    TPackageManager* manager = static_cast<TPackageManager*>(backend);
    manager->installPackageFromFile(fileName);
}

}

And the corresponding backend implementation:

// In TPackageManager.cpp (backend code)
std::pair<bool, QString> TPackageManager::installPackageFromFile(const QString& fileName)
{
    return mpHost->installPackage(fileName, enums::PackageModuleType::Package);
}

Notice how the C API uses void* backend to pass a pointer to the backend object. This is a common pattern in C APIs to allow interaction with specific instances of backend logic. The frontend code remains relatively clean, delegating the actual work to the backend via the C API. The comments highlight the conceptual shift: mpHost will be moved to the backend, and a new dedicated backend class (TPackageManager) will manage these operations. This mirrors the strategy of having a backend equivalent for each frontend class, at least initially.

Handling Asynchronous Operations: A Larger Example

More complex operations, like installing packages from a remote repository, involve asynchronous network requests and error handling. The original code for slot_installPackageFromRepository() handled this using Qt's networking classes and managing states like pendingDownloads, remainingDownloads, and activeReplies. This logic needs to be moved to the backend, and the C API needs to support callbacks for asynchronous operations.

When a package fails to download due to a network error, the frontend needs to inform the user. The C API facilitates this by allowing the frontend to provide callback functions that the backend can invoke. In the proposed solution, functions like onError and onSuccess callbacks are passed to the backend, which then calls them via a callbackDispatcher when the operation completes or fails.

Here’s a glimpse of the modified frontend code:

// In dlgPackageManager.cpp (frontend code)

// ... inside slot_installPackageFromRepository ...

mudlet_api_package_manager_install_package_from_repository(
    backend,
    packageName.toUtf8().constData(),
    c_dispatcher, // A C-compatible dispatcher for callbacks
    new CallbackWrapper{ .callback = onError }, // Callback for errors
    new CallbackWrapper{ .callback = onSuccess }); // Callback for success

The backend implementation would look something like this:

// In mudlet_api_package_manager.cpp (backend code)
extern "C" {
void mudlet_api_package_manager_install_package_from_repository(void* backend, const char* packageName, void (*callbackDispatcher)(void*), void* onError, void* onSuccess)
{
   TPackageManager* manager = static_cast<TPackageManager*>(backend);
   // The actual call to the backend's method, passing the callbacks
   manager->installPackageFromRepository(packageName, callbackDispatcher, onError, onSuccess);
}
}

And within the backend's TPackageManager class, the installPackageFromRepository method would manage the network request and invoke the provided callbacks:

// In TPackageManager.cpp (backend code)
void TPackageManager::installPackageFromRepository(const QString &packageName, void (*callbackDispatcher)(void*), void* onError, void* onSuccess)
{
   // ... network request logic ...
   QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, callbackDispatcher, onError, onSuccess, packageName]() {
       if (reply->error() != QNetworkReply::NoError) {
           callbackDispatcher(onError); // Invoke the error callback
       } else {
           callbackDispatcher(onSuccess); // Invoke the success callback
       }
   });
   // ... other logic ...
}

This callback mechanism is crucial for maintaining a responsive user interface. The frontend can initiate a long-running operation on the backend and be notified when it's done, without blocking the UI thread. The use of void* for callbacks allows passing arbitrary data (like the error or success handlers) to the backend, which then uses the dispatcher to call them.

Performance and Type Safety Considerations

A common concern with architectural changes is performance. However, in this scenario, the performance impact is expected to be negligible. Since the C API functions are called from within the same executable, the overhead is minimal. The calls essentially represent function calls within the same process, not inter-process communication or network requests.

Regarding type safety, the use of void* in the C API might raise eyebrows. While void* can indeed accept any pointer type and bypass compile-time checks for specific types, the plan clarifies that basic C types like int, float, and char* are still fully type-checked at compile time. Furthermore, tools like AddressSanitizer remain fully operational, helping to catch memory-related errors during development. The C API acts as a boundary, and within the C++ backend and frontend, type safety is maintained. The key is to use void* judiciously, primarily for passing opaque pointers to backend objects or for callback mechanisms where the exact type isn't known at the C API level but is managed correctly within the C++ code.

An Incremental Integration Plan

One of the most significant advantages of this proposed architecture is the incremental integration plan. The team can tackle the separation class by class, submitting one pull request (PR) for each frontend class that gets its backend counterpart. This modular approach offers several benefits:

  • Reduced Risk: Changes are small and isolated, making it easier to review, test, and merge.
  • Continuous Delivery: The codebase remains in a working state throughout the integration process, minimizing the risk of introducing major regressions.
  • Maintainability: As classes are moved, the codebase becomes cleaner and more organized, improving long-term maintainability.
  • No Performance Degradation: As discussed, this approach is not expected to negatively impact the app's performance at any stage.

The plan outlines a comprehensive list of classes to be refactored, categorized by their function:

  • Main classes with important business logic (9 classes): These are the core components of Mudlet, like the console, command line, map rendering, and the main window itself.
  • Dialog classes with business logic (18 classes): These include various dialogs for settings, editing, and management tasks.
  • T UI classes with business logic (15 classes):* These are UI elements that have specific behaviors and data handling.
  • Specialized UI classes (6 classes): These are more specific UI components like custom delegates or editors.
  • OpenGL classes (2 classes): Related to map rendering.

Additionally, a few backend files currently contain some frontend code that needs to be relocated. The team estimates that this refactoring will involve approximately 58 new backend classes. This structured approach ensures that all parts of the application are considered for separation.

This detailed plan, inspired by robust projects like libVLC, sets a clear path for Mudlet's future. By embracing a C API for decoupling, Mudlet is positioning itself for greater extensibility, paving the way for new platforms and features. It’s an exciting time for Mudlet development, aiming to make this powerful MUD client even more versatile!

For more information on architectural patterns in software development, you can explore resources from Microsoft's Azure Architecture Center or read about clean architecture principles.