Compare commits

..

7 Commits

Author SHA1 Message Date
ULTRAVIOLENC3
b39f583097 fix: create DNS WFP sublayer for app split tunneling (#2768) 2026-07-02 20:15:30 +08:00
ULTRAVIOLENC3
6c7b65cac6 fix windows app split tunnel path conversion (#2691) 2026-07-02 20:12:52 +08:00
vkamn
0f6847219b chore: bump version (#2772) 2026-06-26 13:04:36 +08:00
yp
5d16645b84 fix: android icons (#2725)
* update icons android

* remove comment
2026-06-24 00:08:21 +08:00
Yaroslav Gurov
203a092dc9 fix: blobs for macos-ne and codesigning of qt blobs (#2754) 2026-06-24 00:07:42 +08:00
yp
d8b8590bc4 fix: XRay validation audit (#2749)
* flow default and config fixes

* host/SNI/path validation backend + flow-default flag

* input validation, numeric limits and live Save on settings pages

* MinMaxRowType clamping + DropDownType fit-content drawer
2026-06-24 00:07:26 +08:00
yp
9b8bfaa6f8 fix: regression testing vs 4.8.15.4 (#2730)
* fixed revoke

* fixed async update xray/mtproxy/telemt

* fixed connect premium config

* fixed autostart app hide

* fixed clear profile

* (6) fixed xtls-rprx-vision→empty

* (7) fixed appendClient abort & fix restore admin

* (8) fixed async|clientsUpdated

* fixed increment name server N

* remove comment & reset file

* chore: add tr to nextAvailableServerName

---------

Co-authored-by: vkamn <vk@amnezia.org>
2026-06-23 23:05:58 +08:00
44 changed files with 752 additions and 176 deletions

View File

@@ -4,7 +4,7 @@ set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(PROJECT AmneziaVPN)
set(AMNEZIAVPN_VERSION 4.9.0.2)
set(AMNEZIAVPN_VERSION 4.9.0.3)
set(QT_CREATOR_SKIP_PACKAGE_MANAGER_SETUP ON CACHE BOOL "" FORCE)
set(CMAKE_PROJECT_TOP_LEVEL_INCLUDES
@@ -28,7 +28,7 @@ string(TIMESTAMP CURRENT_DATE "%Y-%m-%d")
set(RELEASE_DATE "${CURRENT_DATE}")
set(APP_MAJOR_VERSION ${CMAKE_PROJECT_VERSION_MAJOR}.${CMAKE_PROJECT_VERSION_MINOR}.${CMAKE_PROJECT_VERSION_PATCH})
set(APP_ANDROID_VERSION_CODE 2123)
set(APP_ANDROID_VERSION_CODE 2126)
if(${CMAKE_SYSTEM_NAME} STREQUAL "Linux")
set(MZ_PLATFORM_NAME "linux")

View File

@@ -119,7 +119,13 @@ void AmneziaApplication::init()
win->setPersistentSceneGraph(true);
win->setPersistentGraphics(true);
#endif
#if defined(Q_OS_ANDROID) || defined(Q_OS_IOS)
win->show();
#else
if (!m_coreController || !m_coreController->pageController()->shouldStartMinimized()) {
win->show();
}
#endif
}
},
Qt::QueuedConnection);

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:type="linear"
android:angle="135"
android:startColor="#2A2A2E"
android:centerColor="#17171A"
android:endColor="#0E0E11" />
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_amnezia_round"
android:insetLeft="19.5%"
android:insetTop="19.5%"
android:insetRight="19.5%"
android:insetBottom="19.5%" />

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="NoActionBar">
<item name="android:windowBackground">@color/black</item>
<item name="android:colorBackground">@color/black</item>
<item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:enforceNavigationBarContrast">false</item>
<item name="android:enforceStatusBarContrast">false</item>
<item name="android:windowSplashScreenBackground">@color/ic_launcher_background</item>
<item name="android:windowSplashScreenIconBackgroundColor">@color/ic_launcher_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@mipmap/icon</item>
</style>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#0E0E11</color>
</resources>

View File

@@ -152,5 +152,5 @@ message(${QtCore_location})
get_filename_component(QT_BIN_DIR_DETECTED "${QtCore_location}/../../../../../bin" ABSOLUTE)
add_custom_command(TARGET ${PROJECT} POST_BUILD
COMMAND ${QT_BIN_DIR_DETECTED}/macdeployqt $<TARGET_BUNDLE_DIR:AmneziaVPN> -appstore-compliant -qmldir=${CMAKE_CURRENT_SOURCE_DIR}
COMMAND ${QT_BIN_DIR_DETECTED}/macdeployqt $<TARGET_BUNDLE_DIR:AmneziaVPN> -appstore-compliant -qmldir=${CMAKE_CURRENT_SOURCE_DIR} -no-codesign
)

View File

@@ -244,11 +244,7 @@ ErrorCode XrayConfigurator::applyServerSettingsToRemote(const ServerCredentials
<< "container=" << static_cast<int>(container) << "host=" << credentials.hostName
<< "transport=" << srv.transport << "security=" << srv.security << "port=" << srv.port
<< "appendClient=" << appendNewClient;
QString flowValue = srv.flow;
if (flowValue.isEmpty() && srv.security == QLatin1String("reality")) {
flowValue = QStringLiteral("xtls-rprx-vision");
}
const QString flowValue = srv.flow;
QString realityPublicKey;
QString realityShortId;
if (srv.security == QLatin1String("reality")) {
@@ -563,9 +559,12 @@ QJsonObject XrayConfigurator::buildStreamSettings(const XrayServerConfig &srv, c
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();
const int fromV = pad.bytesMin.isEmpty() ? 1 : pad.bytesMin.toInt();
int toV = pad.bytesMax.isEmpty() ? 256 : pad.bytesMax.toInt();
if (toV < fromV)
toV = fromV;
br[QStringLiteral("from")] = fromV;
br[QStringLiteral("to")] = toV;
xo[QStringLiteral("xPaddingBytes")] = br;
}
xo[QStringLiteral("xPaddingKey")] = pad.key.isEmpty() ? QStringLiteral("x_padding") : pad.key;

View File

@@ -106,7 +106,8 @@ ErrorCode ConnectionController::isConnectionSupported(const QString &serverId) c
return ErrorCode::AmneziaServiceNotRunning;
}
if (serverConfigUtils::isLegacyApiSubscription(m_serversRepository->serverKind(serverId))) {
const serverConfigUtils::ConfigType kind = m_serversRepository->serverKind(serverId);
if (serverConfigUtils::isLegacyApiSubscription(kind)) {
return ErrorCode::LegacyApiV1NotSupportedError;
}
@@ -117,6 +118,9 @@ ErrorCode ConnectionController::isConnectionSupported(const QString &serverId) c
}
if (container == DockerContainer::None) {
if (serverConfigUtils::isApiV2Subscription(kind)) {
return ErrorCode::NoError;
}
return ErrorCode::NoInstalledContainersError;
}

View File

@@ -115,7 +115,6 @@ protected:
IpSplitTunnelingModel* ipSplitTunnelingModelProtected() const { return m_ipSplitTunnelingModel; }
LanguageModel* languageModelProtected() const { return m_languageModel; }
ConnectionUiController* connectionUiControllerProtected() const { return m_connectionUiController; }
InstallUiController* installUiControllerProtected() const { return m_installUiController; }
ImportController* importCoreControllerProtected() const { return m_importCoreController; }
ExportController* exportControllerProtected() const { return m_exportController; }

View File

@@ -1,6 +1,7 @@
#include "coreSignalHandlers.h"
#include <QTimer>
#include <QtConcurrent>
#include "core/utils/selfhosted/sshSession.h"
#include "core/utils/errorCodes.h"
@@ -144,7 +145,9 @@ void CoreSignalHandlers::initExportControllerHandler()
});
connect(m_coreController->m_exportController, &ExportController::revokeClientRequested, this,
[this](const QString &serverId, int row, DockerContainer container) {
m_coreController->m_usersController->revokeClient(serverId, row, container);
QtConcurrent::run([this, serverId, row, container]() {
m_coreController->m_usersController->revokeClient(serverId, row, container);
});
});
connect(m_coreController->m_exportController, &ExportController::renameClientRequested, this,
[this](const QString &serverId, int row, const QString &clientName, DockerContainer container) {
@@ -202,13 +205,15 @@ void CoreSignalHandlers::initAdminConfigRevokedHandler()
{
connect(m_coreController->m_installController, &InstallController::clientRevocationRequested, this,
[this](const QString &serverId, const ContainerConfig &containerConfig, DockerContainer container) {
m_coreController->m_usersController->revokeClient(serverId, containerConfig, container);
QtConcurrent::run([this, serverId, containerConfig, container]() {
m_coreController->m_usersController->revokeClient(serverId, containerConfig, container);
});
});
connect(m_coreController->m_installController, &InstallController::clientAppendRequested, this,
[this](const QString &serverId, const QString &clientId, const QString &clientName, DockerContainer container) {
m_coreController->m_usersController->appendClient(serverId, clientId, clientName, container);
});
}, Qt::DirectConnection);
connect(m_coreController->m_usersController, &UsersController::adminConfigRevoked, m_coreController->m_installController,
&InstallController::clearCachedProfile);
@@ -285,6 +290,8 @@ void CoreSignalHandlers::initClientManagementModelUpdateHandler()
m_coreController->m_clientManagementModel, &ClientManagementModel::updateModel);
connect(m_coreController->m_usersController, &UsersController::clientRenamed,
m_coreController->m_clientManagementModel, &ClientManagementModel::updateClientName);
connect(m_coreController->m_usersController, &UsersController::revokeFinished,
m_coreController->m_exportController, &ExportController::revokeFinished);
}
void CoreSignalHandlers::initSitesModelUpdateHandler()

View File

@@ -48,6 +48,7 @@ signals:
void appendClientRequested(const QString &serverId, const QString &clientId, const QString &clientName, DockerContainer container);
void updateClientsRequested(const QString &serverId, DockerContainer container);
void revokeClientRequested(const QString &serverId, int row, DockerContainer container);
void revokeFinished(ErrorCode errorCode);
void renameClientRequested(const QString &serverId, int row, const QString &clientName, DockerContainer container);
public slots:

View File

@@ -234,7 +234,9 @@ ErrorCode InstallController::updateServerConfig(const QString &serverId, DockerC
} else if (container == DockerContainer::Telemt) {
TelemtInstaller::uploadClientSettingsSnapshot(sshSession, credentials, container, newConfig);
}
clearCachedProfile(serverId, container);
if (reinstallRequired) {
clearCachedProfile(serverId, container);
}
adminConfig->updateContainerConfig(container, newConfig);
m_serversRepository->editServer(serverId, adminConfig->toJson(), serverConfigUtils::ConfigType::SelfHostedAdmin);
}

View File

@@ -698,7 +698,7 @@ ErrorCode UsersController::revokeXray(const int row,
QString restartScript = QString("sudo docker restart $CONTAINER_NAME");
error = sshSession->runScript(
credentials,
credentials,
sshSession->replaceVars(restartScript, amnezia::genBaseVars(credentials, container, QString(), QString()))
);
if (error != ErrorCode::NoError) {
@@ -758,14 +758,17 @@ ErrorCode UsersController::revokeClient(const QString &serverId, const int index
ContainerConfig containerCfg = adminConfig->containerConfig(container);
QString containerClientId = containerCfg.protocolConfig.clientId();
if (!clientId.isEmpty() && !containerClientId.isEmpty() && containerClientId.contains(clientId)) {
const bool isAdminMatch = !clientId.isEmpty() && !containerClientId.isEmpty() && containerClientId.contains(clientId);
if (isAdminMatch) {
emit adminConfigRevoked(serverId, container);
}
emit clientRevoked(index);
emit clientsUpdated(m_clientsTable);
}
emit clientsUpdated(m_clientsTable);
emit revokeFinished(errorCode);
return errorCode;
}

View File

@@ -37,6 +37,7 @@ signals:
void clientAdded(const QJsonObject &client);
void clientRenamed(int row, const QString &newName);
void clientRevoked(int row);
void revokeFinished(ErrorCode errorCode);
void adminConfigRevoked(const QString &serverId, DockerContainer container);
public slots:

View File

@@ -32,7 +32,7 @@ XrayXPaddingConfig XrayXPaddingConfig::fromJson(const QJsonObject &json)
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.key = json.value(configKey::xPaddingKey).toString();
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);
@@ -365,6 +365,8 @@ XrayServerConfig XrayServerConfig::fromJson(const QJsonObject &json)
bool XrayServerConfig::hasEqualServerSettings(const XrayServerConfig &other) const
{
return port == other.port
&& transportProto == other.transportProto
&& subnetAddress == other.subnetAddress
&& site == other.site
&& security == other.security
&& flow == other.flow
@@ -466,6 +468,17 @@ XrayProtocolConfig XrayProtocolConfig::fromJson(const QJsonObject &json)
}
}
}
const QJsonArray outbounds = parsed.value(protocols::xray::outbounds).toArray();
if (!outbounds.isEmpty()) {
const QJsonObject settings = outbounds[0].toObject().value(protocols::xray::settings).toObject();
const QJsonArray vnext = settings.value(protocols::xray::vnext).toArray();
if (!vnext.isEmpty()) {
const QJsonArray users = vnext[0].toObject().value(protocols::xray::users).toArray();
if (!users.isEmpty()) {
clientCfg.id = users[0].toObject().value(protocols::xray::id).toString();
}
}
}
c.clientConfig = clientCfg;
} else {
c.clientConfig = XrayClientConfig::fromJson(parsed);

View File

@@ -208,8 +208,8 @@ QString SecureServersRepository::nextAvailableServerName() const
int i = 0;
QString candidate;
do {
i++;
candidate = QStringLiteral("Server %1").arg(i);
++i;
candidate = tr("Server") + QLatin1Char(' ') + QString::number(i);
} while (usedNames.contains(candidate));
return candidate;

View File

@@ -25,6 +25,8 @@ set_target_properties(AmneziaVPNNetworkExtension PROPERTIES
XCODE_ATTRIBUTE_INFOPLIST_FILE ${CMAKE_CURRENT_SOURCE_DIR}/Info.plist.in
XCODE_ATTRIBUTE_LD_RUNPATH_SEARCH_PATHS "@executable_path/../../../../Frameworks @loader_path/../../../../Frameworks"
XCODE_LINK_BUILD_PHASE_MODE KNOWN_LOCATION
)
if(DEPLOY)
@@ -118,10 +120,20 @@ target_include_directories(AmneziaVPNNetworkExtension PRIVATE ${CLIENT_ROOT_DIR}
target_include_directories(AmneziaVPNNetworkExtension PRIVATE ${CMAKE_CURRENT_BINARY_DIR})
find_package(openvpnadapter REQUIRED)
# FIXME(ygurov): https://github.com/conan-io/conan/issues/20034
set_property(TARGET amnezia::openvpnadapter APPEND PROPERTY IMPORTED_CONFIGURATIONS DEBUG)
set_property(TARGET amnezia::openvpnadapter APPEND PROPERTY IMPORTED_CONFIGURATIONS MINSIZEREL)
set_property(TARGET amnezia::openvpnadapter APPEND PROPERTY IMPORTED_CONFIGURATIONS RELEASE)
set_property(TARGET amnezia::openvpnadapter APPEND PROPERTY IMPORTED_CONFIGURATIONS RELWITHDEBINFO)
target_link_libraries(AmneziaVPNNetworkExtension PRIVATE amnezia::openvpnadapter)
find_package(awg-apple REQUIRED)
target_link_libraries(AmneziaVPNNetworkExtension PRIVATE amnezia::awg-apple)
find_package(hev-socks5-tunnel REQUIRED)
# FIXME(ygurov): https://github.com/conan-io/conan/issues/20034
set_property(TARGET heiher::hev-socks5-tunnel APPEND PROPERTY IMPORTED_CONFIGURATIONS DEBUG)
set_property(TARGET heiher::hev-socks5-tunnel APPEND PROPERTY IMPORTED_CONFIGURATIONS MINSIZEREL)
set_property(TARGET heiher::hev-socks5-tunnel APPEND PROPERTY IMPORTED_CONFIGURATIONS RELEASE)
set_property(TARGET heiher::hev-socks5-tunnel APPEND PROPERTY IMPORTED_CONFIGURATIONS RELWITHDEBINFO)
target_link_libraries(AmneziaVPNNetworkExtension PRIVATE heiher::hev-socks5-tunnel)

View File

@@ -62,12 +62,29 @@ void WindowsDaemon::prepareActivation(const InterfaceConfig& config, int inetAda
}
void WindowsDaemon::activateSplitTunnel(const InterfaceConfig& config, int vpnAdapterIndex) {
if (m_splitTunnelManager == nullptr)
if (m_splitTunnelManager == nullptr) {
if (config.m_vpnDisabledApps.length() > 0) {
logger.error() << "Split tunnel manager is not initialized";
emit backendFailure(DaemonError::ERROR_SPLIT_TUNNEL_INIT_FAILURE);
}
return;
}
if (config.m_vpnDisabledApps.length() > 0) {
m_splitTunnelManager->start(m_inetAdapterIndex, vpnAdapterIndex);
m_splitTunnelManager->excludeApps(config.m_vpnDisabledApps);
if (!m_splitTunnelManager->start(m_inetAdapterIndex, vpnAdapterIndex)) {
logger.error() << "Failed to start split tunnel";
emit backendFailure(DaemonError::ERROR_SPLIT_TUNNEL_START_FAILURE);
return;
}
if (!m_splitTunnelManager->excludeApps(config.m_vpnDisabledApps)) {
logger.error() << "Failed to apply split tunnel app exclusions";
emit backendFailure(DaemonError::ERROR_SPLIT_TUNNEL_EXCLUDE_FAILURE);
return;
}
if (!m_splitTunnelManager->isRunning()) {
logger.error() << "Split tunnel did not reach running state";
emit backendFailure(DaemonError::ERROR_SPLIT_TUNNEL_START_FAILURE);
}
} else {
m_splitTunnelManager->stop();
}
@@ -79,7 +96,9 @@ bool WindowsDaemon::run(Op op, const InterfaceConfig& config) {
// The Client has sent us a list of disabled apps, but we failed
// to init the the split tunnel driver.
// So let the client know this was not possible
logger.error() << "Split tunnel manager is not initialized";
emit backendFailure(DaemonError::ERROR_SPLIT_TUNNEL_INIT_FAILURE);
return false;
}
return true;
}
@@ -90,14 +109,20 @@ bool WindowsDaemon::run(Op op, const InterfaceConfig& config) {
}
if (config.m_vpnDisabledApps.length() > 0) {
if (!m_splitTunnelManager->start(m_inetAdapterIndex)) {
logger.error() << "Split tunnel start failed";
emit backendFailure(DaemonError::ERROR_SPLIT_TUNNEL_START_FAILURE);
return false;
};
if (!m_splitTunnelManager->excludeApps(config.m_vpnDisabledApps)) {
logger.error() << "Split tunnel app exclusion failed";
emit backendFailure(DaemonError::ERROR_SPLIT_TUNNEL_EXCLUDE_FAILURE);
return false;
};
// Now the driver should be running (State == 4)
if (!m_splitTunnelManager->isRunning()) {
logger.error() << "Split tunnel did not reach running state";
emit backendFailure(DaemonError::ERROR_SPLIT_TUNNEL_START_FAILURE);
return false;
}
return true;
}

View File

@@ -36,6 +36,8 @@
// ID for the Firewall Sublayer
DEFINE_GUID(ST_FW_WINFW_BASELINE_SUBLAYER_KEY, 0xc78056ff, 0x2bc1, 0x4211, 0xaa,
0xdd, 0x7f, 0x35, 0x8d, 0xef, 0x20, 0x2d);
DEFINE_GUID(ST_FW_WINFW_DNS_SUBLAYER_KEY, 0x60090787, 0xcca1, 0x4937, 0xaa,
0xce, 0x51, 0x25, 0x6e, 0xf4, 0x81, 0xf3);
// ID for the Mullvad Split-Tunnel Sublayer Provider
DEFINE_GUID(ST_FW_PROVIDER_KEY, 0xe2c114ee, 0xf32a, 0x4264, 0xa6, 0xcb, 0x3f,
0xa7, 0x99, 0x63, 0x56, 0xd9);
@@ -49,6 +51,53 @@ constexpr uint8_t LOW_WEIGHT = 0;
constexpr uint8_t MED_WEIGHT = 7;
constexpr uint8_t HIGH_WEIGHT = 13;
constexpr uint8_t MAX_WEIGHT = 15;
bool ensureSublayer(HANDLE wfp, const GUID& key, const wchar_t* name,
const wchar_t* description) {
FWPM_SUBLAYER0* maybeLayer = nullptr;
DWORD result = FwpmSubLayerGetByKey0(wfp, &key, &maybeLayer);
if (result == ERROR_SUCCESS) {
logger.debug() << "The Sublayer Already Exists:"
<< QString::fromWCharArray(name);
FwpmFreeMemory0(reinterpret_cast<void**>(&maybeLayer));
return true;
}
if (result != FWP_E_SUBLAYER_NOT_FOUND) {
logger.error() << "FwpmSubLayerGetByKey0 failed. Return value:.\n"
<< result;
return false;
}
result = FwpmTransactionBegin(wfp, NULL);
if (result != ERROR_SUCCESS) {
logger.error() << "FwpmTransactionBegin0 failed. Return value:.\n"
<< result;
return false;
}
FWPM_SUBLAYER0 subLayer;
memset(&subLayer, 0, sizeof(subLayer));
subLayer.subLayerKey = key;
subLayer.displayData.name = const_cast<PWSTR>(name);
subLayer.displayData.description = const_cast<PWSTR>(description);
subLayer.weight = 0xFFFF;
result = FwpmSubLayerAdd0(wfp, &subLayer, NULL);
if (result != ERROR_SUCCESS) {
FwpmTransactionAbort0(wfp);
logger.error() << "FwpmSubLayerAdd0 failed. Return value:.\n" << result;
return false;
}
result = FwpmTransactionCommit0(wfp);
if (result != ERROR_SUCCESS) {
logger.error() << "FwpmTransactionCommit0 failed. Return value:.\n"
<< result;
return false;
}
logger.debug() << "Initialised Sublayer:" << QString::fromWCharArray(name);
return true;
}
} // namespace
WindowsFirewall* WindowsFirewall::create(QObject* parent) {
@@ -116,47 +165,12 @@ bool WindowsFirewall::initSublayer() {
}
auto cleanup = qScopeGuard([&] { FwpmEngineClose0(wfp); });
// Check if the Layer Already Exists
FWPM_SUBLAYER0* maybeLayer;
result = FwpmSubLayerGetByKey0(wfp, &ST_FW_WINFW_BASELINE_SUBLAYER_KEY,
&maybeLayer);
if (result == ERROR_SUCCESS) {
logger.debug() << "The Sublayer Already Exists!";
FwpmFreeMemory0((void**)&maybeLayer);
return true;
}
// Step 1: Start Transaction
result = FwpmTransactionBegin(wfp, NULL);
if (result != ERROR_SUCCESS) {
logger.error() << "FwpmTransactionBegin0 failed. Return value:.\n"
<< result;
return false;
}
// Step 3: Add Sublayer
FWPM_SUBLAYER0 subLayer;
memset(&subLayer, 0, sizeof(subLayer));
subLayer.subLayerKey = ST_FW_WINFW_BASELINE_SUBLAYER_KEY;
subLayer.displayData.name = (PWSTR)L"Amnezia-SplitTunnel-Sublayer";
subLayer.displayData.description =
(PWSTR)L"Filters that enforce a good baseline";
subLayer.weight = 0xFFFF;
result = FwpmSubLayerAdd0(wfp, &subLayer, NULL);
if (result != ERROR_SUCCESS) {
logger.error() << "FwpmSubLayerAdd0 failed. Return value:.\n" << result;
return false;
}
// Step 4: Commit!
result = FwpmTransactionCommit0(wfp);
if (result != ERROR_SUCCESS) {
logger.error() << "FwpmTransactionCommit0 failed. Return value:.\n"
<< result;
return false;
}
logger.debug() << "Initialised Sublayer";
return true;
return ensureSublayer(wfp, ST_FW_WINFW_BASELINE_SUBLAYER_KEY,
L"Amnezia-SplitTunnel-Sublayer",
L"Filters that enforce a good baseline") &&
ensureSublayer(wfp, ST_FW_WINFW_DNS_SUBLAYER_KEY,
L"Amnezia-SplitTunnel-DNS-Sublayer",
L"DNS filters for split tunneling");
}
bool WindowsFirewall::enableInterface(int vpnAdapterIndex) {

View File

@@ -24,6 +24,7 @@
#include <QFileInfo>
#include <QNetworkInterface>
#include <QScopeGuard>
#include <QUrl>
#pragma region
@@ -148,6 +149,18 @@ ProcessInfo getProcessInfo(HANDLE process, const PROCESSENTRY32W& processMeta) {
return pi;
}
QString normalizeExecutablePath(const QString& path) {
QString normalized = path.trimmed();
if (normalized.startsWith("file:", Qt::CaseInsensitive)) {
const QString localPath = QUrl(normalized).toLocalFile();
if (!localPath.isEmpty()) {
normalized = localPath;
}
}
normalized.replace('/', '\\');
return normalized;
}
} // namespace
std::unique_ptr<WindowsSplitTunnel> WindowsSplitTunnel::create(
@@ -287,6 +300,10 @@ bool WindowsSplitTunnel::excludeApps(const QStringList& appPaths) {
logger.debug() << "Pushing new Ruleset for Split-Tunnel " << state;
auto config = generateAppConfiguration(appPaths);
if (config.empty()) {
logger.error() << "No valid split-tunnel application rules generated";
return false;
}
DWORD bytesReturned;
auto ok = DeviceIoControl(m_driver, IOCTL_SET_CONFIGURATION, &config[0],
@@ -314,7 +331,7 @@ bool WindowsSplitTunnel::start(int inetAdapterIndex, int vpnAdapterIndex) {
auto ok = DeviceIoControl(m_driver, IOCTL_INITIALIZE, nullptr, 0, nullptr,
0, &bytesReturned, nullptr);
if (!ok) {
logger.error() << "Driver init failed";
logger.error() << "Driver init failed. Error:" << GetLastError();
return false;
}
}
@@ -323,11 +340,16 @@ bool WindowsSplitTunnel::start(int inetAdapterIndex, int vpnAdapterIndex) {
if (getState() == STATE_INITIALIZED) {
logger.debug() << "State is Init, requires process config";
auto config = generateProcessBlob();
if (config.empty()) {
logger.error() << "Process configuration blob is empty";
return false;
}
auto ok = DeviceIoControl(m_driver, IOCTL_REGISTER_PROCESSES, &config[0],
(DWORD)config.size(), nullptr, 0, &bytesReturned,
nullptr);
if (!ok) {
logger.error() << "Failed to set Process Config";
logger.error() << "Failed to set Process Config. Error:"
<< GetLastError();
return false;
}
logger.debug() << "Set Process Config ok || new State:" << stateString();
@@ -340,11 +362,16 @@ bool WindowsSplitTunnel::start(int inetAdapterIndex, int vpnAdapterIndex) {
logger.debug() << "Driver is ready || new State:" << stateString();
auto config = generateIPConfiguration(inetAdapterIndex, vpnAdapterIndex);
if (config.empty()) {
logger.error() << "Network configuration blob is empty. Internet adapter:"
<< inetAdapterIndex << "VPN adapter:" << vpnAdapterIndex;
return false;
}
auto ok = DeviceIoControl(m_driver, IOCTL_REGISTER_IP_ADDRESSES, &config[0],
(DWORD)config.size(), nullptr, 0, &bytesReturned,
nullptr);
if (!ok) {
logger.error() << "Failed to set Network Config";
logger.error() << "Failed to set Network Config. Error:" << GetLastError();
return false;
}
logger.debug() << "New Network Config Applied || new State:" << stateString();
@@ -404,13 +431,22 @@ std::vector<uint8_t> WindowsSplitTunnel::generateAppConfiguration(
size_t cummulated_string_size = 0;
QStringList dosPaths;
for (auto const& path : appPaths) {
auto dosPath = convertPath(path);
const QString normalizedPath = normalizeExecutablePath(path);
auto dosPath = convertPath(normalizedPath);
if (dosPath.isEmpty()) {
logger.error() << "Rejecting split-tunnel app path with empty device "
"conversion:"
<< normalizedPath;
continue;
}
dosPaths.append(dosPath);
cummulated_string_size += dosPath.toStdWString().size() * sizeof(wchar_t);
logger.debug() << dosPath;
}
if (dosPaths.isEmpty()) {
return {};
}
size_t bufferSize = sizeof(CONFIGURATION_HEADER) +
(sizeof(CONFIGURATION_ENTRY) * appPaths.size()) +
(sizeof(CONFIGURATION_ENTRY) * dosPaths.size()) +
cummulated_string_size;
std::vector<uint8_t> outBuffer(bufferSize);
@@ -418,7 +454,7 @@ std::vector<uint8_t> WindowsSplitTunnel::generateAppConfiguration(
auto entry = (CONFIGURATION_ENTRY*)(header + 1);
auto stringDest = &outBuffer[0] + sizeof(CONFIGURATION_HEADER) +
(sizeof(CONFIGURATION_ENTRY) * appPaths.size());
(sizeof(CONFIGURATION_ENTRY) * dosPaths.size());
SIZE_T stringOffset = 0;
@@ -437,7 +473,7 @@ std::vector<uint8_t> WindowsSplitTunnel::generateAppConfiguration(
stringOffset += stringLength;
}
header->NumEntries = appPaths.length();
header->NumEntries = dosPaths.length();
header->TotalLength = bufferSize;
return outBuffer;
@@ -449,9 +485,7 @@ std::vector<std::byte> WindowsSplitTunnel::generateIPConfiguration(
auto config = reinterpret_cast<IP_ADDRESSES_CONFIG*>(&out[0]);
auto ifaces = QNetworkInterface::allInterfaces();
if (vpnAdapterIndex == 0) {
if (vpnAdapterIndex == 0) {
vpnAdapterIndex = WindowsCommons::VPNAdapterIndex();
}
// Always the VPN
@@ -520,7 +554,7 @@ std::vector<uint8_t> WindowsSplitTunnel::generateProcessBlob() {
auto process_handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE,
currentProcess.th32ProcessID);
if (process_handle == INVALID_HANDLE_VALUE) {
if (process_handle == nullptr) {
continue;
}
ProcessInfo info = getProcessInfo(process_handle, currentProcess);
@@ -640,23 +674,48 @@ bool WindowsSplitTunnel::isInstalled() {
}
QString WindowsSplitTunnel::convertPath(const QString& path) {
auto parts = path.split("/");
const QString normalizedPath = normalizeExecutablePath(path);
if (normalizedPath.isEmpty()) {
logger.error() << "Empty executable path for DOS device conversion";
return "";
}
auto parts = normalizedPath.split("\\", Qt::SkipEmptyParts);
if (parts.isEmpty()) {
logger.error() << "Invalid executable path for DOS device conversion:"
<< normalizedPath;
return "";
}
QString driveLetter = parts.takeFirst();
if (!driveLetter.contains(":") || parts.size() == 0) {
// device should contain : for e.g C:
logger.error() << "Invalid executable path for DOS device conversion:"
<< normalizedPath;
return "";
}
QByteArray buffer(2048, 0xFFu);
auto ok = QueryDosDeviceW(qUtf16Printable(driveLetter),
(wchar_t*)buffer.data(), buffer.size() / 2);
if (ok == ERROR_INSUFFICIENT_BUFFER) {
QByteArray buffer(2048 * sizeof(wchar_t), 0);
DWORD ok = 0;
DWORD err = ERROR_SUCCESS;
for (int attempt = 0; attempt < 4; ++attempt) {
ok = QueryDosDeviceW(reinterpret_cast<LPCWSTR>(driveLetter.utf16()),
reinterpret_cast<LPWSTR>(buffer.data()),
buffer.size() / sizeof(wchar_t));
if (ok != 0) {
break;
}
err = GetLastError();
if (err != ERROR_INSUFFICIENT_BUFFER) {
WindowsUtils::windowsLog("Err fetching dos path");
logger.error() << "QueryDosDeviceW failed for" << driveLetter
<< "error:" << err;
return "";
}
buffer.resize(buffer.size() * 2);
ok = QueryDosDeviceW(qUtf16Printable(driveLetter), (wchar_t*)buffer.data(),
buffer.size() / 2);
buffer.fill(0);
}
if (ok == 0) {
WindowsUtils::windowsLog("Err fetching dos path");
logger.error() << "QueryDosDeviceW failed after buffer growth for"
<< driveLetter << "error:" << err;
return "";
}
QString deviceName;

View File

@@ -128,6 +128,11 @@ void PageController::showOnStartup()
}
}
bool PageController::shouldStartMinimized() const
{
return m_settingsController->isStartMinimizedEnabled();
}
bool PageController::isTriggeredByConnectButton()
{
return m_isTriggeredByConnectButton;

View File

@@ -123,6 +123,7 @@ public slots:
void updateNavigationBarColor(const int color);
void showOnStartup();
bool shouldStartMinimized() const;
bool isTriggeredByConnectButton();
void setTriggeredByConnectButton(bool trigger);

View File

@@ -9,6 +9,13 @@ ExportUiController::ExportUiController(ExportController* exportController, QObje
: QObject(parent),
m_exportController(exportController)
{
connect(m_exportController, &ExportController::revokeFinished, this, [this](ErrorCode errorCode) {
if (errorCode == ErrorCode::NoError) {
emit revokeConfigFinished();
} else {
emit exportErrorOccurred(errorCode);
}
});
}
void ExportUiController::generateFullAccessConfig(const QString &serverId)
@@ -92,7 +99,6 @@ void ExportUiController::updateClientManagementModel(const QString &serverId, in
void ExportUiController::revokeConfig(int row, const QString &serverId, int containerIndex)
{
m_exportController->revokeConfig(row, serverId, containerIndex);
emit revokeConfigFinished();
}
void ExportUiController::renameClient(int row, const QString &clientName, const QString &serverId, int containerIndex)

View File

@@ -306,14 +306,17 @@ void InstallUiController::updateServerConfig(const QString &serverId, int contai
|| container == DockerContainer::Xray || container == DockerContainer::SSXray;
if (asyncUpdate) {
emit serverIsBusy(true);
const bool emitBusy = container == DockerContainer::MtProxy || container == DockerContainer::Telemt;
if (emitBusy)
emit serverIsBusy(true);
auto *watcher = new QFutureWatcher<ErrorCode>(this);
const Proto protocolTypeCopy = protocolType;
QObject::connect(watcher, &QFutureWatcher<ErrorCode>::finished, this,
[this, watcher, serverId, container, closePage, protocolTypeCopy]() {
[this, watcher, serverId, container, closePage, protocolTypeCopy, emitBusy]() {
const ErrorCode errorCode = watcher->result();
watcher->deleteLater();
emit serverIsBusy(false);
if (emitBusy)
emit serverIsBusy(false);
if (errorCode == ErrorCode::NoError) {
const ContainerConfig updatedConfig =

View File

@@ -4,6 +4,10 @@
#include "core/protocols/protocolUtils.h"
#include "core/utils/constants/configKeys.h"
#include "core/utils/constants/protocolConstants.h"
#include "core/utils/networkUtilities.h"
#include <QHostAddress>
#include <QRegularExpression>
using namespace amnezia;
using namespace ProtocolUtils;
@@ -272,7 +276,7 @@ void XrayConfigModel::updateModel(amnezia::DockerContainer container, const amne
}
if (!m_protocolConfig.serverConfig.isThirdPartyConfig) {
applyDefaultsToServerConfig(m_protocolConfig.serverConfig);
applyDefaultsToServerConfig(m_protocolConfig.serverConfig, false);
}
m_originalProtocolConfig = m_protocolConfig;
@@ -283,7 +287,7 @@ void XrayConfigModel::updateModel(amnezia::DockerContainer container, const amne
}
}
void XrayConfigModel::applyDefaultsToServerConfig(amnezia::XrayServerConfig &config)
void XrayConfigModel::applyDefaultsToServerConfig(amnezia::XrayServerConfig &config, bool fillFlowDefault)
{
if (config.port.isEmpty()) {
config.port = protocols::xray::defaultPort;
@@ -306,7 +310,7 @@ void XrayConfigModel::applyDefaultsToServerConfig(amnezia::XrayServerConfig &con
config.security = protocols::xray::defaultSecurity;
}
if (config.flow.isEmpty()) {
if (fillFlowDefault && config.flow.isEmpty()) {
config.flow = protocols::xray::defaultFlow;
}
@@ -585,3 +589,87 @@ QString XrayConfigModel::mkcpDefaultWriteBufferSize()
{
return QString::fromLatin1(protocols::xray::defaultMkcpWriteBufferSize);
}
namespace {
bool isValidSingleHost(const QString &t)
{
if (t.isEmpty() || t.length() > 253) {
return false;
}
QHostAddress a(t);
if (a.protocol() == QHostAddress::IPv4Protocol) {
return NetworkUtilities::checkIPv4Format(t);
}
if (a.protocol() == QHostAddress::IPv6Protocol) {
return true;
}
static const QRegularExpression onlyDigits(QStringLiteral(R"(^\d+$)"));
if (onlyDigits.match(t).hasMatch()) {
return false;
}
QRegExp re = NetworkUtilities::domainRegExp();
re.setCaseSensitivity(Qt::CaseInsensitive);
return re.exactMatch(t);
}
}
bool XrayConfigModel::isValidHost(const QString &host)
{
const QString t = host.trimmed();
if (t.isEmpty()) {
return true;
}
return isValidSingleHost(t);
}
bool XrayConfigModel::isValidSni(const QString &sni)
{
const QString t = sni.trimmed();
if (t.isEmpty()) {
return true;
}
if (t.startsWith(QLatin1String("*."))) {
return isValidSingleHost(t.mid(2));
}
return isValidSingleHost(t);
}
bool XrayConfigModel::isValidPath(const QString &path)
{
const QString t = path.trimmed();
if (t.isEmpty()) {
return true;
}
return t.startsWith(QLatin1Char('/'));
}
QStringList XrayConfigModel::validationErrors() const
{
QStringList errs;
const auto &srv = m_protocolConfig.serverConfig;
if (!srv.port.isEmpty()) {
bool ok = false;
const int p = srv.port.toInt(&ok);
if (!ok || p < 1 || p > 65535) {
errs << tr("Port must be in the range of 1 to 65535");
}
}
if (srv.security == QLatin1String("tls") || srv.security == QLatin1String("reality")) {
if (!isValidSni(srv.sni)) {
errs << tr("SNI: enter a valid IP address or domain name");
}
}
if (srv.transport == QLatin1String("xhttp")) {
if (!isValidHost(srv.xhttp.host)) {
errs << tr("Host: enter a valid IP address or domain name");
}
if (!isValidPath(srv.xhttp.path)) {
errs << tr("Path must start with \"/\"");
}
}
return errs;
}

View File

@@ -118,6 +118,11 @@ public:
Q_INVOKABLE static QString mkcpDefaultReadBufferSize();
Q_INVOKABLE static QString mkcpDefaultWriteBufferSize();
Q_INVOKABLE static bool isValidHost(const QString &host);
Q_INVOKABLE static bool isValidSni(const QString &sni);
Q_INVOKABLE static bool isValidPath(const QString &path);
Q_INVOKABLE QStringList validationErrors() const;
public slots:
void updateModel(amnezia::DockerContainer container, const amnezia::XrayProtocolConfig& protocolConfig);
amnezia::XrayProtocolConfig getProtocolConfig();
@@ -137,7 +142,7 @@ private:
amnezia::XrayProtocolConfig m_protocolConfig;
amnezia::XrayProtocolConfig m_originalProtocolConfig;
void applyDefaultsToServerConfig(amnezia::XrayServerConfig& config);
void applyDefaultsToServerConfig(amnezia::XrayServerConfig& config, bool fillFlowDefault = true);
};
#endif // XRAYCONFIGMODEL_H

View File

@@ -42,6 +42,7 @@ Item {
property int rootButtonTextBottomMargin: 16
property real drawerHeight: 0.9
property bool fitContent: false
property Item drawerParent
property Component listView
@@ -219,12 +220,20 @@ Item {
parent: drawerParent
anchors.fill: parent
expandedHeight: drawerParent.height * drawerHeight
property real measuredContentHeight: 0
expandedHeight: (root.fitContent && measuredContentHeight > 0)
? Math.min(measuredContentHeight, drawerParent.height * root.drawerHeight)
: drawerParent.height * root.drawerHeight
expandedStateContent: Item {
id: container
implicitHeight: menu.expandedHeight
property real fitHeight: backButton.implicitHeight + titleLabel.implicitHeight
+ (listViewLoader.item ? listViewLoader.item.contentHeight : 0) + 48
onFitHeightChanged: menu.measuredContentHeight = fitHeight
Component.onCompleted: menu.measuredContentHeight = fitHeight
ColumnLayout {
id: header
@@ -238,6 +247,7 @@ Item {
}
Header2Type {
id: titleLabel
Layout.leftMargin: 16
Layout.rightMargin: 16
Layout.bottomMargin: 16

View File

@@ -12,8 +12,8 @@ import "../Controls2/TextTypes"
// MinMaxRowType {
// minValue: "0"
// maxValue: "0"
// onMinChanged: someProperty = val
// onMaxChanged: someProperty = val
// onMinChanged: function(val) { someProperty = val }
// onMaxChanged: function(val) { someProperty = val }
// }
Item {
id: root
@@ -21,41 +21,128 @@ Item {
property string minValue: "0"
property string maxValue: "0"
property int minLimit: 0
property int maxLimit: 2147483647
property string hintText: root.minLimit > 0
? (root.minLimit + "" + root.maxLimit)
: ("≤ " + root.maxLimit)
signal minChanged(string val)
signal maxChanged(string val)
signal edited()
implicitHeight: row.implicitHeight
implicitWidth: row.implicitWidth
implicitHeight: col.implicitHeight
implicitWidth: col.implicitWidth
RowLayout {
id: row
function clampValue(text) {
if (text === "")
return ""
var n = parseInt(text, 10)
if (isNaN(n))
return ""
if (n < root.minLimit)
n = root.minLimit
if (n > root.maxLimit)
n = root.maxLimit
return String(n)
}
function capEdit(tf, holder) {
if (tf.text !== "" && parseInt(tf.text, 10) > root.maxLimit) {
tf.text = holder.lastValid
tf.cursorPosition = tf.text.length
} else {
holder.lastValid = tf.text
}
}
ColumnLayout {
id: col
anchors.fill: parent
spacing: 10
spacing: 4
// Min field
TextFieldWithHeaderType {
RowLayout {
id: row
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)
spacing: 10
// Min field
TextFieldWithHeaderType {
id: minField
property string lastValid: ""
Layout.fillWidth: true
headerText: qsTr("Min")
textField.maximumLength: 10
textField.validator: RegularExpressionValidator { regularExpression: /^\d*$/ }
textField.onActiveFocusChanged: {
if (minField.textField.activeFocus)
minField.lastValid = minField.textField.text
}
textField.onTextEdited: { root.capEdit(minField.textField, minField); root.edited() }
textField.onEditingFinished: {
var v = root.clampValue(minField.textField.text)
if (v !== "" && root.maxValue !== "") {
var mx = parseInt(root.maxValue, 10)
if (!isNaN(mx) && parseInt(v, 10) > mx)
root.maxChanged(v)
}
if (v !== root.minValue)
root.minChanged(v)
else if (minField.textField.text !== v)
minField.textField.text = v
}
Binding {
target: minField.textField
property: "text"
value: root.minValue
when: !minField.textField.activeFocus
restoreMode: Binding.RestoreNone
}
}
// Max field
TextFieldWithHeaderType {
id: maxField
property string lastValid: ""
Layout.fillWidth: true
headerText: qsTr("Max")
textField.maximumLength: 10
textField.validator: RegularExpressionValidator { regularExpression: /^\d*$/ }
textField.onActiveFocusChanged: {
if (maxField.textField.activeFocus)
maxField.lastValid = maxField.textField.text
}
textField.onTextEdited: { root.capEdit(maxField.textField, maxField); root.edited() }
textField.onEditingFinished: {
var v = root.clampValue(maxField.textField.text)
if (v !== "" && root.minValue !== "") {
var mn = parseInt(root.minValue, 10)
if (!isNaN(mn) && parseInt(v, 10) < mn)
v = String(mn)
}
if (v !== root.maxValue)
root.maxChanged(v)
else if (maxField.textField.text !== v)
maxField.textField.text = v
}
Binding {
target: maxField.textField
property: "text"
value: root.maxValue
when: !maxField.textField.activeFocus
restoreMode: Binding.RestoreNone
}
}
}
// Max field
TextFieldWithHeaderType {
SmallTextType {
visible: root.hintText !== ""
text: root.hintText
color: AmneziaStyle.color.mutedGray
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)
}
}
}
}
}

View File

@@ -15,6 +15,8 @@ import "../Components"
PageType {
id: root
property bool editDirty: false
BackButtonType {
id: backButton
anchors.top: parent.top
@@ -90,6 +92,7 @@ PageType {
DropDownType {
id: tlsAlpnDropDown
fitContent: true
Layout.fillWidth: true
Layout.topMargin: 16
Layout.leftMargin: 16
@@ -133,6 +136,7 @@ PageType {
DropDownType {
id: tlsFingerprintDropDown
fitContent: true
Layout.fillWidth: true
Layout.topMargin: 8
Layout.leftMargin: 16
@@ -175,14 +179,21 @@ PageType {
}
TextFieldWithHeaderType {
id: sniFieldTls
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
Layout.topMargin: 8
headerText: qsTr("Server Name (SNI)")
textField.text: sni
textField.validator: RegularExpressionValidator { regularExpression: /^[A-Za-z0-9.*_-]*$/ }
textField.onTextEdited: root.editDirty = (textField.text !== sni)
textField.onEditingFinished: {
if (textField.text !== sni) sni = textField.text
var v = textField.text.trim()
if (v !== sni) sni = v
else if (textField.text !== v) textField.text = v
sniFieldTls.errorText = XrayConfigModel.isValidSni(v) ? "" : qsTr("Enter a valid IP address or domain name")
root.editDirty = false
}
}
}
@@ -195,6 +206,7 @@ PageType {
DropDownType {
id: realityFingerprintDropDown
fitContent: true
Layout.fillWidth: true
Layout.topMargin: 16
Layout.leftMargin: 16
@@ -237,14 +249,21 @@ PageType {
}
TextFieldWithHeaderType {
id: sniFieldReality
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
Layout.topMargin: 8
headerText: qsTr("Server Name (SNI)")
textField.text: sni
textField.validator: RegularExpressionValidator { regularExpression: /^[A-Za-z0-9.*_-]*$/ }
textField.onTextEdited: root.editDirty = (textField.text !== sni)
textField.onEditingFinished: {
if (textField.text !== sni) sni = textField.text
var v = textField.text.trim()
if (v !== sni) sni = v
else if (textField.text !== v) textField.text = v
sniFieldReality.errorText = XrayConfigModel.isValidSni(v) ? "" : qsTr("Enter a valid IP address or domain name")
root.editDirty = false
}
}
}
@@ -265,10 +284,15 @@ PageType {
anchors.rightMargin: 16
anchors.bottomMargin: 16 + PageController.safeAreaBottomMargin
visible: listView.enabled && XrayConfigModel.hasUnsavedChanges
visible: listView.enabled && (XrayConfigModel.hasUnsavedChanges || root.editDirty)
enabled: visible
text: qsTr("Save")
clickedFunc: function () {
var errs = XrayConfigModel.validationErrors()
if (errs.length > 0) {
PageController.showErrorMessage(errs.join("\n"))
return
}
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")

View File

@@ -109,6 +109,7 @@ PageType {
Layout.rightMargin: 16
enabled: listView.enabled
headerText: qsTr("Port")
subtitleText: qsTr("165535")
Binding {
target: textFieldWithHeaderType.textField
@@ -119,8 +120,8 @@ PageType {
}
textField.maximumLength: 5
textField.validator: IntValidator {
bottom: 1; top: 65535
textField.validator: RegularExpressionValidator {
regularExpression: /^(|\d{1,4}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5])$/
}
textField.onActiveFocusChanged: {
if (textField.activeFocus && textField.text === "" && port !== "") {
@@ -131,9 +132,19 @@ PageType {
root.portDirty = (textField.text !== port)
}
textField.onEditingFinished: {
if (textField.text !== port) {
port = textField.text
var v = textFieldWithHeaderType.textField.text
if (v !== "") {
var n = parseInt(v, 10)
if (isNaN(n) || n < 1)
n = 1
if (n > 65535)
n = 65535
v = String(n)
if (textFieldWithHeaderType.textField.text !== v)
textFieldWithHeaderType.textField.text = v
}
if (v !== port)
port = v
root.portDirty = false
}
checkEmptyText: true
@@ -198,6 +209,11 @@ PageType {
text: qsTr("Save")
onClicked: function() {
forceActiveFocus()
var errs = XrayConfigModel.validationErrors()
if (errs.length > 0) {
PageController.showErrorMessage(errs.join("\n"))
return
}
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")

View File

@@ -15,6 +15,21 @@ import "../Components"
PageType {
id: root
property bool editDirty: false
function clampInt(text, lo, hi) {
if (text === "")
return ""
var n = parseInt(text, 10)
if (isNaN(n))
return ""
if (n < lo)
n = lo
if (n > hi)
n = hi
return String(n)
}
BackButtonType {
id: backButton
anchors.top: parent.top
@@ -108,10 +123,16 @@ PageType {
Layout.rightMargin: 16
Layout.topMargin: 8
headerText: qsTr("TTI")
subtitleText: qsTr("Default: %1 ms", "mKCP TTI").arg(XrayConfigModel.mkcpDefaultTti())
subtitleText: qsTr("Range 10100, default %1 ms", "mKCP TTI").arg(XrayConfigModel.mkcpDefaultTti())
textField.text: mkcpTti
textField.maximumLength: 3
textField.validator: RegularExpressionValidator { regularExpression: /^(|\d{1,2}|100)$/ }
textField.onTextEdited: root.editDirty = (textField.text !== mkcpTti)
textField.onEditingFinished: {
if (textField.text !== mkcpTti) mkcpTti = textField.text
var v = root.clampInt(textField.text, 10, 100)
if (v !== mkcpTti) mkcpTti = v
else if (textField.text !== v) textField.text = v
root.editDirty = false
}
}
@@ -121,10 +142,16 @@ PageType {
Layout.rightMargin: 16
Layout.topMargin: 8
headerText: qsTr("uplinkCapacity")
subtitleText: qsTr("Default: %1 Mbit/s", "mKCP uplink").arg(XrayConfigModel.mkcpDefaultUplinkCapacity())
subtitleText: qsTr("≥ 0, default %1 MB/s", "mKCP uplink").arg(XrayConfigModel.mkcpDefaultUplinkCapacity())
textField.text: mkcpUplinkCapacity
textField.maximumLength: 10
textField.validator: RegularExpressionValidator { regularExpression: /^\d*$/ }
textField.onTextEdited: root.editDirty = (textField.text !== mkcpUplinkCapacity)
textField.onEditingFinished: {
if (textField.text !== mkcpUplinkCapacity) mkcpUplinkCapacity = textField.text
var v = root.clampInt(textField.text, 0, 2147483647)
if (v !== mkcpUplinkCapacity) mkcpUplinkCapacity = v
else if (textField.text !== v) textField.text = v
root.editDirty = false
}
}
@@ -134,10 +161,16 @@ PageType {
Layout.rightMargin: 16
Layout.topMargin: 8
headerText: qsTr("downlinkCapacity")
subtitleText: qsTr("Default: %1 Mbit/s", "mKCP downlink").arg(XrayConfigModel.mkcpDefaultDownlinkCapacity())
subtitleText: qsTr("≥ 0, default %1 MB/s", "mKCP downlink").arg(XrayConfigModel.mkcpDefaultDownlinkCapacity())
textField.text: mkcpDownlinkCapacity
textField.maximumLength: 10
textField.validator: RegularExpressionValidator { regularExpression: /^\d*$/ }
textField.onTextEdited: root.editDirty = (textField.text !== mkcpDownlinkCapacity)
textField.onEditingFinished: {
if (textField.text !== mkcpDownlinkCapacity) mkcpDownlinkCapacity = textField.text
var v = root.clampInt(textField.text, 0, 2147483647)
if (v !== mkcpDownlinkCapacity) mkcpDownlinkCapacity = v
else if (textField.text !== v) textField.text = v
root.editDirty = false
}
}
@@ -147,10 +180,16 @@ PageType {
Layout.rightMargin: 16
Layout.topMargin: 8
headerText: qsTr("readBufferSize")
subtitleText: qsTr("Default: %1 MiB").arg(XrayConfigModel.mkcpDefaultReadBufferSize())
subtitleText: qsTr("≥ 1, default %1 MB").arg(XrayConfigModel.mkcpDefaultReadBufferSize())
textField.text: mkcpReadBufferSize
textField.maximumLength: 10
textField.validator: RegularExpressionValidator { regularExpression: /^\d*$/ }
textField.onTextEdited: root.editDirty = (textField.text !== mkcpReadBufferSize)
textField.onEditingFinished: {
if (textField.text !== mkcpReadBufferSize) mkcpReadBufferSize = textField.text
var v = root.clampInt(textField.text, 1, 2147483647)
if (v !== mkcpReadBufferSize) mkcpReadBufferSize = v
else if (textField.text !== v) textField.text = v
root.editDirty = false
}
}
@@ -160,10 +199,16 @@ PageType {
Layout.rightMargin: 16
Layout.topMargin: 8
headerText: qsTr("writeBufferSize")
subtitleText: qsTr("Default: %1 MiB").arg(XrayConfigModel.mkcpDefaultWriteBufferSize())
subtitleText: qsTr("≥ 1, default %1 MB").arg(XrayConfigModel.mkcpDefaultWriteBufferSize())
textField.text: mkcpWriteBufferSize
textField.maximumLength: 10
textField.validator: RegularExpressionValidator { regularExpression: /^\d*$/ }
textField.onTextEdited: root.editDirty = (textField.text !== mkcpWriteBufferSize)
textField.onEditingFinished: {
if (textField.text !== mkcpWriteBufferSize) mkcpWriteBufferSize = textField.text
var v = root.clampInt(textField.text, 1, 2147483647)
if (v !== mkcpWriteBufferSize) mkcpWriteBufferSize = v
else if (textField.text !== v) textField.text = v
root.editDirty = false
}
}
@@ -187,6 +232,7 @@ PageType {
DropDownType {
id: modeDropDown
fitContent: true
Layout.fillWidth: true
Layout.topMargin: 16
Layout.leftMargin: 16
@@ -239,31 +285,46 @@ PageType {
}
TextFieldWithHeaderType {
id: hostField
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
Layout.topMargin: 8
headerText: qsTr("Host")
textField.text: xhttpHost
textField.validator: RegularExpressionValidator { regularExpression: /^[A-Za-z0-9._:,-]*$/ }
textField.onTextEdited: root.editDirty = (textField.text !== xhttpHost)
textField.onEditingFinished: {
if (textField.text !== xhttpHost) xhttpHost = textField.text
var v = textField.text.trim()
if (v !== xhttpHost) xhttpHost = v
else if (textField.text !== v) textField.text = v
hostField.errorText = XrayConfigModel.isValidHost(v) ? "" : qsTr("Enter a valid IP address or domain name")
root.editDirty = false
}
}
TextFieldWithHeaderType {
id: pathField
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
Layout.topMargin: 8
headerText: qsTr("Path")
textField.text: xhttpPath
textField.validator: RegularExpressionValidator { regularExpression: /^[A-Za-z0-9\-._~:\/?#\[\]@!$&'()*+,;=%]*$/ }
textField.onTextEdited: root.editDirty = (textField.text !== xhttpPath)
textField.onEditingFinished: {
if (textField.text !== xhttpPath) xhttpPath = textField.text
var v = textField.text.trim()
if (v !== xhttpPath) xhttpPath = v
else if (textField.text !== v) textField.text = v
pathField.errorText = XrayConfigModel.isValidPath(v) ? "" : qsTr("Path must start with \"/\"")
root.editDirty = false
}
}
DropDownType {
id: headersDropDown
fitContent: true
Layout.fillWidth: true
Layout.topMargin: 8
Layout.leftMargin: 16
@@ -307,6 +368,7 @@ PageType {
DropDownType {
id: uplinkMethodDropDown
fitContent: true
Layout.fillWidth: true
Layout.topMargin: 8
Layout.leftMargin: 16
@@ -386,6 +448,7 @@ PageType {
DropDownType {
id: sessionPlacementDropDown
fitContent: true
Layout.fillWidth: true
Layout.topMargin: 8
Layout.leftMargin: 16
@@ -429,6 +492,7 @@ PageType {
DropDownType {
id: sessionKeyDropDown
fitContent: true
Layout.fillWidth: true
Layout.topMargin: 8
Layout.leftMargin: 16
@@ -472,6 +536,7 @@ PageType {
DropDownType {
id: seqPlacementDropDown
fitContent: true
Layout.fillWidth: true
Layout.topMargin: 8
Layout.leftMargin: 16
@@ -520,13 +585,19 @@ PageType {
Layout.topMargin: 8
headerText: qsTr("SeqKey")
textField.text: xhttpSeqKey
textField.validator: RegularExpressionValidator { regularExpression: /^[A-Za-z0-9_-]*$/ }
textField.onTextEdited: root.editDirty = (textField.text !== xhttpSeqKey)
textField.onEditingFinished: {
if (textField.text !== xhttpSeqKey) xhttpSeqKey = textField.text
var v = textField.text.trim()
if (v !== xhttpSeqKey) xhttpSeqKey = v
else if (textField.text !== v) textField.text = v
root.editDirty = false
}
}
DropDownType {
id: uplinkDataPlacementDropDown
fitContent: true
Layout.fillWidth: true
Layout.topMargin: 8
Layout.leftMargin: 16
@@ -575,8 +646,13 @@ PageType {
Layout.topMargin: 8
headerText: qsTr("UplinkDataKey")
textField.text: xhttpUplinkDataKey
textField.validator: RegularExpressionValidator { regularExpression: /^[A-Za-z0-9_-]*$/ }
textField.onTextEdited: root.editDirty = (textField.text !== xhttpUplinkDataKey)
textField.onEditingFinished: {
if (textField.text !== xhttpUplinkDataKey) xhttpUplinkDataKey = textField.text
var v = textField.text.trim()
if (v !== xhttpUplinkDataKey) xhttpUplinkDataKey = v
else if (textField.text !== v) textField.text = v
root.editDirty = false
}
}
@@ -597,12 +673,16 @@ PageType {
Layout.rightMargin: 16
Layout.topMargin: 8
headerText: qsTr("UplinkChunkSize")
subtitleText: qsTr("≥ 0 (0 = off)")
textField.text: xhttpUplinkChunkSize
textField.validator: IntValidator {
bottom: 0
}
textField.maximumLength: 10
textField.validator: RegularExpressionValidator { regularExpression: /^\d*$/ }
textField.onTextEdited: root.editDirty = (textField.text !== xhttpUplinkChunkSize)
textField.onEditingFinished: {
if (textField.text !== xhttpUplinkChunkSize) xhttpUplinkChunkSize = textField.text
var v = root.clampInt(textField.text, 0, 2147483647)
if (v !== xhttpUplinkChunkSize) xhttpUplinkChunkSize = v
else if (textField.text !== v) textField.text = v
root.editDirty = false
}
}
@@ -612,9 +692,16 @@ PageType {
Layout.rightMargin: 16
Layout.topMargin: 8
headerText: qsTr("scMaxBufferedPosts")
subtitleText: qsTr("≥ 0")
textField.text: xhttpScMaxBufferedPosts
textField.maximumLength: 10
textField.validator: RegularExpressionValidator { regularExpression: /^\d*$/ }
textField.onTextEdited: root.editDirty = (textField.text !== xhttpScMaxBufferedPosts)
textField.onEditingFinished: {
if (textField.text !== xhttpScMaxBufferedPosts) xhttpScMaxBufferedPosts = textField.text
var v = root.clampInt(textField.text, 0, 2147483647)
if (v !== xhttpScMaxBufferedPosts) xhttpScMaxBufferedPosts = v
else if (textField.text !== v) textField.text = v
root.editDirty = false
}
}
@@ -633,8 +720,9 @@ PageType {
Layout.rightMargin: 16
minValue: xhttpScMaxEachPostBytesMin
maxValue: xhttpScMaxEachPostBytesMax
onMinChanged: xhttpScMaxEachPostBytesMin = val
onMaxChanged: xhttpScMaxEachPostBytesMax = val
onMinChanged: function(val) { xhttpScMaxEachPostBytesMin = val; root.editDirty = false }
onMaxChanged: function(val) { xhttpScMaxEachPostBytesMax = val; root.editDirty = false }
onEdited: root.editDirty = true
}
CaptionTextType {
@@ -652,8 +740,9 @@ PageType {
Layout.rightMargin: 16
minValue: xhttpScStreamUpServerSecsMin
maxValue: xhttpScStreamUpServerSecsMax
onMinChanged: xhttpScStreamUpServerSecsMin = val
onMaxChanged: xhttpScStreamUpServerSecsMax = val
onMinChanged: function(val) { xhttpScStreamUpServerSecsMin = val; root.editDirty = false }
onMaxChanged: function(val) { xhttpScStreamUpServerSecsMax = val; root.editDirty = false }
onEdited: root.editDirty = true
}
CaptionTextType {
@@ -671,8 +760,9 @@ PageType {
Layout.rightMargin: 16
minValue: xhttpScMinPostsIntervalMsMin
maxValue: xhttpScMinPostsIntervalMsMax
onMinChanged: xhttpScMinPostsIntervalMsMin = val
onMaxChanged: xhttpScMinPostsIntervalMsMax = val
onMinChanged: function(val) { xhttpScMinPostsIntervalMsMin = val; root.editDirty = false }
onMaxChanged: function(val) { xhttpScMinPostsIntervalMsMax = val; root.editDirty = false }
onEdited: root.editDirty = true
}
// ── Padding and multiplexing ──────────────────────────
@@ -728,10 +818,15 @@ PageType {
anchors.rightMargin: 16
anchors.bottomMargin: 16 + PageController.safeAreaBottomMargin
visible: listView.enabled && XrayConfigModel.hasUnsavedChanges
visible: listView.enabled && (XrayConfigModel.hasUnsavedChanges || root.editDirty)
enabled: visible
text: qsTr("Save")
clickedFunc: function () {
var errs = XrayConfigModel.validationErrors()
if (errs.length > 0) {
PageController.showErrorMessage(errs.join("\n"))
return
}
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")

View File

@@ -15,6 +15,8 @@ import "../Components"
PageType {
id: root
property bool editDirty: false
BackButtonType {
id: backButton
anchors.top: parent.top
@@ -61,8 +63,9 @@ PageType {
Layout.rightMargin: 16
minValue: xPaddingBytesMin
maxValue: xPaddingBytesMax
onMinChanged: xPaddingBytesMin = val
onMaxChanged: xPaddingBytesMax = val
onMinChanged: function(val) { xPaddingBytesMin = val; root.editDirty = false }
onMaxChanged: function(val) { xPaddingBytesMax = val; root.editDirty = false }
onEdited: root.editDirty = true
}
Item {
@@ -81,7 +84,7 @@ PageType {
anchors.rightMargin: 16
anchors.bottomMargin: 16 + PageController.safeAreaBottomMargin
visible: listView.enabled && XrayConfigModel.hasUnsavedChanges
visible: listView.enabled && (XrayConfigModel.hasUnsavedChanges || root.editDirty)
enabled: visible
text: qsTr("Save")
clickedFunc: function () {

View File

@@ -15,6 +15,8 @@ import "../Components"
PageType {
id: root
property bool editDirty: false
BackButtonType {
id: backButton
anchors.top: parent.top
@@ -78,8 +80,13 @@ PageType {
Layout.topMargin: 16
headerText: qsTr("xPaddingKey")
textField.text: xPaddingKey
textField.validator: RegularExpressionValidator { regularExpression: /^[A-Za-z0-9_-]*$/ }
textField.onTextEdited: root.editDirty = (textField.text !== xPaddingKey)
textField.onEditingFinished: {
if (textField.text !== xPaddingKey) xPaddingKey = textField.text
var v = textField.text.trim()
if (v !== xPaddingKey) xPaddingKey = v
else if (textField.text !== v) textField.text = v
root.editDirty = false
}
}
@@ -90,13 +97,19 @@ PageType {
Layout.topMargin: 8
headerText: qsTr("xPaddingHeader")
textField.text: xPaddingHeader
textField.validator: RegularExpressionValidator { regularExpression: /^[A-Za-z0-9_-]*$/ }
textField.onTextEdited: root.editDirty = (textField.text !== xPaddingHeader)
textField.onEditingFinished: {
if (textField.text !== xPaddingHeader) xPaddingHeader = textField.text
var v = textField.text.trim()
if (v !== xPaddingHeader) xPaddingHeader = v
else if (textField.text !== v) textField.text = v
root.editDirty = false
}
}
DropDownType {
id: placementDropDown
fitContent: true
Layout.fillWidth: true
Layout.topMargin: 8
Layout.leftMargin: 16
@@ -140,6 +153,7 @@ PageType {
DropDownType {
id: methodDropDown
fitContent: true
Layout.fillWidth: true
Layout.topMargin: 8
Layout.leftMargin: 16
@@ -197,7 +211,7 @@ PageType {
anchors.rightMargin: 16
anchors.bottomMargin: 16 + PageController.safeAreaBottomMargin
visible: listView.enabled && XrayConfigModel.hasUnsavedChanges
visible: listView.enabled && (XrayConfigModel.hasUnsavedChanges || root.editDirty)
enabled: visible
text: qsTr("Save")
clickedFunc: function () {

View File

@@ -15,6 +15,21 @@ import "../Components"
PageType {
id: root
property bool editDirty: false
function clampSigned(text) {
if (text === "" || text === "-")
return ""
var n = parseInt(text, 10)
if (isNaN(n))
return ""
if (n > 2147483647)
n = 2147483647
if (n < -2147483648)
n = -2147483648
return String(n)
}
BackButtonType {
id: backButton
anchors.top: parent.top
@@ -78,8 +93,9 @@ PageType {
Layout.rightMargin: 16
minValue: xmuxMaxConcurrencyMin
maxValue: xmuxMaxConcurrencyMax
onMinChanged: xmuxMaxConcurrencyMin = val
onMaxChanged: xmuxMaxConcurrencyMax = val
onMinChanged: function(val) { xmuxMaxConcurrencyMin = val; root.editDirty = false }
onMaxChanged: function(val) { xmuxMaxConcurrencyMax = val; root.editDirty = false }
onEdited: root.editDirty = true
}
// maxConnections
@@ -98,8 +114,9 @@ PageType {
Layout.rightMargin: 16
minValue: xmuxMaxConnectionsMin
maxValue: xmuxMaxConnectionsMax
onMinChanged: xmuxMaxConnectionsMin = val
onMaxChanged: xmuxMaxConnectionsMax = val
onMinChanged: function(val) { xmuxMaxConnectionsMin = val; root.editDirty = false }
onMaxChanged: function(val) { xmuxMaxConnectionsMax = val; root.editDirty = false }
onEdited: root.editDirty = true
}
// cMaxReuseTimes
@@ -118,8 +135,9 @@ PageType {
Layout.rightMargin: 16
minValue: xmuxCMaxReuseTimesMin
maxValue: xmuxCMaxReuseTimesMax
onMinChanged: xmuxCMaxReuseTimesMin = val
onMaxChanged: xmuxCMaxReuseTimesMax = val
onMinChanged: function(val) { xmuxCMaxReuseTimesMin = val; root.editDirty = false }
onMaxChanged: function(val) { xmuxCMaxReuseTimesMax = val; root.editDirty = false }
onEdited: root.editDirty = true
}
// hMaxRequestTimes
@@ -138,8 +156,9 @@ PageType {
Layout.rightMargin: 16
minValue: xmuxHMaxRequestTimesMin
maxValue: xmuxHMaxRequestTimesMax
onMinChanged: xmuxHMaxRequestTimesMin = val
onMaxChanged: xmuxHMaxRequestTimesMax = val
onMinChanged: function(val) { xmuxHMaxRequestTimesMin = val; root.editDirty = false }
onMaxChanged: function(val) { xmuxHMaxRequestTimesMax = val; root.editDirty = false }
onEdited: root.editDirty = true
}
// hMaxReusableSecs
@@ -158,8 +177,9 @@ PageType {
Layout.rightMargin: 16
minValue: xmuxHMaxReusableSecsMin
maxValue: xmuxHMaxReusableSecsMax
onMinChanged: xmuxHMaxReusableSecsMin = val
onMaxChanged: xmuxHMaxReusableSecsMax = val
onMinChanged: function(val) { xmuxHMaxReusableSecsMin = val; root.editDirty = false }
onMaxChanged: function(val) { xmuxHMaxReusableSecsMax = val; root.editDirty = false }
onEdited: root.editDirty = true
}
TextFieldWithHeaderType {
@@ -168,12 +188,16 @@ PageType {
Layout.rightMargin: 16
Layout.topMargin: 16
headerText: qsTr("hKeepAlivePeriod")
subtitleText: qsTr("Integer, may be negative")
textField.text: xmuxHKeepAlivePeriod
textField.validator: IntValidator {
bottom: 0
}
textField.maximumLength: 11
textField.validator: RegularExpressionValidator { regularExpression: /^-?\d*$/ }
textField.onTextEdited: root.editDirty = (textField.text !== xmuxHKeepAlivePeriod)
textField.onEditingFinished: {
if (textField.text !== xmuxHKeepAlivePeriod) xmuxHKeepAlivePeriod = textField.text
var v = root.clampSigned(textField.text)
if (v !== xmuxHKeepAlivePeriod) xmuxHKeepAlivePeriod = v
else if (textField.text !== v) textField.text = v
root.editDirty = false
}
}
}
@@ -194,7 +218,7 @@ PageType {
anchors.rightMargin: 16
anchors.bottomMargin: 16 + PageController.safeAreaBottomMargin
visible: listView.enabled && XrayConfigModel.hasUnsavedChanges
visible: listView.enabled && (XrayConfigModel.hasUnsavedChanges || root.editDirty)
enabled: visible
text: qsTr("Save")
clickedFunc: function () {

View File

@@ -91,6 +91,7 @@ PageType {
}
function onExportErrorOccurred(error) {
PageController.showBusyIndicator(false)
PageController.showErrorMessage(error)
}
}

View File

@@ -53,7 +53,7 @@ Window {
}
}
visible: true
visible: !GC.isDesktop()
width: GC.screenWidth
height: GC.screenHeight
minimumWidth: GC.isDesktop() ? 360 : 0