diff --git a/.gitmodules b/.gitmodules index 1611f4b..0d3934b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,3 +10,6 @@ [submodule "vendor/logos-storage-nim"] path = vendor/logos-storage-nim url = https://github.com/logos-storage/logos-storage-nim +[submodule "logos-design-system"] + path = logos-design-system + url = https://github.com/logos-co/logos-design-system diff --git a/CMakeLists.txt b/CMakeLists.txt index 9970423..791805a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -130,7 +130,7 @@ endif() # Discover the required dependencies. # Without this discovery part, the dependencies cannot be found. # COMPONENTS is kind of generic for Qt modules. -find_package(Qt6 REQUIRED COMPONENTS Core Widgets RemoteObjects Quick QuickWidgets) +find_package(Qt6 REQUIRED COMPONENTS Core Widgets RemoteObjects Quick QuickWidgets Network) # Get the real path to handle symlinks correctly #get_filename_component(REAL_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}" REALPATH) @@ -419,6 +419,7 @@ target_link_libraries(storage_ui PRIVATE Qt6::RemoteObjects Qt6::Quick Qt6::QuickWidgets + Qt6::Network component-interfaces ) diff --git a/README.md b/README.md index b1ccea3..8fe864b 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,41 @@ To restart the onboarding process, simply delete the prefences file and relaunch The application also provides a JSON editor in the debug panel for runtime configuration tweaks. To apply changes, restart the Storage Module. +## Troubleshooting + +### Node has no peers + +**Symptom:** +The node starts successfully but never connects to any peer. + +**Cause:** +Logos Storage uses a discovery port (default `8090`) to announce itself to the DHT and find peers. If this port is already use by another process, the DHT cannot work properly. + +**Fix:** +Ensure that no process is running on `8090` process or change the default port value in the advanced configuration. + +### UPnP not working + +**Symptom:** +You selected UPnP during setup but the node remains unreachable. + +**Cause:** +UPnP relies on your router supporting and enabling the UPnP protocol. Many routers have it disabled by default for security reasons. + +**Fix:** +Make sure UPnP is enabled on your router or switch to port forwarding config. + +### Manual port forwarding + +**Symptom:** +You configure the port forwarding with a TCP port but the node remains unreachable. + +**Cause:** +The port is not open on your router. + +**Fix:** +Make port forwarding is enabled for this port on your router. + #### Nix Organization The nix build system is organized into modular files in the `/nix` directory: diff --git a/logos-design-system b/logos-design-system new file mode 160000 index 0000000..596811c --- /dev/null +++ b/logos-design-system @@ -0,0 +1 @@ +Subproject commit 596811cbb0a0644322267368e87fab80e34203d8 diff --git a/nix/default.nix b/nix/default.nix index cd277d5..b299677 100644 --- a/nix/default.nix +++ b/nix/default.nix @@ -14,9 +14,9 @@ ]; # Common runtime dependencies - buildInputs = [ - pkgs.qt6.qtbase - pkgs.qt6.qtremoteobjects + buildInputs = [ + pkgs.qt6.qtbase + pkgs.qt6.qtremoteobjects pkgs.zstd pkgs.krb5 pkgs.abseil-cpp diff --git a/src/StorageBackend.cpp b/src/StorageBackend.cpp index 592b3f7..494e0dc 100644 --- a/src/StorageBackend.cpp +++ b/src/StorageBackend.cpp @@ -8,6 +8,10 @@ #include #include #include +#include +#include +#include +#include // StorageBackend is responsible for managing the interaction with the storage module. // It is mocked in the QML. @@ -20,6 +24,9 @@ StorageBackend::StorageBackend(LogosAPI* logosAPI, QObject* parent) : QObject(parent), m_status(Destroyed), m_logosAPI(nullptr), m_logos(nullptr) { qDebug() << "Initializing StorageBackend..."; + + // Disable system proxy detection — it crashes in Nix/some Linux environments + QNetworkProxyFactory::setUseSystemConfiguration(false); if (logosAPI) { m_logosAPI = logosAPI; @@ -28,6 +35,8 @@ StorageBackend::StorageBackend(LogosAPI* logosAPI, QObject* parent) } m_logos = new LogosModules(m_logosAPI); + + emit ready(); } StorageBackend::~StorageBackend() @@ -36,21 +45,24 @@ StorageBackend::~StorageBackend() m_logos = nullptr; } -LogosResult StorageBackend::init(const QString& configJson = "{}") { +LogosResult StorageBackend::init(const QString& configJson) { qDebug() << "StorageBackend::initStorage called"; - if (configJson != "{}") { - m_configJson = configJson; + m_config = QJsonDocument::fromJson(configJson.toUtf8()); + if (m_config.isNull()) { + qDebug() << "StorageBackend::initStorage invalid json config" << configJson; + reportError("Failed to create the storage: invalid JSON config"); + return {false, "", "Failed to create the storage, invalid json config"}; } - bool result = m_logos->storage_module.init(m_configJson); + bool result = m_logos->storage_module.init(configJson); qDebug() << "StorageBackend::initStorage: init"; if (!result) { setStatus(Destroyed); - debug("Failed to init storage"); - return {false, "", "Filed to init storage"}; + reportError("Failed to init storage"); + return {false, "", "Failed to init storage"}; } setStatus(Stopped); @@ -63,11 +75,12 @@ LogosResult StorageBackend::init(const QString& configJson = "{}") { setStatus(Stopped); debug("Failed to start Storage module:" + message); emit startFailed(message); + reportError("Failed to start: " + message); } else { setStatus(Running); debug("Storage module started."); - QMetaObject::invokeMethod(this, &StorageBackend::downloadManifests, Qt::QueuedConnection); - QMetaObject::invokeMethod(this, &StorageBackend::space, Qt::QueuedConnection); + // QMetaObject::invokeMethod(this, &StorageBackend::downloadManifests, Qt::QueuedConnection); + // QMetaObject::invokeMethod(this, &StorageBackend::space, Qt::QueuedConnection); emit startCompleted(); } })) { @@ -195,13 +208,8 @@ LogosResult StorageBackend::init(const QString& configJson = "{}") { qWarning() << "StorageWidget: failed to subscribe to storageDownloadProgress events"; } - if (configJson != "{}") { - m_configJson = configJson; - emit configJsonChanged(); - debug("new config is: " + m_configJson); - } - emit initCompleted(); + debug("new config is: " + configJson); return {true, ""}; } @@ -233,6 +241,8 @@ LogosResult StorageBackend::start(const QString& newConfigJson) { setStatus(Starting); debug("Starting Storage module..."); + // TODO trach the start attempts in a file + auto result = m_logos->storage_module.start(); if (!result) { @@ -291,6 +301,11 @@ void StorageBackend::destroy() { QString StorageBackend::debugLogs() const { return m_debugLogs; }; +void StorageBackend::reportError(const QString& message) { + debug(message); + emit error(message); +} + void StorageBackend::debug(const QString& log) { if (!m_debugLogs.isEmpty()) { m_debugLogs += "\n"; @@ -723,12 +738,7 @@ void StorageBackend::space() { static constexpr qint64 DEFAULT_QUOTA = 20LL * 1024 * 1024 * 1024; // 20 GB // Check config for a quota-max-bytes override - qint64 configQuota = 0; - QJsonDocument doc = QJsonDocument::fromJson(m_configJson.toUtf8()); - if (!doc.isNull()) { - configQuota = doc.object().value("quota-max-bytes").toVariant().toLongLong(); - } - + qint64 configQuota = m_config.object().value("quota-max-bytes").toVariant().toLongLong(); qint64 apiQuota = result.getInt("quotaMaxBytes"); m_quotaMaxBytes = apiQuota > 0 ? apiQuota : (configQuota > 0 ? configQuota : DEFAULT_QUOTA); m_quotaUsedBytes = result.getInt("quotaUsedBytes"); @@ -762,30 +772,24 @@ StorageBackend::StorageStatus StorageBackend::status() const { return m_status; QString StorageBackend::cid() const { return m_cid; } -QString StorageBackend::configJson() const { return m_configJson; } - int StorageBackend::uploadProgress() const { return m_uploadProgress; } QString StorageBackend::uploadStatus() const { return m_uploadStatus; } void StorageBackend::reloadIfChanged(const QString& configJson) { - if (configJson == m_configJson) { + QJsonDocument config = QJsonDocument::fromJson(configJson.toUtf8()); + if (config.isNull()) { + debug("Invalid json detected !"); + return; + } + + if (m_config == config) { debug("No change detected in the config"); return; } debug("New config detected"); - QJsonDocument doc = QJsonDocument::fromJson(configJson.toUtf8()); - if (doc.isNull()) { - debug("Invalid json detected !"); - - m_configJson = configJson; - emit configJsonChanged(); - - return; - } - if (m_status == StorageStatus::Running || m_status == StorageStatus::Stopping || m_status == StorageStatus::Starting) { debug("Cannot reload the config while running, stopping or starting..."); @@ -803,105 +807,216 @@ void StorageBackend::reloadIfChanged(const QString& configJson) { } } - bool result = m_logos->storage_module.init(configJson); + LogosResult result = init(configJson); - if (!result) { - debug("Failed to init context with new config, will rollback."); - - bool result = m_logos->storage_module.init(m_configJson); - - if (!result) { - debug("Failed to init context with old config, that's a serious issue."); - } else { - debug("Old config restored"); - setStatus(StorageStatus::Stopped); - - m_configJson = configJson; - emit configJsonChanged(); - } + if (!result.success) { + debug("Failed to init context with new config: " + result.getError()); return; } debug("New config loaded successfully"); - m_configJson = configJson; + m_config = config; setStatus(StorageStatus::Stopped); - emit configJsonChanged(); +} + +void StorageBackend::saveCurrentConfig() { + qDebug() << "StorageBackend::saveUserConfig"; + saveUserConfig(QString::fromUtf8(m_config.toJson(QJsonDocument::Indented))); } void StorageBackend::saveUserConfig(const QString& configJson) { qDebug() << "StorageBackend::saveUserConfig"; - QString configPath = getUserConfigPath(); - QString folderPath = QFileInfo(configPath).absolutePath(); + QString folderPath = QFileInfo(USER_CONFIG_PATH).absolutePath(); QDir().mkpath(folderPath); - QFile file(configPath); + QFile file(USER_CONFIG_PATH); if (file.open(QIODevice::WriteOnly | QIODevice::Text)) { file.write(configJson.toUtf8()); file.close(); - debug("Config saved to " + configPath); + debug("Config saved to " + USER_CONFIG_PATH); } else { - debug("Failed to save config to " + configPath); + debug("Failed to save config to " + USER_CONFIG_PATH); } } -QString StorageBackend::buildConfig(const QString& dataDir, int discPort, int tcpPort) { - debug("StorageBackend::updateBasicConfig called with dataDir=" + dataDir); - - QJsonDocument doc = QJsonDocument::fromJson(m_configJson.toUtf8()); +QJsonDocument StorageBackend::defaultConfig() { + QJsonDocument doc = QJsonDocument(); QJsonObject obj = doc.object(); - obj["data-dir"] = dataDir; - obj["disc-port"] = discPort; - - QJsonArray listenAddrs = {QString("/ip4/0.0.0.0/tcp/%1").arg(tcpPort)}; - obj["listen-addrs"] = listenAddrs; - QJsonArray bootstrapArray; for (const QString& node : BOOTSTRAP_NODES) { bootstrapArray.append(node); } obj["bootstrap-node"] = bootstrapArray; - return QJsonDocument(obj).toJson(QJsonDocument::Indented); + obj["data-dir"] = DEFAULT_DATA_DIR; + + return QJsonDocument(obj); } -QString StorageBackend::buildConfigFromFile(const QString& path) { - qDebug() << "StorageBackend::buildConfigFromFile called"; +void StorageBackend::enableUpnpConfig() { + debug("StorageBackend::enableUpnpConfig called"); - QFile file(path); - if (file.exists() && file.open(QIODevice::ReadOnly | QIODevice::Text)) { - QString configJson = QString::fromUtf8(file.readAll()); + QJsonDocument doc = defaultConfig(); + QJsonObject obj = doc.object(); - debug("StorageUIPlugin: config.json is found, configJson=" + configJson); + obj["nat"] = "upnp"; - return configJson; + reloadIfChanged(QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Indented))); +} + +void StorageBackend::enableNatExtConfig(int tcpPort) { + qDebug() << "StorageBackend::enableNatExtConfig called with tcpPort" << tcpPort; + + QJsonDocument doc = defaultConfig(); + QJsonObject obj = doc.object(); + + QJsonArray listenAddrs = {QString("/ip4/0.0.0.0/tcp/%1").arg(tcpPort)}; + obj["listen-addrs"] = listenAddrs; + + // Fetch the public IP asynchronously so we can set nat=extip:IP in the config. + // If the request fails, we proceed without the IP (node will still start, just without extip NAT). + debug("Retrieving public IP..."); + + QNetworkAccessManager* manager = new QNetworkAccessManager(this); + QNetworkRequest request(ECHO_PROVIDER); + + // Set text/plain to receive only the IP + request.setRawHeader("Accept", "text/plain"); + + QNetworkReply* reply = manager->get(request); + + connect(reply, &QNetworkReply::finished, this, [this, reply, manager, obj]() mutable { + reply->deleteLater(); + manager->deleteLater(); + + if (reply->error() != QNetworkReply::NoError) { + debug("Failed to retrieve public IP: " + reply->errorString() + ". Proceeding without extip NAT."); + } else { + QString ip = QString::fromUtf8(reply->readAll()).trimmed(); + debug("Public IP: " + ip); + obj["nat"] = "extip:" + ip; + } + + reloadIfChanged(QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Compact))); + emit natExtConfigCompleted(); + }); +} + +void StorageBackend::checkNodeIsUp() { + qDebug() << "StorageBackend::checkNodeIsUp called."; + + // First we get the debug info in order to get the peers and + // the announceAddresses + LogosResult result = m_logos->storage_module.debug(); + if (!result.success) { + qDebug() << "Failed to get node debug info: " << result.getError(); + emit nodeIsntUp("Failed to get node debug info: " + result.getError()); + return; } - debug("StorageUIPlugin: Failed to load config.json"); - return "{}"; + // Ensure that the node has at least one peer. + QVariantMap table = result.getValue("table"); + QVariantList nodes = table["nodes"].toList(); + + debug(QString("Connected peers: %1").arg(nodes.size())); + if (nodes.isEmpty()) { + emit nodeIsntUp("No peers connected. " + "Try modifying the discovery port (default 8090) in the advanced settings."); + return; + } + + debug("DHT seems okay, found peers"); + + // Extract TCP ports from announceAddresses. + // Format: "/ip4/1.2.3.4/tcp/PORT" + QVariantList announceAddresses = result.getValue("announceAddresses"); + QList ports; + for (const QVariant& addr : announceAddresses) { + QStringList parts = addr.toString().split("/"); + // "/ip4/1.2.3.4/tcp/8079" splits to ["", "ip4", "1.2.3.4", "tcp", "8079"] + int tcpIndex = parts.indexOf("tcp"); + if (tcpIndex >= 0 && tcpIndex + 1 < parts.size()) { + int port = parts[tcpIndex + 1].toInt(); + if (port > 0 && !ports.contains(port)) { + ports.append(port); + } + } + } + + QString nat = m_config.object()["nat"].toString(); + + if (ports.isEmpty()) { + debug("No TCP ports found in announce addresses, considering node as not up"); + if (nat == "upnp") { + emit nodeIsntUp("UPnP is configured but there is no announced addresses. " + "Try going back and configure port forwarding manually on your router."); + } else { + emit nodeIsntUp("No announced addresses found. Your TCP port is propably incorrect. " + "Try going back and check your port forwarding configuration."); + } + + return; + } + + debug(QString("Checking reachability for %1 port(s)...").arg(ports.size())); + + // Check each port via the echo service, one by one. + bool foundReachable = false; + for (int port : ports) { + QNetworkAccessManager manager; + QNetworkRequest request(QUrl(QString("%1/port/%2").arg(ECHO_PROVIDER).arg(port))); + QNetworkReply* reply = manager.get(request); + + QEventLoop loop; + connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + loop.exec(); + + if (reply->error() == QNetworkReply::NoError) { + bool reachable = QJsonDocument::fromJson(reply->readAll()).object()["reachable"].toBool(); + debug("Port " + QString::number(port) + (reachable ? " is reachable" : " is not reachable")); + if (reachable) { + foundReachable = true; + } + } else { + debug("Port check failed for port " + QString::number(port) + ": " + reply->errorString()); + } + + reply->deleteLater(); + } + + if (foundReachable) { + emit nodeIsUp(); + } else { + if (nat == "upnp") { + emit nodeIsntUp("UPnP is configured but the node is not reachable from the internet. " + "Try going back and configure port forwarding manually on your router."); + } else { + emit nodeIsntUp("No ports are reachable from the internet. " + "Try going back and check your port forwarding configuration."); + } + } } void StorageBackend::status(StorageStatus status) { m_status = status; } -QString StorageBackend::getUserConfigPath() { return QDir::homePath() + "/.logos_storage/config.json"; } +void StorageBackend::loadUserConfig() { + qDebug() << "StorageBackend::loadUserConfig called."; + + QFile file(USER_CONFIG_PATH); + LogosResult result; -QString StorageBackend::getUserConfig() { - QFile file(getUserConfigPath()); if (file.exists() && file.open(QIODevice::ReadOnly | QIODevice::Text)) { - return QString::fromUtf8(file.readAll()); + result = init(QString::fromUtf8(file.readAll())); + } else { + qWarning() << "StorageBackend::loadUserConfig Failed to read the user config file, fallback to default config"; + result = init(QString::fromUtf8(defaultConfig().toJson(QJsonDocument::Indented))); } - return "{}"; -} - -QString StorageBackend::defaultDataDir() { - QString home = QDir::homePath(); -#ifdef Q_OS_WIN - return home + "/AppData/Roaming/Storage"; -#elif defined(Q_OS_MACOS) - return home + "/Library/Application Support/Storage"; -#else - return home + "/.cache/storage"; -#endif + if (!result.success) { + qWarning() << "StorageBackend::loadUserConfig Failed to load the user config: " + result.getError(); + } else { + debug("User config loaded successfully"); + } } diff --git a/src/StorageBackend.h b/src/StorageBackend.h index e46ed89..06f8eab 100644 --- a/src/StorageBackend.h +++ b/src/StorageBackend.h @@ -1,6 +1,7 @@ #pragma once #include "logos_api.h" #include "logos_sdk.h" +#include #include #include #include @@ -10,6 +11,10 @@ static const int RET_OK = 0; static const int RET_PROGRESS = 3; +static const QString ECHO_PROVIDER = "https://echo.codex.storage/"; +static const QString APP_HOME = QDir::homePath() + "/.logos_storage"; +static const QString DEFAULT_DATA_DIR = APP_HOME + "/data"; +static const QString USER_CONFIG_PATH = APP_HOME + "/config.json"; // Add manual SPR from https://spr.codex.storage/devnet static const QStringList BOOTSTRAP_NODES = { @@ -30,7 +35,6 @@ class StorageBackend : public QObject { Q_PROPERTY(QString debugLogs READ debugLogs NOTIFY debugLogsChanged) Q_PROPERTY(StorageStatus status READ status WRITE status NOTIFY statusChanged) Q_PROPERTY(QString cid READ cid NOTIFY cidChanged) - Q_PROPERTY(QString configJson READ configJson NOTIFY configJsonChanged) Q_PROPERTY(int uploadProgress READ uploadProgress NOTIFY uploadProgressChanged) Q_PROPERTY(QString uploadStatus READ uploadStatus NOTIFY uploadStatusChanged) Q_PROPERTY(QVariantList manifests READ manifests NOTIFY manifestsChanged) @@ -39,13 +43,25 @@ class StorageBackend : public QObject { Q_PROPERTY(qint64 quotaReservedBytes READ quotaReservedBytes NOTIFY quotaChanged) public: - enum StorageStatus { Stopped = 0, Starting, Running, Stopping, Destroyed }; + enum StorageStatus { + // Stopped means that the context is created but the module is not started + Stopped = 0, + + Starting, + + // Running means the module is started + Running, + + Stopping, + + // Destroyed means the context is not created (or has been destroyed). + Destroyed + }; Q_ENUM(StorageStatus) QString cid() const; QString debugLogs() const; StorageStatus status() const; - QString configJson() const; int uploadProgress() const; QString uploadStatus() const; QVariantList manifests() const; @@ -53,9 +69,7 @@ class StorageBackend : public QObject { qint64 quotaUsedBytes() const; qint64 quotaReservedBytes() const; - Q_INVOKABLE static QString defaultDataDir(); - static QString getUserConfig(); - static QString getUserConfigPath(); + static QJsonDocument defaultConfig(); explicit StorageBackend(LogosAPI* logosAPI = nullptr, QObject* parent = nullptr); ~StorageBackend(); @@ -82,25 +96,77 @@ class StorageBackend : public QObject { void space(); LogosResult init(const QString& configJson); void updateLogLevel(const QString& logLevel); - void reloadIfChanged(const QString& configJson); void status(StorageStatus status); - QString buildConfig(const QString& dataDir, int discPort, int tcpPort); - QString buildConfigFromFile(const QString& path); + + // Save the user config passed in parameter + // into the user config json. void saveUserConfig(const QString& configJson); + // Save the current config object + // into the user config json. + void saveCurrentConfig(); + + // Load the user config saved previously + void loadUserConfig(); + + // Take a new config json and reload the Storage context + // if the configuration has changed. + // + // This method cannot be used if the Storage Module + // is running, starting or stopping. + // + // If the Storage Module was already created, + // it will be destroyed first. + // + // On success, the status will be set to Stopped. + // + // Emit initCompleted on success. + // Emit initFailed on failure. + void reloadIfChanged(const QString& configJson); + + // Enables the upnp in the config + // and re-create a context with the new configuration + void enableUpnpConfig(); + + // Enables the net external in the config + // and re-create a context with the new configuration + void enableNatExtConfig(int tcpPort); + + // This method will ensure that the node is ready to be used. + // + // 1- Make a call to debug function in the storage module and + // make sure that the node has peer. If not, the UI should suggest + // to modifiy the discovery port (8090) in the advance settings (to come). + // + // 2- Ensure that the tcp port is open to remote connection. If not, + // the UI should suggest to change go back and try another port and double + // check that the port forwarding is enabled on the router. + // + // Emit nodeIsUp() on success + // Emit nodeIsntUp(error) on failure + void checkNodeIsUp(); + signals: + void ready(); void startCompleted(); void startFailed(const QString& error); void statusChanged(); void debugLogsChanged(); void stopCompleted(); void cidChanged(); - void configJsonChanged(); void uploadProgressChanged(); void uploadStatusChanged(); void manifestsChanged(); void quotaChanged(); void initCompleted(); + void natExtConfigCompleted(); + void error(const QString& message); + + // Emitted when the node port is reachable from the internet + void nodeIsUp(); + + // Emitted when the node port is not reachable, with a reason + void nodeIsntUp(const QString& reason); private slots: @@ -108,13 +174,13 @@ class StorageBackend : public QObject { void setStatus(StorageStatus newStatus); void peerConnect(const QString& peerId); void debug(const QString& log); + void reportError(const QString& message); LogosAPI* m_logosAPI; LogosModules* m_logos; StorageStatus m_status; QString m_debugLogs; QString m_cid; - QString m_configJson; int m_uploadProgress = 0; QString m_uploadStatus = ""; qint64 m_uploadTotalBytes = 0; @@ -123,4 +189,5 @@ class StorageBackend : public QObject { qint64 m_quotaMaxBytes = 0; qint64 m_quotaUsedBytes = 0; qint64 m_quotaReservedBytes = 0; + QJsonDocument m_config; }; diff --git a/src/StorageUIPlugin.cpp b/src/StorageUIPlugin.cpp index 09ffcfd..0513c92 100644 --- a/src/StorageUIPlugin.cpp +++ b/src/StorageUIPlugin.cpp @@ -54,29 +54,19 @@ QWidget* StorageUIPlugin::createWidget(LogosAPI* logosAPI) { root->setProperty("backend", QVariant::fromValue(static_cast(backend))); + backend->ready(); + // Storage init is done in the QML // Build config from settings if onboarding was done, otherwise use empty config - QString configJson = StorageBackend::getUserConfig(); - qDebug() << "UserConfig" << StorageBackend::getUserConfigPath(); - qDebug() << "configJson" << configJson; - // if (onboardingCompleted && !dataDir.isEmpty()) { - // configJson = backend->buildConfig(dataDir, discoveryPort, tcpPort); + // QString configJson = StorageBackend::getUserConfig(); + // qDebug() << "UserConfig" << StorageBackend::getUserConfigPath(); + // qDebug() << "configJson" << configJson; + + // LogosResult result = backend->init(configJson); + + // if (!result.success) { + // qWarning() << "StorageUIPlugin: Failed to init backend:" << result.getError(); // } - // config.json overrides everything (dev/debug use) - // QFileInfo info("config.json"); - // if (info.exists() && info.isFile()) { - // qWarning() << "StorageUIPlugin: config.json found — overriding settings config"; - // configJson = backend->buildConfigFromFile("config.json"); - // } - - // qDebug() << "StorageUIPlugin: configJson=" << configJson; - - LogosResult result = backend->init(configJson); - - if (!result.success) { - qWarning() << "StorageUIPlugin: Failed to init backend:" << result.getError(); - } - return quickWidget; } @@ -112,13 +102,13 @@ void StorageUIPlugin::destroyWidget(QWidget* widget) { return; } - if (backend->status() != StorageBackend::StorageStatus::Destroyed) { - qDebug() << "StorageUIPlugin::destroyWidget: backend is not initialised so let's detroy it."; + if (backend->status() == StorageBackend::StorageStatus::Destroyed) { + qDebug() << "StorageUIPlugin::destroyWidget: backend is not initialised so let's delete the widget."; quickWidget->deleteLater(); return; } - if (backend->status() == StorageBackend::StorageStatus::Running) { + if (backend->status() != StorageBackend::StorageStatus::Running) { qDebug() << "StorageUIPlugin::destroyWidget: backend is not running so let's detroy it."; backend->destroy(); diff --git a/src/qml/AdvancedIcon.qml b/src/qml/AdvancedIcon.qml new file mode 100644 index 0000000..b81ea6d --- /dev/null +++ b/src/qml/AdvancedIcon.qml @@ -0,0 +1,12 @@ +import QtQuick + +// X pattern — advanced / expert mode +DotIcon { + pattern: [ + 1, 0, 0, 0, 1, + 0, 1, 0, 1, 0, + 0, 0, 1, 0, 0, + 0, 1, 0, 1, 0, + 1, 0, 0, 0, 1 + ] +} diff --git a/src/qml/AdvancedSetup.qml b/src/qml/AdvancedSetup.qml new file mode 100644 index 0000000..8e23e22 --- /dev/null +++ b/src/qml/AdvancedSetup.qml @@ -0,0 +1,100 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Logos.Theme +import Logos.Controls + +LogosStorageLayout { + id: root + + property var backend: null + + signal back + signal completed + + ColumnLayout { + anchors.fill: parent + anchors.margins: 40 + spacing: Theme.spacing.medium + + LogosText { + text: "Advanced Configuration" + font.pixelSize: Theme.typography.titleText + Layout.alignment: Qt.AlignHCenter + } + + LogosText { + text: "Edit the JSON configuration below, then click Validate." + font.pixelSize: Theme.typography.primaryText + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + // ── JSON editor ────────────────────────────────────────────────── + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: Theme.palette.backgroundElevated + radius: 8 + border.color: jsonArea.isValid ? Theme.palette.borderSecondary : Theme.palette.error + border.width: 1 + + ScrollView { + anchors.fill: parent + anchors.margins: 2 + + TextArea { + id: jsonArea + font.family: "monospace" + font.pixelSize: 12 + color: Theme.palette.text + wrapMode: Text.WrapAnywhere + background: Item {} + + property bool isValid: true + + Component.onCompleted: { + text = (root.backend + && root.backend.configJson) ? root.backend.configJson : "{}" + validate() + } + + function validate() { + try { + JSON.parse(text) + isValid = true + } catch (e) { + isValid = false + } + } + + onTextChanged: validate() + } + } + } + + // ── Buttons ────────────────────────────────────────────────────── + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: Theme.spacing.medium + + LogosStorageButton { + text: "Back" + onClicked: root.back() + } + + LogosStorageButton { + text: "Validate" + variant: "success" + enabled: jsonArea.isValid + onClicked: { + root.backend.saveUserConfig(jsonArea.text) + root.backend.reloadIfChanged(jsonArea.text) + root.completed() + } + } + } + } +} diff --git a/src/qml/CMakeLists.txt b/src/qml/CMakeLists.txt index a3073a4..a96ce58 100644 --- a/src/qml/CMakeLists.txt +++ b/src/qml/CMakeLists.txt @@ -132,6 +132,18 @@ qt_add_qml_module(appqml StartNode.qml LogosTextField.qml LogosStorageButton.qml + LogosStorageLayout.qml + PortForwarding.qml + ErrorToast.qml + HealthIndicator.qml + ModeSelector.qml + AdvancedSetup.qml + DotIcon.qml + NodeStatusIcon.qml + GuideIcon.qml + AdvancedIcon.qml + UpnpIcon.qml + PortIcon.qml ) # Set up QML module directory for runtime diff --git a/src/qml/DotIcon.qml b/src/qml/DotIcon.qml new file mode 100644 index 0000000..de2a32a --- /dev/null +++ b/src/qml/DotIcon.qml @@ -0,0 +1,74 @@ +import QtQuick + +// Generic Nothing OS style dot grid icon. +// Static mode : set `pattern` (flat array of 0/1) and leave `animated: false` +// Animated mode: set `animated: true` — wave expands from center automatically +Item { + id: root + + // Static pattern — flat array of 0/1, row-major + property var pattern: [] + + // Dimensions + property int columns: 5 + property int dotSize: 6 + property int dotSpacing: 4 + + // Appearance + property color dotColor: "white" + property real inactiveOpacity: 0.1 + property real activeOpacity: 0.9 + + // Animation + property bool animated: false + property int animPhase: 0 + + readonly property int rows: Math.max(1, Math.ceil(pattern.length / columns)) + readonly property int count: animated ? columns * columns : pattern.length + + implicitWidth: columns * dotSize + Math.max(0, columns - 1) * dotSpacing + implicitHeight: rows * dotSize + Math.max(0, rows - 1) * dotSpacing + width: implicitWidth + height: implicitHeight + + Timer { + interval: 140 + repeat: true + running: root.animated + onTriggered: root.animPhase = (root.animPhase + 1) % (root.columns * 2) + } + + Grid { + columns: root.columns + spacing: root.dotSpacing + + Repeater { + model: root.count + + Rectangle { + width: root.dotSize + height: root.dotSize + radius: root.dotSize * 0.3 + color: root.dotColor + + opacity: { + if (!root.animated) { + return (index < root.pattern.length && root.pattern[index]) + ? root.activeOpacity : root.inactiveOpacity + } + // Wave from center + const cx = Math.floor(root.columns / 2) + const cy = Math.floor(root.columns / 2) + const col = index % root.columns + const row = Math.floor(index / root.columns) + const d = Math.abs(col - cx) + Math.abs(row - cy) + const wave = root.animPhase % root.columns + const diff = Math.abs(d - wave) + if (diff === 0) return root.activeOpacity + if (diff === 1) return 0.35 + return root.inactiveOpacity + } + } + } + } +} diff --git a/src/qml/ErrorToast.qml b/src/qml/ErrorToast.qml new file mode 100644 index 0000000..0a5c30f --- /dev/null +++ b/src/qml/ErrorToast.qml @@ -0,0 +1,125 @@ +import QtQuick +import QtQuick.Layouts +import Logos.Theme +import Logos.Controls + +Rectangle { + id: root + + property alias title: titleText.text + property alias message: messageText.text + + function show(title, message) { + root.title = title + root.message = message + root.visible = true + slideAnim.restart() + } + + function hide() { + hideAnim.restart() + } + + visible: false + opacity: 0 + width: 500 + radius: Theme.spacing.tiny + color: "#3D2020" + + implicitHeight: content.implicitHeight + Theme.spacing.medium * 2 + + transform: Translate { + id: slideTranslate + y: 20 + } + + ParallelAnimation { + id: hideAnim + + NumberAnimation { + target: slideTranslate + property: "y" + from: 0 + to: 20 + duration: 300 + easing.type: Easing.InCubic + } + + NumberAnimation { + target: root + property: "opacity" + from: 1 + to: 0 + duration: 300 + easing.type: Easing.InCubic + } + + onFinished: root.visible = false + } + + ParallelAnimation { + id: slideAnim + + NumberAnimation { + target: slideTranslate + property: "y" + from: 20 + to: 0 + duration: 500 + easing.type: Easing.OutCubic + } + + NumberAnimation { + target: root + property: "opacity" + from: 0 + to: 1 + duration: 500 + easing.type: Easing.OutCubic + } + } + + // Close button top right + LogosText { + text: "x" + font.pixelSize: Theme.typography.primaryText + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: Theme.spacing.small + z: 1 + color: Theme.palette.text + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.hide() + } + } + + ColumnLayout { + id: content + anchors { + left: parent.left + right: parent.right + verticalCenter: parent.verticalCenter + margins: Theme.spacing.medium + } + spacing: Theme.spacing.tiny + + LogosText { + id: titleText + Layout.fillWidth: true + color: Theme.palette.error + font.pixelSize: Theme.typography.primaryText + font.bold: true + } + + LogosText { + id: messageText + Layout.fillWidth: true + color: Theme.palette.text + font.pixelSize: Theme.typography.primaryText + wrapMode: Text.WordWrap + } + } +} diff --git a/src/qml/GuideIcon.qml b/src/qml/GuideIcon.qml new file mode 100644 index 0000000..b6be23a --- /dev/null +++ b/src/qml/GuideIcon.qml @@ -0,0 +1,12 @@ +import QtQuick + +// Crosshair pattern — step-by-step guide +DotIcon { + pattern: [ + 0, 0, 1, 0, 0, + 0, 0, 1, 0, 0, + 1, 1, 0, 1, 1, + 0, 0, 1, 0, 0, + 0, 0, 1, 0, 0 + ] +} diff --git a/src/qml/HealthIndicator.qml b/src/qml/HealthIndicator.qml new file mode 100644 index 0000000..3f18b7c --- /dev/null +++ b/src/qml/HealthIndicator.qml @@ -0,0 +1,79 @@ +import QtQuick +import Logos.Theme + +Item { + id: root + + property bool nodeIsUp: false + property var backend: mockBackend + readonly property int running: 2 + + Timer { + readonly property int threeMinutes: 180000 + + interval: threeMinutes + repeat: true + running: root.backend.status == root.running + triggeredOnStart: true + onTriggered: root.backend.checkNodeIsUp() + } + + Connections { + target: root.backend + + function onNodeIsUp() { + root.nodeIsUp = true + } + + function onNodeIsntUp(reason) { + root.nodeIsUp = false + } + + function onStatusChanged() { + if (root.backend.status !== root.running) { + root.nodeIsUp = false + } + } + } + + property bool blinkOn: true + + Timer { + interval: 600 + repeat: true + running: true + onTriggered: root.blinkOn = !root.blinkOn + } + + Row { + id: nodeStatusBadge + anchors.top: parent.top + anchors.right: parent.right + anchors.topMargin: 18 + anchors.rightMargin: 20 + spacing: 7 + + Rectangle { + width: 10 + height: 10 + radius: 5 + anchors.verticalCenter: parent.verticalCenter + color: root.nodeIsUp ? Theme.palette.success : Theme.palette.error + opacity: root.blinkOn ? 1.0 : 0.15 + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.nodeIsUp ? "Node reachable" : "Node unreachable" + color: root.nodeIsUp ? Theme.palette.success : Theme.palette.error + font.pixelSize: 12 + } + } + + QtObject { + id: mockBackend + + signal nodeIsUp + signal nodeIsntUp(string reason) + } +} diff --git a/src/qml/LogosStorageButton.qml b/src/qml/LogosStorageButton.qml index 8b5bfb1..90fcddf 100644 --- a/src/qml/LogosStorageButton.qml +++ b/src/qml/LogosStorageButton.qml @@ -6,10 +6,21 @@ Button { id: control padding: Theme.spacing.small + // "default" | "success" + property string variant: "default" + + readonly property bool isSuccess: variant === "success" + contentItem: Text { text: control.text font.pixelSize: Theme.typography.primaryText - color: control.enabled ? Theme.palette.text : Theme.palette.textMuted + color: { + if (!control.enabled) + return Theme.palette.textMuted + if (control.isSuccess) + return Theme.palette.background + return Theme.palette.text + } horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } @@ -18,10 +29,18 @@ Button { color: { if (!control.enabled) return Theme.palette.backgroundElevated + if (control.isSuccess) + return Theme.palette.success return Theme.palette.backgroundSecondary } border.width: 1 - border.color: Theme.palette.border + border.color: { + if (!control.enabled) + return Theme.palette.border + if (control.isSuccess) + return Theme.palette.success + return Theme.palette.border + } radius: Theme.spacing.tiny Behavior on color { diff --git a/src/qml/LogosStorageLayout.qml b/src/qml/LogosStorageLayout.qml new file mode 100644 index 0000000..0ca2ac8 --- /dev/null +++ b/src/qml/LogosStorageLayout.qml @@ -0,0 +1,12 @@ +import QtQuick +import QtQuick.Layouts +import Logos.Theme + +Rectangle { + id: root + color: Theme.palette.background + Layout.fillWidth: true + Layout.fillHeight: true + implicitWidth: 600 + implicitHeight: 400 +} diff --git a/src/qml/Main.qml b/src/qml/Main.qml index 1a13bf0..a5c0a31 100644 --- a/src/qml/Main.qml +++ b/src/qml/Main.qml @@ -2,139 +2,212 @@ import QtQuick import QtQuick.Controls import QtCore import Logos.Theme -import Logos.Controls // qmllint disable unqualified + +// Application flow overview: +// On startup, the onboarding screen is shown by default. +// If the storage backend emits a ready event and onboarding is already +// complete, the onboarding screen is immediately replaced by the +// storageComponent. +// Onboarding offers two choices: +// 1. UPnP : the user proceeds directly to the startNodeComponent. +// 2. Port forwarding : the user selects a TCP port before proceeding +// to the startNodeComponent. +// The startNodeComponent waits for the node to start and verifies that +// it is reachable. If the node is unreachable, the user is prompted to +// edit the configuration. Once reachable, clicking "Next" marks +// onboarding as complete. Item { id: root - implicitWidth: 600 - implicitHeight: 400 + implicitWidth: 800 + implicitHeight: 800 property var backend: mockBackend - // Timer { - // readonly property int running: 2 + Connections { + target: root.backend - // id: timer - // interval: 2000 - // repeat: false - // onTriggered: { - // console.log("timer triggered") - // // root.backend.status = running - // // root.backend.startCompleted() - // // console.info(root.backend.status) - // } - // } - QtObject { - id: mockBackend + // The node is stopped during the onboarding + // when the user try to change his settings + // and click on "Back", + // In that case, we pop the navigation after + // the node is stopped. + // function onStopCompleted() { + // if (!settings.onboardingCompleted) { - property int status + // // stackView.pop() + // } + // } - signal startCompleted - signal startFailed - signal stopCompleted - - function updateBasicConfig(dataDir, discPort) { - console.log("updateBasicConfig", dataDir, discPort) + // When the onboarding is completed, + // the user should have a config save in his + // home folder. + // After the config is loaded, the node will be + // started and the storeComponent will replace + // the stackView item immediatly. + function onReady() { + if (settings.onboardingCompleted) { + root.backend.loadUserConfig() + stackView.replace(storageComponent, StackView.Immediate) + } } - function start() { - // timer.start() - console.log("mock start callde") + // If there is any error, display it in a toast view + function onError(message) { + errorToast.show("Error", message) } - - function stop() { - root.backend.stopCompleted() - } - - function defaultDataDir() { - return ".cache/storage" - } - - function buildConfig() {} - - function reloadIfChanged() {} - - function init() {} } Settings { id: settings category: "Storage" - property int discoveryPort: 8090 - property int tcpPort: 0 - property string dataDir: "" property bool onboardingCompleted: false } StackView { id: stackView anchors.fill: parent - initialItem: onboarding + initialItem: modeSelectorComponent } Component { - id: onboarding + id: modeSelectorComponent - OnBoarding { - id: onboardingInstance - backend: root.backend - discoveryPort: settings.discoveryPort - tcpPort: settings.tcpPort - dataDir: settings.dataDir.length > 0 ? settings.dataDir : root.backend.defaultDataDir() - - onCompleted: { - settings.discoveryPort = discoveryPort - settings.dataDir = dataDir - settings.tcpPort = tcpPort - settings.onboardingCompleted = true - - let config = root.backend.buildConfig(dataDir, - discoveryPort, tcpPort) - root.backend.saveUserConfig(config) - root.backend.reloadIfChanged(config) - root.backend.start() - - stackView.push(startNodeView) + ModeSelector { + onCompleted: function (isGuide) { + if (isGuide) { + stackView.push(onboardingComponent) + } else { + stackView.push(advancedSetupComponent) + } } } } Component { - id: storageView + id: onboardingComponent + + OnBoarding { + backend: root.backend + + onBack: stackView.pop() + + onCompleted: function (upnpEnabled) { + if (upnpEnabled) { + stackView.push(startNodeComponent) + } else { + stackView.push(portForwardingComponent) + } + } + } + } + + Component { + id: advancedSetupComponent + + AdvancedSetup { + backend: root.backend + + onBack: stackView.pop() + + onCompleted: function () { + settings.onboardingCompleted = true + stackView.replace(storageComponent, StackView.Immediate) + } + } + } + + Component { + id: storageComponent + StorageView { backend: root.backend } } Component { - id: startNodeView + id: startNodeComponent StartNode { backend: root.backend onBack: { - root.backend.stop() + stackView.pop() } + onNext: { - stackView.push(storageView) + settings.onboardingCompleted = true + stackView.push(storageComponent) } } } - Connections { - target: root.backend + Component { + id: portForwardingComponent - function onStopCompleted() { - stackView.pop() + PortForwarding { + backend: root.backend + loading: false + + onBack: { + stackView.pop() + } + + onCompleted: function () { + stackView.push(startNodeComponent) + } + } + } + + ErrorToast { + id: errorToast + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: Theme.spacing.medium + } + + QtObject { + id: mockBackend + + readonly property bool isMock: true + property int status + + signal startCompleted + signal startFailed + signal stopCompleted + signal initCompleted + signal ready + signal error + signal natExtConfigCompleted + signal nodeIsUp + signal nodeIsntUp + + function start() { + console.log("mock start called") } - function onInitCompleted() { - if (settings.onboardingCompleted) { - root.backend.start() - stackView.replace(storageView, StackView.Immediate) - } + function saveUserConfig() {} + + function loadUserConfig() {} + + function reloadIfChanged() {} + + function enableUpnpConfig() {} + + function enableNatExtConfig() { + natExtConfigCompleted() + } + + function saveCurrentConfig() {} + + function stop() {} + + function checkNodeIsUp() {} + + function guessResolution() { + return "" } } } diff --git a/src/qml/ModeSelector.qml b/src/qml/ModeSelector.qml new file mode 100644 index 0000000..eaac2c9 --- /dev/null +++ b/src/qml/ModeSelector.qml @@ -0,0 +1,137 @@ +import QtQuick +import QtQuick.Layouts +import Logos.Theme +import Logos.Controls + +LogosStorageLayout { + id: root + + signal completed(bool isGuide) + + property int selectedMode: -1 + + ColumnLayout { + anchors.centerIn: parent + spacing: Theme.spacing.medium + width: 430 + + LogosText { + text: "Logos Storage" + font.pixelSize: Theme.typography.titleText + Layout.alignment: Qt.AlignHCenter + } + + LogosText { + text: "How would you like to set up your node?" + font.pixelSize: Theme.typography.primaryText + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + Item { height: Theme.spacing.medium } + + Row { + spacing: Theme.spacing.medium + Layout.alignment: Qt.AlignHCenter + + // ── Guide card ─────────────────────────────────────────────── + Rectangle { + width: 190 + height: 230 + radius: 14 + color: root.selectedMode === 0 ? Theme.palette.overlayLight : "transparent" + border.color: root.selectedMode === 0 ? Theme.palette.text : Theme.palette.borderTertiaryMuted + border.width: root.selectedMode === 0 ? 2 : 1 + + ColumnLayout { + anchors.centerIn: parent + spacing: 14 + + GuideIcon { + dotColor: Theme.palette.text + Layout.alignment: Qt.AlignHCenter + } + + Text { + text: "Guide" + color: Theme.palette.text + font.pixelSize: 16 + font.bold: true + Layout.alignment: Qt.AlignHCenter + } + + Text { + text: "Step-by-step setup.\nRecommended for\nmost users." + color: Theme.palette.textSecondary + font.pixelSize: 12 + horizontalAlignment: Text.AlignHCenter + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: 150 + wrapMode: Text.WordWrap + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.selectedMode = 0 + } + } + + // ── Advanced card ──────────────────────────────────────────── + Rectangle { + width: 190 + height: 230 + radius: 14 + color: root.selectedMode === 1 ? Theme.palette.overlayLight : "transparent" + border.color: root.selectedMode === 1 ? Theme.palette.text : Theme.palette.borderTertiaryMuted + border.width: root.selectedMode === 1 ? 2 : 1 + + ColumnLayout { + anchors.centerIn: parent + spacing: 14 + + AdvancedIcon { + dotColor: Theme.palette.text + Layout.alignment: Qt.AlignHCenter + } + + Text { + text: "Advanced" + color: Theme.palette.text + font.pixelSize: 16 + font.bold: true + Layout.alignment: Qt.AlignHCenter + } + + Text { + text: "Manual JSON\nconfiguration for\nexperienced users." + color: Theme.palette.textSecondary + font.pixelSize: 12 + horizontalAlignment: Text.AlignHCenter + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: 150 + wrapMode: Text.WordWrap + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.selectedMode = 1 + } + } + } + + Item { height: Theme.spacing.small } + + LogosStorageButton { + text: "Continue" + enabled: root.selectedMode !== -1 + onClicked: root.completed(root.selectedMode === 0) + Layout.alignment: Qt.AlignHCenter + } + } +} diff --git a/src/qml/NodeStatusIcon.qml b/src/qml/NodeStatusIcon.qml new file mode 100644 index 0000000..8c42999 --- /dev/null +++ b/src/qml/NodeStatusIcon.qml @@ -0,0 +1,71 @@ +import QtQuick +import Logos.Theme + +// 7x7 animated dot grid for the StartNode screen. +// States: +// starting=true → white wave expanding from center +// starting=false, success → all dots green (Theme.palette.success) +// starting=false, !success → red X pattern (Theme.palette.error) +Item { + id: root + + property bool starting: true + property bool success: false + property int animPhase: 0 + + readonly property int columns: 7 + readonly property int dotSize: 8 + readonly property int dotSpacing: 5 + + implicitWidth: columns * dotSize + (columns - 1) * dotSpacing + implicitHeight: columns * dotSize + (columns - 1) * dotSpacing + width: implicitWidth + height: implicitHeight + + Timer { + interval: 120 + repeat: true + running: root.starting + onTriggered: root.animPhase = (root.animPhase + 1) % 14 + } + + Grid { + columns: root.columns + spacing: root.dotSpacing + + Repeater { + model: root.columns * root.columns + + Rectangle { + width: root.dotSize + height: root.dotSize + radius: root.dotSize * 0.25 + + color: { + if (root.success) return Theme.palette.success + if (!root.starting) return Theme.palette.error + return Theme.palette.text + } + + opacity: { + const col = index % root.columns + const row = Math.floor(index / root.columns) + const d = Math.abs(col - 3) + Math.abs(row - 3) + + if (root.starting) { + const wave = root.animPhase % root.columns + const diff = Math.abs(d - wave) + if (diff === 0) return 0.9 + if (diff === 1) return 0.35 + return 0.1 + } + + if (root.success) return 0.85 + + // Error — X pattern + return (col === row || col + row === 6) ? 0.9 : 0.1 + } + } + } + } +} diff --git a/src/qml/OnBoarding.qml b/src/qml/OnBoarding.qml index c60464c..4843c89 100644 --- a/src/qml/OnBoarding.qml +++ b/src/qml/OnBoarding.qml @@ -1,145 +1,160 @@ import QtQuick -import QtQuick.Controls -import QtQuick.Dialogs import QtQuick.Layouts import Logos.Theme import Logos.Controls -Rectangle { +LogosStorageLayout { id: root - color: Theme.palette.background - Layout.fillWidth: true - Layout.fillHeight: true - implicitWidth: 600 - implicitHeight: 400 - property int discoveryPort: 8090 - property int tcpPort: 0 property var backend: mockBackend - property string dataDir: backend.defaultDataDir() - signal completed - QtObject { - id: mockBackend + signal back + signal completed(bool upnpEnabled) - function defaultDataDir() { - return ".cache/storage" - } - } + property int selectedMode: -1 ColumnLayout { anchors.centerIn: parent spacing: Theme.spacing.medium - width: 400 + width: 430 LogosText { - id: titleText + text: "Network Configuration" font.pixelSize: Theme.typography.titleText - text: "Logos Storage" - // anchors.verticalCenter: parent.verticalCenter + Layout.alignment: Qt.AlignHCenter } - ColumnLayout { - id: discoveryPortColumn - spacing: Theme.spacing.tiny + LogosText { + text: "How is your network configured?" + font.pixelSize: Theme.typography.primaryText + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap Layout.fillWidth: true + } - LogosText { - text: "Discovery port" - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.text + Item { height: Theme.spacing.medium } + + Row { + spacing: Theme.spacing.medium + Layout.alignment: Qt.AlignHCenter + + // ── UPnP card ──────────────────────────────────────────────── + Rectangle { + width: 190 + height: 230 + radius: 14 + color: root.selectedMode === 0 ? Theme.palette.overlayLight : "transparent" + border.color: root.selectedMode === 0 ? Theme.palette.text : Theme.palette.borderTertiaryMuted + border.width: root.selectedMode === 0 ? 2 : 1 + + ColumnLayout { + anchors.centerIn: parent + spacing: 14 + + UpnpIcon { + dotColor: Theme.palette.text + Layout.alignment: Qt.AlignHCenter + } + + Text { + text: "UPnP" + color: Theme.palette.text + font.pixelSize: 16 + font.bold: true + Layout.alignment: Qt.AlignHCenter + } + + Text { + text: "Automatic port\nforwarding via\nUPnP router." + color: Theme.palette.textSecondary + font.pixelSize: 12 + horizontalAlignment: Text.AlignHCenter + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: 150 + wrapMode: Text.WordWrap + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.selectedMode = 0 + } } - LogosTextField { - isValid: acceptableInput && text.length > 0 - id: discoveryPortTextField - placeholderText: "Enter the discovery port" - text: root.discoveryPort - validator: IntValidator { - bottom: 1 - top: 65535 - } - onTextChanged: { - if (isValid) { - root.discoveryPort = parseInt(text) + // ── Port Forwarding card ───────────────────────────────────── + Rectangle { + width: 190 + height: 230 + radius: 14 + color: root.selectedMode === 1 ? Theme.palette.overlayLight : "transparent" + border.color: root.selectedMode === 1 ? Theme.palette.text : Theme.palette.borderTertiaryMuted + border.width: root.selectedMode === 1 ? 2 : 1 + + ColumnLayout { + anchors.centerIn: parent + spacing: 14 + + PortIcon { + dotColor: Theme.palette.text + Layout.alignment: Qt.AlignHCenter } + + Text { + text: "Port Forwarding" + color: Theme.palette.text + font.pixelSize: 16 + font.bold: true + Layout.alignment: Qt.AlignHCenter + } + + Text { + text: "Manual TCP port\nconfiguration on\nyour router." + color: Theme.palette.textSecondary + font.pixelSize: 12 + horizontalAlignment: Text.AlignHCenter + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: 150 + wrapMode: Text.WordWrap + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.selectedMode = 1 } } } - ColumnLayout { - id: tcpPortColumn - spacing: Theme.spacing.tiny - Layout.fillWidth: true + Item { height: Theme.spacing.small } - LogosText { - text: "TCP port" - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.text + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: Theme.spacing.medium + + LogosStorageButton { + text: "Back" + onClicked: root.back() } - LogosTextField { - isValid: acceptableInput && text.length > 0 - id: tcpPortTextField - placeholderText: "Enter the TCP port" - text: root.tcpPort - validator: IntValidator { - bottom: 0 - top: 65535 - } - onTextChanged: { - if (isValid) { - root.tcpPort = parseInt(text) + LogosStorageButton { + text: "Continue" + enabled: root.selectedMode !== -1 + onClicked: { + if (root.selectedMode === 0) { + root.backend.enableUpnpConfig() } - } - } - } - - ColumnLayout { - spacing: Theme.spacing.tiny - Layout.fillWidth: true - - LogosText { - text: "Data dir" - } - - RowLayout { - spacing: Theme.spacing.tiny - - LogosTextField { - isValid: text.trim().length > 0 - id: dataDirTextField - placeholderText: "Enter the data dir" - text: root.dataDir - Layout.fillWidth: true - onTextChanged: { - root.dataDir = text - } - } - - LogosStorageButton { - text: "Choose" - onClicked: folderDialog.open() - } - } - - FolderDialog { - id: folderDialog - onAccepted: { - dataDirTextField.text = selectedFolder + root.completed(root.selectedMode === 0) } } } } - LogosStorageButton { - text: "Next" - anchors.bottom: parent.bottom - anchors.right: parent.right - anchors.bottomMargin: 10 - anchors.rightMargin: 10 - enabled: discoveryPortTextField.acceptableInput - && tcpPortTextField.acceptableInput && dataDirTextField.isValid - onClicked: root.completed() + QtObject { + id: mockBackend + + function enableUpnpConfig() {} } } diff --git a/src/qml/PortForwarding.qml b/src/qml/PortForwarding.qml new file mode 100644 index 0000000..26bc48b --- /dev/null +++ b/src/qml/PortForwarding.qml @@ -0,0 +1,103 @@ +import QtQuick +import QtQuick.Layouts +import Logos.Theme +import Logos.Controls + +LogosStorageLayout { + id: root + + property var tcpPort: 0 + property bool loading: false + property var backend: mockBackend + + signal back + signal completed(int port) + + Connections { + target: root.backend + + function onNatExtConfigCompleted() { + root.loading = false + root.completed(root.tcpPort) + } + } + + ColumnLayout { + anchors.centerIn: parent + spacing: Theme.spacing.medium + width: 400 + + PortIcon { + animated: root.loading + dotColor: Theme.palette.text + Layout.alignment: Qt.AlignHCenter + } + + LogosText { + font.pixelSize: Theme.typography.titleText + text: "Port Configuration" + Layout.alignment: Qt.AlignHCenter + } + + LogosText { + font.pixelSize: Theme.typography.primaryText + text: "The TCP port must be open to connect with remote peers." + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + LogosTextField { + Layout.fillWidth: true + id: tcpPortTextField + placeholderText: "Enter the TCP port" + text: root.tcpPort + enabled: !root.loading + isValid: { + const val = parseInt(text) + return !isNaN(val) && val >= 0 && val <= 65535 + } + onTextChanged: { + const val = parseInt(text) + if (!isNaN(val) && val >= 0 && val <= 65535) { + root.tcpPort = val + } + } + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: Theme.spacing.small + + LogosStorageButton { + text: "Back" + enabled: !root.loading + onClicked: root.back() + } + + LogosStorageButton { + text: "Next" + enabled: !root.loading && tcpPortTextField.isValid + onClicked: { + root.loading = true + root.backend.enableNatExtConfig(root.tcpPort) + } + } + } + + LogosText { + font.pixelSize: Theme.typography.primaryText + text: "Retrieving your public IP..." + color: Theme.palette.textTertiary + visible: root.loading + Layout.alignment: Qt.AlignHCenter + } + } + + QtObject { + id: mockBackend + + function enableNatExtConfig(port) {} + } +} diff --git a/src/qml/PortIcon.qml b/src/qml/PortIcon.qml new file mode 100644 index 0000000..0a3cafb --- /dev/null +++ b/src/qml/PortIcon.qml @@ -0,0 +1,12 @@ +import QtQuick + +// Right arrow pattern — manual port forwarding +DotIcon { + pattern: [ + 0, 0, 1, 0, 0, + 0, 0, 0, 1, 0, + 1, 1, 1, 1, 1, + 0, 0, 0, 1, 0, + 0, 0, 1, 0, 0 + ] +} diff --git a/src/qml/StartNode.qml b/src/qml/StartNode.qml index f218c97..7c466fb 100644 --- a/src/qml/StartNode.qml +++ b/src/qml/StartNode.qml @@ -1,57 +1,100 @@ import QtQuick import QtQuick.Layouts +import QtQuick.Controls import Logos.Controls import Logos.Theme -Rectangle { +LogosStorageLayout { id: root - color: Theme.palette.background - Layout.fillWidth: true - Layout.fillHeight: true - implicitWidth: 600 - implicitHeight: 400 - property var backend + property var backend: mockBackend property string status: "" + property string title: "Starting your node" + property string resolution: "" property bool starting: true property bool success: false signal back signal next + function onNodeStarted() { + root.starting = false + root.status = "Your node is up and reachable." + root.title = "Node is ready" + root.success = true + } + + Component.onCompleted: root.backend.start() + + Timer { + id: nodeCheckTimer + interval: 500 + repeat: false + onTriggered: root.backend.checkNodeIsUp() + } + Connections { target: root.backend function onStartCompleted() { - console.log("onStartCompleted received") - root.starting = false - root.status = "Logos Storage started successfully." - root.success = true + root.title = "Checking connectivity" + root.status = "Node started, verifying reachability..." + nodeCheckTimer.start() } function onStartFailed(error) { - console.log("onStartFailed received") root.starting = false - root.status = "Failed to start: " + error + root.title = "Failed to start" + root.status = "Your node failed to start: " + error + } + + function onNodeIsUp() { + root.onNodeStarted() + } + + function onNodeIsntUp(reason) { + root.starting = false + root.title = "Node unreachable" + root.status = "" + root.resolution = reason } } ColumnLayout { anchors.centerIn: parent - spacing: Theme.spacing.medium width: 400 + spacing: Theme.spacing.medium LogosText { - id: titleText font.pixelSize: Theme.typography.titleText - text: "Starting your node...." + text: root.title + Layout.alignment: Qt.AlignHCenter + } + + NodeStatusIcon { + starting: root.starting + success: root.success Layout.alignment: Qt.AlignHCenter } LogosText { - id: statusText font.pixelSize: Theme.typography.primaryText text: root.status + visible: root.status !== "" + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + LogosText { + font.pixelSize: Theme.typography.primaryText + text: root.resolution + visible: root.resolution !== "" + color: Theme.palette.error + wrapMode: Text.WordWrap + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter Layout.alignment: Qt.AlignHCenter } } @@ -62,8 +105,11 @@ Rectangle { anchors.bottomMargin: 10 anchors.leftMargin: 10 text: "Back" - onClicked: root.back() - enabled: root.starting == false + enabled: !root.starting + onClicked: { + root.backend.stop() + root.back() + } } LogosStorageButton { @@ -72,7 +118,33 @@ Rectangle { anchors.bottomMargin: 10 anchors.rightMargin: 10 text: "Next" - onClicked: root.next() - enabled: root.success == true + enabled: root.success + onClicked: { + root.backend.saveCurrentConfig() + root.next() + } + } + + Timer { + interval: 2000 + running: root.backend && root.backend.isMock === true + repeat: false + onTriggered: root.onNodeStarted() + } + + QtObject { + id: mockBackend + + readonly property bool isMock: true + + signal startCompleted + signal startFailed(string error) + signal nodeIsUp + signal nodeIsntUp(string reason) + + function checkNodeIsUp() {} + function stop() {} + function saveCurrentConfig() {} + function start() {} } } diff --git a/src/qml/StorageView.qml b/src/qml/StorageView.qml index f7cebda..78a55a0 100644 --- a/src/qml/StorageView.qml +++ b/src/qml/StorageView.qml @@ -4,13 +4,9 @@ import QtQuick.Dialogs import QtQuick.Layouts import QtCore -Rectangle { +// qmllint disable unqualified +LogosStorageLayout { id: root - Layout.fillWidth: true - Layout.fillHeight: true - implicitWidth: 600 - implicitHeight: 600 - color: "#000000" property var backend: mockBackend readonly property int stopped: 0 @@ -57,15 +53,20 @@ Rectangle { return backend.status == running } + Component.onCompleted: root.backend.start() + QtObject { id: mockBackend property var status: root.stopped property var debugLogs: "Hello !" property var configJson: "{}" - property url cid: "" property string uploadStatus: "" property int uploadProgress: 0 + property var manifests: [] + property var quotaMaxBytes: 20 * 1024 * 1024 * 1024 // 20 GB default + property var quotaUsedBytes: 0 + property var quotaReservedBytes: 0 function start(newConfigJson) { status = root.running @@ -74,63 +75,6 @@ Rectangle { function stop() { status = root.stopped } - - function tryPeerConnect(peerId) { - console.log("Attempting peer connection...") - } - - function tryDebug() { - console.log("Attempting peer connection...") - } - - function spr() {} - - function showPeerId() {} - - function version() {} - - function dataDir() {} - - function tryUploadFinalize() { - console.log("Attempting upload finalize") - } - - function tryUploadFile(file) { - console.log("Attempting upload file") - } - - function tryDownloadFile(cid, file) { - console.log("Attempting download a file", cid, file) - } - - function exists(cid) { - console.log("Attempting exists", cid) - } - - function fetch(cid) { - console.log("Attempting fetch", cid) - } - - function remove(cid) { - console.log("Attempting remove", cid) - } - - function downloadManifest(cid) { - console.log("Attempting downloadManifest", cid) - } - - function downloadManifests() { - console.log("Attempting downloadManifests") - } - - function space() {} - - function updateLogLevel(logLevel) {} - - property var manifests: [] - property var quotaMaxBytes: 20 * 1024 * 1024 * 1024 // 20 GB default - property var quotaUsedBytes: 0 - property var quotaReservedBytes: 0 } function formatBytes(bytes) { @@ -145,6 +89,11 @@ Rectangle { return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB" } + HealthIndicator { + backend: root.backend + anchors.fill: parent + } + Text { id: statusTextElement objectName: "status" @@ -157,13 +106,15 @@ Rectangle { } Button { + property var isStopped: root.backend.status == root.stopped + id: startStopButton objectName: "startStopButton" anchors.leftMargin: 50 text: root.startStopText() enabled: root.canStartStop() - onClicked: root.backend.status == root.stopped ? root.backend.start( - jsonEditor.text) : root.backend.stop() + onClicked: isStopped ? root.backend.start( + jsonEditor.text) : root.backend.stop() anchors.horizontalCenter: parent.horizontalCenter anchors.top: statusTextElement.bottom anchors.topMargin: 10 @@ -216,7 +167,7 @@ Rectangle { implicitHeight: 6 Rectangle { - width: parent.width * parent.parent.visualPosition + width: parent.width * control.visualPosition height: parent.height radius: 3 color: "#4CAF50" diff --git a/src/qml/UpnpIcon.qml b/src/qml/UpnpIcon.qml new file mode 100644 index 0000000..f59e08c --- /dev/null +++ b/src/qml/UpnpIcon.qml @@ -0,0 +1,12 @@ +import QtQuick + +// Diamond / network pattern — UPnP automatic port forwarding +DotIcon { + pattern: [ + 0, 0, 1, 0, 0, + 0, 1, 0, 1, 0, + 1, 0, 1, 0, 1, + 0, 1, 0, 1, 0, + 0, 0, 1, 0, 0 + ] +} diff --git a/src/qml/main.cpp b/src/qml/main.cpp index 1a59950..6c9f90a 100644 --- a/src/qml/main.cpp +++ b/src/qml/main.cpp @@ -1,6 +1,9 @@ #include #include #include +#include + +static QQmlTriviallyDestructibleDebuggingEnabler enabler; int main(int argc, char* argv[]) { QGuiApplication app(argc, argv); @@ -16,7 +19,7 @@ int main(int argc, char* argv[]) { Qt::QueuedConnection); engine.addImportPath(QCoreApplication::applicationDirPath() + "/qml"); - engine.loadFromModule("StorageBackend", "OnBoarding"); + engine.loadFromModule("StorageBackend", "Main"); return app.exec(); } diff --git a/src/storage_resources.qrc b/src/storage_resources.qrc index a5196b8..1ad838e 100644 --- a/src/storage_resources.qrc +++ b/src/storage_resources.qrc @@ -6,6 +6,18 @@ qml/StorageView.qml qml/LogosTextField.qml qml/LogosStorageButton.qml + qml/LogosStorageLayout.qml + qml/PortForwarding.qml + qml/ErrorToast.qml + qml/HealthIndicator.qml + qml/ModeSelector.qml + qml/AdvancedSetup.qml + qml/DotIcon.qml + qml/NodeStatusIcon.qml + qml/GuideIcon.qml + qml/AdvancedIcon.qml + qml/UpnpIcon.qml + qml/PortIcon.qml icons/storage.png diff --git a/vendor/logos-storage-module b/vendor/logos-storage-module index 7dceb6a..b4ecf7a 160000 --- a/vendor/logos-storage-module +++ b/vendor/logos-storage-module @@ -1 +1 @@ -Subproject commit 7dceb6a8dfd5a5bbd47dc9b00e55f80b62ab1e6e +Subproject commit b4ecf7a871233608f63b817eeae426f6273695d9