Merge branch 'dev' into feature/local-proxy-integration

This commit is contained in:
aiamnezia
2026-04-21 16:14:49 +04:00
26 changed files with 421 additions and 52 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
cmake_minimum_required(VERSION 3.25.0 FATAL_ERROR) cmake_minimum_required(VERSION 3.25.0 FATAL_ERROR)
set(PROJECT AmneziaVPN) set(PROJECT AmneziaVPN)
set(AMNEZIAVPN_VERSION 4.8.15.0) set(AMNEZIAVPN_VERSION 4.8.15.2)
project(${PROJECT} VERSION ${AMNEZIAVPN_VERSION} project(${PROJECT} VERSION ${AMNEZIAVPN_VERSION}
DESCRIPTION "AmneziaVPN" DESCRIPTION "AmneziaVPN"
@@ -12,7 +12,7 @@ string(TIMESTAMP CURRENT_DATE "%Y-%m-%d")
set(RELEASE_DATE "${CURRENT_DATE}") set(RELEASE_DATE "${CURRENT_DATE}")
set(APP_MAJOR_VERSION ${CMAKE_PROJECT_VERSION_MAJOR}.${CMAKE_PROJECT_VERSION_MINOR}.${CMAKE_PROJECT_VERSION_PATCH}) set(APP_MAJOR_VERSION ${CMAKE_PROJECT_VERSION_MAJOR}.${CMAKE_PROJECT_VERSION_MINOR}.${CMAKE_PROJECT_VERSION_PATCH})
set(APP_ANDROID_VERSION_CODE 2118) set(APP_ANDROID_VERSION_CODE 2119)
if(${CMAKE_SYSTEM_NAME} STREQUAL "Linux") if(${CMAKE_SYSTEM_NAME} STREQUAL "Linux")
set(MZ_PLATFORM_NAME "linux") set(MZ_PLATFORM_NAME "linux")
+75 -2
View File
@@ -4,6 +4,9 @@ import android.content.Context
import android.net.VpnService.Builder import android.net.VpnService.Builder
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.net.InetAddress
import java.net.ServerSocket
import java.util.UUID
import go.Seq import go.Seq
import org.amnezia.vpn.protocol.BadConfigException import org.amnezia.vpn.protocol.BadConfigException
import org.amnezia.vpn.protocol.Protocol import org.amnezia.vpn.protocol.Protocol
@@ -19,11 +22,32 @@ import org.amnezia.vpn.util.Log
import org.amnezia.vpn.util.net.InetNetwork import org.amnezia.vpn.util.net.InetNetwork
import org.amnezia.vpn.util.net.ip import org.amnezia.vpn.util.net.ip
import org.amnezia.vpn.util.net.parseInetAddress import org.amnezia.vpn.util.net.parseInetAddress
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
private const val TAG = "Xray" private const val TAG = "Xray"
private const val LIBXRAY_TAG = "libXray" private const val LIBXRAY_TAG = "libXray"
private fun findSocksInboundIndex(inbounds: JSONArray): Int {
for (i in 0 until inbounds.length()) {
val o = inbounds.optJSONObject(i) ?: continue
if (o.optString("protocol").equals("socks", ignoreCase = true)) {
return i
}
}
return -1
}
private fun acquireFreeLocalPort(): Int {
try {
ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")).use { return it.localPort }
} catch (e: Exception) {
throw VpnStartException(
"Failed to acquire free TCP port on 127.0.0.1 for SOCKS inbound: ${e.message}"
)
}
}
class Xray : Protocol() { class Xray : Protocol() {
private var isRunning: Boolean = false private var isRunning: Boolean = false
@@ -56,6 +80,10 @@ class Xray : Protocol() {
val xrayJsonConfig = config.optJSONObject("xray_config_data") val xrayJsonConfig = config.optJSONObject("xray_config_data")
?: config.optJSONObject("ssxray_config_data") ?: config.optJSONObject("ssxray_config_data")
?: throw BadConfigException("config_data not found") ?: throw BadConfigException("config_data not found")
// Inject SOCKS5 auth before starting xray. Re-uses existing credentials if present.
ensureInboundAuth(xrayJsonConfig)
val xrayConfig = parseConfig(config, xrayJsonConfig) val xrayConfig = parseConfig(config, xrayJsonConfig)
(xrayJsonConfig.optJSONObject("log") ?: JSONObject().also { xrayJsonConfig.put("log", it) }) (xrayJsonConfig.optJSONObject("log") ?: JSONObject().also { xrayJsonConfig.put("log", it) })
@@ -97,9 +125,22 @@ class Xray : Protocol() {
if (it.isNotBlank()) setMtu(it.toInt()) if (it.isNotBlank()) setMtu(it.toInt())
} }
val socksConfig = xrayJsonConfig.getJSONArray("inbounds")[0] as JSONObject val inbounds = xrayJsonConfig.getJSONArray("inbounds")
val socksIdx = findSocksInboundIndex(inbounds)
if (socksIdx < 0) {
throw BadConfigException("socks inbound not found")
}
val socksConfig = inbounds.getJSONObject(socksIdx)
socksConfig.getInt("port").let { setSocksPort(it) } socksConfig.getInt("port").let { setSocksPort(it) }
val socksSettings = socksConfig.optJSONObject("settings")
val accounts = socksSettings?.optJSONArray("accounts")
if (accounts != null && accounts.length() > 0) {
val account = accounts.getJSONObject(0)
setSocksUser(account.optString("user"))
setSocksPass(account.optString("pass"))
}
configSplitTunneling(config) configSplitTunneling(config)
configAppSplitTunneling(config) configAppSplitTunneling(config)
} }
@@ -162,9 +203,10 @@ class Xray : Protocol() {
} }
private fun runTun2Socks(config: XrayConfig, fd: Int) { private fun runTun2Socks(config: XrayConfig, fd: Int) {
val proxyUrl = "socks5://${config.socksUser}:${config.socksPass}@127.0.0.1:${config.socksPort}"
val tun2SocksConfig = Tun2SocksConfig().apply { val tun2SocksConfig = Tun2SocksConfig().apply {
mtu = config.mtu.toLong() mtu = config.mtu.toLong()
proxy = "socks5://127.0.0.1:${config.socksPort}" proxy = proxyUrl
device = "fd://$fd" device = "fd://$fd"
logLevel = "warn" logLevel = "warn"
} }
@@ -173,6 +215,37 @@ class Xray : Protocol() {
} }
} }
// Ensures SOCKS5 auth is present on the socks inbound settings.
// Re-uses existing credentials if already configured; otherwise generates random ones.
private fun ensureInboundAuth(xrayConfig: JSONObject) {
val inbounds = xrayConfig.optJSONArray("inbounds") ?: return
val socksIdx = findSocksInboundIndex(inbounds)
if (socksIdx < 0) return
val inbound = inbounds.getJSONObject(socksIdx)
inbound.put("port", acquireFreeLocalPort())
val settings = inbound.optJSONObject("settings") ?: JSONObject().also { inbound.put("settings", it) }
val accounts = settings.optJSONArray("accounts")
if (accounts != null && accounts.length() > 0) {
val account = accounts.getJSONObject(0)
if (account.optString("user").isNotEmpty() && account.optString("pass").isNotEmpty()) {
// Ensure auth mode is enforced even for imported configs that had accounts
// but auth: "noauth" (or no auth field).
settings.put("auth", "password")
inbound.put("settings", settings)
inbounds.put(socksIdx, inbound)
return
}
}
val user = UUID.randomUUID().toString().replace("-", "").substring(0, 16)
val pass = UUID.randomUUID().toString().replace("-", "")
settings.put("auth", "password")
settings.put("accounts", JSONArray().put(JSONObject().put("user", user).put("pass", pass)))
inbound.put("settings", settings)
inbounds.put(socksIdx, inbound)
}
companion object { companion object {
val instance: Xray by lazy { Xray() } val instance: Xray by lazy { Xray() }
} }
@@ -9,12 +9,16 @@ private const val XRAY_DEFAULT_MAX_MEMORY: Long = 50 shl 20 // 50 MB
class XrayConfig protected constructor( class XrayConfig protected constructor(
protocolConfigBuilder: ProtocolConfig.Builder, protocolConfigBuilder: ProtocolConfig.Builder,
val socksPort: Int, val socksPort: Int,
val socksUser: String,
val socksPass: String,
val maxMemory: Long, val maxMemory: Long,
) : ProtocolConfig(protocolConfigBuilder) { ) : ProtocolConfig(protocolConfigBuilder) {
protected constructor(builder: Builder) : this( protected constructor(builder: Builder) : this(
builder, builder,
builder.socksPort, builder.socksPort,
builder.socksUser,
builder.socksPass,
builder.maxMemory builder.maxMemory
) )
@@ -22,6 +26,12 @@ class XrayConfig protected constructor(
internal var socksPort: Int = 0 internal var socksPort: Int = 0
private set private set
internal var socksUser: String = ""
private set
internal var socksPass: String = ""
private set
internal var maxMemory: Long = XRAY_DEFAULT_MAX_MEMORY internal var maxMemory: Long = XRAY_DEFAULT_MAX_MEMORY
private set private set
@@ -29,6 +39,10 @@ class XrayConfig protected constructor(
fun setSocksPort(port: Int) = apply { socksPort = port } fun setSocksPort(port: Int) = apply { socksPort = port }
fun setSocksUser(user: String) = apply { socksUser = user }
fun setSocksPass(pass: String) = apply { socksPass = pass }
fun setMaxMemory(maxMemory: Long) = apply { this.maxMemory = maxMemory } fun setMaxMemory(maxMemory: Long) = apply { this.maxMemory = maxMemory }
override fun build(): XrayConfig = configBuild().run { XrayConfig(this@Builder) } override fun build(): XrayConfig = configBuild().run { XrayConfig(this@Builder) }
+2 -1
View File
@@ -10,7 +10,6 @@ namespace apiDefs
AmneziaFreeV3, AmneziaFreeV3,
AmneziaPremiumV1, AmneziaPremiumV1,
AmneziaPremiumV2, AmneziaPremiumV2,
AmneziaTrialV2,
SelfHosted, SelfHosted,
ExternalPremium, ExternalPremium,
ExternalTrial ExternalTrial
@@ -57,6 +56,7 @@ namespace apiDefs
constexpr QLatin1String maxDeviceCount("max_device_count"); constexpr QLatin1String maxDeviceCount("max_device_count");
constexpr QLatin1String subscriptionEndDate("subscription_end_date"); constexpr QLatin1String subscriptionEndDate("subscription_end_date");
constexpr QLatin1String subscriptionExpiredByServer("subscription_expired_by_server"); constexpr QLatin1String subscriptionExpiredByServer("subscription_expired_by_server");
constexpr QLatin1String subscriptionStatus("subscription_status");
constexpr QLatin1String subscription("subscription"); constexpr QLatin1String subscription("subscription");
constexpr QLatin1String endDate("end_date"); constexpr QLatin1String endDate("end_date");
constexpr QLatin1String issuedConfigs("issued_configs"); constexpr QLatin1String issuedConfigs("issued_configs");
@@ -83,6 +83,7 @@ namespace apiDefs
constexpr QLatin1String serviceInfo("service_info"); constexpr QLatin1String serviceInfo("service_info");
constexpr QLatin1String isAdVisible("is_ad_visible"); constexpr QLatin1String isAdVisible("is_ad_visible");
constexpr QLatin1String isRenewalAvailable("is_renewal_available");
constexpr QLatin1String adHeader("ad_header"); constexpr QLatin1String adHeader("ad_header");
constexpr QLatin1String adDescription("ad_description"); constexpr QLatin1String adDescription("ad_description");
constexpr QLatin1String adEndpoint("ad_endpoint"); constexpr QLatin1String adEndpoint("ad_endpoint");
+3 -7
View File
@@ -101,7 +101,6 @@ apiDefs::ConfigType apiUtils::getConfigType(const QJsonObject &serverConfigObjec
}; };
case apiDefs::ConfigSource::AmneziaGateway: { case apiDefs::ConfigSource::AmneziaGateway: {
constexpr QLatin1String servicePremium("amnezia-premium"); constexpr QLatin1String servicePremium("amnezia-premium");
constexpr QLatin1String serviceTrial("amnezia-trial");
constexpr QLatin1String serviceFree("amnezia-free"); constexpr QLatin1String serviceFree("amnezia-free");
constexpr QLatin1String serviceExternalPremium("external-premium"); constexpr QLatin1String serviceExternalPremium("external-premium");
constexpr QLatin1String serviceExternalTrial("external-trial"); constexpr QLatin1String serviceExternalTrial("external-trial");
@@ -111,8 +110,6 @@ apiDefs::ConfigType apiUtils::getConfigType(const QJsonObject &serverConfigObjec
if (serviceType == servicePremium) { if (serviceType == servicePremium) {
return apiDefs::ConfigType::AmneziaPremiumV2; return apiDefs::ConfigType::AmneziaPremiumV2;
} else if (serviceType == serviceTrial) {
return apiDefs::ConfigType::AmneziaTrialV2;
} else if (serviceType == serviceFree) { } else if (serviceType == serviceFree) {
return apiDefs::ConfigType::AmneziaFreeV3; return apiDefs::ConfigType::AmneziaFreeV3;
} else if (serviceType == serviceExternalPremium) { } else if (serviceType == serviceExternalPremium) {
@@ -198,8 +195,7 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList<QSslError> &ssl
bool apiUtils::isPremiumServer(const QJsonObject &serverConfigObject) bool apiUtils::isPremiumServer(const QJsonObject &serverConfigObject)
{ {
static const QSet<apiDefs::ConfigType> premiumTypes = { apiDefs::ConfigType::AmneziaPremiumV1, apiDefs::ConfigType::AmneziaPremiumV2, static const QSet<apiDefs::ConfigType> premiumTypes = { apiDefs::ConfigType::AmneziaPremiumV1, apiDefs::ConfigType::AmneziaPremiumV2,
apiDefs::ConfigType::AmneziaTrialV2, apiDefs::ConfigType::ExternalPremium, apiDefs::ConfigType::ExternalPremium, apiDefs::ConfigType::ExternalTrial };
apiDefs::ConfigType::ExternalTrial };
return premiumTypes.contains(getConfigType(serverConfigObject)); return premiumTypes.contains(getConfigType(serverConfigObject));
} }
@@ -244,8 +240,8 @@ QString apiUtils::getPremiumV1VpnKey(const QJsonObject &serverConfigObject)
QString apiUtils::getPremiumV2VpnKey(const QJsonObject &serverConfigObject) QString apiUtils::getPremiumV2VpnKey(const QJsonObject &serverConfigObject)
{ {
auto configType = apiUtils::getConfigType(serverConfigObject); auto configType = apiUtils::getConfigType(serverConfigObject);
if (configType != apiDefs::ConfigType::AmneziaPremiumV2 && configType != apiDefs::ConfigType::AmneziaTrialV2 if (configType != apiDefs::ConfigType::AmneziaPremiumV2 && configType != apiDefs::ConfigType::ExternalPremium
&& configType != apiDefs::ConfigType::ExternalPremium && configType != apiDefs::ConfigType::ExternalTrial) { && configType != apiDefs::ConfigType::ExternalTrial) {
return {}; return {};
} }
+1 -1
View File
@@ -13,7 +13,7 @@ namespace apiUtils
bool isSubscriptionExpired(const QString &subscriptionEndDate); bool isSubscriptionExpired(const QString &subscriptionEndDate);
bool isSubscriptionExpiringSoon(const QString &subscriptionEndDate, int withinDays = 10); bool isSubscriptionExpiringSoon(const QString &subscriptionEndDate, int withinDays = 30);
bool isPremiumServer(const QJsonObject &serverConfigObject); bool isPremiumServer(const QJsonObject &serverConfigObject);
+107 -2
View File
@@ -1,6 +1,11 @@
#include <QString> #include <QString>
#include <QJsonArray>
#include <QJsonObject> #include <QJsonObject>
#include <QList> #include <QList>
#include <QHostAddress>
#include <QRandomGenerator>
#include <QTcpServer>
#include <stdexcept>
#include "3rd/QJsonStruct/QJsonIO.hpp" #include "3rd/QJsonStruct/QJsonIO.hpp"
#include "transfer.h" #include "transfer.h"
#include "serialization.h" #include "serialization.h"
@@ -14,25 +19,125 @@ namespace amnezia::serialization::inbounds
// "port": 10808, // "port": 10808,
// "protocol": "socks", // "protocol": "socks",
// "settings": { // "settings": {
// "auth": "password",
// "accounts": [{"user": "...", "pass": "..."}],
// "udp": true // "udp": true
// } // }
// } // }
//], //],
const static QString listen = "127.0.0.1"; const static QString listen = "127.0.0.1";
const static int port = 10808; const static int defaultPort = 10808;
const static QString protocol = "socks"; const static QString protocol = "socks";
static int indexOfSocksInbound(const QJsonArray &inbounds)
{
for (int i = 0; i < inbounds.size(); ++i) {
const QString p = inbounds.at(i).toObject().value(QLatin1String("protocol")).toString();
if (p.compare(QLatin1String("socks"), Qt::CaseInsensitive) == 0)
return i;
}
return -1;
}
// Ask the OS for a free TCP port on loopback (same stack as inbound "listen": "127.0.0.1").
static int acquireFreeLocalPort()
{
QTcpServer probe;
if (!probe.listen(QHostAddress(QStringLiteral("127.0.0.1")), 0)) {
throw std::runtime_error(
"Failed to bind a local TCP port on 127.0.0.1 for SOCKS inbound "
"(QTcpServer::listen failed; possible permission or OS network error).");
}
return static_cast<int>(probe.serverPort());
}
// Generates a hex string of `byteCount` random bytes (URL-safe, no special chars).
static QString generateRandomHex(int byteCount)
{
if (byteCount <= 0)
return {};
// fillRange writes full quint32 words; size the buffer to a multiple of 4 bytes to avoid
// overrunning a short buffer when byteCount is not divisible by 4.
const int numUint32 = (byteCount + int(sizeof(quint32)) - 1) / int(sizeof(quint32));
QByteArray buf(numUint32 * int(sizeof(quint32)), '\0');
QRandomGenerator::system()->fillRange(reinterpret_cast<quint32 *>(buf.data()), numUint32);
return QString::fromLatin1(buf.left(byteCount).toHex());
}
QJsonObject GenerateInboundEntry() QJsonObject GenerateInboundEntry()
{ {
QJsonObject root; QJsonObject root;
QJsonIO::SetValue(root, listen, "listen"); QJsonIO::SetValue(root, listen, "listen");
QJsonIO::SetValue(root, port, "port"); QJsonIO::SetValue(root, defaultPort, "port");
QJsonIO::SetValue(root, protocol, "protocol"); QJsonIO::SetValue(root, protocol, "protocol");
QJsonIO::SetValue(root, true, "settings", "udp"); QJsonIO::SetValue(root, true, "settings", "udp");
return root; return root;
} }
InboundCredentials GetInboundCredentials(const QJsonObject &xrayConfig)
{
InboundCredentials creds;
creds.port = defaultPort;
const QJsonArray inbounds = xrayConfig.value("inbounds").toArray();
const int socksIdx = indexOfSocksInbound(inbounds);
if (socksIdx < 0)
return creds;
const QJsonObject inbound = inbounds.at(socksIdx).toObject();
creds.port = inbound.value("port").toInt(defaultPort);
const QJsonObject settings = inbound.value("settings").toObject();
const QJsonArray accounts = settings.value("accounts").toArray();
if (accounts.isEmpty())
return creds;
const QJsonObject account = accounts.first().toObject();
creds.username = account.value("user").toString();
creds.password = account.value("pass").toString();
return creds;
}
InboundCredentials EnsureInboundAuth(QJsonObject &xrayConfig)
{
QJsonArray inbounds = xrayConfig.value("inbounds").toArray();
const int socksIdx = indexOfSocksInbound(inbounds);
if (socksIdx < 0)
return GetInboundCredentials(xrayConfig); // no SOCKS inbound to patch
QJsonObject inbound = inbounds.at(socksIdx).toObject();
InboundCredentials creds;
creds.port = acquireFreeLocalPort();
inbound["port"] = creds.port;
QJsonObject settings = inbound.value("settings").toObject();
const QJsonArray accounts = settings.value("accounts").toArray();
if (!accounts.isEmpty()) {
const QJsonObject account = accounts.first().toObject();
creds.username = account.value("user").toString();
creds.password = account.value("pass").toString();
}
if (creds.username.isEmpty() || creds.password.isEmpty()) {
// Generate fresh credentials for this session (never persisted)
creds.username = generateRandomHex(8); // 16 hex chars
creds.password = generateRandomHex(16); // 32 hex chars
QJsonObject account;
account["user"] = creds.username;
account["pass"] = creds.password;
settings["accounts"] = QJsonArray{ account };
}
// Always ensure auth mode is enforced, even for imported configs that had
// accounts but auth: "noauth" (or no auth field at all).
settings["auth"] = QStringLiteral("password");
inbound["settings"] = settings;
inbounds[socksIdx] = inbound;
xrayConfig["inbounds"] = inbounds;
return creds;
}
} // namespace amnezia::serialization::inbounds } // namespace amnezia::serialization::inbounds
+17
View File
@@ -60,7 +60,24 @@ namespace amnezia::serialization
namespace inbounds namespace inbounds
{ {
struct InboundCredentials {
QString username;
QString password;
int port;
};
QJsonObject GenerateInboundEntry(); QJsonObject GenerateInboundEntry();
// Reads existing SOCKS5 auth from the first inbound with protocol "socks"
// (.settings.accounts[0]). Returns empty username/password if none.
InboundCredentials GetInboundCredentials(const QJsonObject &xrayConfig);
// Ensures SOCKS5 auth is present on the inbound whose protocol is "socks".
// Re-uses existing credentials if already set; otherwise generates random ones
// and writes them into the config. Assigns a free loopback TCP port each session
// (OS-assigned). Throws std::runtime_error if a SOCKS inbound exists but binding
// a local port on 127.0.0.1 fails (e.g. permissions or OS error).
InboundCredentials EnsureInboundAuth(QJsonObject &xrayConfig);
} }
} }
@@ -1,3 +1,4 @@
import Darwin
import Foundation import Foundation
import NetworkExtension import NetworkExtension
@@ -6,6 +7,7 @@ enum XrayErrors: Error {
case xrayConfigIsWrong case xrayConfigIsWrong
case cantSaveXrayConfig case cantSaveXrayConfig
case cantParseListenAndPort case cantParseListenAndPort
case cantAcquireLocalPort
case cantSaveHevSocksConfig case cantSaveHevSocksConfig
} }
@@ -21,6 +23,42 @@ extension Constants {
} }
extension PacketTunnelProvider { extension PacketTunnelProvider {
/// TCP port chosen by the OS on IPv6 loopback (::1), matching inbound listen address.
private func acquireFreeLocalPort() throws -> Int {
let fd = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP)
guard fd != -1 else {
throw XrayErrors.cantAcquireLocalPort
}
defer { close(fd) }
var reuse: Int32 = 1
_ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout<Int32>.size))
var addr = sockaddr_in6()
addr.sin6_len = UInt8(MemoryLayout<sockaddr_in6>.size)
addr.sin6_family = sa_family_t(AF_INET6)
addr.sin6_port = in_port_t(0).bigEndian
addr.sin6_addr = in6addr_loopback
addr.sin6_scope_id = 0
let bindResult = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { p in
bind(fd, p, socklen_t(MemoryLayout<sockaddr_in6>.size))
}
}
guard bindResult == 0 else {
throw XrayErrors.cantAcquireLocalPort
}
var bound = sockaddr_in6()
var len = socklen_t(MemoryLayout<sockaddr_in6>.size)
let gr = withUnsafeMutablePointer(to: &bound) { p in
p.withMemoryRebound(to: sockaddr.self, capacity: 1) { bp in
getsockname(fd, bp, &len)
}
}
guard gr == 0 else {
throw XrayErrors.cantAcquireLocalPort
}
return Int(bound.sin6_port.byteSwapped)
}
private func applyXraySplitTunnel(_ xrayConfig: XrayConfig, private func applyXraySplitTunnel(_ xrayConfig: XrayConfig,
settings: NEPacketTunnelNetworkSettings) { settings: NEPacketTunnelNetworkSettings) {
guard let splitTunnelType = xrayConfig.splitTunnelType else { guard let splitTunnelType = xrayConfig.splitTunnelType else {
@@ -129,14 +167,11 @@ extension PacketTunnelProvider {
return return
} }
let port = 10808 let port = try acquireFreeLocalPort()
let address = "::1" let address = "::1"
if var inboundsArray = jsonDict["inbounds"] as? [[String: Any]], !inboundsArray.isEmpty { // Extract existing SOCKS5 credentials or generate new ones per session.
inboundsArray[0]["port"] = port let socksCredentials = ensureInboundAuth(jsonDict: &jsonDict, port: port, address: address)
inboundsArray[0]["listen"] = address
jsonDict["inbounds"] = inboundsArray
}
let updatedData = try JSONSerialization.data(withJSONObject: jsonDict, options: []) let updatedData = try JSONSerialization.data(withJSONObject: jsonDict, options: [])
@@ -159,6 +194,8 @@ extension PacketTunnelProvider {
self?.setupAndRunTun2socks(configData: updatedData, self?.setupAndRunTun2socks(configData: updatedData,
address: address, address: address,
port: port, port: port,
username: socksCredentials.username,
password: socksCredentials.password,
completionHandler: completionHandler) completionHandler: completionHandler)
} }
} }
@@ -183,6 +220,62 @@ extension PacketTunnelProvider {
} }
} }
private struct SocksCredentials {
let username: String
let password: String
}
private func indexOfSocksInbound(in inboundsArray: [[String: Any]]) -> Int? {
for (i, inbound) in inboundsArray.enumerated() {
guard let proto = inbound["protocol"] as? String else { continue }
if proto.caseInsensitiveCompare("socks") == .orderedSame {
return i
}
}
return nil
}
// Returns existing SOCKS5 credentials from the inbound config, or generates and injects
// new random ones. Also sets port and address on the socks inbound entry.
private func ensureInboundAuth(jsonDict: inout [String: Any], port: Int, address: String) -> SocksCredentials {
var inboundsArray = jsonDict["inbounds"] as? [[String: Any]] ?? []
if let socksIdx = indexOfSocksInbound(in: inboundsArray) {
var inbound = inboundsArray[socksIdx]
inbound["port"] = port
inbound["listen"] = address
var settings = inbound["settings"] as? [String: Any] ?? [:]
if let accounts = settings["accounts"] as? [[String: Any]],
let first = accounts.first,
let user = first["user"] as? String, !user.isEmpty,
let pass = first["pass"] as? String, !pass.isEmpty {
// Re-use existing credentials, but always enforce auth mode in case the
// imported config had accounts but auth: "noauth" (or no auth field).
settings["auth"] = "password"
inbound["settings"] = settings
inboundsArray[socksIdx] = inbound
jsonDict["inbounds"] = inboundsArray
return SocksCredentials(username: user, password: pass)
}
// Generate new random credentials for this session
let user = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased().prefix(16)
let pass = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
settings["auth"] = "password"
settings["accounts"] = [["user": String(user), "pass": pass]]
inbound["settings"] = settings
inboundsArray[socksIdx] = inbound
jsonDict["inbounds"] = inboundsArray
return SocksCredentials(username: String(user), password: pass)
}
// Fallback: no socks inbound generate credentials but can't inject
let user = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased().prefix(16)
let pass = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
return SocksCredentials(username: String(user), password: pass)
}
private func setupAndStartXray(configData: Data, private func setupAndStartXray(configData: Data,
completionHandler: @escaping (Error?) -> Void) { completionHandler: @escaping (Error?) -> Void) {
let path = Constants.cachesDirectory.appendingPathComponent("config.json", isDirectory: false).path let path = Constants.cachesDirectory.appendingPathComponent("config.json", isDirectory: false).path
@@ -214,6 +307,8 @@ extension PacketTunnelProvider {
private func setupAndRunTun2socks(configData: Data, private func setupAndRunTun2socks(configData: Data,
address: String, address: String,
port: Int, port: Int,
username: String,
password: String,
completionHandler: @escaping (Error?) -> Void) { completionHandler: @escaping (Error?) -> Void) {
let config = """ let config = """
tunnel: tunnel:
@@ -221,6 +316,8 @@ extension PacketTunnelProvider {
socks5: socks5:
port: \(port) port: \(port)
address: \(address) address: \(address)
username: \(username)
password: \(password)
udp: 'udp' udp: 'udp'
misc: misc:
task-stack-size: 20480 task-stack-size: 20480
+5 -8
View File
@@ -218,16 +218,13 @@ bool IosController::connectVpn(amnezia::Proto proto, const QJsonObject& configur
m_rawConfig = configuration; m_rawConfig = configuration;
m_serverAddress = configuration.value(config_key::hostName).toString().toNSString(); m_serverAddress = configuration.value(config_key::hostName).toString().toNSString();
const QString serverDescription = configuration.value(config_key::description).toString().trimmed();
QString tunnelName; QString tunnelName;
if (configuration.value(config_key::description).toString().isEmpty()) { if (serverDescription.isEmpty()) {
tunnelName = ProtocolProps::protoToString(proto);
} else {
tunnelName = QString("%1 %2") tunnelName = QString("%1 %2")
.arg(configuration.value(config_key::hostName).toString()) .arg(serverDescription)
.arg(ProtocolProps::protoToString(proto));
}
else {
tunnelName = QString("%1 (%2) %3")
.arg(configuration.value(config_key::description).toString())
.arg(configuration.value(config_key::hostName).toString())
.arg(ProtocolProps::protoToString(proto)); .arg(ProtocolProps::protoToString(proto));
} }
@@ -164,8 +164,13 @@ bool LinuxRouteMonitor::rtmSendRoute(int action, int flags, int type,
} }
if (rtm->rtm_type == RTN_THROW) { if (rtm->rtm_type == RTN_THROW) {
QString gateway = NetworkUtilities::getGatewayAndIface().first;
if (gateway.isEmpty()) {
logger.warning() << "No default gateway available, skipping exclusion route";
return false;
}
struct in_addr ip4; struct in_addr ip4;
inet_pton(AF_INET, NetworkUtilities::getGatewayAndIface().first.toUtf8(), &ip4); inet_pton(AF_INET, gateway.toUtf8(), &ip4);
nlmsg_append_attr(nlmsg, sizeof(buf), RTA_GATEWAY, &ip4, sizeof(ip4)); nlmsg_append_attr(nlmsg, sizeof(buf), RTA_GATEWAY, &ip4, sizeof(ip4));
nlmsg_append_attr32(nlmsg, sizeof(buf), RTA_PRIORITY, 0); nlmsg_append_attr32(nlmsg, sizeof(buf), RTA_PRIORITY, 0);
rtm->rtm_type = RTN_UNICAST; rtm->rtm_type = RTN_UNICAST;
@@ -44,6 +44,9 @@ void LinuxNetworkWatcher::initialize() {
connect(m_worker, &LinuxNetworkWatcherWorker::wakeup, this, connect(m_worker, &LinuxNetworkWatcherWorker::wakeup, this,
&NetworkWatcherImpl::wakeup); &NetworkWatcherImpl::wakeup);
connect(m_worker, &LinuxNetworkWatcherWorker::networkChanged, this,
[this]() { emit networkChanged(""); });
// Let's wait a few seconds to allow the UI to be fully loaded and shown. // Let's wait a few seconds to allow the UI to be fully loaded and shown.
// This is not strictly needed, but it's better for user experience because // This is not strictly needed, but it's better for user experience because
// it makes the UI faster to appear, plus it gives a bit of delay between the // it makes the UI faster to appear, plus it gives a bit of delay between the
@@ -37,6 +37,7 @@
enum NMState { enum NMState {
NM_STATE_UNKNOWN = 0, NM_STATE_UNKNOWN = 0,
NM_STATE_ASLEEP = 10, NM_STATE_ASLEEP = 10,
NM_STATE_DISABLED = 10,
NM_STATE_DISCONNECTED = 20, NM_STATE_DISCONNECTED = 20,
NM_STATE_DISCONNECTING = 30, NM_STATE_DISCONNECTING = 30,
NM_STATE_CONNECTING = 40, NM_STATE_CONNECTING = 40,
@@ -199,10 +200,11 @@ void LinuxNetworkWatcherWorker::checkDevices() {
void LinuxNetworkWatcherWorker::NMStateChanged(quint32 state) void LinuxNetworkWatcherWorker::NMStateChanged(quint32 state)
{ {
if (state == NM_STATE_ASLEEP) { logger.debug() << "NMStateChanged " << state;
emit wakeup();
}
logger.debug() << "NMStateChanged " << state;
}
if (state == NM_STATE_ASLEEP || state == NM_STATE_DISABLED) {
emit wakeup();
} else if (state == NM_STATE_CONNECTED_GLOBAL) {
emit networkChanged();
}
}
@@ -24,6 +24,7 @@ class LinuxNetworkWatcherWorker final : public QObject {
signals: signals:
void unsecuredNetwork(const QString& networkName, const QString& networkId); void unsecuredNetwork(const QString& networkName, const QString& networkId);
void wakeup(); void wakeup();
void networkChanged();
public slots: public slots:
void initialize(); void initialize();
+21 -2
View File
@@ -1,6 +1,7 @@
#include "xrayprotocol.h" #include "xrayprotocol.h"
#include "core/ipcclient.h" #include "core/ipcclient.h"
#include "core/serialization/serialization.h"
#include "ipc.h" #include "ipc.h"
#include "utilities.h" #include "utilities.h"
#include "core/networkUtilities.h" #include "core/networkUtilities.h"
@@ -14,6 +15,8 @@
#include <QtCore/qobjectdefs.h> #include <QtCore/qobjectdefs.h>
#include <QtCore/qprocess.h> #include <QtCore/qprocess.h>
#include <exception>
#ifdef Q_OS_MACOS #ifdef Q_OS_MACOS
static const QString tunName = "utun22"; static const QString tunName = "utun22";
#else #else
@@ -53,6 +56,19 @@ ErrorCode XrayProtocol::start()
{ {
qDebug() << "XrayProtocol::start()"; qDebug() << "XrayProtocol::start()";
// Inject SOCKS5 auth into the inbound before starting xray.
// Re-uses existing credentials if the config already has them (e.g. imported config).
amnezia::serialization::inbounds::InboundCredentials creds;
try {
creds = amnezia::serialization::inbounds::EnsureInboundAuth(m_xrayConfig);
} catch (const std::exception &e) {
qCritical() << "EnsureInboundAuth failed:" << e.what();
return ErrorCode::InternalError;
}
m_socksUser = creds.username;
m_socksPassword = creds.password;
m_socksPort = creds.port;
return IpcClient::withInterface([&](QSharedPointer<IpcInterfaceReplica> iface) { return IpcClient::withInterface([&](QSharedPointer<IpcInterfaceReplica> iface) {
auto xrayStart = iface->xrayStart(QJsonDocument(m_xrayConfig).toJson()); auto xrayStart = iface->xrayStart(QJsonDocument(m_xrayConfig).toJson());
if (!xrayStart.waitForFinished() || !xrayStart.returnValue()) { if (!xrayStart.waitForFinished() || !xrayStart.returnValue()) {
@@ -121,8 +137,11 @@ ErrorCode XrayProtocol::startTun2Socks()
return ErrorCode::AmneziaServiceConnectionFailed; return ErrorCode::AmneziaServiceConnectionFailed;
} }
const QString proxyUrl = QString("socks5://%1:%2@127.0.0.1:%3")
.arg(m_socksUser, m_socksPassword, QString::number(m_socksPort));
m_tun2socksProcess->setProgram(PermittedProcess::Tun2Socks); m_tun2socksProcess->setProgram(PermittedProcess::Tun2Socks);
m_tun2socksProcess->setArguments({"-device", QString("tun://%1").arg(tunName), "-proxy", "socks5://127.0.0.1:10808" }); m_tun2socksProcess->setArguments({"-device", QString("tun://%1").arg(tunName), "-proxy", proxyUrl});
connect(m_tun2socksProcess.data(), &IpcProcessInterfaceReplica::readyReadStandardOutput, this, [this]() { connect(m_tun2socksProcess.data(), &IpcProcessInterfaceReplica::readyReadStandardOutput, this, [this]() {
auto readAllStandardOutput = m_tun2socksProcess->readAllStandardOutput(); auto readAllStandardOutput = m_tun2socksProcess->readAllStandardOutput();
@@ -136,7 +155,7 @@ ErrorCode XrayProtocol::startTun2Socks()
if (!line.contains("[TCP]") && !line.contains("[UDP]")) if (!line.contains("[TCP]") && !line.contains("[UDP]"))
qDebug() << "[tun2socks]:" << line; qDebug() << "[tun2socks]:" << line;
if (line.contains("[STACK] tun://") && line.contains("<-> socks5://127.0.0.1")) { if (line.contains("[STACK] tun://") && line.contains("<-> socks5://")) {
disconnect(m_tun2socksProcess.data(), &IpcProcessInterfaceReplica::readyReadStandardOutput, this, nullptr); disconnect(m_tun2socksProcess.data(), &IpcProcessInterfaceReplica::readyReadStandardOutput, this, nullptr);
if (ErrorCode res = setupRouting(); res != ErrorCode::NoError) { if (ErrorCode res = setupRouting(); res != ErrorCode::NoError) {
+4
View File
@@ -26,6 +26,10 @@ private:
QList<QHostAddress> m_dnsServers; QList<QHostAddress> m_dnsServers;
QString m_remoteAddress; QString m_remoteAddress;
QString m_socksUser;
QString m_socksPassword;
int m_socksPort = 10808;
QSharedPointer<IpcProcessInterfaceReplica> m_tun2socksProcess; QSharedPointer<IpcProcessInterfaceReplica> m_tun2socksProcess;
}; };
@@ -1,4 +1,4 @@
FROM 3proxy/3proxy:latest FROM 3proxy/3proxy:0.9.5
LABEL maintainer="AmneziaVPN" LABEL maintainer="AmneziaVPN"
@@ -7,4 +7,4 @@ RUN echo -e "#!/bin/bash\ntail -f /dev/null" > /opt/amnezia/start.sh
RUN chmod a+x /opt/amnezia/start.sh RUN chmod a+x /opt/amnezia/start.sh
ENTRYPOINT [ "/bin/sh", "/opt/amnezia/start.sh" ] ENTRYPOINT [ "/bin/sh", "/opt/amnezia/start.sh" ]
CMD [ "" ] CMD [ "" ]
@@ -23,6 +23,19 @@ namespace
} }
const int requestTimeoutMsecs = 12 * 1000; // 12 secs const int requestTimeoutMsecs = 12 * 1000; // 12 secs
QString getSubscriptionStatusForRenewal(const QSharedPointer<ApiAccountInfoModel> &accountInfoModel)
{
if (!accountInfoModel.isNull() && accountInfoModel->data(QStringLiteral("isSubscriptionExpired")).toBool()) {
return QStringLiteral("expired");
}
if (!accountInfoModel.isNull() && accountInfoModel->data(QStringLiteral("isSubscriptionExpiringSoon")).toBool()) {
return QStringLiteral("expire_soon");
}
return QStringLiteral("active");
}
} }
ApiSettingsController::ApiSettingsController(const QSharedPointer<ServersModel> &serversModel, ApiSettingsController::ApiSettingsController(const QSharedPointer<ServersModel> &serversModel,
@@ -105,6 +118,7 @@ void ApiSettingsController::getRenewalLink()
apiPayload[configKey::authData] = authData; apiPayload[configKey::authData] = authData;
apiPayload[apiDefs::key::cliVersion] = QString(APP_VERSION); apiPayload[apiDefs::key::cliVersion] = QString(APP_VERSION);
apiPayload[apiDefs::key::appLanguage] = m_settings->getAppLanguage().name().split("_").first(); apiPayload[apiDefs::key::appLanguage] = m_settings->getAppLanguage().name().split("_").first();
apiPayload[apiDefs::key::subscriptionStatus] = getSubscriptionStatusForRenewal(m_apiAccountInfoModel);
auto future = gatewayController->postAsync(QString("%1v1/renewal_link"), apiPayload); auto future = gatewayController->postAsync(QString("%1v1/renewal_link"), apiPayload);
future.then(this, [this, gatewayController](QPair<ErrorCode, QByteArray> result) { future.then(this, [this, gatewayController](QPair<ErrorCode, QByteArray> result) {
+2 -4
View File
@@ -54,14 +54,11 @@ QVariant ApiAccountInfoModel::data(const QModelIndex &index, int role) const
} }
case IsComponentVisibleRole: { case IsComponentVisibleRole: {
return m_accountInfoData.configType == apiDefs::ConfigType::AmneziaPremiumV2 return m_accountInfoData.configType == apiDefs::ConfigType::AmneziaPremiumV2
|| m_accountInfoData.configType == apiDefs::ConfigType::AmneziaTrialV2
|| m_accountInfoData.configType == apiDefs::ConfigType::ExternalPremium || m_accountInfoData.configType == apiDefs::ConfigType::ExternalPremium
|| m_accountInfoData.configType == apiDefs::ConfigType::ExternalTrial; || m_accountInfoData.configType == apiDefs::ConfigType::ExternalTrial;
} }
case IsSubscriptionRenewalAvailableRole: { case IsSubscriptionRenewalAvailableRole: {
return m_accountInfoData.configType == apiDefs::ConfigType::AmneziaPremiumV2 return m_accountInfoData.isRenewalAvailable;
|| m_accountInfoData.configType == apiDefs::ConfigType::AmneziaTrialV2
|| m_accountInfoData.configType == apiDefs::ConfigType::ExternalTrial;
} }
case HasExpiredWorkerRole: { case HasExpiredWorkerRole: {
for (int i = 0; i < m_issuedConfigsInfo.size(); i++) { for (int i = 0; i < m_issuedConfigsInfo.size(); i++) {
@@ -133,6 +130,7 @@ void ApiAccountInfoModel::updateModel(const QJsonObject &accountInfoObject, cons
accountInfoData.isInAppPurchase = apiConfig.value(apiDefs::key::isInAppPurchase).toBool(false); accountInfoData.isInAppPurchase = apiConfig.value(apiDefs::key::isInAppPurchase).toBool(false);
accountInfoData.subscriptionDescription = accountInfoObject.value(apiDefs::key::subscriptionDescription).toString(); accountInfoData.subscriptionDescription = accountInfoObject.value(apiDefs::key::subscriptionDescription).toString();
accountInfoData.isRenewalAvailable = accountInfoObject.value(apiDefs::key::isRenewalAvailable).toBool(false);
for (const auto &protocol : accountInfoObject.value(apiDefs::key::supportedProtocols).toArray()) { for (const auto &protocol : accountInfoObject.value(apiDefs::key::supportedProtocols).toArray()) {
accountInfoData.supportedProtocols.push_back(protocol.toString()); accountInfoData.supportedProtocols.push_back(protocol.toString());
@@ -61,6 +61,7 @@ private:
QString subscriptionDescription; QString subscriptionDescription;
bool isInAppPurchase = false; bool isInAppPurchase = false;
bool isRenewalAvailable = false;
}; };
AccountInfoData m_accountInfoData; AccountInfoData m_accountInfoData;
@@ -91,6 +91,12 @@ QVariant ApiServicesModel::data(const QModelIndex &index, int role) const
} }
return true; return true;
} }
case IsPremiumRole: {
return serviceType == serviceType::amneziaPremium;
}
case HasSubscriptionPlansRole: {
return !apiServiceData.subscriptionPlansJson.isEmpty();
}
case PriceRole: { case PriceRole: {
return apiServiceData.minPriceLabel; return apiServiceData.minPriceLabel;
} }
@@ -233,6 +239,8 @@ QHash<int, QByteArray> ApiServicesModel::roleNames() const
roles[CardDescriptionRole] = "cardDescription"; roles[CardDescriptionRole] = "cardDescription";
roles[ServiceDescriptionRole] = "serviceDescription"; roles[ServiceDescriptionRole] = "serviceDescription";
roles[IsServiceAvailableRole] = "isServiceAvailable"; roles[IsServiceAvailableRole] = "isServiceAvailable";
roles[IsPremiumRole] = "isPremium";
roles[HasSubscriptionPlansRole] = "hasSubscriptionPlans";
roles[PriceRole] = "price"; roles[PriceRole] = "price";
roles[EndDateRole] = "endDate"; roles[EndDateRole] = "endDate";
roles[TermsOfUseUrlRole] = "termsOfUseUrl"; roles[TermsOfUseUrlRole] = "termsOfUseUrl";
+2
View File
@@ -54,6 +54,8 @@ public:
CardDescriptionRole, CardDescriptionRole,
ServiceDescriptionRole, ServiceDescriptionRole,
IsServiceAvailableRole, IsServiceAvailableRole,
IsPremiumRole,
HasSubscriptionPlansRole,
PriceRole, PriceRole,
EndDateRole, EndDateRole,
TermsOfUseUrlRole, TermsOfUseUrlRole,
+4
View File
@@ -179,6 +179,9 @@ QVariant ServersModel::data(const QModelIndex &index, int role) const
case AdEndpointRole: { case AdEndpointRole: {
return apiConfig.value(apiDefs::key::serviceInfo).toObject().value(apiDefs::key::adEndpoint).toString(); return apiConfig.value(apiDefs::key::serviceInfo).toObject().value(apiDefs::key::adEndpoint).toString();
} }
case IsRenewalAvailableRole: {
return apiConfig.value(apiDefs::key::serviceInfo).toObject().value(apiDefs::key::isRenewalAvailable).toBool(false);
}
case IsSubscriptionExpiredRole: { case IsSubscriptionExpiredRole: {
if (configVersion != apiDefs::ConfigSource::AmneziaGateway) { if (configVersion != apiDefs::ConfigSource::AmneziaGateway) {
return false; return false;
@@ -476,6 +479,7 @@ QHash<int, QByteArray> ServersModel::roleNames() const
roles[AdHeaderRole] = "adHeader"; roles[AdHeaderRole] = "adHeader";
roles[AdDescriptionRole] = "adDescription"; roles[AdDescriptionRole] = "adDescription";
roles[AdEndpointRole] = "adEndpoint"; roles[AdEndpointRole] = "adEndpoint";
roles[IsRenewalAvailableRole] = "isRenewalAvailable";
roles[IsSubscriptionExpiredRole] = "isSubscriptionExpired"; roles[IsSubscriptionExpiredRole] = "isSubscriptionExpired";
roles[IsSubscriptionExpiringSoonRole] = "isSubscriptionExpiringSoon"; roles[IsSubscriptionExpiringSoonRole] = "isSubscriptionExpiringSoon";
+1
View File
@@ -51,6 +51,7 @@ public:
AdHeaderRole, AdHeaderRole,
AdDescriptionRole, AdDescriptionRole,
AdEndpointRole, AdEndpointRole,
IsRenewalAvailableRole,
IsSubscriptionExpiredRole, IsSubscriptionExpiredRole,
IsSubscriptionExpiringSoonRole, IsSubscriptionExpiringSoonRole,
@@ -12,11 +12,10 @@ import "../Controls2/TextTypes"
DrawerType2 { DrawerType2 {
id: root id: root
property bool isRenewalActionAvailable: false property bool isRenewalAvailable: false
onOpened: { onOpened: {
isRenewalActionAvailable = ApiAccountInfoModel.data("isSubscriptionRenewalAvailable") isRenewalAvailable = ServersModel.getProcessedServerData("isRenewalAvailable") && !ApiAccountInfoModel.data("isInAppPurchase")
&& !ApiAccountInfoModel.data("isInAppPurchase")
} }
expandedStateContent: ColumnLayout { expandedStateContent: ColumnLayout {
@@ -44,13 +43,13 @@ DrawerType2 {
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
text: qsTr("Amnezia Premium subscription has expired") text: ServersModel.getProcessedServerData("name") + qsTr(" subscription has expired")
horizontalAlignment: Text.AlignLeft horizontalAlignment: Text.AlignLeft
} }
} }
ParagraphTextType { ParagraphTextType {
visible: root.isRenewalActionAvailable visible: root.isRenewalAvailable
Layout.fillWidth: true Layout.fillWidth: true
Layout.topMargin: 8 Layout.topMargin: 8
@@ -62,7 +61,7 @@ DrawerType2 {
} }
BasicButtonType { BasicButtonType {
visible: root.isRenewalActionAvailable visible: root.isRenewalAvailable
Layout.fillWidth: true Layout.fillWidth: true
Layout.topMargin: 16 Layout.topMargin: 16
@@ -96,8 +95,13 @@ DrawerType2 {
text: qsTr("Support") text: qsTr("Support")
clickedFunc: function() { clickedFunc: function() {
root.closeTriggered() PageController.showBusyIndicator(true)
PageController.goToPage(PageEnum.PageSettingsApiSupport) let result = ApiSettingsController.getAccountInfo(false)
PageController.showBusyIndicator(false)
if (result) {
root.closeTriggered()
PageController.goToPage(PageEnum.PageSettingsApiSupport)
}
} }
} }
} }
@@ -67,8 +67,11 @@ PageType {
} }
delegate: ColumnLayout { delegate: ColumnLayout {
property bool hideCard: isPremium && !hasSubscriptionPlans
width: listView.width width: listView.width
visible: !hideCard
height: hideCard ? 0 : implicitHeight
enabled: isServiceAvailable enabled: isServiceAvailable