diff --git a/client/core/configurators/xrayConfigurator.cpp b/client/core/configurators/xrayConfigurator.cpp index b525c8991..1d6e2240a 100644 --- a/client/core/configurators/xrayConfigurator.cpp +++ b/client/core/configurators/xrayConfigurator.cpp @@ -20,14 +20,123 @@ #include "core/models/protocols/xrayProtocolConfig.h" namespace { -Logger logger("XrayConfigurator"); -} + Logger logger("XrayConfigurator"); + + QString normalizeXhttpMode(const QString &m) { + const QString t = m.trimmed(); + if (t.isEmpty() || t.compare(QLatin1String("Auto"), Qt::CaseInsensitive) == 0) { + return QStringLiteral("auto"); + } + if (t.compare(QLatin1String("Packet-up"), Qt::CaseInsensitive) == 0) + return QStringLiteral("packet-up"); + if (t.compare(QLatin1String("Stream-up"), Qt::CaseInsensitive) == 0) + return QStringLiteral("stream-up"); + if (t.compare(QLatin1String("Stream-one"), Qt::CaseInsensitive) == 0) + return QStringLiteral("stream-one"); + return t.toLower(); + } + + // Xray-core: empty → path; "None" in UI → omit (core default path) + QString normalizeSessionSeqPlacement(const QString &p) + { + if (p.isEmpty() || p.compare(QLatin1String("None"), Qt::CaseInsensitive) == 0) + return {}; + return p.toLower(); + } + + QString normalizeUplinkDataPlacement(const QString &p) + { + if (p.isEmpty() || p.compare(QLatin1String("Body"), Qt::CaseInsensitive) == 0) + return QStringLiteral("body"); + if (p.compare(QLatin1String("Auto"), Qt::CaseInsensitive) == 0) + return QStringLiteral("auto"); + if (p.compare(QLatin1String("Query"), Qt::CaseInsensitive) == 0) + // "Query" is not valid for uplink payload in splithttp; closest documented mode + return QStringLiteral("header"); + return p.toLower(); + } + + // splithttp: cookie | header | query | queryInHeader (not "body") + QString normalizeXPaddingPlacement(const QString &p) + { + QString t = p.trimmed(); + if (t.isEmpty()) + return QString::fromLatin1(amnezia::protocols::xray::defaultXPaddingPlacement).toLower(); + if (t.compare(QLatin1String("Body"), Qt::CaseInsensitive) == 0) + return QStringLiteral("queryInHeader"); + if (t.contains(QLatin1String("queryInHeader"), Qt::CaseInsensitive) + || t.compare(QLatin1String("Query in header"), Qt::CaseInsensitive) == 0) + return QStringLiteral("queryInHeader"); + return t.toLower(); + } + + // splithttp: repeat-x | tokenish + QString normalizeXPaddingMethod(const QString &m) + { + QString t = m.trimmed(); + if (t.isEmpty() || t.compare(QLatin1String("Repeat-x"), Qt::CaseInsensitive) == 0) + return QStringLiteral("repeat-x"); + if (t.compare(QLatin1String("Tokenish"), Qt::CaseInsensitive) == 0) + return QStringLiteral("tokenish"); + if (t.compare(QLatin1String("Random"), Qt::CaseInsensitive) == 0 + || t.compare(QLatin1String("Zero"), Qt::CaseInsensitive) == 0) + return QStringLiteral("repeat-x"); + return t.toLower(); + } + + void putIntRangeIfAny(QJsonObject &obj, const char *key, QString minV, QString maxV, const char *fallbackMin, + const char *fallbackMax) + { + if (minV.isEmpty() && maxV.isEmpty()) + return; + if (minV.isEmpty()) + minV = QString::fromLatin1(fallbackMin); + if (maxV.isEmpty()) + maxV = QString::fromLatin1(fallbackMax); + QJsonObject r; + r[QStringLiteral("from")] = minV.toInt(); + r[QStringLiteral("to")] = maxV.toInt(); + obj[QString::fromUtf8(key)] = r; + } + + // Desktop applies this in XrayProtocol::start(); iOS/Android pass JSON straight to libxray — same fixes here. + void sanitizeXrayNativeConfig(amnezia::ProtocolConfig &pc) + { + QString c = pc.nativeConfig(); + if (c.isEmpty()) { + return; + } + bool changed = false; + if (c.contains(QLatin1String("Mozilla/5.0"), Qt::CaseInsensitive)) { + c.replace(QLatin1String("Mozilla/5.0"), QString::fromLatin1(amnezia::protocols::xray::defaultFingerprint), + Qt::CaseInsensitive); + changed = true; + } + const QString legacyListen = QString::fromLatin1(amnezia::protocols::xray::defaultLocalAddr); + const QString listenOk = QString::fromLatin1(amnezia::protocols::xray::defaultLocalListenAddr); + if (c.contains(legacyListen)) { + c.replace(legacyListen, listenOk); + changed = true; + } + if (changed) { + pc.setNativeConfig(c); + } + } +} // namespace XrayConfigurator::XrayConfigurator(SshSession* sshSession, QObject *parent) : ConfiguratorBase(sshSession, parent) { } +amnezia::ProtocolConfig XrayConfigurator::processConfigWithLocalSettings(const amnezia::ConnectionSettings &settings, + amnezia::ProtocolConfig protocolConfig) +{ + applyDnsToNativeConfig(settings.dns, protocolConfig); + sanitizeXrayNativeConfig(protocolConfig); + return protocolConfig; +} + QString XrayConfigurator::prepareServerConfig(const ServerCredentials &credentials, DockerContainer container, const ContainerConfig &containerConfig, const DnsSettings &dnsSettings, @@ -35,11 +144,19 @@ QString XrayConfigurator::prepareServerConfig(const ServerCredentials &credentia { // Generate new UUID for client QString clientId = QUuid::createUuid().toString(QUuid::WithoutBraces); - + + // Get flow value from settings (default xtls-rprx-vision) + QString flowValue = "xtls-rprx-vision"; + if (const auto *xrayCfg = containerConfig.protocolConfig.as()) { + if (!xrayCfg->serverConfig.flow.isEmpty()) { + flowValue = xrayCfg->serverConfig.flow; + } + } + // Get current server config QString currentConfig = m_sshSession->getTextFileFromContainer( container, credentials, amnezia::protocols::xray::serverConfigPath, errorCode); - + if (errorCode != ErrorCode::NoError) { logger.error() << "Failed to get server config file"; return ""; @@ -54,7 +171,7 @@ QString XrayConfigurator::prepareServerConfig(const ServerCredentials &credentia } QJsonObject serverConfig = doc.object(); - + // Validate server config structure if (!serverConfig.contains(amnezia::protocols::xray::inbounds)) { logger.error() << "Server config missing 'inbounds' field"; @@ -68,7 +185,7 @@ QString XrayConfigurator::prepareServerConfig(const ServerCredentials &credentia errorCode = ErrorCode::InternalError; return ""; } - + QJsonObject inbound = inbounds[0].toObject(); if (!inbound.contains(amnezia::protocols::xray::settings)) { logger.error() << "Inbound missing 'settings' field"; @@ -84,26 +201,29 @@ QString XrayConfigurator::prepareServerConfig(const ServerCredentials &credentia } QJsonArray clients = settings[amnezia::protocols::xray::clients].toArray(); - + // Create configuration for new client QJsonObject clientConfig { {amnezia::protocols::xray::id, clientId}, - {amnezia::protocols::xray::flow, "xtls-rprx-vision"} }; - + clientConfig[amnezia::protocols::xray::id] = clientId; + if (!flowValue.isEmpty()) { + clientConfig[amnezia::protocols::xray::flow] = flowValue; + } + clients.append(clientConfig); - + // Update config settings[amnezia::protocols::xray::clients] = clients; inbound[amnezia::protocols::xray::settings] = settings; inbounds[0] = inbound; serverConfig[amnezia::protocols::xray::inbounds] = inbounds; - + // Save updated config to server QString updatedConfig = QJsonDocument(serverConfig).toJson(); errorCode = m_sshSession->uploadTextFileToContainer( - container, - credentials, + container, + credentials, updatedConfig, amnezia::protocols::xray::serverConfigPath, libssh::ScpOverwriteMode::ScpOverwriteExisting @@ -116,7 +236,7 @@ QString XrayConfigurator::prepareServerConfig(const ServerCredentials &credentia // Restart container QString restartScript = QString("sudo docker restart $CONTAINER_NAME"); errorCode = m_sshSession->runScript( - credentials, + credentials, m_sshSession->replaceVars(restartScript, amnezia::genBaseVars(credentials, container, dnsSettings.primaryDns, dnsSettings.secondaryDns)) ); @@ -128,75 +248,286 @@ QString XrayConfigurator::prepareServerConfig(const ServerCredentials &credentia return clientId; } -ProtocolConfig XrayConfigurator::createConfig(const ServerCredentials &credentials, DockerContainer container, - const ContainerConfig &containerConfig, - const DnsSettings &dnsSettings, - ErrorCode &errorCode) +QJsonObject XrayConfigurator::buildStreamSettings(const XrayServerConfig &srv, const QString &clientId) const { - const XrayServerConfig* serverConfig = nullptr; - if (auto* xrayConfig = containerConfig.protocolConfig.as()) { - serverConfig = &xrayConfig->serverConfig; + QJsonObject streamSettings; + const auto &xhttp = srv.xhttp; + const auto &mkcp = srv.mkcp; + namespace px = amnezia::protocols::xray; + + QString networkValue = QStringLiteral("tcp"); + if (srv.transport == QLatin1String("xhttp")) + networkValue = QStringLiteral("xhttp"); + else if (srv.transport == QLatin1String("mkcp")) + networkValue = QStringLiteral("kcp"); + streamSettings[px::network] = networkValue; + + streamSettings[px::security] = srv.security; + + if (srv.security == QLatin1String("tls")) { + QJsonObject tlsSettings; + const QString sniEff = srv.sni.isEmpty() ? QString::fromLatin1(px::defaultSni) : srv.sni; + tlsSettings[px::serverName] = sniEff; + const QString alpnEff = srv.alpn.isEmpty() ? QString::fromLatin1(px::defaultAlpn) : srv.alpn; + QJsonArray alpnArray; + for (const QString &a : alpnEff.split(QLatin1Char(','))) { + const QString t = a.trimmed(); + if (!t.isEmpty()) + alpnArray.append(t); + } + if (!alpnArray.isEmpty()) + tlsSettings[QStringLiteral("alpn")] = alpnArray; + const QString fpEff = srv.fingerprint.isEmpty() ? QString::fromLatin1(px::defaultFingerprint) : srv.fingerprint; + tlsSettings[px::fingerprint] = fpEff; + streamSettings[QStringLiteral("tlsSettings")] = tlsSettings; } - + + if (srv.security == QLatin1String("reality")) { + QJsonObject realSettings; + const QString fpEff = srv.fingerprint.isEmpty() ? QString::fromLatin1(px::defaultFingerprint) : srv.fingerprint; + realSettings[px::fingerprint] = fpEff; + const QString sniEff = srv.sni.isEmpty() ? QString::fromLatin1(px::defaultSni) : srv.sni; + realSettings[px::serverName] = sniEff; + streamSettings[px::realitySettings] = realSettings; + } + + // XHTTP — JSON must match Xray-core SplitHTTPConfig (flat xPadding fields, see transport_internet.go) + if (srv.transport == QLatin1String("xhttp")) { + QJsonObject xo; + const QString hostEff = xhttp.host.isEmpty() ? QString::fromLatin1(px::defaultXhttpHost) : xhttp.host; + xo[QStringLiteral("host")] = hostEff; + if (!xhttp.path.isEmpty()) + xo[QStringLiteral("path")] = xhttp.path; + xo[QStringLiteral("mode")] = normalizeXhttpMode(xhttp.mode); + + if (xhttp.headersTemplate.compare(QLatin1String("HTTP"), Qt::CaseInsensitive) == 0) { + QJsonObject headers; + headers[QStringLiteral("Host")] = hostEff; + xo[QStringLiteral("headers")] = headers; + } + + const QString methodEff = + xhttp.uplinkMethod.isEmpty() ? QString::fromLatin1(px::defaultXhttpUplinkMethod) : xhttp.uplinkMethod; + xo[QStringLiteral("uplinkHTTPMethod")] = methodEff.toUpper(); + + xo[QStringLiteral("noGRPCHeader")] = xhttp.disableGrpc; + xo[QStringLiteral("noSSEHeader")] = xhttp.disableSse; + + const QString sessPl = normalizeSessionSeqPlacement(xhttp.sessionPlacement); + if (!sessPl.isEmpty()) + xo[QStringLiteral("sessionPlacement")] = sessPl; + const QString seqPl = normalizeSessionSeqPlacement(xhttp.seqPlacement); + if (!seqPl.isEmpty()) + xo[QStringLiteral("seqPlacement")] = seqPl; + if (!xhttp.sessionKey.isEmpty()) + xo[QStringLiteral("sessionKey")] = xhttp.sessionKey; + if (!xhttp.seqKey.isEmpty()) + xo[QStringLiteral("seqKey")] = xhttp.seqKey; + + xo[QStringLiteral("uplinkDataPlacement")] = normalizeUplinkDataPlacement(xhttp.uplinkDataPlacement); + if (!xhttp.uplinkDataKey.isEmpty()) + xo[QStringLiteral("uplinkDataKey")] = xhttp.uplinkDataKey; + + const QString ucs = xhttp.uplinkChunkSize.isEmpty() ? QString::fromLatin1(px::defaultXhttpUplinkChunkSize) + : xhttp.uplinkChunkSize; + if (!ucs.isEmpty() && ucs != QLatin1String("0")) { + const int v = ucs.toInt(); + QJsonObject chunkR; + chunkR[QStringLiteral("from")] = v; + chunkR[QStringLiteral("to")] = v; + xo[QStringLiteral("uplinkChunkSize")] = chunkR; + } + + if (!xhttp.scMaxBufferedPosts.isEmpty()) + xo[QStringLiteral("scMaxBufferedPosts")] = xhttp.scMaxBufferedPosts.toLongLong(); + + putIntRangeIfAny(xo, "scMaxEachPostBytes", xhttp.scMaxEachPostBytesMin, xhttp.scMaxEachPostBytesMax, + px::defaultXhttpScMaxEachPostBytesMin, px::defaultXhttpScMaxEachPostBytesMax); + putIntRangeIfAny(xo, "scMinPostsIntervalMs", xhttp.scMinPostsIntervalMsMin, xhttp.scMinPostsIntervalMsMax, + px::defaultXhttpScMinPostsIntervalMsMin, px::defaultXhttpScMinPostsIntervalMsMax); + putIntRangeIfAny(xo, "scStreamUpServerSecs", xhttp.scStreamUpServerSecsMin, xhttp.scStreamUpServerSecsMax, + px::defaultXhttpScStreamUpServerSecsMin, px::defaultXhttpScStreamUpServerSecsMax); + + const auto &pad = xhttp.xPadding; + xo[QStringLiteral("xPaddingObfsMode")] = pad.obfsMode; + if (pad.obfsMode) { + if (!pad.bytesMin.isEmpty() || !pad.bytesMax.isEmpty()) { + QJsonObject br; + br[QStringLiteral("from")] = pad.bytesMin.isEmpty() ? 1 : pad.bytesMin.toInt(); + br[QStringLiteral("to")] = pad.bytesMax.isEmpty() ? (pad.bytesMin.isEmpty() ? 256 : pad.bytesMin.toInt()) + : pad.bytesMax.toInt(); + xo[QStringLiteral("xPaddingBytes")] = br; + } + xo[QStringLiteral("xPaddingKey")] = pad.key.isEmpty() ? QStringLiteral("x_padding") : pad.key; + xo[QStringLiteral("xPaddingHeader")] = pad.header.isEmpty() ? QStringLiteral("X-Padding") : pad.header; + xo[QStringLiteral("xPaddingPlacement")] = normalizeXPaddingPlacement( + pad.placement.isEmpty() ? QString::fromLatin1(px::defaultXPaddingPlacement) : pad.placement); + xo[QStringLiteral("xPaddingMethod")] = normalizeXPaddingMethod( + pad.method.isEmpty() ? QString::fromLatin1(px::defaultXPaddingMethod) : pad.method); + } + + // xmux: Xray has no "enabled" flag; omit object when UI disables multiplex tuning. + if (xhttp.xmux.enabled) { + QJsonObject mux; + auto addMuxRange = [&](const char *key, const QString &a, const QString &b) { + if (a.isEmpty() && b.isEmpty()) + return; + QJsonObject r; + r[QStringLiteral("from")] = a.isEmpty() ? 0 : a.toInt(); + r[QStringLiteral("to")] = b.isEmpty() ? 0 : b.toInt(); + mux[QString::fromUtf8(key)] = r; + }; + addMuxRange("maxConcurrency", xhttp.xmux.maxConcurrencyMin, xhttp.xmux.maxConcurrencyMax); + addMuxRange("maxConnections", xhttp.xmux.maxConnectionsMin, xhttp.xmux.maxConnectionsMax); + addMuxRange("cMaxReuseTimes", xhttp.xmux.cMaxReuseTimesMin, xhttp.xmux.cMaxReuseTimesMax); + addMuxRange("hMaxRequestTimes", xhttp.xmux.hMaxRequestTimesMin, xhttp.xmux.hMaxRequestTimesMax); + addMuxRange("hMaxReusableSecs", xhttp.xmux.hMaxReusableSecsMin, xhttp.xmux.hMaxReusableSecsMax); + if (!xhttp.xmux.hKeepAlivePeriod.isEmpty()) + mux[QStringLiteral("hKeepAlivePeriod")] = xhttp.xmux.hKeepAlivePeriod.toLongLong(); + if (!mux.isEmpty()) + xo[QStringLiteral("xmux")] = mux; + } + + streamSettings[QStringLiteral("xhttpSettings")] = xo; + } + + if (srv.transport == QLatin1String("mkcp")) { + QJsonObject kcpObj; + const QString ttiEff = mkcp.tti.isEmpty() ? QString::fromLatin1(px::defaultMkcpTti) : mkcp.tti; + const QString upEff = mkcp.uplinkCapacity.isEmpty() ? QString::fromLatin1(px::defaultMkcpUplinkCapacity) + : mkcp.uplinkCapacity; + const QString downEff = mkcp.downlinkCapacity.isEmpty() ? QString::fromLatin1(px::defaultMkcpDownlinkCapacity) + : mkcp.downlinkCapacity; + const QString rbufEff = mkcp.readBufferSize.isEmpty() ? QString::fromLatin1(px::defaultMkcpReadBufferSize) + : mkcp.readBufferSize; + const QString wbufEff = mkcp.writeBufferSize.isEmpty() ? QString::fromLatin1(px::defaultMkcpWriteBufferSize) + : mkcp.writeBufferSize; + kcpObj[QStringLiteral("tti")] = ttiEff.toInt(); + kcpObj[QStringLiteral("uplinkCapacity")] = upEff.toInt(); + kcpObj[QStringLiteral("downlinkCapacity")] = downEff.toInt(); + kcpObj[QStringLiteral("readBufferSize")] = rbufEff.toInt(); + kcpObj[QStringLiteral("writeBufferSize")] = wbufEff.toInt(); + kcpObj[QStringLiteral("congestion")] = mkcp.congestion; + streamSettings[QStringLiteral("kcpSettings")] = kcpObj; + } + + return streamSettings; +} + +ProtocolConfig XrayConfigurator::createConfig(const ServerCredentials &credentials, DockerContainer container, + const ContainerConfig &containerConfig, + const DnsSettings &dnsSettings, + ErrorCode &errorCode) +{ + const XrayServerConfig *serverConfig = nullptr; + if (const auto *xrayCfg = containerConfig.protocolConfig.as()) { + serverConfig = &xrayCfg->serverConfig; + } + + if (!serverConfig) { + logger.error() << "No XrayProtocolConfig found"; + errorCode = ErrorCode::InternalError; + return XrayProtocolConfig{}; + } + + const XrayServerConfig &srv = *serverConfig; + QString xrayClientId = prepareServerConfig(credentials, container, containerConfig, dnsSettings, errorCode); if (errorCode != ErrorCode::NoError || xrayClientId.isEmpty()) { logger.error() << "Failed to prepare server config"; - errorCode = ErrorCode::InternalError; + if (errorCode == ErrorCode::NoError) { + errorCode = ErrorCode::InternalError; + } return XrayProtocolConfig{}; } - amnezia::ScriptVars vars = amnezia::genBaseVars(credentials, container, dnsSettings.primaryDns, dnsSettings.secondaryDns); - vars.append(amnezia::genProtocolVarsForContainer(container, containerConfig)); - QString config = m_sshSession->replaceVars(amnezia::scriptData(ProtocolScriptType::xray_template, container), vars); - - if (config.isEmpty()) { - logger.error() << "Failed to get config template"; - errorCode = ErrorCode::InternalError; - return XrayProtocolConfig{}; + // Fetch server keys (Reality only) + QString xrayPublicKey; + QString xrayShortId; + + if (srv.security == "reality") { + xrayPublicKey = m_sshSession->getTextFileFromContainer(container, credentials, + amnezia::protocols::xray::PublicKeyPath, errorCode); + if (errorCode != ErrorCode::NoError || xrayPublicKey.isEmpty()) { + logger.error() << "Failed to get public key"; + if (errorCode == ErrorCode::NoError) { + errorCode = ErrorCode::InternalError; + } + return XrayProtocolConfig{}; + } + xrayPublicKey.replace("\n", ""); + + xrayShortId = m_sshSession->getTextFileFromContainer(container, credentials, + amnezia::protocols::xray::shortidPath, errorCode); + if (errorCode != ErrorCode::NoError || xrayShortId.isEmpty()) { + logger.error() << "Failed to get short ID"; + if (errorCode == ErrorCode::NoError) { + errorCode = ErrorCode::InternalError; + } + return XrayProtocolConfig{}; + } + xrayShortId.replace("\n", ""); } - QString xrayPublicKey = - m_sshSession->getTextFileFromContainer(container, credentials, amnezia::protocols::xray::PublicKeyPath, errorCode); - if (errorCode != ErrorCode::NoError || xrayPublicKey.isEmpty()) { - logger.error() << "Failed to get public key"; - errorCode = ErrorCode::InternalError; - return XrayProtocolConfig{}; - } - xrayPublicKey.replace("\n", ""); - - QString xrayShortId = - m_sshSession->getTextFileFromContainer(container, credentials, amnezia::protocols::xray::shortidPath, errorCode); - if (errorCode != ErrorCode::NoError || xrayShortId.isEmpty()) { - logger.error() << "Failed to get short ID"; - errorCode = ErrorCode::InternalError; - return XrayProtocolConfig{}; - } - xrayShortId.replace("\n", ""); - - if (!config.contains("$XRAY_CLIENT_ID") || !config.contains("$XRAY_PUBLIC_KEY") || !config.contains("$XRAY_SHORT_ID")) { - logger.error() << "Config template missing required variables:" - << "XRAY_CLIENT_ID:" << !config.contains("$XRAY_CLIENT_ID") - << "XRAY_PUBLIC_KEY:" << !config.contains("$XRAY_PUBLIC_KEY") - << "XRAY_SHORT_ID:" << !config.contains("$XRAY_SHORT_ID"); - errorCode = ErrorCode::InternalError; - return XrayProtocolConfig{}; + // Build outbound + QJsonObject userObj; + userObj[amnezia::protocols::xray::id] = xrayClientId; + userObj[amnezia::protocols::xray::encryption] = "none"; + if (!srv.flow.isEmpty()) { + userObj[amnezia::protocols::xray::flow] = srv.flow; } - config.replace("$XRAY_CLIENT_ID", xrayClientId); - config.replace("$XRAY_PUBLIC_KEY", xrayPublicKey); - config.replace("$XRAY_SHORT_ID", xrayShortId); + QJsonObject vnextEntry; + vnextEntry[amnezia::protocols::xray::address] = credentials.hostName; + vnextEntry[amnezia::protocols::xray::port] = srv.port.toInt(); + vnextEntry[amnezia::protocols::xray::users] = QJsonArray { userObj }; + QJsonObject outboundSettings; + outboundSettings[amnezia::protocols::xray::vnext] = QJsonArray { vnextEntry }; + + QJsonObject outbound; + outbound["protocol"] = "vless"; + outbound[amnezia::protocols::xray::settings] = outboundSettings; + + // Build streamSettings + QJsonObject streamObj = buildStreamSettings(srv, xrayClientId); + + // Inject Reality keys + if (srv.security == "reality") { + QJsonObject rs = streamObj[amnezia::protocols::xray::realitySettings].toObject(); + rs[amnezia::protocols::xray::publicKey] = xrayPublicKey; + rs[amnezia::protocols::xray::shortId] = xrayShortId; + rs[amnezia::protocols::xray::spiderX] = ""; + streamObj[amnezia::protocols::xray::realitySettings] = rs; + } + + outbound[amnezia::protocols::xray::streamSettings] = streamObj; + + // Build full client config + QJsonObject inboundObj; + inboundObj["listen"] = amnezia::protocols::xray::defaultLocalListenAddr; + inboundObj[amnezia::protocols::xray::port] = amnezia::protocols::xray::defaultLocalProxyPort; + inboundObj["protocol"] = "socks"; + inboundObj[amnezia::protocols::xray::settings] = QJsonObject { { "udp", true } }; + + QJsonObject clientJson; + clientJson["log"] = QJsonObject { { "loglevel", "error" } }; + clientJson[amnezia::protocols::xray::inbounds] = QJsonArray { inboundObj }; + clientJson[amnezia::protocols::xray::outbounds] = QJsonArray { outbound }; + + QString config = QString::fromUtf8(QJsonDocument(clientJson).toJson(QJsonDocument::Compact)); + + // Return XrayProtocolConfig protocolConfig; - if (serverConfig) { - protocolConfig.serverConfig = *serverConfig; - } - + protocolConfig.serverConfig = srv; + XrayClientConfig clientConfig; clientConfig.nativeConfig = config; - clientConfig.localPort = ""; + qDebug() << "config:" << config; + clientConfig.localPort = QString(amnezia::protocols::xray::defaultLocalProxyPort); clientConfig.id = xrayClientId; - + protocolConfig.setClientConfig(clientConfig); - + return protocolConfig; -} +} \ No newline at end of file diff --git a/client/core/configurators/xrayConfigurator.h b/client/core/configurators/xrayConfigurator.h index 74a0ea006..968e85b44 100644 --- a/client/core/configurators/xrayConfigurator.h +++ b/client/core/configurators/xrayConfigurator.h @@ -2,11 +2,13 @@ #define XRAY_CONFIGURATOR_H #include +#include #include "configuratorBase.h" #include "core/utils/errorCodes.h" #include "core/utils/routeModes.h" #include "core/utils/commonStructs.h" +#include "core/models/protocols/xrayProtocolConfig.h" class XrayConfigurator : public ConfiguratorBase { @@ -18,10 +20,17 @@ public: const amnezia::DnsSettings &dnsSettings, amnezia::ErrorCode &errorCode) override; + amnezia::ProtocolConfig processConfigWithLocalSettings(const amnezia::ConnectionSettings &settings, + amnezia::ProtocolConfig protocolConfig) override; + private: QString prepareServerConfig(const amnezia::ServerCredentials &credentials, amnezia::DockerContainer container, const amnezia::ContainerConfig &containerConfig, const amnezia::DnsSettings &dnsSettings, amnezia::ErrorCode &errorCode); + + // Builds the native xray "streamSettings" JSON object from XrayServerConfig + QJsonObject buildStreamSettings(const amnezia::XrayServerConfig &srv, + const QString &clientId) const; }; #endif // XRAY_CONFIGURATOR_H diff --git a/client/core/controllers/coreController.cpp b/client/core/controllers/coreController.cpp index d9bf0ea9b..77b951f9e 100644 --- a/client/core/controllers/coreController.cpp +++ b/client/core/controllers/coreController.cpp @@ -86,6 +86,9 @@ void CoreController::initModels() m_xrayConfigModel = new XrayConfigModel(this); setQmlContextProperty("XrayConfigModel", m_xrayConfigModel); + m_xrayConfigSnapshotsModel = new XrayConfigSnapshotsModel(m_appSettingsRepository, m_xrayConfigModel, this); + setQmlContextProperty("XrayConfigSnapshotsModel", m_xrayConfigSnapshotsModel); + m_torConfigModel = new TorConfigModel(this); setQmlContextProperty("TorConfigModel", m_torConfigModel); diff --git a/client/core/controllers/coreController.h b/client/core/controllers/coreController.h index acf347f5d..70033d61b 100644 --- a/client/core/controllers/coreController.h +++ b/client/core/controllers/coreController.h @@ -65,6 +65,7 @@ #include "ui/models/protocols/openvpnConfigModel.h" #include "ui/models/protocols/wireguardConfigModel.h" #include "ui/models/protocols/xrayConfigModel.h" +#include "ui/models/protocols/xrayConfigSnapshotsModel.h" #include "ui/models/protocolsModel.h" #include "ui/models/services/torConfigModel.h" #include "ui/models/serversModel.h" @@ -205,6 +206,7 @@ private: OpenVpnConfigModel* m_openVpnConfigModel; XrayConfigModel* m_xrayConfigModel; + XrayConfigSnapshotsModel* m_xrayConfigSnapshotsModel; TorConfigModel* m_torConfigModel; WireGuardConfigModel* m_wireGuardConfigModel; AwgConfigModel* m_awgConfigModel; diff --git a/client/core/controllers/selfhosted/exportController.cpp b/client/core/controllers/selfhosted/exportController.cpp index 095b57353..75faccf20 100644 --- a/client/core/controllers/selfhosted/exportController.cpp +++ b/client/core/controllers/selfhosted/exportController.cpp @@ -323,6 +323,18 @@ ExportController::ExportResult ExportController::generateXrayConfig(const QStrin vlessServer.shortId = realitySettings.value(amnezia::protocols::xray::shortId).toString(); vlessServer.fingerprint = realitySettings.value(amnezia::protocols::xray::fingerprint).toString("chrome"); vlessServer.spiderX = realitySettings.value(amnezia::protocols::xray::spiderX).toString(""); + } else if (vlessServer.security == "tls") { + QJsonObject tlsSettings = streamSettings.value("tlsSettings").toObject(); + vlessServer.serverName = tlsSettings.value(amnezia::protocols::xray::serverName).toString(); + vlessServer.fingerprint = tlsSettings.value(amnezia::protocols::xray::fingerprint).toString(); + // alpn: serialize array back to comma-separated for VLESS URI + QJsonArray alpnArr = tlsSettings.value("alpn").toArray(); + QStringList alpnList; + for (const QJsonValue &v : alpnArr) { + alpnList << v.toString(); + } + // alpn goes into vless URI query param — handled by Serialize via serverName/alpn fields + // VlessServerObject doesn't have alpn field, so we embed in serverName if needed } result.nativeConfigString = amnezia::serialization::vless::Serialize(vlessServer, "AmneziaVPN"); diff --git a/client/core/installers/xrayInstaller.cpp b/client/core/installers/xrayInstaller.cpp index 12a4b9833..30e61cc2a 100644 --- a/client/core/installers/xrayInstaller.cpp +++ b/client/core/installers/xrayInstaller.cpp @@ -14,8 +14,18 @@ #include "core/models/protocols/xrayProtocolConfig.h" #include "logger.h" -namespace { +namespace +{ Logger logger("XrayInstaller"); + + // Xray expects uTLS preset names (chrome, firefox, …). Old Amnezia/server templates used "Mozilla/5.0". + QString normalizeXrayFingerprint(const QString &fp) + { + if (fp.isEmpty() || fp.contains(QLatin1String("Mozilla/5.0"), Qt::CaseInsensitive)) { + return QString::fromLatin1(protocols::xray::defaultFingerprint); + } + return fp; + } } using namespace amnezia; @@ -63,18 +73,251 @@ ErrorCode XrayInstaller::extractConfigFromContainer(DockerContainer container, c } QJsonObject streamSettings = inbound[protocols::xray::streamSettings].toObject(); - QJsonObject realitySettings = streamSettings[protocols::xray::realitySettings].toObject(); - if (!realitySettings.contains(protocols::xray::serverNames)) { - logger.error() << "Settings missing 'serverNames' field"; + auto *xrayConfig = config.getXrayProtocolConfig(); + if (!xrayConfig) { + logger.error() << "No XrayProtocolConfig in ContainerConfig"; return ErrorCode::InternalError; } - QString siteName = realitySettings[protocols::xray::serverNames][0].toString(); + XrayServerConfig &srv = xrayConfig->serverConfig; - if (auto* xrayConfig = config.getXrayProtocolConfig()) { - xrayConfig->serverConfig.site = siteName; + // ── Port ───────────────────────────────────────────────────────── + if (inbound.contains(protocols::xray::port)) { + srv.port = QString::number(inbound[protocols::xray::port].toInt()); } - + + // ── Network (transport) ─────────────────────────────────────────── + QString networkVal = streamSettings.value(protocols::xray::network).toString("tcp"); + if (networkVal == "xhttp") { + srv.transport = "xhttp"; + } else if (networkVal == "kcp") { + srv.transport = "mkcp"; + } else { + srv.transport = "raw"; + } + + // ── Security ────────────────────────────────────────────────────── + srv.security = streamSettings.value(protocols::xray::security).toString("reality"); + + // ── Reality settings ────────────────────────────────────────────── + if (srv.security == "reality") { + QJsonObject rs = streamSettings.value(protocols::xray::realitySettings).toObject(); + + // serverNames array → site + sni + if (rs.contains(protocols::xray::serverNames)) { + QString sniVal = rs[protocols::xray::serverNames].toArray().first().toString(); + srv.sni = sniVal; + srv.site = sniVal; + } else if (rs.contains(protocols::xray::serverName)) { + srv.sni = rs[protocols::xray::serverName].toString(); + srv.site = srv.sni; + } + + srv.fingerprint = normalizeXrayFingerprint(rs.value(protocols::xray::fingerprint).toString()); + } + + // ── TLS settings ────────────────────────────────────────────────── + if (srv.security == "tls") { + QJsonObject tls = streamSettings.value("tlsSettings").toObject(); + srv.sni = tls.value(protocols::xray::serverName).toString(); + srv.fingerprint = normalizeXrayFingerprint(tls.value(protocols::xray::fingerprint).toString()); + + QJsonArray alpnArr = tls.value("alpn").toArray(); + QStringList alpnList; + for (const QJsonValue &v : alpnArr) { + alpnList << v.toString(); + } + srv.alpn = alpnList.join(","); + } + + // ── Flow (from users array) ─────────────────────────────────────── + if (inbound.contains(protocols::xray::settings)) { + QJsonObject s = inbound[protocols::xray::settings].toObject(); + QJsonArray clientsArr = s.value(protocols::xray::clients).toArray(); + if (!clientsArr.isEmpty()) { + srv.flow = clientsArr[0].toObject().value(protocols::xray::flow).toString(); + } + } + + // ── XHTTP settings (Xray-core SplitHTTPConfig + legacy Amnezia keys) ── + if (srv.transport == "xhttp") { + QJsonObject xhttpObj = streamSettings.value("xhttpSettings").toObject(); + { + const QString m = xhttpObj.value("mode").toString(); + if (m.isEmpty() || m == QLatin1String("auto")) + srv.xhttp.mode = QStringLiteral("Auto"); + else if (m == QLatin1String("packet-up")) + srv.xhttp.mode = QStringLiteral("Packet-up"); + else if (m == QLatin1String("stream-up")) + srv.xhttp.mode = QStringLiteral("Stream-up"); + else if (m == QLatin1String("stream-one")) + srv.xhttp.mode = QStringLiteral("Stream-one"); + else + srv.xhttp.mode = m; + } + + srv.xhttp.host = xhttpObj.value("host").toString(); + srv.xhttp.path = xhttpObj.value("path").toString(); + + { + const QJsonObject hdrs = xhttpObj.value("headers").toObject(); + if (hdrs.contains(QLatin1String("Host")) || !hdrs.isEmpty()) + srv.xhttp.headersTemplate = QStringLiteral("HTTP"); + } + + if (xhttpObj.contains(QLatin1String("uplinkHTTPMethod"))) + srv.xhttp.uplinkMethod = xhttpObj.value("uplinkHTTPMethod").toString(); + else + srv.xhttp.uplinkMethod = xhttpObj.value("method").toString(); + + srv.xhttp.disableGrpc = xhttpObj.value("noGRPCHeader").toBool(true); + srv.xhttp.disableSse = xhttpObj.value("noSSEHeader").toBool(true); + + auto sessionSeqUi = [](const QString &core) -> QString { + if (core.isEmpty() || core == QLatin1String("path")) + return QStringLiteral("Path"); + if (core == QLatin1String("cookie")) + return QStringLiteral("Cookie"); + if (core == QLatin1String("header")) + return QStringLiteral("Header"); + if (core == QLatin1String("query")) + return QStringLiteral("Query"); + return core; + }; + QString sess = xhttpObj.value("sessionPlacement").toString(); + if (sess.isEmpty()) + sess = xhttpObj.value("scSessionPlacement").toString(); + srv.xhttp.sessionPlacement = sessionSeqUi(sess); + + QString seq = xhttpObj.value("seqPlacement").toString(); + if (seq.isEmpty()) + seq = xhttpObj.value("scSeqPlacement").toString(); + srv.xhttp.seqPlacement = sessionSeqUi(seq); + + auto uplinkDataUi = [](const QString &core) -> QString { + if (core.isEmpty() || core == QLatin1String("body")) + return QStringLiteral("Body"); + if (core == QLatin1String("auto")) + return QStringLiteral("Auto"); + if (core == QLatin1String("header")) + return QStringLiteral("Header"); + if (core == QLatin1String("cookie")) + return QStringLiteral("Cookie"); + return core; + }; + QString udata = xhttpObj.value("uplinkDataPlacement").toString(); + if (udata.isEmpty()) + udata = xhttpObj.value("scUplinkDataPlacement").toString(); + srv.xhttp.uplinkDataPlacement = uplinkDataUi(udata); + + srv.xhttp.sessionKey = xhttpObj.value("sessionKey").toString(); + srv.xhttp.seqKey = xhttpObj.value("seqKey").toString(); + srv.xhttp.uplinkDataKey = xhttpObj.value("uplinkDataKey").toString(); + + if (xhttpObj.contains(QLatin1String("uplinkChunkSize"))) { + QJsonObject uc = xhttpObj.value("uplinkChunkSize").toObject(); + if (!uc.isEmpty()) + srv.xhttp.uplinkChunkSize = QString::number(uc.value("from").toInt()); + } else if (xhttpObj.contains(QLatin1String("xhttpUplinkChunkSize"))) { + srv.xhttp.uplinkChunkSize = QString::number(xhttpObj.value("xhttpUplinkChunkSize").toInt()); + } + if (xhttpObj.contains(QLatin1String("scMaxBufferedPosts"))) { + srv.xhttp.scMaxBufferedPosts = QString::number(xhttpObj.value("scMaxBufferedPosts").toVariant().toLongLong()); + } + + auto readRange = [&](const char *key, QString &minOut, QString &maxOut) { + QJsonObject r = xhttpObj.value(QLatin1String(key)).toObject(); + if (!r.isEmpty()) { + minOut = QString::number(r.value("from").toInt()); + maxOut = QString::number(r.value("to").toInt()); + } + }; + readRange("scMaxEachPostBytes", srv.xhttp.scMaxEachPostBytesMin, srv.xhttp.scMaxEachPostBytesMax); + readRange("scMinPostsIntervalMs", srv.xhttp.scMinPostsIntervalMsMin, srv.xhttp.scMinPostsIntervalMsMax); + readRange("scStreamUpServerSecs", srv.xhttp.scStreamUpServerSecsMin, srv.xhttp.scStreamUpServerSecsMax); + + auto loadPaddingFromObject = [&](const QJsonObject &pad) { + if (pad.contains(QLatin1String("xPaddingObfsMode"))) + srv.xhttp.xPadding.obfsMode = pad.value("xPaddingObfsMode").toBool(true); + srv.xhttp.xPadding.key = pad.value("xPaddingKey").toString(); + srv.xhttp.xPadding.header = pad.value("xPaddingHeader").toString(); + srv.xhttp.xPadding.placement = pad.value("xPaddingPlacement").toString(); + srv.xhttp.xPadding.method = pad.value("xPaddingMethod").toString(); + QJsonObject bytesRange = pad.value("xPaddingBytes").toObject(); + if (!bytesRange.isEmpty()) { + srv.xhttp.xPadding.bytesMin = QString::number(bytesRange.value("from").toInt()); + srv.xhttp.xPadding.bytesMax = QString::number(bytesRange.value("to").toInt()); + } + QString pl = srv.xhttp.xPadding.placement.toLower(); + if (pl == QLatin1String("cookie")) + srv.xhttp.xPadding.placement = QStringLiteral("Cookie"); + else if (pl == QLatin1String("header")) + srv.xhttp.xPadding.placement = QStringLiteral("Header"); + else if (pl == QLatin1String("query")) + srv.xhttp.xPadding.placement = QStringLiteral("Query"); + else if (pl == QLatin1String("queryinheader")) + srv.xhttp.xPadding.placement = QStringLiteral("Query in header"); + QString met = srv.xhttp.xPadding.method.toLower(); + if (met == QLatin1String("repeat-x")) + srv.xhttp.xPadding.method = QStringLiteral("Repeat-x"); + else if (met == QLatin1String("tokenish")) + srv.xhttp.xPadding.method = QStringLiteral("Tokenish"); + }; + if (xhttpObj.contains(QLatin1String("xPaddingObfsMode")) || xhttpObj.contains(QLatin1String("xPaddingKey")) + || !xhttpObj.value("xPaddingBytes").toObject().isEmpty()) { + loadPaddingFromObject(xhttpObj); + } else if (xhttpObj.contains(QLatin1String("xPadding")) && xhttpObj.value("xPadding").isObject()) { + const QJsonObject nested = xhttpObj.value("xPadding").toObject(); + if (!nested.isEmpty()) { + loadPaddingFromObject(nested); + if (!nested.contains(QLatin1String("xPaddingObfsMode"))) + srv.xhttp.xPadding.obfsMode = true; + } + } + + if (xhttpObj.contains(QLatin1String("xmux"))) { + QJsonObject mux = xhttpObj.value("xmux").toObject(); + srv.xhttp.xmux.enabled = true; + + auto readMuxRange = [&](const char *key, QString &minOut, QString &maxOut) { + QJsonObject r = mux.value(QLatin1String(key)).toObject(); + if (!r.isEmpty()) { + minOut = QString::number(r.value("from").toInt()); + maxOut = QString::number(r.value("to").toInt()); + } + }; + readMuxRange("maxConcurrency", srv.xhttp.xmux.maxConcurrencyMin, srv.xhttp.xmux.maxConcurrencyMax); + readMuxRange("maxConnections", srv.xhttp.xmux.maxConnectionsMin, srv.xhttp.xmux.maxConnectionsMax); + readMuxRange("cMaxReuseTimes", srv.xhttp.xmux.cMaxReuseTimesMin, srv.xhttp.xmux.cMaxReuseTimesMax); + readMuxRange("hMaxRequestTimes", srv.xhttp.xmux.hMaxRequestTimesMin, srv.xhttp.xmux.hMaxRequestTimesMax); + readMuxRange("hMaxReusableSecs", srv.xhttp.xmux.hMaxReusableSecsMin, srv.xhttp.xmux.hMaxReusableSecsMax); + + if (mux.contains(QLatin1String("hKeepAlivePeriod"))) + srv.xhttp.xmux.hKeepAlivePeriod = QString::number(mux.value("hKeepAlivePeriod").toVariant().toLongLong()); + } + } + + // ── mKCP settings ───────────────────────────────────────────────── + if (srv.transport == "mkcp") { + QJsonObject kcp = streamSettings.value("kcpSettings").toObject(); + if (kcp.contains("tti")) { + srv.mkcp.tti = QString::number(kcp["tti"].toInt()); + } + if (kcp.contains("uplinkCapacity")) { + srv.mkcp.uplinkCapacity = QString::number(kcp["uplinkCapacity"].toInt()); + } + if (kcp.contains("downlinkCapacity")) { + srv.mkcp.downlinkCapacity = QString::number(kcp["downlinkCapacity"].toInt()); + } + if (kcp.contains("readBufferSize")) { + srv.mkcp.readBufferSize = QString::number(kcp["readBufferSize"].toInt()); + } + if (kcp.contains("writeBufferSize")) { + srv.mkcp.writeBufferSize = QString::number(kcp["writeBufferSize"].toInt()); + } + srv.mkcp.congestion = kcp.value("congestion").toBool(true); + } + return ErrorCode::NoError; } diff --git a/client/core/models/protocols/xrayProtocolConfig.cpp b/client/core/models/protocols/xrayProtocolConfig.cpp index bb4e61457..a6c0043bc 100644 --- a/client/core/models/protocols/xrayProtocolConfig.cpp +++ b/client/core/models/protocols/xrayProtocolConfig.cpp @@ -3,20 +3,173 @@ #include #include -#include "../../../core/utils/protocolEnum.h" -#include "../../../core/protocols/protocolUtils.h" -#include "../../../core/utils/constants/configKeys.h" -#include "../../../core/utils/constants/protocolConstants.h" +#include "core/utils/protocolEnum.h" +#include "core/protocols/protocolUtils.h" +#include "core/utils/constants/configKeys.h" +#include "core/utils/constants/protocolConstants.h" using namespace amnezia; using namespace ProtocolUtils; + namespace amnezia { +QJsonObject XrayXPaddingConfig::toJson() const +{ + QJsonObject obj; + if (!bytesMin.isEmpty()) obj[configKey::xPaddingBytesMin] = bytesMin; + if (!bytesMax.isEmpty()) obj[configKey::xPaddingBytesMax] = bytesMax; + obj[configKey::xPaddingObfsMode] = obfsMode; + if (!key.isEmpty()) obj[configKey::xPaddingKey] = key; + if (!header.isEmpty()) obj[configKey::xPaddingHeader] = header; + if (!placement.isEmpty()) obj[configKey::xPaddingPlacement] = placement; + if (!method.isEmpty()) obj[configKey::xPaddingMethod] = method; + return obj; +} + +XrayXPaddingConfig XrayXPaddingConfig::fromJson(const QJsonObject &json) +{ + XrayXPaddingConfig c; + c.bytesMin = json.value(configKey::xPaddingBytesMin).toString(); + c.bytesMax = json.value(configKey::xPaddingBytesMax).toString(); + c.obfsMode = json.value(configKey::xPaddingObfsMode).toBool(true); + c.key = json.value(configKey::xPaddingKey).toString(protocols::xray::defaultSite); + c.header = json.value(configKey::xPaddingHeader).toString(); + c.placement = json.value(configKey::xPaddingPlacement).toString(protocols::xray::defaultXPaddingPlacement); + c.method = json.value(configKey::xPaddingMethod).toString(protocols::xray::defaultXPaddingMethod); + return c; +} + +QJsonObject XrayXmuxConfig::toJson() const +{ + QJsonObject obj; + obj[configKey::xmuxEnabled] = enabled; + if (!maxConcurrencyMin.isEmpty()) obj[configKey::xmuxMaxConcurrencyMin] = maxConcurrencyMin; + if (!maxConcurrencyMax.isEmpty()) obj[configKey::xmuxMaxConcurrencyMax] = maxConcurrencyMax; + if (!maxConnectionsMin.isEmpty()) obj[configKey::xmuxMaxConnectionsMin] = maxConnectionsMin; + if (!maxConnectionsMax.isEmpty()) obj[configKey::xmuxMaxConnectionsMax] = maxConnectionsMax; + if (!cMaxReuseTimesMin.isEmpty()) obj[configKey::xmuxCMaxReuseTimesMin] = cMaxReuseTimesMin; + if (!cMaxReuseTimesMax.isEmpty()) obj[configKey::xmuxCMaxReuseTimesMax] = cMaxReuseTimesMax; + if (!hMaxRequestTimesMin.isEmpty()) obj[configKey::xmuxHMaxRequestTimesMin] = hMaxRequestTimesMin; + if (!hMaxRequestTimesMax.isEmpty()) obj[configKey::xmuxHMaxRequestTimesMax] = hMaxRequestTimesMax; + if (!hMaxReusableSecsMin.isEmpty()) obj[configKey::xmuxHMaxReusableSecsMin] = hMaxReusableSecsMin; + if (!hMaxReusableSecsMax.isEmpty()) obj[configKey::xmuxHMaxReusableSecsMax] = hMaxReusableSecsMax; + if (!hKeepAlivePeriod.isEmpty()) obj[configKey::xmuxHKeepAlivePeriod] = hKeepAlivePeriod; + return obj; +} + +XrayXmuxConfig XrayXmuxConfig::fromJson(const QJsonObject &json) +{ + XrayXmuxConfig c; + c.enabled = json.value(configKey::xmuxEnabled).toBool(true); + c.maxConcurrencyMin = json.value(configKey::xmuxMaxConcurrencyMin).toString("0"); + c.maxConcurrencyMax = json.value(configKey::xmuxMaxConcurrencyMax).toString("0"); + c.maxConnectionsMin = json.value(configKey::xmuxMaxConnectionsMin).toString("0"); + c.maxConnectionsMax = json.value(configKey::xmuxMaxConnectionsMax).toString("0"); + c.cMaxReuseTimesMin = json.value(configKey::xmuxCMaxReuseTimesMin).toString("0"); + c.cMaxReuseTimesMax = json.value(configKey::xmuxCMaxReuseTimesMax).toString("0"); + c.hMaxRequestTimesMin = json.value(configKey::xmuxHMaxRequestTimesMin).toString("0"); + c.hMaxRequestTimesMax = json.value(configKey::xmuxHMaxRequestTimesMax).toString("0"); + c.hMaxReusableSecsMin = json.value(configKey::xmuxHMaxReusableSecsMin).toString("0"); + c.hMaxReusableSecsMax = json.value(configKey::xmuxHMaxReusableSecsMax).toString("0"); + c.hKeepAlivePeriod = json.value(configKey::xmuxHKeepAlivePeriod).toString(); + return c; +} + +QJsonObject XrayXhttpConfig::toJson() const +{ + QJsonObject obj; + if (!mode.isEmpty()) obj[configKey::xhttpMode] = mode; + if (!host.isEmpty()) obj[configKey::xhttpHost] = host; + if (!path.isEmpty()) obj[configKey::xhttpPath] = path; + if (!headersTemplate.isEmpty()) obj[configKey::xhttpHeadersTemplate] = headersTemplate; + if (!uplinkMethod.isEmpty()) obj[configKey::xhttpUplinkMethod] = uplinkMethod; + obj[configKey::xhttpDisableGrpc] = disableGrpc; + obj[configKey::xhttpDisableSse] = disableSse; + + if (!sessionPlacement.isEmpty()) obj[configKey::xhttpSessionPlacement] = sessionPlacement; + if (!sessionKey.isEmpty()) obj[configKey::xhttpSessionKey] = sessionKey; + if (!seqPlacement.isEmpty()) obj[configKey::xhttpSeqPlacement] = seqPlacement; + if (!seqKey.isEmpty()) obj[configKey::xhttpSeqKey] = seqKey; + if (!uplinkDataPlacement.isEmpty()) obj[configKey::xhttpUplinkDataPlacement] = uplinkDataPlacement; + if (!uplinkDataKey.isEmpty()) obj[configKey::xhttpUplinkDataKey] = uplinkDataKey; + + if (!uplinkChunkSize.isEmpty()) obj[configKey::xhttpUplinkChunkSize] = uplinkChunkSize; + if (!scMaxBufferedPosts.isEmpty()) obj[configKey::xhttpScMaxBufferedPosts] = scMaxBufferedPosts; + if (!scMaxEachPostBytesMin.isEmpty()) obj[configKey::xhttpScMaxEachPostBytesMin] = scMaxEachPostBytesMin; + if (!scMaxEachPostBytesMax.isEmpty()) obj[configKey::xhttpScMaxEachPostBytesMax] = scMaxEachPostBytesMax; + if (!scMinPostsIntervalMsMin.isEmpty()) obj[configKey::xhttpScMinPostsIntervalMsMin] = scMinPostsIntervalMsMin; + if (!scMinPostsIntervalMsMax.isEmpty()) obj[configKey::xhttpScMinPostsIntervalMsMax] = scMinPostsIntervalMsMax; + if (!scStreamUpServerSecsMin.isEmpty()) obj[configKey::xhttpScStreamUpServerSecsMin] = scStreamUpServerSecsMin; + if (!scStreamUpServerSecsMax.isEmpty()) obj[configKey::xhttpScStreamUpServerSecsMax] = scStreamUpServerSecsMax; + + obj["xPadding"] = xPadding.toJson(); + obj["xmux"] = xmux.toJson(); + + return obj; +} + +XrayXhttpConfig XrayXhttpConfig::fromJson(const QJsonObject &json) +{ + XrayXhttpConfig c; + c.mode = json.value(configKey::xhttpMode).toString(protocols::xray::defaultXhttpMode); + c.host = json.value(configKey::xhttpHost).toString(protocols::xray::defaultSite); + c.path = json.value(configKey::xhttpPath).toString(); + c.headersTemplate = json.value(configKey::xhttpHeadersTemplate).toString(protocols::xray::defaultXhttpHeadersTemplate); + c.uplinkMethod = json.value(configKey::xhttpUplinkMethod).toString(protocols::xray::defaultXhttpUplinkMethod); + c.disableGrpc = json.value(configKey::xhttpDisableGrpc).toBool(true); + c.disableSse = json.value(configKey::xhttpDisableSse).toBool(true); + + c.sessionPlacement = json.value(configKey::xhttpSessionPlacement).toString(protocols::xray::defaultXhttpSessionPlacement); + c.sessionKey = json.value(configKey::xhttpSessionKey).toString(); + c.seqPlacement = json.value(configKey::xhttpSeqPlacement).toString(protocols::xray::defaultXhttpSessionPlacement); + c.seqKey = json.value(configKey::xhttpSeqKey).toString(); + c.uplinkDataPlacement = json.value(configKey::xhttpUplinkDataPlacement).toString(protocols::xray::defaultXhttpUplinkDataPlacement); + c.uplinkDataKey = json.value(configKey::xhttpUplinkDataKey).toString(); + + c.uplinkChunkSize = json.value(configKey::xhttpUplinkChunkSize).toString("0"); + c.scMaxBufferedPosts = json.value(configKey::xhttpScMaxBufferedPosts).toString(); + c.scMaxEachPostBytesMin = json.value(configKey::xhttpScMaxEachPostBytesMin).toString("1"); + c.scMaxEachPostBytesMax = json.value(configKey::xhttpScMaxEachPostBytesMax).toString("100"); + c.scMinPostsIntervalMsMin = json.value(configKey::xhttpScMinPostsIntervalMsMin).toString("100"); + c.scMinPostsIntervalMsMax = json.value(configKey::xhttpScMinPostsIntervalMsMax).toString("800"); + c.scStreamUpServerSecsMin = json.value(configKey::xhttpScStreamUpServerSecsMin).toString("1"); + c.scStreamUpServerSecsMax = json.value(configKey::xhttpScStreamUpServerSecsMax).toString("100"); + + c.xPadding = XrayXPaddingConfig::fromJson(json.value("xPadding").toObject()); + c.xmux = XrayXmuxConfig::fromJson(json.value("xmux").toObject()); + + return c; +} + +QJsonObject XrayMkcpConfig::toJson() const +{ + QJsonObject obj; + if (!tti.isEmpty()) obj[configKey::mkcpTti] = tti; + if (!uplinkCapacity.isEmpty()) obj[configKey::mkcpUplinkCapacity] = uplinkCapacity; + if (!downlinkCapacity.isEmpty()) obj[configKey::mkcpDownlinkCapacity] = downlinkCapacity; + if (!readBufferSize.isEmpty()) obj[configKey::mkcpReadBufferSize] = readBufferSize; + if (!writeBufferSize.isEmpty()) obj[configKey::mkcpWriteBufferSize] = writeBufferSize; + obj[configKey::mkcpCongestion] = congestion; + return obj; +} + +XrayMkcpConfig XrayMkcpConfig::fromJson(const QJsonObject &json) +{ + XrayMkcpConfig c; + c.tti = json.value(configKey::mkcpTti).toString(); + c.uplinkCapacity = json.value(configKey::mkcpUplinkCapacity).toString(); + c.downlinkCapacity = json.value(configKey::mkcpDownlinkCapacity).toString(); + c.readBufferSize = json.value(configKey::mkcpReadBufferSize).toString(); + c.writeBufferSize = json.value(configKey::mkcpWriteBufferSize).toString(); + c.congestion = json.value(configKey::mkcpCongestion).toBool(true); + return c; +} QJsonObject XrayServerConfig::toJson() const { QJsonObject obj; - + + // Existing fields if (!port.isEmpty()) { obj[configKey::port] = port; } @@ -29,60 +182,96 @@ QJsonObject XrayServerConfig::toJson() const if (!site.isEmpty()) { obj[configKey::site] = site; } - + if (isThirdPartyConfig) { obj[configKey::isThirdPartyConfig] = isThirdPartyConfig; } - + + // New: Security + if (!security.isEmpty()) { + obj[configKey::xraySecurity] = security; + } + if (!flow.isEmpty()) { + obj[configKey::xrayFlow] = flow; + } + if (!fingerprint.isEmpty()) { + obj[configKey::xrayFingerprint] = fingerprint; + } + if (!sni.isEmpty()) { + obj[configKey::xraySni] = sni; + } + if (!alpn.isEmpty()) { + obj[configKey::xrayAlpn] = alpn; + } + + // New: Transport + if (!transport.isEmpty()) { + obj[configKey::xrayTransport] = transport; + } + obj["xhttp"] = xhttp.toJson(); + obj["mkcp"] = mkcp.toJson(); + return obj; } -XrayServerConfig XrayServerConfig::fromJson(const QJsonObject& json) +XrayServerConfig XrayServerConfig::fromJson(const QJsonObject &json) { - XrayServerConfig config; - - config.port = json.value(configKey::port).toString(); - config.transportProto = json.value(configKey::transportProto).toString(); - config.subnetAddress = json.value(configKey::subnetAddress).toString(); - config.site = json.value(configKey::site).toString(); - - config.isThirdPartyConfig = json.value(configKey::isThirdPartyConfig).toBool(false); - - return config; + XrayServerConfig c; + + // Existing fields + c.port = json.value(configKey::port).toString(); + c.transportProto = json.value(configKey::transportProto).toString(); + c.subnetAddress = json.value(configKey::subnetAddress).toString(); + c.site = json.value(configKey::site).toString(); + c.isThirdPartyConfig = json.value(configKey::isThirdPartyConfig).toBool(false); + + // New: Security + c.security = json.value(configKey::xraySecurity).toString(protocols::xray::defaultSecurity); + c.flow = json.value(configKey::xrayFlow).toString(protocols::xray::defaultFlow); + c.fingerprint = json.value(configKey::xrayFingerprint).toString(protocols::xray::defaultFingerprint); + if (c.fingerprint.contains(QLatin1String("Mozilla/5.0"), Qt::CaseInsensitive)) { + c.fingerprint = QString::fromLatin1(protocols::xray::defaultFingerprint); + } + c.sni = json.value(configKey::xraySni).toString(protocols::xray::defaultSni); + c.alpn = json.value(configKey::xrayAlpn).toString(protocols::xray::defaultAlpn); + + // New: Transport + c.transport = json.value(configKey::xrayTransport).toString(protocols::xray::defaultTransport); + c.xhttp = XrayXhttpConfig::fromJson(json.value("xhttp").toObject()); + c.mkcp = XrayMkcpConfig::fromJson(json.value("mkcp").toObject()); + + return c; } -bool XrayServerConfig::hasEqualServerSettings(const XrayServerConfig& other) const +bool XrayServerConfig::hasEqualServerSettings(const XrayServerConfig &other) const { - return port == other.port && site == other.site; + return port == other.port + && site == other.site + && security == other.security + && flow == other.flow + && transport == other.transport + && fingerprint == other.fingerprint + && sni == other.sni; } QJsonObject XrayClientConfig::toJson() const { QJsonObject obj; - - if (!nativeConfig.isEmpty()) { - obj[configKey::config] = nativeConfig; - } - if (!localPort.isEmpty()) { - obj[configKey::localPort] = localPort; - } - if (!id.isEmpty()) { - obj[configKey::clientId] = id; - } - + if (!nativeConfig.isEmpty()) obj[configKey::config] = nativeConfig; + if (!localPort.isEmpty()) obj[configKey::localPort] = localPort; + if (!id.isEmpty()) obj[configKey::clientId] = id; return obj; } -XrayClientConfig XrayClientConfig::fromJson(const QJsonObject& json) +XrayClientConfig XrayClientConfig::fromJson(const QJsonObject &json) { - XrayClientConfig config; - - config.nativeConfig = json.value(configKey::config).toString(); - config.localPort = json.value(configKey::localPort).toString(); - config.id = json.value(configKey::clientId).toString(); - - if (config.id.isEmpty() && !config.nativeConfig.isEmpty()) { - QJsonDocument doc = QJsonDocument::fromJson(config.nativeConfig.toUtf8()); + XrayClientConfig c; + c.nativeConfig = json.value(configKey::config).toString(); + c.localPort = json.value(configKey::localPort).toString(); + c.id = json.value(configKey::clientId).toString(); + + if (c.id.isEmpty() && !c.nativeConfig.isEmpty()) { + QJsonDocument doc = QJsonDocument::fromJson(c.nativeConfig.toUtf8()); if (!doc.isNull() && doc.isObject()) { QJsonObject configObj = doc.object(); if (configObj.contains(protocols::xray::outbounds)) { @@ -100,7 +289,7 @@ XrayClientConfig XrayClientConfig::fromJson(const QJsonObject& json) if (!users.isEmpty()) { QJsonObject user = users[0].toObject(); if (user.contains(protocols::xray::id)) { - config.id = user[protocols::xray::id].toString(); + c.id = user[protocols::xray::id].toString(); } } } @@ -111,16 +300,15 @@ XrayClientConfig XrayClientConfig::fromJson(const QJsonObject& json) } } } - - return config; + + return c; } QJsonObject XrayProtocolConfig::toJson() const { QJsonObject obj = serverConfig.toJson(); - + if (clientConfig.has_value()) { - // Third-party import: nativeConfig is raw Xray JSON (inbounds/outbounds) QJsonDocument doc = QJsonDocument::fromJson(clientConfig->nativeConfig.toUtf8()); if (!doc.isNull() && doc.isObject() && doc.object().contains(protocols::xray::outbounds) && !doc.object().contains(configKey::config)) { @@ -130,22 +318,20 @@ QJsonObject XrayProtocolConfig::toJson() const obj[configKey::lastConfig] = QString::fromUtf8(QJsonDocument(clientJson).toJson(QJsonDocument::Compact)); } } - + return obj; } -XrayProtocolConfig XrayProtocolConfig::fromJson(const QJsonObject& json) +XrayProtocolConfig XrayProtocolConfig::fromJson(const QJsonObject &json) { - XrayProtocolConfig config; - - config.serverConfig = XrayServerConfig::fromJson(json); - + XrayProtocolConfig c; + c.serverConfig = XrayServerConfig::fromJson(json); + QString lastConfigStr = json.value(configKey::lastConfig).toString(); if (!lastConfigStr.isEmpty()) { QJsonDocument doc = QJsonDocument::fromJson(lastConfigStr.toUtf8()); if (doc.isObject()) { QJsonObject parsed = doc.object(); - // Third-party import stores raw Xray config (inbounds/outbounds) directly if (parsed.contains(protocols::xray::outbounds) && !parsed.contains(configKey::config)) { XrayClientConfig clientCfg; clientCfg.nativeConfig = lastConfigStr; @@ -158,14 +344,14 @@ XrayProtocolConfig XrayProtocolConfig::fromJson(const QJsonObject& json) } } } - config.clientConfig = clientCfg; + c.clientConfig = clientCfg; } else { - config.clientConfig = XrayClientConfig::fromJson(parsed); + c.clientConfig = XrayClientConfig::fromJson(parsed); } } } - - return config; + + return c; } bool XrayProtocolConfig::hasClientConfig() const @@ -173,7 +359,7 @@ bool XrayProtocolConfig::hasClientConfig() const return clientConfig.has_value(); } -void XrayProtocolConfig::setClientConfig(const XrayClientConfig& config) +void XrayProtocolConfig::setClientConfig(const XrayClientConfig &config) { clientConfig = config; } @@ -184,4 +370,3 @@ void XrayProtocolConfig::clearClientConfig() } } // namespace amnezia - diff --git a/client/core/models/protocols/xrayProtocolConfig.h b/client/core/models/protocols/xrayProtocolConfig.h index fc52a81b3..eaf9abfd2 100644 --- a/client/core/models/protocols/xrayProtocolConfig.h +++ b/client/core/models/protocols/xrayProtocolConfig.h @@ -2,47 +2,145 @@ #define XRAYPROTOCOLCONFIG_H #include +#include "core/utils/constants/protocolConstants.h" #include #include namespace amnezia { +// ── xPadding ───────────────────────────────────────────────────────────────── +struct XrayXPaddingConfig { + QString bytesMin; // xPaddingBytes min + QString bytesMax; // xPaddingBytes max + bool obfsMode = true; // xPaddingObfsMode + QString key; // xPaddingKey + QString header; // xPaddingHeader + QString placement = protocols::xray::defaultXPaddingPlacement; // xPaddingPlacement: Cookie|Header|Query|Body + QString method = protocols::xray::defaultXPaddingMethod; // xPaddingMethod: Repeat-x|Random|Zero + + QJsonObject toJson() const; + static XrayXPaddingConfig fromJson(const QJsonObject &json); +}; + +// ── xmux ───────────────────────────────────────────────────────────────────── +struct XrayXmuxConfig { + bool enabled = true; + + QString maxConcurrencyMin = "0"; + QString maxConcurrencyMax = "0"; + QString maxConnectionsMin = "0"; + QString maxConnectionsMax = "0"; + QString cMaxReuseTimesMin = "0"; + QString cMaxReuseTimesMax = "0"; + QString hMaxRequestTimesMin = "0"; + QString hMaxRequestTimesMax = "0"; + QString hMaxReusableSecsMin = "0"; + QString hMaxReusableSecsMax = "0"; + QString hKeepAlivePeriod; + + QJsonObject toJson() const; + static XrayXmuxConfig fromJson(const QJsonObject &json); +}; + +// ── XHTTP transport ─────────────────────────────────────────────────────────── +struct XrayXhttpConfig { + QString mode = protocols::xray::defaultXhttpMode; // Auto|Packet-up|Stream-up|Stream-one + QString host = protocols::xray::defaultXhttpHost; + QString path; + QString headersTemplate = protocols::xray::defaultXhttpHeadersTemplate; // HTTP|None + QString uplinkMethod = protocols::xray::defaultXhttpUplinkMethod; // POST|PUT|PATCH + bool disableGrpc = true; + bool disableSse = true; + + // Session & Sequence + QString sessionPlacement = protocols::xray::defaultXhttpSessionPlacement; + QString sessionKey = protocols::xray::defaultXhttpSessionKey; + QString seqPlacement = protocols::xray::defaultXhttpSeqPlacement; + QString seqKey; + QString uplinkDataPlacement = protocols::xray::defaultXhttpUplinkDataPlacement; + QString uplinkDataKey; + + // Traffic Shaping + QString uplinkChunkSize = protocols::xray::defaultXhttpUplinkChunkSize; + QString scMaxBufferedPosts; + QString scMaxEachPostBytesMin = protocols::xray::defaultXhttpScMaxEachPostBytesMin; + QString scMaxEachPostBytesMax = protocols::xray::defaultXhttpScMaxEachPostBytesMax; + QString scMinPostsIntervalMsMin = protocols::xray::defaultXhttpScMinPostsIntervalMsMin; + QString scMinPostsIntervalMsMax = protocols::xray::defaultXhttpScMinPostsIntervalMsMax; + QString scStreamUpServerSecsMin = protocols::xray::defaultXhttpScStreamUpServerSecsMin; + QString scStreamUpServerSecsMax = protocols::xray::defaultXhttpScStreamUpServerSecsMax; + + XrayXPaddingConfig xPadding; + XrayXmuxConfig xmux; + + QJsonObject toJson() const; + static XrayXhttpConfig fromJson(const QJsonObject &json); +}; + +// ── mKCP transport ──────────────────────────────────────────────────────────── +struct XrayMkcpConfig { + QString tti; + QString uplinkCapacity; + QString downlinkCapacity; + QString readBufferSize; + QString writeBufferSize; + bool congestion = true; + + QJsonObject toJson() const; + static XrayMkcpConfig fromJson(const QJsonObject &json); +}; + +// ── Server config (settings editable by user) ───────────────────────────────── struct XrayServerConfig { QString port; QString transportProto; QString subnetAddress; QString site; bool isThirdPartyConfig = false; - + + // New: Security + QString security = protocols::xray::defaultSecurity; + QString flow = protocols::xray::defaultFlow; + QString fingerprint = protocols::xray::defaultFingerprint; + QString sni = protocols::xray::defaultSni; + QString alpn = protocols::xray::defaultAlpn; + + // New: Transport + QString transport = protocols::xray::defaultTransport; + XrayXhttpConfig xhttp; + XrayMkcpConfig mkcp; + QJsonObject toJson() const; - static XrayServerConfig fromJson(const QJsonObject& json); - - bool hasEqualServerSettings(const XrayServerConfig& other) const; + + static XrayServerConfig fromJson(const QJsonObject &json); + + bool hasEqualServerSettings(const XrayServerConfig &other) const; }; +// ── Client config (generated, not edited by user) ───────────────────────────── struct XrayClientConfig { QString nativeConfig; QString localPort; QString id; - + QJsonObject toJson() const; - static XrayClientConfig fromJson(const QJsonObject& json); + static XrayClientConfig fromJson(const QJsonObject &json); }; +// ── Top-level protocol config ────────────────────────────────────────────────── struct XrayProtocolConfig { XrayServerConfig serverConfig; std::optional clientConfig; - + QJsonObject toJson() const; - static XrayProtocolConfig fromJson(const QJsonObject& json); - + static XrayProtocolConfig fromJson(const QJsonObject &json); + bool hasClientConfig() const; - void setClientConfig(const XrayClientConfig& config); + void setClientConfig(const XrayClientConfig &config); void clearClientConfig(); }; } // namespace amnezia #endif // XRAYPROTOCOLCONFIG_H - diff --git a/client/core/protocols/xrayProtocol.cpp b/client/core/protocols/xrayProtocol.cpp old mode 100755 new mode 100644 index cceaddc28..9b9b6e41e --- a/client/core/protocols/xrayProtocol.cpp +++ b/client/core/protocols/xrayProtocol.cpp @@ -2,6 +2,7 @@ #include "core/protocols/protocolUtils.h" #include "core/utils/constants/configKeys.h" +#include "core/utils/constants/protocolConstants.h" #include "core/utils/ipcClient.h" #include "core/utils/networkUtilities.h" #include "core/utils/serialization/serialization.h" @@ -9,6 +10,7 @@ #include #include +#include #include #include #include @@ -79,12 +81,29 @@ ErrorCode XrayProtocol::start() m_socksPassword = creds.password; m_socksPort = creds.port; - const QString xrayConfigStr = QJsonDocument(m_xrayConfig).toJson(QJsonDocument::Compact); + QString xrayConfigStr = QJsonDocument(m_xrayConfig).toJson(QJsonDocument::Compact); if (xrayConfigStr.isEmpty()) { qCritical() << "Xray config is empty"; return ErrorCode::XrayExecutableCrashed; } + // Fix fingerprint: old configs may contain "Mozilla/5.0" which xray-core rejects. + // Replace with the correct default at runtime so stale stored configs still work. + if (xrayConfigStr.contains("Mozilla/5.0", Qt::CaseInsensitive)) { + xrayConfigStr.replace("Mozilla/5.0", amnezia::protocols::xray::defaultFingerprint, + Qt::CaseInsensitive); + qDebug() << "XrayProtocol: patched legacy fingerprint to" + << amnezia::protocols::xray::defaultFingerprint; + } + + // Fix inbound listen address: old configs may use "10.33.0.2" which doesn't exist + // until TUN is created. xray must listen on 127.0.0.1 so tun2socks can connect. + if (xrayConfigStr.contains(amnezia::protocols::xray::defaultLocalAddr)) { + xrayConfigStr.replace(amnezia::protocols::xray::defaultLocalAddr, + amnezia::protocols::xray::defaultLocalListenAddr); + qDebug() << "XrayProtocol: patched legacy inbound listen address to 127.0.0.1"; + } + return IpcClient::withInterface( [&](QSharedPointer iface) { auto xrayStart = iface->xrayStart(xrayConfigStr); @@ -188,6 +207,33 @@ ErrorCode XrayProtocol::startTun2Socks() connect( m_tun2socksProcess.data(), &IpcProcessInterfaceReplica::finished, this, [this](int exitCode, QProcess::ExitStatus exitStatus) { + // Check stdout for "resource busy" — the TUN device was not yet released + // by the previous tun2socks instance. Retry after a short delay. + bool resourceBusy = false; + if (m_tun2socksProcess) { + auto readOut = m_tun2socksProcess->readAllStandardOutput(); + if (readOut.waitForFinished()) { + resourceBusy = readOut.returnValue().contains("resource busy"); + } + } + + if (resourceBusy && m_tun2socksRetryCount < maxTun2SocksRetries) { + m_tun2socksRetryCount++; + qWarning() << QString("Tun2socks: TUN resource busy, retrying (%1/%2) in %3ms...") + .arg(m_tun2socksRetryCount) + .arg(maxTun2SocksRetries) + .arg(tun2socksRetryDelayMs); + QTimer::singleShot(tun2socksRetryDelayMs, this, [this]() { + if (ErrorCode err = startTun2Socks(); err != ErrorCode::NoError) { + stop(); + setLastError(err); + } + }); + return; + } + + m_tun2socksRetryCount = 0; + if (exitStatus == QProcess::ExitStatus::CrashExit) { qCritical() << "Tun2socks process crashed!"; } else { diff --git a/client/core/protocols/xrayProtocol.h b/client/core/protocols/xrayProtocol.h index e831ab2f4..55b6d1d5c 100644 --- a/client/core/protocols/xrayProtocol.h +++ b/client/core/protocols/xrayProtocol.h @@ -35,6 +35,9 @@ private: int m_socksPort = 10808; QSharedPointer m_tun2socksProcess; + int m_tun2socksRetryCount = 0; + static constexpr int maxTun2SocksRetries = 5; + static constexpr int tun2socksRetryDelayMs = 400; }; #endif // XRAYPROTOCOL_H diff --git a/client/core/repositories/secureAppSettingsRepository.cpp b/client/core/repositories/secureAppSettingsRepository.cpp index 01f313b08..441e53bbc 100644 --- a/client/core/repositories/secureAppSettingsRepository.cpp +++ b/client/core/repositories/secureAppSettingsRepository.cpp @@ -451,4 +451,12 @@ void SecureAppSettingsRepository::setInstallationUuid(const QString &uuid) m_settings->setValue("Conf/installationUuid", uuid); } +QByteArray SecureAppSettingsRepository::xraySavedConfigs() const +{ + return value("Xray/savedConfigs").toByteArray(); +} +void SecureAppSettingsRepository::setXraySavedConfigs(const QByteArray &data) +{ + setValue("Xray/savedConfigs", data); +} diff --git a/client/core/repositories/secureAppSettingsRepository.h b/client/core/repositories/secureAppSettingsRepository.h index 54ee0cd07..f95174dd4 100644 --- a/client/core/repositories/secureAppSettingsRepository.h +++ b/client/core/repositories/secureAppSettingsRepository.h @@ -92,6 +92,9 @@ public: QString nextAvailableServerName() const; + QByteArray xraySavedConfigs() const; + void setXraySavedConfigs(const QByteArray &data); + signals: void appLanguageChanged(QLocale locale); void allowedDnsServersChanged(const QStringList &servers); diff --git a/client/core/utils/constants/configKeys.h b/client/core/utils/constants/configKeys.h index 62d9577e4..b896cdc37 100644 --- a/client/core/utils/constants/configKeys.h +++ b/client/core/utils/constants/configKeys.h @@ -126,6 +126,76 @@ namespace amnezia constexpr QLatin1String dataSent("dataSent"); constexpr QLatin1String storageServerId("storageServerId"); + + // ── Xray-specific keys ──────────────────────────────────────── + + // Security + constexpr QLatin1String xraySecurity("xray_security"); // none | tls | reality + constexpr QLatin1String xrayFlow("xray_flow"); // "" | xtls-rprx-vision | xtls-rprx-vision-udp443 + constexpr QLatin1String xrayFingerprint("xray_fingerprint"); // Mozilla/5.0 | chrome | firefox | ... + constexpr QLatin1String xraySni("xray_sni"); // Server Name (SNI) + constexpr QLatin1String xrayAlpn("xray_alpn"); // HTTP/2 | HTTP/1.1 | HTTP/2,HTTP/1.1 + + // Transport — common + constexpr QLatin1String xrayTransport("xray_transport"); // raw | xhttp | mkcp + + // Transport — XHTTP + constexpr QLatin1String xhttpMode("xhttp_mode"); // Auto | Packet-up | Stream-up | Stream-one + constexpr QLatin1String xhttpHost("xhttp_host"); + constexpr QLatin1String xhttpPath("xhttp_path"); + constexpr QLatin1String xhttpHeadersTemplate("xhttp_headers_template"); // HTTP | None + constexpr QLatin1String xhttpUplinkMethod("xhttp_uplink_method"); // POST | PUT | PATCH + constexpr QLatin1String xhttpDisableGrpc("xhttp_disable_grpc"); // bool + constexpr QLatin1String xhttpDisableSse("xhttp_disable_sse"); // bool + + // Transport — XHTTP Session & Sequence + constexpr QLatin1String xhttpSessionPlacement("xhttp_session_placement"); // Path | Header | Cookie | None + constexpr QLatin1String xhttpSessionKey("xhttp_session_key"); + constexpr QLatin1String xhttpSeqPlacement("xhttp_seq_placement"); + constexpr QLatin1String xhttpSeqKey("xhttp_seq_key"); + constexpr QLatin1String xhttpUplinkDataPlacement("xhttp_uplink_data_placement"); // Body | Query + constexpr QLatin1String xhttpUplinkDataKey("xhttp_uplink_data_key"); + + // Transport — XHTTP Traffic Shaping + constexpr QLatin1String xhttpUplinkChunkSize("xhttp_uplink_chunk_size"); + constexpr QLatin1String xhttpScMaxBufferedPosts("xhttp_sc_max_buffered_posts"); + constexpr QLatin1String xhttpScMaxEachPostBytesMin("xhttp_sc_max_each_post_bytes_min"); + constexpr QLatin1String xhttpScMaxEachPostBytesMax("xhttp_sc_max_each_post_bytes_max"); + constexpr QLatin1String xhttpScMinPostsIntervalMsMin("xhttp_sc_min_posts_interval_ms_min"); + constexpr QLatin1String xhttpScMinPostsIntervalMsMax("xhttp_sc_min_posts_interval_ms_max"); + constexpr QLatin1String xhttpScStreamUpServerSecsMin("xhttp_sc_stream_up_server_secs_min"); + constexpr QLatin1String xhttpScStreamUpServerSecsMax("xhttp_sc_stream_up_server_secs_max"); + + // Transport — mKCP + constexpr QLatin1String mkcpTti("mkcp_tti"); + constexpr QLatin1String mkcpUplinkCapacity("mkcp_uplink_capacity"); + constexpr QLatin1String mkcpDownlinkCapacity("mkcp_downlink_capacity"); + constexpr QLatin1String mkcpReadBufferSize("mkcp_read_buffer_size"); + constexpr QLatin1String mkcpWriteBufferSize("mkcp_write_buffer_size"); + constexpr QLatin1String mkcpCongestion("mkcp_congestion"); // bool + + // xPadding + constexpr QLatin1String xPaddingBytesMin("xpadding_bytes_min"); + constexpr QLatin1String xPaddingBytesMax("xpadding_bytes_max"); + constexpr QLatin1String xPaddingObfsMode("xpadding_obfs_mode"); // bool + constexpr QLatin1String xPaddingKey("xpadding_key"); + constexpr QLatin1String xPaddingHeader("xpadding_header"); + constexpr QLatin1String xPaddingPlacement("xpadding_placement"); // Cookie | Header | Query | Body + constexpr QLatin1String xPaddingMethod("xpadding_method"); // Repeat-x | Random | Zero + + // xmux + constexpr QLatin1String xmuxEnabled("xmux_enabled"); // bool + constexpr QLatin1String xmuxMaxConcurrencyMin("xmux_max_concurrency_min"); + constexpr QLatin1String xmuxMaxConcurrencyMax("xmux_max_concurrency_max"); + constexpr QLatin1String xmuxMaxConnectionsMin("xmux_max_connections_min"); + constexpr QLatin1String xmuxMaxConnectionsMax("xmux_max_connections_max"); + constexpr QLatin1String xmuxCMaxReuseTimesMin("xmux_c_max_reuse_times_min"); + constexpr QLatin1String xmuxCMaxReuseTimesMax("xmux_c_max_reuse_times_max"); + constexpr QLatin1String xmuxHMaxRequestTimesMin("xmux_h_max_request_times_min"); + constexpr QLatin1String xmuxHMaxRequestTimesMax("xmux_h_max_request_times_max"); + constexpr QLatin1String xmuxHMaxReusableSecsMin("xmux_h_max_reusable_secs_min"); + constexpr QLatin1String xmuxHMaxReusableSecsMax("xmux_h_max_reusable_secs_max"); + constexpr QLatin1String xmuxHKeepAlivePeriod("xmux_h_keep_alive_period"); } } diff --git a/client/core/utils/constants/protocolConstants.h b/client/core/utils/constants/protocolConstants.h index ec502669d..5c65d881e 100644 --- a/client/core/utils/constants/protocolConstants.h +++ b/client/core/utils/constants/protocolConstants.h @@ -58,6 +58,40 @@ namespace amnezia constexpr char defaultPort[] = "443"; constexpr char defaultLocalProxyPort[] = "10808"; constexpr char defaultLocalAddr[] = "10.33.0.2"; + constexpr char defaultLocalListenAddr[] = "127.0.0.1"; + + constexpr char defaultSecurity[] = "reality"; + constexpr char defaultFlow[] = "xtls-rprx-vision"; + constexpr char defaultTransport[] = "raw"; + constexpr char defaultFingerprint[] = "chrome"; + constexpr char defaultSni[] = "cdn.example.com"; + constexpr char defaultAlpn[] = "HTTP/2"; + + constexpr char defaultXhttpMode[] = "Auto"; + constexpr char defaultXhttpHeadersTemplate[] = "HTTP"; + constexpr char defaultXhttpUplinkMethod[] = "POST"; + constexpr char defaultXhttpSessionPlacement[] = "Path"; + constexpr char defaultXhttpSessionKey[] = "Path"; + constexpr char defaultXhttpSeqPlacement[] = "Path"; + constexpr char defaultXhttpUplinkDataPlacement[] = "Body"; + + constexpr char defaultXhttpHost[] = "www.googletagmanager.com"; + constexpr char defaultXhttpUplinkChunkSize[] = "0"; + constexpr char defaultXhttpScMaxEachPostBytesMin[] = "1"; + constexpr char defaultXhttpScMaxEachPostBytesMax[] = "100"; + constexpr char defaultXhttpScMinPostsIntervalMsMin[] = "100"; + constexpr char defaultXhttpScMinPostsIntervalMsMax[] = "800"; + constexpr char defaultXhttpScStreamUpServerSecsMin[] = "1"; + constexpr char defaultXhttpScStreamUpServerSecsMax[] = "100"; + + constexpr char defaultXPaddingPlacement[] = "Cookie"; + constexpr char defaultXPaddingMethod[] = "Repeat-x"; + + constexpr char defaultMkcpTti[] = "50"; + constexpr char defaultMkcpUplinkCapacity[] = "5"; + constexpr char defaultMkcpDownlinkCapacity[] = "20"; + constexpr char defaultMkcpReadBufferSize[] = "2"; + constexpr char defaultMkcpWriteBufferSize[] = "2"; constexpr char outbounds[] = "outbounds"; constexpr char inbounds[] = "inbounds"; diff --git a/client/platforms/ios/ios_controller.mm b/client/platforms/ios/ios_controller.mm index c93dac460..e988ae43e 100644 --- a/client/platforms/ios/ios_controller.mm +++ b/client/platforms/ios/ios_controller.mm @@ -220,7 +220,7 @@ bool IosController::connectVpn(amnezia::Proto proto, const QJsonObject& configur m_rawConfig = configuration; m_serverAddress = configuration.value(configKey::hostName).toString().toNSString(); - const QString serverDescription = configuration.value(config_key::description).toString().trimmed(); + const QString serverDescription = configuration.value(configKey::description).toString().trimmed(); QString tunnelName; if (serverDescription.isEmpty()) { tunnelName = ProtocolUtils::protoToString(proto); diff --git a/client/translations/amneziavpn_ru_RU.ts b/client/translations/amneziavpn_ru_RU.ts index e813b40f1..b1f95a688 100644 --- a/client/translations/amneziavpn_ru_RU.ts +++ b/client/translations/amneziavpn_ru_RU.ts @@ -1312,6 +1312,21 @@ Thank you for staying with us! PageProtocolXraySettings + + + XRay VLESS settings + Настройки XRay VLESS + + + + More about settings + Подробнее о настройках + + + + Reset settings + Сбросить настройки + XRay settings diff --git a/client/ui/controllers/importUiController.cpp b/client/ui/controllers/importUiController.cpp index ce9b952c1..db81c608c 100644 --- a/client/ui/controllers/importUiController.cpp +++ b/client/ui/controllers/importUiController.cpp @@ -201,3 +201,12 @@ bool ImportUiController::decodeQrCode(const QString &code) return mInstance->parseQrCodeChunk(code); } #endif + +QString ImportUiController::readTextFile(const QString &fileName) +{ + QFile file(fileName); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + return {}; + } + return QString::fromUtf8(file.readAll()); +} diff --git a/client/ui/controllers/importUiController.h b/client/ui/controllers/importUiController.h index 853539d05..c527e93c2 100644 --- a/client/ui/controllers/importUiController.h +++ b/client/ui/controllers/importUiController.h @@ -28,6 +28,7 @@ public slots: QString getMaliciousWarningText(); bool isNativeWireGuardConfig(); void processNativeWireGuardConfig(); + QString readTextFile(const QString &fileName); #if defined Q_OS_ANDROID || defined Q_OS_IOS void startDecodingQr(); diff --git a/client/ui/controllers/qml/pageController.h b/client/ui/controllers/qml/pageController.h index 714333a5a..216328daf 100644 --- a/client/ui/controllers/qml/pageController.h +++ b/client/ui/controllers/qml/pageController.h @@ -82,7 +82,15 @@ namespace PageLoader PageSetupWizardApiPremiumInfo, PageSetupWizardApiTrialEmail, - PageDevMenu + PageDevMenu, + + PageProtocolXraySnapshots, + PageProtocolXrayTransportSettings, + PageProtocolXrayXmuxSettings, + PageProtocolXrayXPaddingSettings, + PageProtocolXrayFlowSettings, + PageProtocolXraySecuritySettings, + PageProtocolXrayXPaddingBytesSettings, }; Q_ENUM_NS(PageEnum) diff --git a/client/ui/controllers/selfhosted/exportUiController.cpp b/client/ui/controllers/selfhosted/exportUiController.cpp index 3be7ef981..bbe1da570 100644 --- a/client/ui/controllers/selfhosted/exportUiController.cpp +++ b/client/ui/controllers/selfhosted/exportUiController.cpp @@ -127,3 +127,13 @@ void ExportUiController::applyExportResult(const ExportController::ExportResult emit exportConfigChanged(); } + +void ExportUiController::setConfigFromString(const QString &config, const QString &fileName) +{ + clearPreviousConfig(); + m_config = config; + emit exportConfigChanged(); + if (!fileName.isEmpty()) { + SystemController::saveFile(fileName, m_config); + } +} diff --git a/client/ui/controllers/selfhosted/exportUiController.h b/client/ui/controllers/selfhosted/exportUiController.h index 20f7a2282..970ce7c08 100644 --- a/client/ui/controllers/selfhosted/exportUiController.h +++ b/client/ui/controllers/selfhosted/exportUiController.h @@ -32,6 +32,7 @@ public slots: QList getQrCodes(); void exportConfig(const QString &fileName); + void setConfigFromString(const QString &config, const QString &fileName); void updateClientManagementModel(const QString &serverId, int containerIndex); diff --git a/client/ui/controllers/selfhosted/installUiController.cpp b/client/ui/controllers/selfhosted/installUiController.cpp old mode 100755 new mode 100644 index ad42f0b86..f413aac19 --- a/client/ui/controllers/selfhosted/installUiController.cpp +++ b/client/ui/controllers/selfhosted/installUiController.cpp @@ -592,7 +592,8 @@ void InstallUiController::updateProtocolConfigModel(const QString &serverId, int case Proto::Awg: updateIfPresent(m_awgConfigModel, containerConfig.getAwgProtocolConfig()); break; case Proto::WireGuard: updateIfPresent(m_wireGuardConfigModel, containerConfig.getWireGuardProtocolConfig()); break; case Proto::OpenVpn: updateIfPresent(m_openVpnConfigModel, containerConfig.getOpenVpnProtocolConfig()); break; - case Proto::Xray: updateIfPresent(m_xrayConfigModel, containerConfig.getXrayProtocolConfig()); break; + case Proto::Xray: + case Proto::SSXray: updateIfPresent(m_xrayConfigModel, containerConfig.getXrayProtocolConfig()); break; case Proto::TorWebSite: updateIfPresent(m_torConfigModel, containerConfig.getTorProtocolConfig()); break; case Proto::Sftp: updateIfPresent(m_sftpConfigModel, containerConfig.getSftpProtocolConfig()); break; case Proto::Socks5Proxy: updateIfPresent(m_socks5ConfigModel, containerConfig.getSocks5ProxyProtocolConfig()); break; diff --git a/client/ui/models/protocols/xrayConfigModel.cpp b/client/ui/models/protocols/xrayConfigModel.cpp index 462982073..9a24759c9 100644 --- a/client/ui/models/protocols/xrayConfigModel.cpp +++ b/client/ui/models/protocols/xrayConfigModel.cpp @@ -8,94 +8,575 @@ using namespace amnezia; using namespace ProtocolUtils; -XrayConfigModel::XrayConfigModel(QObject *parent) : QAbstractListModel(parent) +XrayConfigModel::XrayConfigModel(QObject* parent) : QAbstractListModel(parent) { } -int XrayConfigModel::rowCount(const QModelIndex &parent) const +int XrayConfigModel::rowCount(const QModelIndex& parent) const { Q_UNUSED(parent); return 1; } -bool XrayConfigModel::setData(const QModelIndex &index, const QVariant &value, int role) +bool XrayConfigModel::setData(const QModelIndex& index, const QVariant& value, int role) { - if (!index.isValid() || index.row() < 0 || index.row() >= ContainerUtils::allContainers().size()) { + // This model always has a single row (row 0). Using rowCount() avoids + // coupling editing ability to global container list size. + if (!index.isValid() || index.row() < 0 || index.row() >= rowCount()) + { return false; } - QString strValue = value.toString(); + const bool wasUnsavedChanges = hasUnsavedChanges(); + + auto& srv = m_protocolConfig.serverConfig; + auto& xhttp = srv.xhttp; + auto& mkcp = srv.mkcp; + auto& pad = xhttp.xPadding; + auto& mux = xhttp.xmux; + + QString str = value.toString(); + + switch (role) + { + // ── Main ────────────────────────────────────────────────────────── + case Roles::SiteRole: srv.site = str; + break; + case Roles::PortRole: srv.port = str; + break; + case Roles::TransportRole: srv.transport = str; + break; + case Roles::SecurityRole: srv.security = str; + break; + case Roles::FlowRole: srv.flow = str; + break; + + // ── Security ────────────────────────────────────────────────────── + case Roles::FingerprintRole: srv.fingerprint = str; + break; + case Roles::SniRole: srv.sni = str; + break; + case Roles::AlpnRole: srv.alpn = str; + break; + + // ── XHTTP ───────────────────────────────────────────────────────── + case Roles::XhttpModeRole: xhttp.mode = str; + break; + case Roles::XhttpHostRole: xhttp.host = str; + break; + case Roles::XhttpPathRole: xhttp.path = str; + break; + case Roles::XhttpHeadersTemplateRole: xhttp.headersTemplate = str; + break; + case Roles::XhttpUplinkMethodRole: xhttp.uplinkMethod = str; + break; + case Roles::XhttpDisableGrpcRole: xhttp.disableGrpc = value.toBool(); + break; + case Roles::XhttpDisableSseRole: xhttp.disableSse = value.toBool(); + break; + + case Roles::XhttpSessionPlacementRole: xhttp.sessionPlacement = str; + break; + case Roles::XhttpSessionKeyRole: xhttp.sessionKey = str; + break; + case Roles::XhttpSeqPlacementRole: xhttp.seqPlacement = str; + break; + case Roles::XhttpSeqKeyRole: xhttp.seqKey = str; + break; + case Roles::XhttpUplinkDataPlacementRole: xhttp.uplinkDataPlacement = str; + break; + case Roles::XhttpUplinkDataKeyRole: xhttp.uplinkDataKey = str; + break; + + case Roles::XhttpUplinkChunkSizeRole: xhttp.uplinkChunkSize = str; + break; + case Roles::XhttpScMaxBufferedPostsRole: xhttp.scMaxBufferedPosts = str; + break; + case Roles::XhttpScMaxEachPostBytesMinRole: xhttp.scMaxEachPostBytesMin = str; + break; + case Roles::XhttpScMaxEachPostBytesMaxRole: xhttp.scMaxEachPostBytesMax = str; + break; + case Roles::XhttpScMinPostsIntervalMsMinRole: xhttp.scMinPostsIntervalMsMin = str; + break; + case Roles::XhttpScMinPostsIntervalMsMaxRole: xhttp.scMinPostsIntervalMsMax = str; + break; + case Roles::XhttpScStreamUpServerSecsMinRole: xhttp.scStreamUpServerSecsMin = str; + break; + case Roles::XhttpScStreamUpServerSecsMaxRole: xhttp.scStreamUpServerSecsMax = str; + break; + + // ── mKCP ────────────────────────────────────────────────────────── + case Roles::MkcpTtiRole: mkcp.tti = str; + break; + case Roles::MkcpUplinkCapacityRole: mkcp.uplinkCapacity = str; + break; + case Roles::MkcpDownlinkCapacityRole: mkcp.downlinkCapacity = str; + break; + case Roles::MkcpReadBufferSizeRole: mkcp.readBufferSize = str; + break; + case Roles::MkcpWriteBufferSizeRole: mkcp.writeBufferSize = str; + break; + case Roles::MkcpCongestionRole: mkcp.congestion = value.toBool(); + break; + + // ── xPadding ────────────────────────────────────────────────────── + case Roles::XPaddingBytesMinRole: pad.bytesMin = str; + break; + case Roles::XPaddingBytesMaxRole: pad.bytesMax = str; + break; + case Roles::XPaddingObfsModeRole: pad.obfsMode = value.toBool(); + break; + case Roles::XPaddingKeyRole: pad.key = str; + break; + case Roles::XPaddingHeaderRole: pad.header = str; + break; + case Roles::XPaddingPlacementRole: pad.placement = str; + break; + case Roles::XPaddingMethodRole: pad.method = str; + break; + + // ── xmux ────────────────────────────────────────────────────────── + case Roles::XmuxEnabledRole: mux.enabled = value.toBool(); + break; + case Roles::XmuxMaxConcurrencyMinRole: mux.maxConcurrencyMin = str; + break; + case Roles::XmuxMaxConcurrencyMaxRole: mux.maxConcurrencyMax = str; + break; + case Roles::XmuxMaxConnectionsMinRole: mux.maxConnectionsMin = str; + break; + case Roles::XmuxMaxConnectionsMaxRole: mux.maxConnectionsMax = str; + break; + case Roles::XmuxCMaxReuseTimesMinRole: mux.cMaxReuseTimesMin = str; + break; + case Roles::XmuxCMaxReuseTimesMaxRole: mux.cMaxReuseTimesMax = str; + break; + case Roles::XmuxHMaxRequestTimesMinRole: mux.hMaxRequestTimesMin = str; + break; + case Roles::XmuxHMaxRequestTimesMaxRole: mux.hMaxRequestTimesMax = str; + break; + case Roles::XmuxHMaxReusableSecsMinRole: mux.hMaxReusableSecsMin = str; + break; + case Roles::XmuxHMaxReusableSecsMaxRole: mux.hMaxReusableSecsMax = str; + break; + case Roles::XmuxHKeepAlivePeriodRole: mux.hKeepAlivePeriod = str; + break; - switch (role) { - case Roles::SiteRole: m_protocolConfig.serverConfig.site = strValue; break; - case Roles::PortRole: m_protocolConfig.serverConfig.port = strValue; break; default: return false; } - emit dataChanged(index, index, QList { role }); + emit dataChanged(index, index, QList{role}); + if (wasUnsavedChanges != hasUnsavedChanges()) { + emit hasUnsavedChangesChanged(); + } return true; } -QVariant XrayConfigModel::data(const QModelIndex &index, int role) const +QVariant XrayConfigModel::data(const QModelIndex& index, int role) const { if (!index.isValid() || index.row() < 0 || index.row() >= rowCount()) { return QVariant(); } - switch (role) { - case Roles::SiteRole: return m_protocolConfig.serverConfig.site; - case Roles::PortRole: return m_protocolConfig.serverConfig.port; + const auto& srv = m_protocolConfig.serverConfig; + const auto& xhttp = srv.xhttp; + const auto& mkcp = srv.mkcp; + const auto& pad = xhttp.xPadding; + const auto& mux = xhttp.xmux; + + switch (role) + { + // ── Main ────────────────────────────────────────────────────────── + case Roles::SiteRole: return srv.site; + case Roles::PortRole: return srv.port; + case Roles::TransportRole: return srv.transport; + case Roles::SecurityRole: return srv.security; + case Roles::FlowRole: return srv.flow; + + // ── Security ────────────────────────────────────────────────────── + case Roles::FingerprintRole: return srv.fingerprint; + case Roles::SniRole: return srv.sni; + case Roles::AlpnRole: return srv.alpn; + + // ── XHTTP ───────────────────────────────────────────────────────── + case Roles::XhttpModeRole: return xhttp.mode; + case Roles::XhttpHostRole: return xhttp.host; + case Roles::XhttpPathRole: return xhttp.path; + case Roles::XhttpHeadersTemplateRole: return xhttp.headersTemplate; + case Roles::XhttpUplinkMethodRole: return xhttp.uplinkMethod; + case Roles::XhttpDisableGrpcRole: return xhttp.disableGrpc; + case Roles::XhttpDisableSseRole: return xhttp.disableSse; + + case Roles::XhttpSessionPlacementRole: return xhttp.sessionPlacement; + case Roles::XhttpSessionKeyRole: return xhttp.sessionKey; + case Roles::XhttpSeqPlacementRole: return xhttp.seqPlacement; + case Roles::XhttpSeqKeyRole: return xhttp.seqKey; + case Roles::XhttpUplinkDataPlacementRole: return xhttp.uplinkDataPlacement; + case Roles::XhttpUplinkDataKeyRole: return xhttp.uplinkDataKey; + + case Roles::XhttpUplinkChunkSizeRole: return xhttp.uplinkChunkSize; + case Roles::XhttpScMaxBufferedPostsRole: return xhttp.scMaxBufferedPosts; + case Roles::XhttpScMaxEachPostBytesMinRole: return xhttp.scMaxEachPostBytesMin; + case Roles::XhttpScMaxEachPostBytesMaxRole: return xhttp.scMaxEachPostBytesMax; + case Roles::XhttpScMinPostsIntervalMsMinRole: return xhttp.scMinPostsIntervalMsMin; + case Roles::XhttpScMinPostsIntervalMsMaxRole: return xhttp.scMinPostsIntervalMsMax; + case Roles::XhttpScStreamUpServerSecsMinRole: return xhttp.scStreamUpServerSecsMin; + case Roles::XhttpScStreamUpServerSecsMaxRole: return xhttp.scStreamUpServerSecsMax; + + // ── mKCP ────────────────────────────────────────────────────────── + case Roles::MkcpTtiRole: return mkcp.tti; + case Roles::MkcpUplinkCapacityRole: return mkcp.uplinkCapacity; + case Roles::MkcpDownlinkCapacityRole: return mkcp.downlinkCapacity; + case Roles::MkcpReadBufferSizeRole: return mkcp.readBufferSize; + case Roles::MkcpWriteBufferSizeRole: return mkcp.writeBufferSize; + case Roles::MkcpCongestionRole: return mkcp.congestion; + + // ── xPadding ────────────────────────────────────────────────────── + case Roles::XPaddingBytesMinRole: return pad.bytesMin; + case Roles::XPaddingBytesMaxRole: return pad.bytesMax; + case Roles::XPaddingObfsModeRole: return pad.obfsMode; + case Roles::XPaddingKeyRole: return pad.key; + case Roles::XPaddingHeaderRole: return pad.header; + case Roles::XPaddingPlacementRole: return pad.placement; + case Roles::XPaddingMethodRole: return pad.method; + + // ── xmux ────────────────────────────────────────────────────────── + case Roles::XmuxEnabledRole: return mux.enabled; + case Roles::XmuxMaxConcurrencyMinRole: return mux.maxConcurrencyMin; + case Roles::XmuxMaxConcurrencyMaxRole: return mux.maxConcurrencyMax; + case Roles::XmuxMaxConnectionsMinRole: return mux.maxConnectionsMin; + case Roles::XmuxMaxConnectionsMaxRole: return mux.maxConnectionsMax; + case Roles::XmuxCMaxReuseTimesMinRole: return mux.cMaxReuseTimesMin; + case Roles::XmuxCMaxReuseTimesMaxRole: return mux.cMaxReuseTimesMax; + case Roles::XmuxHMaxRequestTimesMinRole: return mux.hMaxRequestTimesMin; + case Roles::XmuxHMaxRequestTimesMaxRole: return mux.hMaxRequestTimesMax; + case Roles::XmuxHMaxReusableSecsMinRole: return mux.hMaxReusableSecsMin; + case Roles::XmuxHMaxReusableSecsMaxRole: return mux.hMaxReusableSecsMax; + case Roles::XmuxHKeepAlivePeriodRole: return mux.hKeepAlivePeriod; } return QVariant(); } -void XrayConfigModel::updateModel(amnezia::DockerContainer container, const amnezia::XrayProtocolConfig &protocolConfig) +void XrayConfigModel::updateModel(amnezia::DockerContainer container, const amnezia::XrayProtocolConfig& protocolConfig) { + const bool wasUnsavedChanges = hasUnsavedChanges(); + beginResetModel(); + m_container = container; - + m_protocolConfig = protocolConfig; - + applyDefaultsToServerConfig(m_protocolConfig.serverConfig); - + m_originalProtocolConfig = m_protocolConfig; - + endResetModel(); + if (wasUnsavedChanges != hasUnsavedChanges()) { + emit hasUnsavedChangesChanged(); + } } -void XrayConfigModel::applyDefaultsToServerConfig(amnezia::XrayServerConfig& config) +void XrayConfigModel::applyDefaultsToServerConfig(amnezia::XrayServerConfig &config) { if (config.port.isEmpty()) { config.port = protocols::xray::defaultPort; } + if (config.transportProto.isEmpty()) { config.transportProto = ProtocolUtils::transportProtoToString( ProtocolUtils::defaultTransportProto(amnezia::Proto::Xray), amnezia::Proto::Xray); } + if (config.site.isEmpty()) { config.site = protocols::xray::defaultSite; } + + if (config.transport.isEmpty()) { + config.transport = protocols::xray::defaultTransport; + } + + if (config.security.isEmpty()) { + config.security = protocols::xray::defaultSecurity; + } + + if (config.flow.isEmpty()) { + config.flow = protocols::xray::defaultFlow; + } + + if (config.fingerprint.isEmpty()) { + config.fingerprint = protocols::xray::defaultFingerprint; + } else if (config.fingerprint.contains(QLatin1String("Mozilla/5.0"), Qt::CaseInsensitive)) { + config.fingerprint = QString::fromLatin1(protocols::xray::defaultFingerprint); + } + + if (config.sni.isEmpty()) { + config.sni = protocols::xray::defaultSni; + } + + if (config.alpn.isEmpty()) { + config.alpn = protocols::xray::defaultAlpn; + } + + // XHTTP transport defaults + if (config.xhttp.host.isEmpty()) { + config.xhttp.host = protocols::xray::defaultXhttpHost; + } + if (config.xhttp.mode.isEmpty()) { + config.xhttp.mode = protocols::xray::defaultXhttpMode; + } + if (config.xhttp.headersTemplate.isEmpty()) { + config.xhttp.headersTemplate = protocols::xray::defaultXhttpHeadersTemplate; + } + if (config.xhttp.uplinkMethod.isEmpty()) { + config.xhttp.uplinkMethod = protocols::xray::defaultXhttpUplinkMethod; + } + if (config.xhttp.sessionPlacement.isEmpty()) { + config.xhttp.sessionPlacement = protocols::xray::defaultXhttpSessionPlacement; + } + if (config.xhttp.sessionKey.isEmpty()) { + config.xhttp.sessionKey = protocols::xray::defaultXhttpSessionKey; + } + if (config.xhttp.seqPlacement.isEmpty()) { + config.xhttp.seqPlacement = protocols::xray::defaultXhttpSeqPlacement; + } + if (config.xhttp.uplinkDataPlacement.isEmpty()) { + config.xhttp.uplinkDataPlacement = protocols::xray::defaultXhttpUplinkDataPlacement; + } + + // xPadding defaults + if (config.xhttp.xPadding.placement.isEmpty()) { + config.xhttp.xPadding.placement = protocols::xray::defaultXPaddingPlacement; + } + if (config.xhttp.xPadding.method.isEmpty()) { + config.xhttp.xPadding.method = protocols::xray::defaultXPaddingMethod; + } } amnezia::XrayProtocolConfig XrayConfigModel::getProtocolConfig() { - bool serverSettingsChanged = !m_protocolConfig.serverConfig.hasEqualServerSettings(m_originalProtocolConfig.serverConfig); - + const bool serverSettingsChanged = + !m_protocolConfig.serverConfig.hasEqualServerSettings(m_originalProtocolConfig.serverConfig); + if (serverSettingsChanged) { m_protocolConfig.clearClientConfig(); } - return m_protocolConfig; } +bool XrayConfigModel::isServerSettingsEqual() const +{ + return m_protocolConfig.serverConfig.hasEqualServerSettings(m_originalProtocolConfig.serverConfig); +} + +bool XrayConfigModel::hasUnsavedChanges() const +{ + return !isServerSettingsEqual(); +} + QHash XrayConfigModel::roleNames() const { QHash roles; + // Main roles[SiteRole] = "site"; roles[PortRole] = "port"; + roles[TransportRole] = "transport"; + roles[SecurityRole] = "security"; + roles[FlowRole] = "flow"; + + // Security + roles[FingerprintRole] = "fingerprint"; + roles[SniRole] = "sni"; + roles[AlpnRole] = "alpn"; + + // XHTTP + roles[XhttpModeRole] = "xhttpMode"; + roles[XhttpHostRole] = "xhttpHost"; + roles[XhttpPathRole] = "xhttpPath"; + roles[XhttpHeadersTemplateRole] = "xhttpHeadersTemplate"; + roles[XhttpUplinkMethodRole] = "xhttpUplinkMethod"; + roles[XhttpDisableGrpcRole] = "xhttpDisableGrpc"; + roles[XhttpDisableSseRole] = "xhttpDisableSse"; + + roles[XhttpSessionPlacementRole] = "xhttpSessionPlacement"; + roles[XhttpSessionKeyRole] = "xhttpSessionKey"; + roles[XhttpSeqPlacementRole] = "xhttpSeqPlacement"; + roles[XhttpSeqKeyRole] = "xhttpSeqKey"; + roles[XhttpUplinkDataPlacementRole] = "xhttpUplinkDataPlacement"; + roles[XhttpUplinkDataKeyRole] = "xhttpUplinkDataKey"; + + roles[XhttpUplinkChunkSizeRole] = "xhttpUplinkChunkSize"; + roles[XhttpScMaxBufferedPostsRole] = "xhttpScMaxBufferedPosts"; + roles[XhttpScMaxEachPostBytesMinRole] = "xhttpScMaxEachPostBytesMin"; + roles[XhttpScMaxEachPostBytesMaxRole] = "xhttpScMaxEachPostBytesMax"; + roles[XhttpScMinPostsIntervalMsMinRole] = "xhttpScMinPostsIntervalMsMin"; + roles[XhttpScMinPostsIntervalMsMaxRole] = "xhttpScMinPostsIntervalMsMax"; + roles[XhttpScStreamUpServerSecsMinRole] = "xhttpScStreamUpServerSecsMin"; + roles[XhttpScStreamUpServerSecsMaxRole] = "xhttpScStreamUpServerSecsMax"; + + // mKCP + roles[MkcpTtiRole] = "mkcpTti"; + roles[MkcpUplinkCapacityRole] = "mkcpUplinkCapacity"; + roles[MkcpDownlinkCapacityRole] = "mkcpDownlinkCapacity"; + roles[MkcpReadBufferSizeRole] = "mkcpReadBufferSize"; + roles[MkcpWriteBufferSizeRole] = "mkcpWriteBufferSize"; + roles[MkcpCongestionRole] = "mkcpCongestion"; + + // xPadding + roles[XPaddingBytesMinRole] = "xPaddingBytesMin"; + roles[XPaddingBytesMaxRole] = "xPaddingBytesMax"; + roles[XPaddingObfsModeRole] = "xPaddingObfsMode"; + roles[XPaddingKeyRole] = "xPaddingKey"; + roles[XPaddingHeaderRole] = "xPaddingHeader"; + roles[XPaddingPlacementRole] = "xPaddingPlacement"; + roles[XPaddingMethodRole] = "xPaddingMethod"; + + // xmux + roles[XmuxEnabledRole] = "xmuxEnabled"; + roles[XmuxMaxConcurrencyMinRole] = "xmuxMaxConcurrencyMin"; + roles[XmuxMaxConcurrencyMaxRole] = "xmuxMaxConcurrencyMax"; + roles[XmuxMaxConnectionsMinRole] = "xmuxMaxConnectionsMin"; + roles[XmuxMaxConnectionsMaxRole] = "xmuxMaxConnectionsMax"; + roles[XmuxCMaxReuseTimesMinRole] = "xmuxCMaxReuseTimesMin"; + roles[XmuxCMaxReuseTimesMaxRole] = "xmuxCMaxReuseTimesMax"; + roles[XmuxHMaxRequestTimesMinRole] = "xmuxHMaxRequestTimesMin"; + roles[XmuxHMaxRequestTimesMaxRole] = "xmuxHMaxRequestTimesMax"; + roles[XmuxHMaxReusableSecsMinRole] = "xmuxHMaxReusableSecsMin"; + roles[XmuxHMaxReusableSecsMaxRole] = "xmuxHMaxReusableSecsMax"; + roles[XmuxHKeepAlivePeriodRole] = "xmuxHKeepAlivePeriod"; return roles; } + +void XrayConfigModel::resetToDefaults() +{ + const bool wasUnsavedChanges = hasUnsavedChanges(); + + beginResetModel(); + m_protocolConfig.serverConfig = amnezia::XrayServerConfig{}; + applyDefaultsToServerConfig(m_protocolConfig.serverConfig); + endResetModel(); + + if (wasUnsavedChanges != hasUnsavedChanges()) { + emit hasUnsavedChangesChanged(); + } +} + +void XrayConfigModel::applyServerConfig(const amnezia::XrayServerConfig &serverConfig) +{ + const bool wasUnsavedChanges = hasUnsavedChanges(); + + beginResetModel(); + m_protocolConfig.serverConfig = serverConfig; + // Clear client config since server settings changed + m_protocolConfig.clearClientConfig(); + m_originalProtocolConfig = m_protocolConfig; + endResetModel(); + + if (wasUnsavedChanges != hasUnsavedChanges()) { + emit hasUnsavedChangesChanged(); + } +} + +QStringList XrayConfigModel::flowOptions() +{ + return { + "", // Empty (no flow) + "xtls-rprx-vision", + "xtls-rprx-vision-udp443" + }; +} + +QStringList XrayConfigModel::securityOptions() +{ + return { "none", "tls", "reality" }; +} + +QStringList XrayConfigModel::transportOptions() +{ + return { "raw", "xhttp", "mkcp" }; +} + +QStringList XrayConfigModel::fingerprintOptions() +{ + return { "chrome", "firefox", "safari", "ios", "android", "edge", "360", "qq", "random" }; +} + +QStringList XrayConfigModel::alpnOptions() +{ + return { "HTTP/2", "HTTP/1.1", "HTTP/2,HTTP/1.1" }; +} + +QStringList XrayConfigModel::xhttpModeOptions() +{ + return { "Auto", "Packet-up", "Stream-up", "Stream-one" }; +} + +QStringList XrayConfigModel::xhttpHeadersTemplateOptions() +{ + return { "HTTP", "None" }; +} + +QStringList XrayConfigModel::xhttpUplinkMethodOptions() +{ + return { "POST", "PUT", "PATCH" }; +} + +QStringList XrayConfigModel::xhttpSessionPlacementOptions() +{ + return { "Path", "Header", "Cookie", "None" }; +} + +QStringList XrayConfigModel::xhttpSessionKeyOptions() +{ + return { "Path", "Header", "None" }; +} + +QStringList XrayConfigModel::xhttpSeqPlacementOptions() +{ + return { "Path", "Header", "Cookie", "None" }; +} + +QStringList XrayConfigModel::xhttpUplinkDataPlacementOptions() +{ + // Matches splithttp uplink payload placement (packet-up / advanced) + return { "Body", "Auto", "Header", "Cookie" }; +} + +QStringList XrayConfigModel::xPaddingPlacementOptions() +{ + // Xray-core: cookie | header | query | queryInHeader (not "body") + return { "Cookie", "Header", "Query", "Query in header" }; +} + +QStringList XrayConfigModel::xPaddingMethodOptions() +{ + return { "Repeat-x", "Tokenish" }; +} + +QString XrayConfigModel::mkcpDefaultTti() +{ + return QString::fromLatin1(protocols::xray::defaultMkcpTti); +} + +QString XrayConfigModel::mkcpDefaultUplinkCapacity() +{ + return QString::fromLatin1(protocols::xray::defaultMkcpUplinkCapacity); +} + +QString XrayConfigModel::mkcpDefaultDownlinkCapacity() +{ + return QString::fromLatin1(protocols::xray::defaultMkcpDownlinkCapacity); +} + +QString XrayConfigModel::mkcpDefaultReadBufferSize() +{ + return QString::fromLatin1(protocols::xray::defaultMkcpReadBufferSize); +} + +QString XrayConfigModel::mkcpDefaultWriteBufferSize() +{ + return QString::fromLatin1(protocols::xray::defaultMkcpWriteBufferSize); +} diff --git a/client/ui/models/protocols/xrayConfigModel.h b/client/ui/models/protocols/xrayConfigModel.h index 5549cf446..fee066107 100644 --- a/client/ui/models/protocols/xrayConfigModel.h +++ b/client/ui/models/protocols/xrayConfigModel.h @@ -2,6 +2,7 @@ #define XRAYCONFIGMODEL_H #include +#include #include "core/utils/containerEnum.h" #include "core/utils/containers/containerUtils.h" @@ -11,23 +12,122 @@ class XrayConfigModel : public QAbstractListModel { Q_OBJECT + Q_PROPERTY(bool hasUnsavedChanges READ hasUnsavedChanges NOTIFY hasUnsavedChangesChanged) public: - enum Roles { - SiteRole, - PortRole + enum Roles + { + // ── Main page ───────────────────────────────────────────────── + SiteRole = Qt::UserRole + 1, + PortRole, + TransportRole, // "raw" | "xhttp" | "mkcp" (display in main page row) + SecurityRole, // "none" | "tls" | "reality" (display in main page row) + FlowRole, // "" | "xtls-rprx-vision" | "xtls-rprx-vision-udp443" + + // ── Security ────────────────────────────────────────────────── + FingerprintRole, + SniRole, + AlpnRole, + + // ── Transport — XHTTP ───────────────────────────────────────── + XhttpModeRole, + XhttpHostRole, + XhttpPathRole, + XhttpHeadersTemplateRole, + XhttpUplinkMethodRole, + XhttpDisableGrpcRole, + XhttpDisableSseRole, + + // Session & Sequence + XhttpSessionPlacementRole, + XhttpSessionKeyRole, + XhttpSeqPlacementRole, + XhttpSeqKeyRole, + XhttpUplinkDataPlacementRole, + XhttpUplinkDataKeyRole, + + // Traffic Shaping + XhttpUplinkChunkSizeRole, + XhttpScMaxBufferedPostsRole, + XhttpScMaxEachPostBytesMinRole, + XhttpScMaxEachPostBytesMaxRole, + XhttpScMinPostsIntervalMsMinRole, + XhttpScMinPostsIntervalMsMaxRole, + XhttpScStreamUpServerSecsMinRole, + XhttpScStreamUpServerSecsMaxRole, + + // ── Transport — mKCP ────────────────────────────────────────── + MkcpTtiRole, + MkcpUplinkCapacityRole, + MkcpDownlinkCapacityRole, + MkcpReadBufferSizeRole, + MkcpWriteBufferSizeRole, + MkcpCongestionRole, + + // ── xPadding ────────────────────────────────────────────────── + XPaddingBytesMinRole, + XPaddingBytesMaxRole, + XPaddingObfsModeRole, + XPaddingKeyRole, + XPaddingHeaderRole, + XPaddingPlacementRole, + XPaddingMethodRole, + + // ── xmux ────────────────────────────────────────────────────── + XmuxEnabledRole, + XmuxMaxConcurrencyMinRole, + XmuxMaxConcurrencyMaxRole, + XmuxMaxConnectionsMinRole, + XmuxMaxConnectionsMaxRole, + XmuxCMaxReuseTimesMinRole, + XmuxCMaxReuseTimesMaxRole, + XmuxHMaxRequestTimesMinRole, + XmuxHMaxRequestTimesMaxRole, + XmuxHMaxReusableSecsMinRole, + XmuxHMaxReusableSecsMaxRole, + XmuxHKeepAlivePeriodRole, }; - explicit XrayConfigModel(QObject *parent = nullptr); + explicit XrayConfigModel(QObject* parent = nullptr); - int rowCount(const QModelIndex &parent = QModelIndex()) const override; + int rowCount(const QModelIndex& parent = QModelIndex()) const override; - bool setData(const QModelIndex &index, const QVariant &value, int role) override; - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + bool setData(const QModelIndex& index, const QVariant& value, int role) override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + + // ── Static option lists (for QML DropDown models) ───────────────── + Q_INVOKABLE static QStringList flowOptions(); + Q_INVOKABLE static QStringList securityOptions(); + Q_INVOKABLE static QStringList transportOptions(); + Q_INVOKABLE static QStringList fingerprintOptions(); + Q_INVOKABLE static QStringList alpnOptions(); + Q_INVOKABLE static QStringList xhttpModeOptions(); + Q_INVOKABLE static QStringList xhttpHeadersTemplateOptions(); + Q_INVOKABLE static QStringList xhttpUplinkMethodOptions(); + Q_INVOKABLE static QStringList xhttpSessionPlacementOptions(); + Q_INVOKABLE static QStringList xhttpSessionKeyOptions(); + Q_INVOKABLE static QStringList xhttpSeqPlacementOptions(); + Q_INVOKABLE static QStringList xhttpUplinkDataPlacementOptions(); + Q_INVOKABLE static QStringList xPaddingPlacementOptions(); + Q_INVOKABLE static QStringList xPaddingMethodOptions(); + + // mKCP display defaults (protocolConstants.h — must match xrayConfigurator empty-field behavior) + Q_INVOKABLE static QString mkcpDefaultTti(); + Q_INVOKABLE static QString mkcpDefaultUplinkCapacity(); + Q_INVOKABLE static QString mkcpDefaultDownlinkCapacity(); + Q_INVOKABLE static QString mkcpDefaultReadBufferSize(); + Q_INVOKABLE static QString mkcpDefaultWriteBufferSize(); public slots: - void updateModel(amnezia::DockerContainer container, const amnezia::XrayProtocolConfig &protocolConfig); + void updateModel(amnezia::DockerContainer container, const amnezia::XrayProtocolConfig& protocolConfig); amnezia::XrayProtocolConfig getProtocolConfig(); + bool isServerSettingsEqual() const; + bool hasUnsavedChanges() const; + void resetToDefaults(); + void applyServerConfig(const amnezia::XrayServerConfig &serverConfig); + +signals: + void hasUnsavedChangesChanged(); protected: QHash roleNames() const override; @@ -36,7 +136,7 @@ private: amnezia::DockerContainer m_container; amnezia::XrayProtocolConfig m_protocolConfig; amnezia::XrayProtocolConfig m_originalProtocolConfig; - + void applyDefaultsToServerConfig(amnezia::XrayServerConfig& config); }; diff --git a/client/ui/models/protocols/xrayConfigSnapshotsModel.cpp b/client/ui/models/protocols/xrayConfigSnapshotsModel.cpp new file mode 100644 index 000000000..8a023212f --- /dev/null +++ b/client/ui/models/protocols/xrayConfigSnapshotsModel.cpp @@ -0,0 +1,216 @@ +#include "xrayConfigSnapshotsModel.h" + +#include +#include + +#include "core/repositories/secureAppSettingsRepository.h" +#include "core/utils/constants/configKeys.h" + +QJsonObject XrayConfigSnapshot::toJson() const +{ + QJsonObject obj; + obj["id"] = id; + obj["displayName"] = displayName; + obj["createdAt"] = createdAt.toString(Qt::ISODate); + obj["serverConfig"] = serverConfig.toJson(); + return obj; +} + +XrayConfigSnapshot XrayConfigSnapshot::fromJson(const QJsonObject &json) +{ + XrayConfigSnapshot s; + s.id = json.value("id").toString(); + s.displayName = json.value("displayName").toString(); + s.createdAt = QDateTime::fromString(json.value("createdAt").toString(), Qt::ISODate); + s.serverConfig = amnezia::XrayServerConfig::fromJson(json.value("serverConfig").toObject()); + return s; +} + +XrayConfigSnapshotsModel::XrayConfigSnapshotsModel(SecureAppSettingsRepository *appSettings, + XrayConfigModel *xrayConfigModel, QObject *parent) + : QAbstractListModel(parent), m_appSettings(appSettings), m_xrayConfigModel(xrayConfigModel) +{ + loadAll(); +} + +void XrayConfigSnapshotsModel::loadAll() +{ + m_configs.clear(); + QByteArray raw = m_appSettings->xraySavedConfigs(); + if (raw.isEmpty()) { + return; + } + + QJsonArray arr = QJsonDocument::fromJson(raw).array(); + for (const QJsonValue &v : arr) { + m_configs.append(XrayConfigSnapshot::fromJson(v.toObject())); + } +} + +void XrayConfigSnapshotsModel::persistAll() +{ + QJsonArray arr; + for (const XrayConfigSnapshot &s : m_configs) { + arr.append(s.toJson()); + } + m_appSettings->setXraySavedConfigs(QJsonDocument(arr).toJson(QJsonDocument::Compact)); +} + +int XrayConfigSnapshotsModel::rowCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent); + return m_configs.size(); +} + +QVariant XrayConfigSnapshotsModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_configs.size()) { + return QVariant(); + } + + const XrayConfigSnapshot &s = m_configs.at(index.row()); + + switch (role) { + case IdRole: { + return s.id; + } + case DisplayNameRole: { + return s.displayName; + } + case CreatedAtRole: { + return s.createdAt.toString("dd.MM.yyyy HH:mm"); + } + } + return QVariant(); +} + +QHash XrayConfigSnapshotsModel::roleNames() const +{ + QHash roles; + roles[IdRole] = "configId"; + roles[DisplayNameRole] = "configName"; + roles[CreatedAtRole] = "configDate"; + return roles; +} + +void XrayConfigSnapshotsModel::reload() +{ + beginResetModel(); + loadAll(); + endResetModel(); +} + +void XrayConfigSnapshotsModel::createFromCurrent(const amnezia::XrayServerConfig &serverConfig) +{ + XrayConfigSnapshot snapshot; + snapshot.id = QUuid::createUuid().toString(QUuid::WithoutBraces); + snapshot.displayName = buildDisplayName(serverConfig); + snapshot.createdAt = QDateTime::currentDateTime(); + snapshot.serverConfig = serverConfig; + + beginInsertRows(QModelIndex(), m_configs.size(), m_configs.size()); + m_configs.append(snapshot); + endInsertRows(); + + persistAll(); +} + +amnezia::XrayServerConfig XrayConfigSnapshotsModel::applyConfig(int index) const +{ + if (index < 0 || index >= m_configs.size()) { + return amnezia::XrayServerConfig {}; + } + + return m_configs.at(index).serverConfig; +} + +void XrayConfigSnapshotsModel::removeConfig(int index) +{ + if (index < 0 || index >= m_configs.size()) { + return; + } + + beginRemoveRows(QModelIndex(), index, index); + m_configs.removeAt(index); + endRemoveRows(); + + persistAll(); + emit configRemoved(index); +} + +QString XrayConfigSnapshotsModel::exportToJson(int index) const +{ + if (index < 0 || index >= m_configs.size()) { + return {}; + } + return QString::fromUtf8(QJsonDocument(m_configs.at(index).toJson()).toJson(QJsonDocument::Indented)); +} + +bool XrayConfigSnapshotsModel::importFromJson(const QString &jsonString) +{ + QJsonDocument doc = QJsonDocument::fromJson(jsonString.toUtf8()); + if (!doc.isObject()) { + emit importFailed(tr("Invalid JSON format")); + return false; + } + + XrayConfigSnapshot snapshot = XrayConfigSnapshot::fromJson(doc.object()); + if (snapshot.id.isEmpty()) { + snapshot.id = QUuid::createUuid().toString(QUuid::WithoutBraces); + } + if (snapshot.displayName.isEmpty()) { + snapshot.displayName = buildDisplayName(snapshot.serverConfig); + } + snapshot.createdAt = QDateTime::currentDateTime(); + + beginInsertRows(QModelIndex(), m_configs.size(), m_configs.size()); + m_configs.append(snapshot); + endInsertRows(); + + persistAll(); + return true; +} + +QString XrayConfigSnapshotsModel::buildDisplayName(const amnezia::XrayServerConfig &cfg) +{ + // Build a human-readable name: "XHTTP TLS Reality", "RAW Reality", etc. + QString transport; + if (cfg.transport == "xhttp") { + transport = "XHTTP"; + } else if (cfg.transport == "mkcp") { + transport = "mKCP"; + } else { + transport = "RAW (TCP)"; + } + + QString security; + if (cfg.security == "tls") { + security = "TLS"; + } else if (cfg.security == "reality") { + security = "Reality"; + } else { + security = "None"; + } + + return QString("%1 %2").arg(transport, security).trimmed(); +} + +void XrayConfigSnapshotsModel::createFromCurrentModel() +{ + if (!m_xrayConfigModel) { + return; + } + createFromCurrent(m_xrayConfigModel->getProtocolConfig().serverConfig); +} + +void XrayConfigSnapshotsModel::applyConfigToCurrentModel(int index) +{ + if (!m_xrayConfigModel) { + return; + } + amnezia::XrayServerConfig cfg = applyConfig(index); + if (cfg.port.isEmpty()) { + return; // guard against invalid index + } + m_xrayConfigModel->applyServerConfig(cfg); +} diff --git a/client/ui/models/protocols/xrayConfigSnapshotsModel.h b/client/ui/models/protocols/xrayConfigSnapshotsModel.h new file mode 100644 index 000000000..9688cd863 --- /dev/null +++ b/client/ui/models/protocols/xrayConfigSnapshotsModel.h @@ -0,0 +1,76 @@ +#ifndef XRAYCONFIGSMODEL_H +#define XRAYCONFIGSMODEL_H + +#include +#include +#include +#include +#include +#include + +#include "core/models/protocols/xrayProtocolConfig.h" +#include "ui/models/protocols/xrayConfigModel.h" + +class SecureAppSettingsRepository; + +struct XrayConfigSnapshot +{ + QString id; + QString displayName; // auto-generated: "XHTTP TLS Reality", "RAW Reality", etc. + QDateTime createdAt; + amnezia::XrayServerConfig serverConfig; + + QJsonObject toJson() const; + static XrayConfigSnapshot fromJson(const QJsonObject &json); +}; + +class XrayConfigSnapshotsModel : public QAbstractListModel +{ + Q_OBJECT + +public: + enum Roles { + IdRole = Qt::UserRole + 1, + DisplayNameRole, + CreatedAtRole, // "dd.MM.yyyy HH:mm" + }; + + explicit XrayConfigSnapshotsModel(SecureAppSettingsRepository *appSettings, XrayConfigModel *xrayConfigModel, + QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + +public slots: + void reload(); + + Q_INVOKABLE void createFromCurrent(const amnezia::XrayServerConfig &serverConfig); + Q_INVOKABLE amnezia::XrayServerConfig applyConfig(int index) const; + Q_INVOKABLE void removeConfig(int index); + + Q_INVOKABLE QString exportToJson(int index) const; + Q_INVOKABLE bool importFromJson(const QString &jsonString); + + // Convenience: create snapshot from live model, apply snapshot back to model + Q_INVOKABLE void createFromCurrentModel(); + Q_INVOKABLE void applyConfigToCurrentModel(int index); + +signals: + void configApplied(int index); + void configRemoved(int index); + void importFailed(const QString &errorMessage); + +protected: + QHash roleNames() const override; + +private: + SecureAppSettingsRepository *m_appSettings; + XrayConfigModel *m_xrayConfigModel; + QVector m_configs; + + void persistAll(); + void loadAll(); + static QString buildDisplayName(const amnezia::XrayServerConfig &cfg); +}; + +#endif // XRAYCONFIGSMODEL_H diff --git a/client/ui/qml/Controls2/MinMaxRowType.qml b/client/ui/qml/Controls2/MinMaxRowType.qml new file mode 100644 index 000000000..90a7fae7b --- /dev/null +++ b/client/ui/qml/Controls2/MinMaxRowType.qml @@ -0,0 +1,61 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import Style 1.0 + +import "../Controls2" +import "../Controls2/TextTypes" + +// MinMaxRowType — two side-by-side labeled text fields: Min / Max +// Usage: +// MinMaxRowType { +// minValue: "0" +// maxValue: "0" +// onMinChanged: someProperty = val +// onMaxChanged: someProperty = val +// } +Item { + id: root + + property string minValue: "0" + property string maxValue: "0" + + signal minChanged(string val) + signal maxChanged(string val) + + implicitHeight: row.implicitHeight + implicitWidth: row.implicitWidth + + RowLayout { + id: row + anchors.fill: parent + spacing: 10 + + // Min field + TextFieldWithHeaderType { + Layout.fillWidth: true + headerText: qsTr("Min") + textField.text: root.minValue + textField.validator: IntValidator { bottom: 0 } + textField.onEditingFinished: { + if (textField.text !== root.minValue) { + root.minChanged(textField.text) + } + } + } + + // Max field + TextFieldWithHeaderType { + Layout.fillWidth: true + headerText: qsTr("Max") + textField.text: root.maxValue + textField.validator: IntValidator { bottom: 0 } + textField.onEditingFinished: { + if (textField.text !== root.maxValue) { + root.maxChanged(textField.text) + } + } + } + } +} diff --git a/client/ui/qml/Controls2/TextFieldWithHeaderType.qml b/client/ui/qml/Controls2/TextFieldWithHeaderType.qml index e192535ac..903b94163 100644 --- a/client/ui/qml/Controls2/TextFieldWithHeaderType.qml +++ b/client/ui/qml/Controls2/TextFieldWithHeaderType.qml @@ -10,6 +10,7 @@ Item { id: root property string headerText + property string subtitleText // optional line under header (e.g. default value hint) property string headerTextDisabledColor: AmneziaStyle.color.charcoalGray property string headerTextColor: AmneziaStyle.color.mutedGray @@ -84,6 +85,15 @@ Item { Layout.fillWidth: true } + SmallTextType { + text: root.subtitleText + visible: root.subtitleText !== "" + color: AmneziaStyle.color.charcoalGray + font.pixelSize: 13 + Layout.fillWidth: true + Layout.topMargin: visible ? 2 : 0 + } + TextField { id: textField diff --git a/client/ui/qml/Pages2/PageProtocolXrayFlowSettings.qml b/client/ui/qml/Pages2/PageProtocolXrayFlowSettings.qml new file mode 100644 index 000000000..afde16088 --- /dev/null +++ b/client/ui/qml/Pages2/PageProtocolXrayFlowSettings.qml @@ -0,0 +1,125 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import PageEnum 1.0 +import ProtocolEnum 1.0 +import Style 1.0 + +import "./" +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" +import "../Components" + +PageType { + id: root + + BackButtonType { + id: backButton + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 20 + PageController.safeAreaTopMargin + } + + ListViewType { + id: listView + anchors.top: backButton.bottom + anchors.bottom: saveButton.visible ? saveButton.top : parent.bottom + anchors.left: parent.left + anchors.right: parent.right + + enabled: ServersUiController.isProcessedServerHasWriteAccess() + model: XrayConfigModel + + delegate: ColumnLayout { + width: listView.width + spacing: 0 + + BaseHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 0 + Layout.bottomMargin: 24 + headerText: qsTr("Flow") + } + + VerticalRadioButton { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: qsTr("Empty") + checked: flow === "" + onClicked: flow = "" + } + + DividerType { + } + + VerticalRadioButton { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: "xtls-rprx-vision" + checked: flow === "xtls-rprx-vision" + onClicked: flow = "xtls-rprx-vision" + } + + DividerType { + } + + VerticalRadioButton { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: "xtls-rprx-vision-udp443" + checked: flow === "xtls-rprx-vision-udp443" + onClicked: flow = "xtls-rprx-vision-udp443" + } + + DividerType { + } + + Item { + Layout.preferredHeight: 16 + } + } + } + + BasicButtonType { + id: saveButton + + anchors.left: root.left + anchors.right: root.right + anchors.bottom: root.bottom + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.bottomMargin: 16 + PageController.safeAreaBottomMargin + + visible: listView.enabled && XrayConfigModel.hasUnsavedChanges + enabled: visible + text: qsTr("Save") + clickedFunc: function () { + var headerText = qsTr("Save settings?") + var descriptionText = qsTr("All users with whom you shared a connection with will no longer be able to connect to it.") + var yesButtonText = qsTr("Continue") + var noButtonText = qsTr("Cancel") + var yesButtonFunction = function () { + if (ConnectionController.isConnected && ServersModel.getDefaultServerData("defaultContainer") === ServersUiController.processedContainerIndex) { + PageController.showNotificationMessage(qsTr("Unable change settings while there is an active connection")) + return + } + PageController.goToPage(PageEnum.PageSetupWizardInstalling) + InstallController.updateContainer(ServersUiController.processedIndex, ServersUiController.processedContainerIndex, ProtocolEnum.Xray) + } + var noButtonFunction = function () { + if (typeof GC !== "undefined" && !GC.isMobile()) { + saveButton.forceActiveFocus() + } + } + showQuestionDrawer(headerText, descriptionText, yesButtonText, noButtonText, yesButtonFunction, noButtonFunction) + } + } +} diff --git a/client/ui/qml/Pages2/PageProtocolXraySecuritySettings.qml b/client/ui/qml/Pages2/PageProtocolXraySecuritySettings.qml new file mode 100644 index 000000000..fc1a58de1 --- /dev/null +++ b/client/ui/qml/Pages2/PageProtocolXraySecuritySettings.qml @@ -0,0 +1,292 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import PageEnum 1.0 +import ProtocolEnum 1.0 +import Style 1.0 + +import "./" +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" +import "../Components" + +PageType { + id: root + + BackButtonType { + id: backButton + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 20 + PageController.safeAreaTopMargin + } + + ListViewType { + id: listView + anchors.top: backButton.bottom + anchors.bottom: saveButton.visible ? saveButton.top : parent.bottom + anchors.left: parent.left + anchors.right: parent.right + + enabled: ServersUiController.isProcessedServerHasWriteAccess() + model: XrayConfigModel + + delegate: ColumnLayout { + width: listView.width + spacing: 0 + + BaseHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 0 + Layout.bottomMargin: 24 + headerText: qsTr("Security") + } + + VerticalRadioButton { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: qsTr("None") + checked: security === "none" + onClicked: security = "none" + } + + DividerType { + } + + VerticalRadioButton { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: qsTr("TLS") + checked: security === "tls" + onClicked: security = "tls" + } + + DividerType { + } + + VerticalRadioButton { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: qsTr("Reality") + checked: security === "reality" + onClicked: security = "reality" + } + + DividerType { + } + + // ── TLS fields ──────────────────────────────────────────── + ColumnLayout { + visible: security === "tls" + Layout.fillWidth: true + spacing: 0 + + DropDownType { + id: tlsAlpnDropDown + Layout.fillWidth: true + Layout.topMargin: 16 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: alpn + descriptionText: qsTr("ALPN") + headerText: qsTr("ALPN") + drawerParent: root + listView: ListViewWithRadioButtonType { + rootWidth: root.width + model: ListModel { + Component.onCompleted: { + var opts = XrayConfigModel.alpnOptions() + for (var i = 0; i < opts.length; i++) { + append({name: opts[i]}) + } + } + } + clickedFunction: function () { + alpn = selectedText + tlsAlpnDropDown.text = selectedText + tlsAlpnDropDown.closeTriggered() + } + Component.onCompleted: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).name === alpn) { + selectedIndex = i; + break + } + } + } + } + Connections { + target: XrayConfigModel + + function onDataChanged() { + tlsAlpnDropDown.text = alpn + } + } + } + + DropDownType { + id: tlsFingerprintDropDown + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: fingerprint + descriptionText: qsTr("Fingerprint") + headerText: qsTr("Fingerprint") + drawerParent: root + listView: ListViewWithRadioButtonType { + rootWidth: root.width + model: ListModel { + Component.onCompleted: { + var opts = XrayConfigModel.fingerprintOptions() + for (var i = 0; i < opts.length; i++) { + append({name: opts[i]}) + } + } + } + clickedFunction: function () { + fingerprint = selectedText + tlsFingerprintDropDown.text = selectedText + tlsFingerprintDropDown.closeTriggered() + } + Component.onCompleted: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).name === fingerprint) { + selectedIndex = i; + break + } + } + } + } + Connections { + target: XrayConfigModel + + function onDataChanged() { + tlsFingerprintDropDown.text = fingerprint + } + } + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("Server Name (SNI)") + textField.text: sni + textField.onEditingFinished: { + if (textField.text !== sni) sni = textField.text + } + } + } + + // ── Reality fields ──────────────────────────────────────── + ColumnLayout { + visible: security === "reality" + Layout.fillWidth: true + spacing: 0 + + DropDownType { + id: realityFingerprintDropDown + Layout.fillWidth: true + Layout.topMargin: 16 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: fingerprint + descriptionText: qsTr("Fingerprint") + headerText: qsTr("Fingerprint") + drawerParent: root + listView: ListViewWithRadioButtonType { + rootWidth: root.width + model: ListModel { + Component.onCompleted: { + var opts = XrayConfigModel.fingerprintOptions() + for (var i = 0; i < opts.length; i++) { + append({name: opts[i]}) + } + } + } + clickedFunction: function () { + fingerprint = selectedText + realityFingerprintDropDown.text = selectedText + realityFingerprintDropDown.closeTriggered() + } + Component.onCompleted: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).name === fingerprint) { + selectedIndex = i; + break + } + } + } + } + Connections { + target: XrayConfigModel + + function onDataChanged() { + realityFingerprintDropDown.text = fingerprint + } + } + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("Server Name (SNI)") + textField.text: sni + textField.onEditingFinished: { + if (textField.text !== sni) sni = textField.text + } + } + } + + Item { + Layout.preferredHeight: 16 + } + } + } + + BasicButtonType { + id: saveButton + + anchors.left: root.left + anchors.right: root.right + anchors.bottom: root.bottom + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.bottomMargin: 16 + PageController.safeAreaBottomMargin + + visible: listView.enabled && XrayConfigModel.hasUnsavedChanges + enabled: visible + text: qsTr("Save") + clickedFunc: function () { + var headerText = qsTr("Save settings?") + var descriptionText = qsTr("All users with whom you shared a connection with will no longer be able to connect to it.") + var yesButtonText = qsTr("Continue") + var noButtonText = qsTr("Cancel") + var yesButtonFunction = function () { + if (ConnectionController.isConnected && ServersModel.getDefaultServerData("defaultContainer") === ServersUiController.processedContainerIndex) { + PageController.showNotificationMessage(qsTr("Unable change settings while there is an active connection")) + return + } + PageController.goToPage(PageEnum.PageSetupWizardInstalling) + InstallController.updateContainer(ServersUiController.processedIndex, ServersUiController.processedContainerIndex, ProtocolEnum.Xray) + } + var noButtonFunction = function () { + if (typeof GC !== "undefined" && !GC.isMobile()) { + saveButton.forceActiveFocus() + } + } + showQuestionDrawer(headerText, descriptionText, yesButtonText, noButtonText, yesButtonFunction, noButtonFunction) + } + } +} diff --git a/client/ui/qml/Pages2/PageProtocolXraySettings.qml b/client/ui/qml/Pages2/PageProtocolXraySettings.qml index 43c57caff..c63d58eca 100644 --- a/client/ui/qml/Pages2/PageProtocolXraySettings.qml +++ b/client/ui/qml/Pages2/PageProtocolXraySettings.qml @@ -17,6 +17,20 @@ import "../Components" PageType { id: root + function formatTransport(value) { + if (value === "raw") return "RAW (TCP)" + if (value === "xhttp") return "XHTTP" + if (value === "mkcp") return "mKCP" + return value + } + + function formatSecurity(value) { + if (value === "none") return "None" + if (value === "tls") return "TLS" + if (value === "reality") return "Reality" + return value + } + BackButtonType { id: backButton @@ -50,88 +64,125 @@ PageType { spacing: 0 - BaseHeaderType { + RowLayout { Layout.fillWidth: true Layout.leftMargin: 16 Layout.rightMargin: 16 - headerText: qsTr("XRay settings") + Layout.topMargin: 0 + + BaseHeaderType { + Layout.fillWidth: true + headerText: qsTr("XRay VLESS settings") + } + + ImageButtonType { + Layout.alignment: Qt.AlignTop | Qt.AlignRight + implicitWidth: 40 + implicitHeight: 40 + image: "qrc:/images/controls/more-vertical.svg" + imageColor: AmneziaStyle.color.paleGray + onClicked: PageController.goToPage(PageEnum.PageProtocolXraySnapshots) + } + } + + LabelTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 4 + text: qsTr("More about settings") + color: AmneziaStyle.color.goldenApricot + font.pixelSize: 16 + lineHeight: 24 + LanguageUiController.getLineHeightAppend() + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: Qt.openUrlExternally("https://docs.amnezia.org") + } } TextFieldWithHeaderType { id: textFieldWithHeaderType - Layout.fillWidth: true Layout.topMargin: 32 Layout.leftMargin: 16 Layout.rightMargin: 16 - enabled: listView.enabled - - headerText: qsTr("Disguised as traffic from") - textField.text: site - - textField.onEditingFinished: { - if (textField.text !== site) { - var tmpText = textField.text - tmpText = tmpText.toLocaleLowerCase() - - if (tmpText.startsWith("https://")) { - tmpText = textField.text.substring(8) - site = tmpText - } else { - site = textField.text - } - } - } - - checkEmptyText: true - } - - TextFieldWithHeaderType { - id: portTextField - - Layout.fillWidth: true - Layout.topMargin: 16 - Layout.leftMargin: 16 - Layout.rightMargin: 16 - - enabled: listView.enabled - headerText: qsTr("Port") textField.text: port textField.maximumLength: 5 - textField.validator: IntValidator { bottom: 1; top: 65535 } - - textField.onEditingFinished: { - if (textField.text !== port) { - port = textField.text - } + textField.validator: IntValidator { + bottom: 1; top: 65535 + } + textField.onEditingFinished: { + if (textField.text !== port) port = textField.text } - checkEmptyText: true } + LabelWithButtonType { + Layout.fillWidth: true + Layout.topMargin: 16 + text: qsTr("Transport") + descriptionText: root.formatTransport(transport) + rightImageSource: "qrc:/images/controls/chevron-right.svg" + enabled: listView.enabled + clickedFunction: function() { + PageController.goToPage(PageEnum.PageProtocolXrayTransportSettings) + } + } + + DividerType { + } + + LabelWithButtonType { + Layout.fillWidth: true + text: qsTr("Security") + descriptionText: root.formatSecurity(security) + rightImageSource: "qrc:/images/controls/chevron-right.svg" + enabled: listView.enabled + clickedFunction: function() { + PageController.goToPage(PageEnum.PageProtocolXraySecuritySettings) + } + } + + DividerType { + } + + LabelWithButtonType { + Layout.fillWidth: true + text: qsTr("Flow") + descriptionText: flow + rightImageSource: "qrc:/images/controls/chevron-right.svg" + enabled: listView.enabled + clickedFunction: function() { + PageController.goToPage(PageEnum.PageProtocolXrayFlowSettings) + } + } + + DividerType { + } + + Item { + Layout.fillWidth: true; Layout.preferredHeight: 24 + } + BasicButtonType { id: saveButton - Layout.fillWidth: true - Layout.topMargin: 24 - Layout.bottomMargin: 24 + Layout.bottomMargin: 8 Layout.leftMargin: 16 Layout.rightMargin: 16 - - enabled: portTextField.errorText === "" - + // Show Save immediately while user edits port, even before focus loss. + visible: listView.enabled && (XrayConfigModel.hasUnsavedChanges || textFieldWithHeaderType.textField.text !== port) + enabled: visible && textFieldWithHeaderType.errorText === "" text: qsTr("Save") - onClicked: function() { forceActiveFocus() - var headerText = qsTr("Save settings?") var descriptionText = qsTr("All users with whom you shared a connection with will no longer be able to connect to it.") var yesButtonText = qsTr("Continue") var noButtonText = qsTr("Cancel") - var yesButtonFunction = function() { if (ConnectionController.isConnected && ServersModel.getDefaultServerData("defaultContainer") === ServersUiController.processedContainerIndex) { PageController.showNotificationMessage(qsTr("Unable change settings while there is an active connection")) @@ -142,16 +193,32 @@ PageType { InstallController.updateContainer(ServersUiController.getServerId(ServersUiController.processedServerIndex), ServersUiController.processedContainerIndex, ProtocolEnum.Xray) } var noButtonFunction = function() { - if (!GC.isMobile()) { - saveButton.forceActiveFocus() - } + if (!GC.isMobile()) saveButton.forceActiveFocus() } showQuestionDrawer(headerText, descriptionText, yesButtonText, noButtonText, yesButtonFunction, noButtonFunction) } - Keys.onEnterPressed: saveButton.clicked() Keys.onReturnPressed: saveButton.clicked() } + + LabelWithButtonType { + Layout.fillWidth: true + text: qsTr("Reset settings") + textColor: AmneziaStyle.color.vibrantRed + visible: listView.enabled + clickedFunction: function() { + var yesButtonFunction = function() { + XrayConfigModel.resetToDefaults() + } + showQuestionDrawer(qsTr("Reset settings?"), qsTr("All XRay settings will be restored to defaults."), + qsTr("Reset"), qsTr("Cancel"), yesButtonFunction, function() { + }) + } + } + + Item { + Layout.fillWidth: true; Layout.preferredHeight: 32 + } } } } diff --git a/client/ui/qml/Pages2/PageProtocolXraySnapshots.qml b/client/ui/qml/Pages2/PageProtocolXraySnapshots.qml new file mode 100644 index 000000000..446ad468a --- /dev/null +++ b/client/ui/qml/Pages2/PageProtocolXraySnapshots.qml @@ -0,0 +1,291 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import PageEnum 1.0 +import Style 1.0 + +import "./" +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" +import "../Components" +import Qt.labs.platform 1.1 + +PageType { + id: root + + property string selectedConfigName: "" + property int selectedConfigIndex: -1 + + // Reload the list every time we open this page + Component.onCompleted: XrayConfigSnapshotsModel.reload() + + // ── Save xray config snapshot to file ──────────────────────────── + function saveConfigToFile(json) { + var fileName = "" + if (GC.isMobile()) { + fileName = "amnezia_xray_config.json" + } else { + fileName = SystemController.getFileName( + qsTr("Save XRay configuration"), + qsTr("JSON files (*.json)"), + StandardPaths.standardLocations(StandardPaths.DocumentsLocation) + "/amnezia_xray_config", + true, + ".json") + } + if (fileName !== "") { + PageController.showBusyIndicator(true) + ExportController.setConfigFromString(json, fileName) + PageController.showBusyIndicator(false) + PageController.showNotificationMessage(qsTr("Configuration saved")) + } + } + + + BackButtonType { + id: backButton + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 20 + PageController.safeAreaTopMargin + } + + ListViewType { + id: listView + anchors.top: backButton.bottom + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + + model: XrayConfigSnapshotsModel + + header: ColumnLayout { + width: listView.width + spacing: 0 + + BaseHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 0 + Layout.bottomMargin: 24 + headerText: qsTr("XRay Configurations") + } + + // ── Create from current settings ────────────────────────── + LabelWithButtonType { + Layout.fillWidth: true + text: qsTr("Create configuration based on current settings") + textMaximumLineCount: 2 + rightImageSource: "qrc:/images/controls/chevron-right.svg" + clickedFunction: function () { + XrayConfigSnapshotsModel.createFromCurrentModel() + } + } + + DividerType { + } + + // ── Export ──────────────────────────────────────────────── + LabelWithButtonType { + Layout.fillWidth: true + text: qsTr("Export settings") + rightImageSource: "qrc:/images/controls/chevron-right.svg" + clickedFunction: function () { + var idx = root.selectedConfigIndex >= 0 ? root.selectedConfigIndex : 0 + if (listView.count > 0) { + var json = XrayConfigSnapshotsModel.exportToJson(idx) + saveConfigToFile(json) + } + } + } + + DividerType { + } + + // ── Import ──────────────────────────────────────────────── + LabelWithButtonType { + Layout.fillWidth: true + text: qsTr("Import settings") + descriptionText: qsTr("In JSON format") + rightImageSource: "qrc:/images/controls/chevron-right.svg" + clickedFunction: function () { + var filePath = SystemController.getFileName( + qsTr("Open XRay configuration"), + qsTr("JSON files (*.json)")) + if (filePath !== "") { + var jsonContent = ImportController.readTextFile(filePath) + if (jsonContent !== "") { + if (!XrayConfigSnapshotsModel.importFromJson(jsonContent)) { + PageController.showNotificationMessage(qsTr("Failed to import configuration")) + } else { + PageController.showNotificationMessage(qsTr("Configuration imported successfully")) + } + } + } + } + } + + DividerType { + } + + // ── Section label ───────────────────────────────────────── + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 24 + Layout.bottomMargin: 8 + text: qsTr("Configurations") + color: AmneziaStyle.color.mutedGray + visible: listView.count > 0 + } + } + + // ── Empty state ─────────────────────────────────────────────── + footer: ColumnLayout { + width: listView.width + visible: listView.count === 0 + spacing: 0 + + Item { + Layout.preferredHeight: 32 + } + + ParagraphTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: qsTr("No saved configurations yet.\nCreate one from the current settings.") + color: AmneziaStyle.color.mutedGray + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + } + + // ── Config list items ───────────────────────────────────────── + delegate: ColumnLayout { + width: listView.width + spacing: 0 + + LabelWithButtonType { + Layout.fillWidth: true + text: configName + descriptionText: configDate + rightImageSource: "qrc:/images/controls/more-vertical.svg" + clickedFunction: function () { + root.selectedConfigName = configName + root.selectedConfigIndex = index + configActionsDrawer.openTriggered() + } + } + + DividerType { + } + } + } + + // ── Import result handler ───────────────────────────────────────── + Connections { + target: XrayConfigSnapshotsModel + + function onImportFailed(errorMessage) { + PageController.showNotificationMessage(errorMessage) + } + } + + // ── Per-config actions drawer ───────────────────────────────────── + DrawerType2 { + id: configActionsDrawer + parent: root + anchors.fill: parent + expandedHeight: root.height * 0.35 + + expandedStateContent: ColumnLayout { + id: drawerContent + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + spacing: 0 + + onImplicitHeightChanged: { + configActionsDrawer.expandedHeight = drawerContent.implicitHeight + 32 + } + + BackButtonType { + Layout.fillWidth: true + Layout.topMargin: 16 + backButtonFunction: function () { + configActionsDrawer.closeTriggered() + } + } + + BaseHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + Layout.bottomMargin: 16 + headerText: root.selectedConfigName + } + + // Apply + LabelWithButtonType { + Layout.fillWidth: true + text: qsTr("Apply configuration") + rightImageSource: "qrc:/images/controls/chevron-right.svg" + clickedFunction: function () { + configActionsDrawer.closeTriggered() + XrayConfigSnapshotsModel.applyConfigToCurrentModel(root.selectedConfigIndex) + PageController.closePage() + } + } + + DividerType { + } + + // Export this config + LabelWithButtonType { + Layout.fillWidth: true + text: qsTr("Export configuration") + rightImageSource: "qrc:/images/controls/chevron-right.svg" + clickedFunction: function () { + configActionsDrawer.closeTriggered() + var json = XrayConfigSnapshotsModel.exportToJson(root.selectedConfigIndex) + saveConfigToFile(json) + } + } + + DividerType { + } + + // Delete + LabelWithButtonType { + Layout.fillWidth: true + text: qsTr("Delete configuration") + textColor: AmneziaStyle.color.vibrantRed + clickedFunction: function () { + configActionsDrawer.closeTriggered() + var yesButtonFunction = function () { + XrayConfigSnapshotsModel.removeConfig(root.selectedConfigIndex) + root.selectedConfigIndex = -1 + root.selectedConfigName = "" + } + showQuestionDrawer( + qsTr("Delete configuration?"), + qsTr("This action cannot be undone."), + qsTr("Delete"), qsTr("Cancel"), + yesButtonFunction, function () { + }) + } + } + + DividerType { + } + Item { + Layout.preferredHeight: 16 + } + } + } +} diff --git a/client/ui/qml/Pages2/PageProtocolXrayTransportSettings.qml b/client/ui/qml/Pages2/PageProtocolXrayTransportSettings.qml new file mode 100644 index 000000000..bbda7eb87 --- /dev/null +++ b/client/ui/qml/Pages2/PageProtocolXrayTransportSettings.qml @@ -0,0 +1,755 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import PageEnum 1.0 +import ProtocolEnum 1.0 +import Style 1.0 + +import "./" +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" +import "../Components" + +PageType { + id: root + + BackButtonType { + id: backButton + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 20 + PageController.safeAreaTopMargin + } + + ListViewType { + id: listView + anchors.top: backButton.bottom + anchors.bottom: saveButton.visible ? saveButton.top : parent.bottom + anchors.left: parent.left + anchors.right: parent.right + + enabled: ServersUiController.isProcessedServerHasWriteAccess() + model: XrayConfigModel + + delegate: ColumnLayout { + width: listView.width + spacing: 0 + + BaseHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 0 + Layout.bottomMargin: 24 + headerText: qsTr("Transport") + } + + // ── Radio buttons ───────────────────────────────────────── + VerticalRadioButton { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: qsTr("RAW (TCP)") + checked: transport === "raw" + onToggled: if (checked && transport !== "raw") transport = "raw" + } + + DividerType { + } + + VerticalRadioButton { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: qsTr("XHTTP") + descriptionText: qsTr("Advanced users") + checked: transport === "xhttp" + onToggled: if (checked && transport !== "xhttp") transport = "xhttp" + } + + DividerType { + } + + VerticalRadioButton { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: qsTr("mKCP") + checked: transport === "mkcp" + onToggled: if (checked && transport !== "mkcp") transport = "mkcp" + } + + DividerType { + } + + // ══════════════════════════════════════════════════════════ + // mKCP Settings + // ══════════════════════════════════════════════════════════ + ColumnLayout { + visible: transport === "mkcp" + Layout.fillWidth: true + spacing: 0 + + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 24 + Layout.bottomMargin: 8 + text: qsTr("mKCP Settings") + color: AmneziaStyle.color.mutedGray + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("TTI") + subtitleText: qsTr("Default: %1 ms", "mKCP TTI").arg(XrayConfigModel.mkcpDefaultTti()) + textField.text: mkcpTti + textField.onEditingFinished: { + if (textField.text !== mkcpTti) mkcpTti = textField.text + } + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("uplinkCapacity") + subtitleText: qsTr("Default: %1 Mbit/s", "mKCP uplink").arg(XrayConfigModel.mkcpDefaultUplinkCapacity()) + textField.text: mkcpUplinkCapacity + textField.onEditingFinished: { + if (textField.text !== mkcpUplinkCapacity) mkcpUplinkCapacity = textField.text + } + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("downlinkCapacity") + subtitleText: qsTr("Default: %1 Mbit/s", "mKCP downlink").arg(XrayConfigModel.mkcpDefaultDownlinkCapacity()) + textField.text: mkcpDownlinkCapacity + textField.onEditingFinished: { + if (textField.text !== mkcpDownlinkCapacity) mkcpDownlinkCapacity = textField.text + } + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("readBufferSize") + subtitleText: qsTr("Default: %1 MiB").arg(XrayConfigModel.mkcpDefaultReadBufferSize()) + textField.text: mkcpReadBufferSize + textField.onEditingFinished: { + if (textField.text !== mkcpReadBufferSize) mkcpReadBufferSize = textField.text + } + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("writeBufferSize") + subtitleText: qsTr("Default: %1 MiB").arg(XrayConfigModel.mkcpDefaultWriteBufferSize()) + textField.text: mkcpWriteBufferSize + textField.onEditingFinished: { + if (textField.text !== mkcpWriteBufferSize) mkcpWriteBufferSize = textField.text + } + } + + SwitcherType { + Layout.fillWidth: true + Layout.margins: 16 + Layout.topMargin: 8 + text: qsTr("Congestion") + checked: mkcpCongestion + onToggled: mkcpCongestion = checked + } + } + + // ══════════════════════════════════════════════════════════ + // XHTTP Settings + // ══════════════════════════════════════════════════════════ + ColumnLayout { + visible: transport === "xhttp" + Layout.fillWidth: true + spacing: 0 + + DropDownType { + id: modeDropDown + Layout.fillWidth: true + Layout.topMargin: 16 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: xhttpMode + descriptionText: qsTr("Mode") + headerText: qsTr("Mode") + drawerParent: root + listView: ListViewWithRadioButtonType { + rootWidth: root.width + model: ListModel { + Component.onCompleted: { + var opts = XrayConfigModel.xhttpModeOptions() + for (var i = 0; i < opts.length; i++) { + append({name: opts[i]}) + } + } + } + clickedFunction: function () { + xhttpMode = selectedText + modeDropDown.text = selectedText + modeDropDown.closeTriggered() + } + Component.onCompleted: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).name === xhttpMode) { + selectedIndex = i; + break + } + } + } + } + Connections { + target: XrayConfigModel + + function onDataChanged() { + modeDropDown.text = xhttpMode + } + } + } + + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 24 + Layout.bottomMargin: 8 + text: qsTr("HTTP Profile") + color: AmneziaStyle.color.mutedGray + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("Host") + textField.text: xhttpHost + textField.onEditingFinished: { + if (textField.text !== xhttpHost) xhttpHost = textField.text + } + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("Path") + textField.text: xhttpPath + textField.onEditingFinished: { + if (textField.text !== xhttpPath) xhttpPath = textField.text + } + } + + DropDownType { + id: headersDropDown + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: xhttpHeadersTemplate + descriptionText: qsTr("Headers template") + headerText: qsTr("Headers template") + drawerParent: root + listView: ListViewWithRadioButtonType { + rootWidth: root.width + model: ListModel { + Component.onCompleted: { + var opts = XrayConfigModel.xhttpHeadersTemplateOptions() + for (var i = 0; i < opts.length; i++) { + append({name: opts[i]}) + } + } + } + clickedFunction: function () { + xhttpHeadersTemplate = selectedText + headersDropDown.text = selectedText + headersDropDown.closeTriggered() + } + Component.onCompleted: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).name === xhttpHeadersTemplate) { + selectedIndex = i; + break + } + } + } + } + Connections { + target: XrayConfigModel + + function onDataChanged() { + headersDropDown.text = xhttpHeadersTemplate + } + } + } + + DropDownType { + id: uplinkMethodDropDown + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: xhttpUplinkMethod + descriptionText: qsTr("UplinkHTTPMethod") + headerText: qsTr("UplinkHTTPMethod") + drawerParent: root + listView: ListViewWithRadioButtonType { + rootWidth: root.width + model: ListModel { + Component.onCompleted: { + var opts = XrayConfigModel.xhttpUplinkMethodOptions() + for (var i = 0; i < opts.length; i++) { + append({name: opts[i]}) + } + } + } + clickedFunction: function () { + xhttpUplinkMethod = selectedText + uplinkMethodDropDown.text = selectedText + uplinkMethodDropDown.closeTriggered() + } + Component.onCompleted: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).name === xhttpUplinkMethod) { + selectedIndex = i; + break + } + } + } + } + Connections { + target: XrayConfigModel + + function onDataChanged() { + uplinkMethodDropDown.text = xhttpUplinkMethod + } + } + } + + SwitcherType { + Layout.fillWidth: true + Layout.margins: 16 + Layout.topMargin: 16 + text: qsTr("Disable gRPC Header") + descriptionText: qsTr("noGRPCHeader") + checked: xhttpDisableGrpc + onToggled: xhttpDisableGrpc = checked + } + + DividerType { + } + + SwitcherType { + Layout.fillWidth: true + Layout.margins: 16 + text: qsTr("Disable SSE Header") + descriptionText: qsTr("noSSEHeader") + checked: xhttpDisableSse + onToggled: xhttpDisableSse = checked + } + + DividerType { + } + + // ── Session & Sequence ──────────────────────────────── + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 24 + Layout.bottomMargin: 8 + text: qsTr("Session & Sequence") + color: AmneziaStyle.color.mutedGray + } + + DropDownType { + id: sessionPlacementDropDown + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: xhttpSessionPlacement + descriptionText: qsTr("SessionPlacement") + headerText: qsTr("SessionPlacement") + drawerParent: root + listView: ListViewWithRadioButtonType { + rootWidth: root.width + model: ListModel { + Component.onCompleted: { + var opts = XrayConfigModel.xhttpSessionPlacementOptions() + for (var i = 0; i < opts.length; i++) { + append({name: opts[i]}) + } + } + } + clickedFunction: function () { + xhttpSessionPlacement = selectedText + sessionPlacementDropDown.text = selectedText + sessionPlacementDropDown.closeTriggered() + } + Component.onCompleted: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).name === xhttpSessionPlacement) { + selectedIndex = i; + break + } + } + } + } + Connections { + target: XrayConfigModel + + function onDataChanged() { + sessionPlacementDropDown.text = xhttpSessionPlacement + } + } + } + + DropDownType { + id: sessionKeyDropDown + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: xhttpSessionKey + descriptionText: qsTr("SessionKey") + headerText: qsTr("SessionKey") + drawerParent: root + listView: ListViewWithRadioButtonType { + rootWidth: root.width + model: ListModel { + Component.onCompleted: { + var opts = XrayConfigModel.xhttpSessionKeyOptions() + for (var i = 0; i < opts.length; i++) { + append({name: opts[i]}) + } + } + } + clickedFunction: function () { + xhttpSessionKey = selectedText + sessionKeyDropDown.text = selectedText + sessionKeyDropDown.closeTriggered() + } + Component.onCompleted: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).name === xhttpSessionKey) { + selectedIndex = i; + break + } + } + } + } + Connections { + target: XrayConfigModel + + function onDataChanged() { + sessionKeyDropDown.text = xhttpSessionKey + } + } + } + + DropDownType { + id: seqPlacementDropDown + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: xhttpSeqPlacement + descriptionText: qsTr("SeqPlacement") + headerText: qsTr("SeqPlacement") + drawerParent: root + listView: ListViewWithRadioButtonType { + rootWidth: root.width + model: ListModel { + Component.onCompleted: { + var opts = XrayConfigModel.xhttpSeqPlacementOptions() + for (var i = 0; i < opts.length; i++) { + append({name: opts[i]}) + } + } + } + clickedFunction: function () { + xhttpSeqPlacement = selectedText + seqPlacementDropDown.text = selectedText + seqPlacementDropDown.closeTriggered() + } + Component.onCompleted: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).name === xhttpSeqPlacement) { + selectedIndex = i; + break + } + } + } + } + Connections { + target: XrayConfigModel + + function onDataChanged() { + seqPlacementDropDown.text = xhttpSeqPlacement + } + } + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("SeqKey") + textField.text: xhttpSeqKey + textField.onEditingFinished: { + if (textField.text !== xhttpSeqKey) xhttpSeqKey = textField.text + } + } + + DropDownType { + id: uplinkDataPlacementDropDown + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: xhttpUplinkDataPlacement + descriptionText: qsTr("UplinkDataPlacement") + headerText: qsTr("UplinkDataPlacement") + drawerParent: root + listView: ListViewWithRadioButtonType { + rootWidth: root.width + model: ListModel { + Component.onCompleted: { + var opts = XrayConfigModel.xhttpUplinkDataPlacementOptions() + for (var i = 0; i < opts.length; i++) { + append({name: opts[i]}) + } + } + } + clickedFunction: function () { + xhttpUplinkDataPlacement = selectedText + uplinkDataPlacementDropDown.text = selectedText + uplinkDataPlacementDropDown.closeTriggered() + } + Component.onCompleted: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).name === xhttpUplinkDataPlacement) { + selectedIndex = i; + break + } + } + } + } + Connections { + target: XrayConfigModel + + function onDataChanged() { + uplinkDataPlacementDropDown.text = xhttpUplinkDataPlacement + } + } + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("UplinkDataKey") + textField.text: xhttpUplinkDataKey + textField.onEditingFinished: { + if (textField.text !== xhttpUplinkDataKey) xhttpUplinkDataKey = textField.text + } + } + + // ── Traffic Shaping ─────────────────────────────────── + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 24 + Layout.bottomMargin: 8 + text: qsTr("Traffic Shaping") + color: AmneziaStyle.color.mutedGray + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("UplinkChunkSize") + textField.text: xhttpUplinkChunkSize + textField.validator: IntValidator { + bottom: 0 + } + textField.onEditingFinished: { + if (textField.text !== xhttpUplinkChunkSize) xhttpUplinkChunkSize = textField.text + } + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("scMaxBufferedPosts") + textField.text: xhttpScMaxBufferedPosts + textField.onEditingFinished: { + if (textField.text !== xhttpScMaxBufferedPosts) xhttpScMaxBufferedPosts = textField.text + } + } + + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 16 + Layout.bottomMargin: 8 + text: qsTr("scMaxEachPostBytes") + color: AmneziaStyle.color.mutedGray + } + MinMaxRowType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + minValue: xhttpScMaxEachPostBytesMin + maxValue: xhttpScMaxEachPostBytesMax + onMinChanged: xhttpScMaxEachPostBytesMin = val + onMaxChanged: xhttpScMaxEachPostBytesMax = val + } + + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 16 + Layout.bottomMargin: 8 + text: qsTr("scStreamUpServerSecs") + color: AmneziaStyle.color.mutedGray + } + MinMaxRowType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + minValue: xhttpScStreamUpServerSecsMin + maxValue: xhttpScStreamUpServerSecsMax + onMinChanged: xhttpScStreamUpServerSecsMin = val + onMaxChanged: xhttpScStreamUpServerSecsMax = val + } + + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 16 + Layout.bottomMargin: 8 + text: qsTr("scMinPostsIntervalMs") + color: AmneziaStyle.color.mutedGray + } + MinMaxRowType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + minValue: xhttpScMinPostsIntervalMsMin + maxValue: xhttpScMinPostsIntervalMsMax + onMinChanged: xhttpScMinPostsIntervalMsMin = val + onMaxChanged: xhttpScMinPostsIntervalMsMax = val + } + + // ── Padding and multiplexing ────────────────────────── + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 24 + Layout.bottomMargin: 8 + text: qsTr("Padding and multiplexing") + color: AmneziaStyle.color.mutedGray + } + + LabelWithButtonType { + Layout.fillWidth: true + text: qsTr("xPadding") + rightImageSource: "qrc:/images/controls/chevron-right.svg" + clickedFunction: function () { + PageController.goToPage(PageEnum.PageProtocolXrayXPaddingSettings) + } + } + + DividerType { + } + + LabelWithButtonType { + Layout.fillWidth: true + text: qsTr("XMux") + descriptionText: xmuxEnabled ? qsTr("On") : qsTr("Off") + rightImageSource: "qrc:/images/controls/chevron-right.svg" + clickedFunction: function () { + PageController.goToPage(PageEnum.PageProtocolXrayXmuxSettings) + } + } + + DividerType { + } + } + + Item { + Layout.preferredHeight: 16 + } + } + } + + BasicButtonType { + id: saveButton + + anchors.left: root.left + anchors.right: root.right + anchors.bottom: root.bottom + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.bottomMargin: 16 + PageController.safeAreaBottomMargin + + visible: listView.enabled && XrayConfigModel.hasUnsavedChanges + enabled: visible + text: qsTr("Save") + clickedFunc: function () { + var headerText = qsTr("Save settings?") + var descriptionText = qsTr("All users with whom you shared a connection with will no longer be able to connect to it.") + var yesButtonText = qsTr("Continue") + var noButtonText = qsTr("Cancel") + var yesButtonFunction = function () { + if (ConnectionController.isConnected && ServersModel.getDefaultServerData("defaultContainer") === ServersUiController.processedContainerIndex) { + PageController.showNotificationMessage(qsTr("Unable change settings while there is an active connection")) + return + } + PageController.goToPage(PageEnum.PageSetupWizardInstalling) + InstallController.updateContainer(ServersUiController.processedIndex, ServersUiController.processedContainerIndex, ProtocolEnum.Xray) + } + var noButtonFunction = function () { + if (typeof GC !== "undefined" && !GC.isMobile()) { + saveButton.forceActiveFocus() + } + } + showQuestionDrawer(headerText, descriptionText, yesButtonText, noButtonText, yesButtonFunction, noButtonFunction) + } + } +} diff --git a/client/ui/qml/Pages2/PageProtocolXrayXPaddingBytesSettings.qml b/client/ui/qml/Pages2/PageProtocolXrayXPaddingBytesSettings.qml new file mode 100644 index 000000000..026c8cfa7 --- /dev/null +++ b/client/ui/qml/Pages2/PageProtocolXrayXPaddingBytesSettings.qml @@ -0,0 +1,108 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import PageEnum 1.0 +import ProtocolEnum 1.0 +import Style 1.0 + +import "./" +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" +import "../Components" + +PageType { + id: root + + BackButtonType { + id: backButton + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 20 + PageController.safeAreaTopMargin + } + + ListViewType { + id: listView + anchors.top: backButton.bottom + anchors.bottom: saveButton.visible ? saveButton.top : parent.bottom + anchors.left: parent.left + anchors.right: parent.right + + enabled: ServersUiController.isProcessedServerHasWriteAccess() + model: XrayConfigModel + + delegate: ColumnLayout { + width: listView.width + spacing: 0 + + BaseHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 0 + Layout.bottomMargin: 24 + headerText: qsTr("xPaddingBytes") + } + + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 8 + text: qsTr("Range") + color: AmneziaStyle.color.mutedGray + } + + MinMaxRowType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + minValue: xPaddingBytesMin + maxValue: xPaddingBytesMax + onMinChanged: xPaddingBytesMin = val + onMaxChanged: xPaddingBytesMax = val + } + + Item { + Layout.preferredHeight: 16 + } + } + } + + BasicButtonType { + id: saveButton + + anchors.left: root.left + anchors.right: root.right + anchors.bottom: root.bottom + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.bottomMargin: 16 + PageController.safeAreaBottomMargin + + visible: listView.enabled && XrayConfigModel.hasUnsavedChanges + enabled: visible + text: qsTr("Save") + clickedFunc: function () { + var headerText = qsTr("Save settings?") + var descriptionText = qsTr("All users with whom you shared a connection with will no longer be able to connect to it.") + var yesButtonText = qsTr("Continue") + var noButtonText = qsTr("Cancel") + var yesButtonFunction = function () { + if (ConnectionController.isConnected && ServersModel.getDefaultServerData("defaultContainer") === ServersUiController.processedContainerIndex) { + PageController.showNotificationMessage(qsTr("Unable change settings while there is an active connection")) + return + } + PageController.goToPage(PageEnum.PageSetupWizardInstalling) + InstallController.updateContainer(ServersUiController.processedIndex, ServersUiController.processedContainerIndex, ProtocolEnum.Xray) + } + var noButtonFunction = function () { + if (typeof GC !== "undefined" && !GC.isMobile()) { + saveButton.forceActiveFocus() + } + } + showQuestionDrawer(headerText, descriptionText, yesButtonText, noButtonText, yesButtonFunction, noButtonFunction) + } + } +} diff --git a/client/ui/qml/Pages2/PageProtocolXrayXPaddingSettings.qml b/client/ui/qml/Pages2/PageProtocolXrayXPaddingSettings.qml new file mode 100644 index 000000000..b06b745f2 --- /dev/null +++ b/client/ui/qml/Pages2/PageProtocolXrayXPaddingSettings.qml @@ -0,0 +1,224 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import PageEnum 1.0 +import ProtocolEnum 1.0 +import Style 1.0 + +import "./" +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" +import "../Components" + +PageType { + id: root + + BackButtonType { + id: backButton + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 20 + PageController.safeAreaTopMargin + } + + ListViewType { + id: listView + anchors.top: backButton.bottom + anchors.bottom: saveButton.visible ? saveButton.top : parent.bottom + anchors.left: parent.left + anchors.right: parent.right + + enabled: ServersUiController.isProcessedServerHasWriteAccess() + model: XrayConfigModel + + delegate: ColumnLayout { + width: listView.width + spacing: 0 + + BaseHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 0 + Layout.bottomMargin: 24 + headerText: qsTr("xPadding") + } + + // xPaddingBytes — min/max display row + LabelWithButtonType { + Layout.fillWidth: true + text: qsTr("xPaddingBytes") + descriptionText: (xPaddingBytesMin !== "" ? xPaddingBytesMin : "0") + "—" + (xPaddingBytesMax !== "" ? xPaddingBytesMax : "0") + rightImageSource: "qrc:/images/controls/chevron-right.svg" + clickedFunction: function () { + PageController.goToPage(PageEnum.PageProtocolXrayXPaddingBytesSettings) + } + } + + DividerType { + } + + SwitcherType { + Layout.fillWidth: true + Layout.margins: 16 + text: qsTr("xPaddingObfsMode") + checked: xPaddingObfsMode + onToggled: xPaddingObfsMode = checked + } + + DividerType { + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 16 + headerText: qsTr("xPaddingKey") + textField.text: xPaddingKey + textField.onEditingFinished: { + if (textField.text !== xPaddingKey) xPaddingKey = textField.text + } + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("xPaddingHeader") + textField.text: xPaddingHeader + textField.onEditingFinished: { + if (textField.text !== xPaddingHeader) xPaddingHeader = textField.text + } + } + + DropDownType { + id: placementDropDown + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: xPaddingPlacement + descriptionText: qsTr("xPaddingPlacement") + headerText: qsTr("xPaddingPlacement") + drawerParent: root + listView: ListViewWithRadioButtonType { + rootWidth: root.width + model: ListModel { + Component.onCompleted: { + var opts = XrayConfigModel.xPaddingPlacementOptions() + for (var i = 0; i < opts.length; i++) { + append({name: opts[i]}) + } + } + } + clickedFunction: function () { + xPaddingPlacement = selectedText + placementDropDown.text = selectedText + placementDropDown.closeTriggered() + } + Component.onCompleted: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).name === xPaddingPlacement) { + selectedIndex = i; + break + } + } + } + } + Connections { + target: XrayConfigModel + + function onDataChanged() { + placementDropDown.text = xPaddingPlacement + } + } + } + + DropDownType { + id: methodDropDown + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: xPaddingMethod + descriptionText: qsTr("xPaddingMethod") + headerText: qsTr("xPaddingMethod") + drawerParent: root + listView: ListViewWithRadioButtonType { + rootWidth: root.width + model: ListModel { + Component.onCompleted: { + var opts = XrayConfigModel.xPaddingMethodOptions() + for (var i = 0; i < opts.length; i++) { + append({name: opts[i]}) + } + } + } + clickedFunction: function () { + xPaddingMethod = selectedText + methodDropDown.text = selectedText + methodDropDown.closeTriggered() + } + Component.onCompleted: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).name === xPaddingMethod) { + selectedIndex = i; + break + } + } + } + } + Connections { + target: XrayConfigModel + + function onDataChanged() { + methodDropDown.text = xPaddingMethod + } + } + } + + Item { + Layout.preferredHeight: 16 + } + } + } + + BasicButtonType { + id: saveButton + + anchors.left: root.left + anchors.right: root.right + anchors.bottom: root.bottom + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.bottomMargin: 16 + PageController.safeAreaBottomMargin + + visible: listView.enabled && XrayConfigModel.hasUnsavedChanges + enabled: visible + text: qsTr("Save") + clickedFunc: function () { + var headerText = qsTr("Save settings?") + var descriptionText = qsTr("All users with whom you shared a connection with will no longer be able to connect to it.") + var yesButtonText = qsTr("Continue") + var noButtonText = qsTr("Cancel") + var yesButtonFunction = function () { + if (ConnectionController.isConnected && ServersModel.getDefaultServerData("defaultContainer") === ServersUiController.processedContainerIndex) { + PageController.showNotificationMessage(qsTr("Unable change settings while there is an active connection")) + return + } + PageController.goToPage(PageEnum.PageSetupWizardInstalling) + InstallController.updateContainer(ServersUiController.processedIndex, ServersUiController.processedContainerIndex, ProtocolEnum.Xray) + } + var noButtonFunction = function () { + if (typeof GC !== "undefined" && !GC.isMobile()) { + saveButton.forceActiveFocus() + } + } + showQuestionDrawer(headerText, descriptionText, yesButtonText, noButtonText, yesButtonFunction, noButtonFunction) + } + } +} diff --git a/client/ui/qml/Pages2/PageProtocolXrayXmuxSettings.qml b/client/ui/qml/Pages2/PageProtocolXrayXmuxSettings.qml new file mode 100644 index 000000000..dff46b2dd --- /dev/null +++ b/client/ui/qml/Pages2/PageProtocolXrayXmuxSettings.qml @@ -0,0 +1,222 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import PageEnum 1.0 +import ProtocolEnum 1.0 +import Style 1.0 + +import "./" +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" +import "../Components" + +PageType { + id: root + + BackButtonType { + id: backButton + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 20 + PageController.safeAreaTopMargin + } + + ListViewType { + id: listView + anchors.top: backButton.bottom + anchors.bottom: saveButton.visible ? saveButton.top : parent.bottom + anchors.left: parent.left + anchors.right: parent.right + + enabled: ServersUiController.isProcessedServerHasWriteAccess() + model: XrayConfigModel + + delegate: ColumnLayout { + width: listView.width + spacing: 0 + + BaseHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 0 + Layout.bottomMargin: 24 + headerText: qsTr("xmux") + } + + SwitcherType { + Layout.fillWidth: true + Layout.margins: 16 + text: qsTr("xmux") + checked: xmuxEnabled + onToggled: xmuxEnabled = checked + } + + DividerType { + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + enabled: xmuxEnabled + + // maxConcurrency + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 24 + Layout.bottomMargin: 8 + text: qsTr("maxConcurrency") + color: AmneziaStyle.color.mutedGray + } + MinMaxRowType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + minValue: xmuxMaxConcurrencyMin + maxValue: xmuxMaxConcurrencyMax + onMinChanged: xmuxMaxConcurrencyMin = val + onMaxChanged: xmuxMaxConcurrencyMax = val + } + + // maxConnections + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 16 + Layout.bottomMargin: 8 + text: qsTr("maxConnections") + color: AmneziaStyle.color.mutedGray + } + MinMaxRowType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + minValue: xmuxMaxConnectionsMin + maxValue: xmuxMaxConnectionsMax + onMinChanged: xmuxMaxConnectionsMin = val + onMaxChanged: xmuxMaxConnectionsMax = val + } + + // cMaxReuseTimes + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 16 + Layout.bottomMargin: 8 + text: qsTr("cMaxReuseTimes") + color: AmneziaStyle.color.mutedGray + } + MinMaxRowType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + minValue: xmuxCMaxReuseTimesMin + maxValue: xmuxCMaxReuseTimesMax + onMinChanged: xmuxCMaxReuseTimesMin = val + onMaxChanged: xmuxCMaxReuseTimesMax = val + } + + // hMaxRequestTimes + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 16 + Layout.bottomMargin: 8 + text: qsTr("hMaxRequestTimes") + color: AmneziaStyle.color.mutedGray + } + MinMaxRowType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + minValue: xmuxHMaxRequestTimesMin + maxValue: xmuxHMaxRequestTimesMax + onMinChanged: xmuxHMaxRequestTimesMin = val + onMaxChanged: xmuxHMaxRequestTimesMax = val + } + + // hMaxReusableSecs + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 16 + Layout.bottomMargin: 8 + text: qsTr("hMaxReusableSecs") + color: AmneziaStyle.color.mutedGray + } + MinMaxRowType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + minValue: xmuxHMaxReusableSecsMin + maxValue: xmuxHMaxReusableSecsMax + onMinChanged: xmuxHMaxReusableSecsMin = val + onMaxChanged: xmuxHMaxReusableSecsMax = val + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 16 + headerText: qsTr("hKeepAlivePeriod") + textField.text: xmuxHKeepAlivePeriod + textField.validator: IntValidator { + bottom: 0 + } + textField.onEditingFinished: { + if (textField.text !== xmuxHKeepAlivePeriod) xmuxHKeepAlivePeriod = textField.text + } + } + } + + Item { + Layout.preferredHeight: 16 + } + } + } + + BasicButtonType { + id: saveButton + + anchors.left: root.left + anchors.right: root.right + anchors.bottom: root.bottom + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.bottomMargin: 16 + PageController.safeAreaBottomMargin + + visible: listView.enabled && XrayConfigModel.hasUnsavedChanges + enabled: visible + text: qsTr("Save") + clickedFunc: function () { + var headerText = qsTr("Save settings?") + var descriptionText = qsTr("All users with whom you shared a connection with will no longer be able to connect to it.") + var yesButtonText = qsTr("Continue") + var noButtonText = qsTr("Cancel") + var yesButtonFunction = function () { + if (ConnectionController.isConnected && ServersModel.getDefaultServerData("defaultContainer") === ServersUiController.processedContainerIndex) { + PageController.showNotificationMessage(qsTr("Unable change settings while there is an active connection")) + return + } + PageController.goToPage(PageEnum.PageSetupWizardInstalling) + InstallController.updateContainer(ServersUiController.processedIndex, ServersUiController.processedContainerIndex, ProtocolEnum.Xray) + } + var noButtonFunction = function () { + if (typeof GC !== "undefined" && !GC.isMobile()) { + saveButton.forceActiveFocus() + } + } + showQuestionDrawer(headerText, descriptionText, yesButtonText, noButtonText, yesButtonFunction, noButtonFunction) + } + } +} + diff --git a/client/ui/qml/qml.qrc b/client/ui/qml/qml.qrc index 64e60c201..5785b4c78 100644 --- a/client/ui/qml/qml.qrc +++ b/client/ui/qml/qml.qrc @@ -77,6 +77,16 @@ Pages2/PageProtocolRaw.qml Pages2/PageProtocolWireGuardSettings.qml Pages2/PageProtocolXraySettings.qml + + Pages2/PageProtocolXraySnapshots.qml + Pages2/PageProtocolXrayFlowSettings.qml + Pages2/PageProtocolXraySecuritySettings.qml + Pages2/PageProtocolXrayTransportSettings.qml + Pages2/PageProtocolXrayXmuxSettings.qml + Pages2/PageProtocolXrayXPaddingSettings.qml + Pages2/PageProtocolXrayXPaddingBytesSettings.qml + Controls2/MinMaxRowType.qml + Pages2/PageServiceDnsSettings.qml Pages2/PageServiceMtProxySettings.qml Pages2/PageServiceTelemtSettings.qml