Merge pull request #11 from logos-co/feat/improve-onboarding

feat: improve onboarding
This commit is contained in:
Arnaud 2026-02-20 15:53:18 +04:00 committed by GitHub
commit 30dd14c966
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1524 additions and 406 deletions

3
.gitmodules vendored
View File

@ -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

View File

@ -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
)

View File

@ -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:

1
logos-design-system Submodule

@ -0,0 +1 @@
Subproject commit 596811cbb0a0644322267368e87fab80e34203d8

View File

@ -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

View File

@ -8,6 +8,10 @@
#include <QJsonDocument>
#include <QJsonObject>
#include <QLocale>
#include <QNetworkAccessManager>
#include <QNetworkProxyFactory>
#include <QNetworkReply>
#include <QSslSocket>
// 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<QVariantMap>("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<QVariantList>("announceAddresses");
QList<int> 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");
}
}

View File

@ -1,6 +1,7 @@
#pragma once
#include "logos_api.h"
#include "logos_sdk.h"
#include <QDir>
#include <QFile>
#include <QObject>
#include <QString>
@ -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;
};

View File

@ -54,29 +54,19 @@ QWidget* StorageUIPlugin::createWidget(LogosAPI* logosAPI) {
root->setProperty("backend", QVariant::fromValue(static_cast<QObject*>(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();

12
src/qml/AdvancedIcon.qml Normal file
View File

@ -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
]
}

100
src/qml/AdvancedSetup.qml Normal file
View File

@ -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()
}
}
}
}
}

View File

@ -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

74
src/qml/DotIcon.qml Normal file
View File

@ -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
}
}
}
}
}

125
src/qml/ErrorToast.qml Normal file
View File

@ -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
}
}
}

12
src/qml/GuideIcon.qml Normal file
View File

@ -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
]
}

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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 ""
}
}
}

137
src/qml/ModeSelector.qml Normal file
View File

@ -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
}
}
}

View File

@ -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
}
}
}
}
}

View File

@ -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() {}
}
}

103
src/qml/PortForwarding.qml Normal file
View File

@ -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) {}
}
}

12
src/qml/PortIcon.qml Normal file
View File

@ -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
]
}

View File

@ -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() {}
}
}

View File

@ -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"

12
src/qml/UpnpIcon.qml Normal file
View File

@ -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
]
}

View File

@ -1,6 +1,9 @@
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QtQml>
#include <QQmlDebuggingEnabler>
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();
}

View File

@ -6,6 +6,18 @@
<file alias="StorageView.qml">qml/StorageView.qml</file>
<file alias="LogosTextField.qml">qml/LogosTextField.qml</file>
<file alias="LogosStorageButton.qml">qml/LogosStorageButton.qml</file>
<file alias="LogosStorageLayout.qml">qml/LogosStorageLayout.qml</file>
<file alias="PortForwarding.qml">qml/PortForwarding.qml</file>
<file alias="ErrorToast.qml">qml/ErrorToast.qml</file>
<file alias="HealthIndicator.qml">qml/HealthIndicator.qml</file>
<file alias="ModeSelector.qml">qml/ModeSelector.qml</file>
<file alias="AdvancedSetup.qml">qml/AdvancedSetup.qml</file>
<file alias="DotIcon.qml">qml/DotIcon.qml</file>
<file alias="NodeStatusIcon.qml">qml/NodeStatusIcon.qml</file>
<file alias="GuideIcon.qml">qml/GuideIcon.qml</file>
<file alias="AdvancedIcon.qml">qml/AdvancedIcon.qml</file>
<file alias="UpnpIcon.qml">qml/UpnpIcon.qml</file>
<file alias="PortIcon.qml">qml/PortIcon.qml</file>
<file>icons/storage.png</file>
</qresource>
</RCC>

@ -1 +1 @@
Subproject commit 7dceb6a8dfd5a5bbd47dc9b00e55f80b62ab1e6e
Subproject commit b4ecf7a871233608f63b817eeae426f6273695d9