From e2108a28ea655c045236457433ce73405aa958e0 Mon Sep 17 00:00:00 2001 From: dranik Date: Wed, 13 May 2026 17:41:26 +0300 Subject: [PATCH 1/8] Feat: Add deep link --- client/amneziaApplication.cpp | 119 ++++++++++++++++-- client/amneziaApplication.h | 10 ++ .../src/org/amnezia/vpn/AmneziaActivity.kt | 5 +- .../org/amnezia/vpn/ImportConfigActivity.kt | 1 + client/cmake/ios.cmake | 2 + client/core/controllers/coreController.cpp | 16 +++ client/core/controllers/coreController.h | 2 + .../core/controllers/coreSignalHandlers.cpp | 8 +- .../selfhosted/importController.cpp | 1 + client/ios/app/Info.plist.in | 13 ++ client/macos/app/Info.plist.in | 13 ++ client/main.cpp | 43 +++++-- client/platforms/ios/AmneziaOpenUrlImport.h | 14 +++ client/platforms/ios/AmneziaOpenUrlImport.mm | 51 ++++++++ .../ios/AmneziaSceneDelegateHooks.mm | 31 +---- client/platforms/ios/QtAppDelegate.mm | 30 ++--- client/platforms/ios/ios_controller.mm | 2 +- .../api/subscriptionUiController.cpp | 4 + .../qml/Pages2/PageSetupWizardViewConfig.qml | 4 +- 19 files changed, 292 insertions(+), 77 deletions(-) create mode 100644 client/platforms/ios/AmneziaOpenUrlImport.h create mode 100644 client/platforms/ios/AmneziaOpenUrlImport.mm diff --git a/client/amneziaApplication.cpp b/client/amneziaApplication.cpp index 008cc345d..c0f5053b5 100644 --- a/client/amneziaApplication.cpp +++ b/client/amneziaApplication.cpp @@ -15,6 +15,9 @@ #include #include #include +#include +#include +#include #include #include @@ -81,6 +84,44 @@ AmneziaApplication::~AmneziaApplication() } } +#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) +namespace { +QString vpnUrlFromArguments(const QStringList &args) +{ + for (const QString &arg : args) { + const QString t = arg.trimmed(); + if (t.startsWith(QLatin1String("vpn://"), Qt::CaseInsensitive)) { + return t; + } + } + return {}; +} +} // namespace +#endif + +#if defined(Q_OS_WIN) && !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) +namespace { +void registerWindowsVpnUrlSchemeIfNeeded() +{ + QSettings flag(ORGANIZATION_NAME, APPLICATION_NAME); + if (flag.value(QStringLiteral("protocolHandler/vpnRegistered")).toBool()) { + return; + } + + const QString exe = QDir::toNativeSeparators(QCoreApplication::applicationFilePath()); + + QSettings vpnKey(QStringLiteral("HKEY_CURRENT_USER\\Software\\Classes\\vpn"), QSettings::NativeFormat); + vpnKey.setValue(QStringLiteral("."), QStringLiteral("URL:AmneziaVPN")); + vpnKey.setValue(QStringLiteral("URL Protocol"), QString()); + + QSettings cmdKey(QStringLiteral("HKEY_CURRENT_USER\\Software\\Classes\\vpn\\shell\\open\\command"), QSettings::NativeFormat); + cmdKey.setValue(QStringLiteral("."), QStringLiteral("\"%1\" \"%2\"").arg(exe, QStringLiteral("%1"))); + + flag.setValue(QStringLiteral("protocolHandler/vpnRegistered"), true); +} +} // namespace +#endif + #ifdef Q_OS_ANDROID namespace { static void clearQtCaches() @@ -190,6 +231,18 @@ void AmneziaApplication::init() }); } } + +#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) +# ifdef Q_OS_WIN + registerWindowsVpnUrlSchemeIfNeeded(); +# endif + if (!m_parser.isSet(m_optImport)) { + const QString vpnArg = vpnUrlFromArguments(QCoreApplication::arguments()); + if (!vpnArg.isEmpty()) { + QTimer::singleShot(0, this, [this, vpnArg]() { deliverVpnDeepLink(vpnArg); }); + } + } +#endif } void AmneziaApplication::registerTypes() @@ -250,23 +303,73 @@ bool AmneziaApplication::parseCommands() } #if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) && !defined(MACOS_NE) -void AmneziaApplication::startLocalServer() { - const QString serverName("AmneziaVPNInstance"); +void AmneziaApplication::startLocalServer() +{ + const QString serverName(QStringLiteral("AmneziaVPNInstance")); QLocalServer::removeServer(serverName); QLocalServer *server = new QLocalServer(this); - server->listen(serverName); + if (!server->listen(serverName)) { + qWarning() << "QLocalServer::listen failed:" << server->errorString(); + } - QObject::connect(server, &QLocalServer::newConnection, this, [server, this]() { - if (server) { - QLocalSocket *clientConnection = server->nextPendingConnection(); - clientConnection->deleteLater(); + QObject::connect(server, &QLocalServer::newConnection, this, [this, server]() { + QLocalSocket *sock = server->nextPendingConnection(); + if (!sock) { + return; } - emit m_coreController->pageController()->raiseMainWindow(); //TODO + + QString vpnPayload; + if (sock->waitForReadyRead(3000)) { + const QByteArray buf = sock->readAll(); + static const QByteArray prefix = QByteArrayLiteral("VPN\n"); + if (buf.startsWith(prefix)) { + vpnPayload = QString::fromUtf8(buf.mid(prefix.size())).trimmed(); + } + } + sock->deleteLater(); + + if (!vpnPayload.isEmpty()) { + QTimer::singleShot(0, this, [this, vpnPayload]() { deliverVpnDeepLink(vpnPayload); }); + } + + QTimer::singleShot(0, this, [this]() { + if (m_coreController && m_coreController->pageController()) { + emit m_coreController->pageController()->raiseMainWindow(); + } + }); }); } #endif +#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) +void AmneziaApplication::deliverVpnDeepLink(const QString &payload) +{ + if (!m_coreController) { + return; + } + const QString trimmed = payload.trimmed(); + if (trimmed.isEmpty()) { + return; + } + m_coreController->openVpnKeyImportPreview(trimmed); +} + +bool AmneziaApplication::event(QEvent *event) +{ + if (event->type() == QEvent::FileOpen) { + auto *foe = static_cast(event); + const QUrl url = foe->url(); + if (url.scheme().compare(QLatin1String("vpn"), Qt::CaseInsensitive) == 0) { + const QString payload = url.toString(QUrl::PrettyDecoded); + QTimer::singleShot(0, this, [this, payload]() { deliverVpnDeepLink(payload); }); + return true; + } + } + return AMNEZIA_BASE_CLASS::event(event); +} +#endif + bool AmneziaApplication::eventFilter(QObject *watched, QEvent *event) { if (event->type() == QEvent::Close) { diff --git a/client/amneziaApplication.h b/client/amneziaApplication.h index 33b262c7f..9220b665f 100644 --- a/client/amneziaApplication.h +++ b/client/amneziaApplication.h @@ -12,6 +12,9 @@ #include #endif #include +#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) +#include +#endif #include "core/controllers/coreController.h" #include "secureQSettings.h" @@ -67,12 +70,19 @@ private: QCommandLineOption m_optConnect; QCommandLineOption m_optImport; +#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) + void deliverVpnDeepLink(const QString &payload); +#endif + QSharedPointer m_vpnConnection; QThread m_vpnConnectionThread; QNetworkAccessManager *m_nam; protected: bool eventFilter(QObject *watched, QEvent *event) override; +#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) + bool event(QEvent *event) override; +#endif }; #endif // AMNEZIA_APPLICATION_H diff --git a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt index dca70ee5c..b993344f9 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt @@ -243,7 +243,10 @@ class AmneziaActivity : QtActivity() { override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) Log.v(TAG, "onNewIntent: $intent") - intent?.let(::processIntent) + intent?.let { + setIntent(it) + processIntent(it) + } } private fun processIntent(intent: Intent) { diff --git a/client/android/src/org/amnezia/vpn/ImportConfigActivity.kt b/client/android/src/org/amnezia/vpn/ImportConfigActivity.kt index 49823a364..75f8f23c8 100644 --- a/client/android/src/org/amnezia/vpn/ImportConfigActivity.kt +++ b/client/android/src/org/amnezia/vpn/ImportConfigActivity.kt @@ -36,6 +36,7 @@ class ImportConfigActivity : ComponentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) Log.v(TAG, "onNewIntent: $intent") + setIntent(intent) intent.let(::readConfig) } diff --git a/client/cmake/ios.cmake b/client/cmake/ios.cmake index 86df23d25..11bb32d40 100644 --- a/client/cmake/ios.cmake +++ b/client/cmake/ios.cmake @@ -34,6 +34,7 @@ set(HEADERS ${HEADERS} ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate.h ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/StoreKitController.h ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate-C-Interface.h + ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/AmneziaOpenUrlImport.h ) set_source_files_properties(${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller.h PROPERTIES OBJECTIVE_CPP_HEADER TRUE) @@ -47,6 +48,7 @@ set(SOURCES ${SOURCES} ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate.mm ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/StoreKitController.mm ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/AmneziaSceneDelegateHooks.mm + ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/AmneziaOpenUrlImport.mm ) diff --git a/client/core/controllers/coreController.cpp b/client/core/controllers/coreController.cpp index 227850b6d..cb250df18 100644 --- a/client/core/controllers/coreController.cpp +++ b/client/core/controllers/coreController.cpp @@ -340,3 +340,19 @@ void CoreController::importConfigFromData(const QString &data) m_importController->importConfig(); } } + +void CoreController::openVpnKeyImportPreview(const QString &data) +{ + if (!m_importController || data.isEmpty()) { + return; + } + + emit m_pageController->goToPageHome(); + if (!m_importController->extractConfigFromData(data)) { + return; + } + emit m_pageController->goToPageViewConfig(); +#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) + emit m_pageController->raiseMainWindow(); +#endif +} diff --git a/client/core/controllers/coreController.h b/client/core/controllers/coreController.h index 0d77c5167..c49f088fc 100644 --- a/client/core/controllers/coreController.h +++ b/client/core/controllers/coreController.h @@ -116,6 +116,8 @@ public: void openConnectionByIndex(int serverIndex); void importConfigFromData(const QString &data); + /** Navigate home, parse key, open preview (same path as mobile deep link / share). */ + void openVpnKeyImportPreview(const QString &data); void updateTranslator(const QLocale &locale); signals: diff --git a/client/core/controllers/coreSignalHandlers.cpp b/client/core/controllers/coreSignalHandlers.cpp index 449a8af1d..2296a74e1 100644 --- a/client/core/controllers/coreSignalHandlers.cpp +++ b/client/core/controllers/coreSignalHandlers.cpp @@ -361,10 +361,8 @@ void CoreSignalHandlers::initAndroidConnectionHandler() m_coreController->m_connectionController->restoreConnection(); }); connect(AndroidController::instance(), &AndroidController::importConfigFromOutside, this, [this](QString data) { - emit m_coreController->m_pageController->goToPageHome(); - m_coreController->m_importController->extractConfigFromData(data); + m_coreController->openVpnKeyImportPreview(data); data.clear(); - emit m_coreController->m_pageController->goToPageViewConfig(); }); #endif } @@ -373,9 +371,7 @@ void CoreSignalHandlers::initIosImportHandler() { #ifdef Q_OS_IOS connect(IosController::Instance(), &IosController::importConfigFromOutside, this, [this](QString data) { - emit m_coreController->m_pageController->goToPageHome(); - m_coreController->m_importController->extractConfigFromData(data); - emit m_coreController->m_pageController->goToPageViewConfig(); + m_coreController->openVpnKeyImportPreview(data); }); connect(IosController::Instance(), &IosController::importBackupFromOutside, this, [this](QString filePath) { emit m_coreController->m_pageController->goToPageHome(); diff --git a/client/core/controllers/selfhosted/importController.cpp b/client/core/controllers/selfhosted/importController.cpp index c1c7503eb..3297a86ec 100644 --- a/client/core/controllers/selfhosted/importController.cpp +++ b/client/core/controllers/selfhosted/importController.cpp @@ -387,6 +387,7 @@ void ImportController::importConfig(const QJsonObject &config) } else if (config.contains(configKey::configVersion)) { quint16 crc = qChecksum(QJsonDocument(config).toJson()); if (m_serversRepository->hasServerWithCrc(crc)) { + // Same API key / subscription blob already present (incl. deep link re-import). emit importErrorOccurred(ErrorCode::ApiConfigAlreadyAdded, true); } else { QJsonObject configWithCrc = config; diff --git a/client/ios/app/Info.plist.in b/client/ios/app/Info.plist.in index 6165daf32..decca2ca5 100644 --- a/client/ios/app/Info.plist.in +++ b/client/ios/app/Info.plist.in @@ -86,6 +86,19 @@ CFBundleIcons~ipad + CFBundleURLTypes + + + CFBundleURLName + org.amnezia.AmneziaVPN.vpn-deeplink + CFBundleURLSchemes + + vpn + + CFBundleTypeRole + Editor + + UTImportedTypeDeclarations diff --git a/client/macos/app/Info.plist.in b/client/macos/app/Info.plist.in index 1c9ad48eb..dccbafa60 100644 --- a/client/macos/app/Info.plist.in +++ b/client/macos/app/Info.plist.in @@ -46,6 +46,19 @@ CFBundleIcons + CFBundleURLTypes + + + CFBundleURLName + org.amnezia.AmneziaVPN.vpn-deeplink + CFBundleURLSchemes + + vpn + + CFBundleTypeRole + Editor + + UTImportedTypeDeclarations diff --git a/client/main.cpp b/client/main.cpp index 621692bd7..3239b01b9 100644 --- a/client/main.cpp +++ b/client/main.cpp @@ -1,3 +1,5 @@ +#include +#include #include #include @@ -6,7 +8,7 @@ #include "core/utils/migrations.h" #include "version.h" -#include +#include #ifdef Q_OS_WIN #include "Windows.h" @@ -17,18 +19,41 @@ #endif #if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) && !defined(MACOS_NE) -bool isAnotherInstanceRunning() +namespace { +QString findVpnDeepLinkInArguments(const QStringList &args) +{ + for (const QString &arg : args) { + const QString t = arg.trimmed(); + if (t.startsWith(QLatin1String("vpn://"), Qt::CaseInsensitive)) { + return t; + } + } + return {}; +} + +bool notifyRunningInstanceOrExit(AmneziaApplication &app, const QString &vpnPayload) { QLocalSocket socket; - socket.connectToServer("AmneziaVPNInstance"); - if (socket.waitForConnected(500)) { - qWarning() << "AmneziaVPN is already running"; - return true; + socket.connectToServer(QStringLiteral("AmneziaVPNInstance")); + if (!socket.waitForConnected(500)) { + return false; } - return false; + qWarning() << "AmneziaVPN is already running"; + if (!vpnPayload.isEmpty()) { + const QByteArray msg = QByteArrayLiteral("VPN\n") + vpnPayload.toUtf8() + '\n'; + socket.write(msg); + socket.waitForBytesWritten(3000); + } + socket.flush(); + QTimer::singleShot(1000, &app, [&app]() { app.quit(); }); + return true; } +} // namespace #endif +// Desktop (non-NE): single-instance IPC forwards vpn:// to the running process. MACOS_NE has no IPC here; +// deep links use argv / QFileOpenEvent after registration in the app bundle Info.plist. + int main(int argc, char *argv[]) { Migrations migrationsManager; @@ -48,8 +73,8 @@ int main(int argc, char *argv[]) OsSignalHandler::setup(); #if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) && !defined(MACOS_NE) - if (isAnotherInstanceRunning()) { - QTimer::singleShot(1000, &app, [&]() { app.quit(); }); + const QString vpnFromArgv = findVpnDeepLinkInArguments(QCoreApplication::arguments()); + if (notifyRunningInstanceOrExit(app, vpnFromArgv)) { return app.exec(); } app.startLocalServer(); diff --git a/client/platforms/ios/AmneziaOpenUrlImport.h b/client/platforms/ios/AmneziaOpenUrlImport.h new file mode 100644 index 000000000..dcf6aeb83 --- /dev/null +++ b/client/platforms/ios/AmneziaOpenUrlImport.h @@ -0,0 +1,14 @@ +#pragma once + +#import + +#ifdef __cplusplus +extern "C" { +#endif + +/** Handles custom scheme vpn:// (full absoluteString) and file URLs for config / backup import. */ +void AmneziaHandleOpenUrl(NSURL *url); + +#ifdef __cplusplus +} +#endif diff --git a/client/platforms/ios/AmneziaOpenUrlImport.mm b/client/platforms/ios/AmneziaOpenUrlImport.mm new file mode 100644 index 000000000..3f661ca9b --- /dev/null +++ b/client/platforms/ios/AmneziaOpenUrlImport.mm @@ -0,0 +1,51 @@ +#import "AmneziaOpenUrlImport.h" + +#include "ios_controller.h" + +#include +#include + +#include + +void AmneziaHandleOpenUrl(NSURL *url) +{ + if (!url) { + return; + } + + NSString *scheme = url.scheme ? [url.scheme lowercaseString] : @""; + if ([scheme isEqualToString:@"vpn"]) { + NSString *absolute = url.absoluteString; + if (absolute.length == 0) { + return; + } + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + IosController::Instance()->importConfigFromOutside(QString::fromUtf8([absolute UTF8String])); + }); + return; + } + + if (!url.isFileURL) { + return; + } + + QString filePath = QString::fromUtf8([url.path UTF8String]); + if (filePath.isEmpty()) { + return; + } + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + if (filePath.contains(QLatin1String("backup"))) { + IosController::Instance()->importBackupFromOutside(filePath); + return; + } + + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly)) { + return; + } + + const QByteArray data = file.readAll(); + IosController::Instance()->importConfigFromOutside(QString::fromUtf8(data)); + }); +} diff --git a/client/platforms/ios/AmneziaSceneDelegateHooks.mm b/client/platforms/ios/AmneziaSceneDelegateHooks.mm index 60cbbe0fa..c1d7111d3 100644 --- a/client/platforms/ios/AmneziaSceneDelegateHooks.mm +++ b/client/platforms/ios/AmneziaSceneDelegateHooks.mm @@ -1,12 +1,7 @@ #import #import -#include -#include -#include -#include - -#include "ios_controller.h" +#import "AmneziaOpenUrlImport.h" using SceneOpenURLContexts = void (*)(id, SEL, UIScene *, NSSet *); @@ -14,29 +9,7 @@ static SceneOpenURLContexts g_originalSceneOpenURLContexts = nullptr; static void amnezia_handleURL(NSURL *url) { - if (!url || !url.isFileURL) { - return; - } - - QString filePath(url.path.UTF8String); - if (filePath.isEmpty()) { - return; - } - - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - if (filePath.contains("backup")) { - IosController::Instance()->importBackupFromOutside(filePath); - return; - } - - QFile file(filePath); - if (!file.open(QIODevice::ReadOnly)) { - return; - } - - const QByteArray data = file.readAll(); - IosController::Instance()->importConfigFromOutside(QString::fromUtf8(data)); - }); + AmneziaHandleOpenUrl(url); } static void amnezia_scene_openURLContexts(id self, SEL _cmd, UIScene *scene, NSSet *contexts) diff --git a/client/platforms/ios/QtAppDelegate.mm b/client/platforms/ios/QtAppDelegate.mm index 64ee9425f..301736f1e 100644 --- a/client/platforms/ios/QtAppDelegate.mm +++ b/client/platforms/ios/QtAppDelegate.mm @@ -1,8 +1,5 @@ #import "QtAppDelegate.h" -#import "ios_controller.h" - -#include - +#import "AmneziaOpenUrlImport.h" @implementation QIOSApplicationDelegate (AmneziaVPNDelegate) #if !MACOS_NE @@ -11,6 +8,10 @@ [application setMinimumBackgroundFetchInterval: UIApplicationBackgroundFetchIntervalMinimum]; // Override point for customization after application launch. NSLog(@"Application didFinishLaunchingWithOptions"); + NSURL *launchUrl = launchOptions[UIApplicationLaunchOptionsURLKey]; + if (launchUrl) { + AmneziaHandleOpenUrl(launchUrl); + } return YES; } @@ -35,24 +36,11 @@ - (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary *)options { - if (url.fileURL) { - QString filePath(url.path.UTF8String); - if (filePath.isEmpty()) return NO; - - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{ - NSLog(@"Application openURL: %@", url); - - if (filePath.contains("backup")) { - IosController::Instance()->importBackupFromOutside(filePath); - } else { - QFile file(filePath); - bool isOpenFile = file.open(QIODevice::ReadOnly); - QByteArray data = file.readAll(); - - IosController::Instance()->importConfigFromOutside(QString(data)); - } - }); + NSLog(@"Application openURL: %@", url); + AmneziaHandleOpenUrl(url); + NSString *scheme = url.scheme ? [url.scheme lowercaseString] : @""; + if ([scheme isEqualToString:@"vpn"] || url.fileURL) { return YES; } return NO; diff --git a/client/platforms/ios/ios_controller.mm b/client/platforms/ios/ios_controller.mm index 73aa02484..150ae7261 100644 --- a/client/platforms/ios/ios_controller.mm +++ b/client/platforms/ios/ios_controller.mm @@ -220,7 +220,7 @@ bool IosController::connectVpn(amnezia::Proto proto, const QJsonObject& configur m_rawConfig = configuration; m_serverAddress = configuration.value(configKey::hostName).toString().toNSString(); - const QString serverDescription = configuration.value(config_key::description).toString().trimmed(); + const QString serverDescription = configuration.value(configKey::description).toString().trimmed(); QString tunnelName; if (serverDescription.isEmpty()) { tunnelName = ProtocolUtils::protoToString(proto); diff --git a/client/ui/controllers/api/subscriptionUiController.cpp b/client/ui/controllers/api/subscriptionUiController.cpp index 75d96553e..c1276f432 100644 --- a/client/ui/controllers/api/subscriptionUiController.cpp +++ b/client/ui/controllers/api/subscriptionUiController.cpp @@ -8,6 +8,10 @@ #include "core/utils/api/apiUtils.h" #include "core/utils/qrCodeUtils.h" #include "ui/controllers/systemController.h" +#ifdef Q_OS_IOS +#include "platforms/ios/ios_controller.h" +#include +#endif #include "version.h" #include "core/models/serverConfig.h" #include diff --git a/client/ui/qml/Pages2/PageSetupWizardViewConfig.qml b/client/ui/qml/Pages2/PageSetupWizardViewConfig.qml index 778d5bfa0..4711d47d4 100644 --- a/client/ui/qml/Pages2/PageSetupWizardViewConfig.qml +++ b/client/ui/qml/Pages2/PageSetupWizardViewConfig.qml @@ -72,7 +72,7 @@ PageType { Layout.leftMargin: 16 Layout.rightMargin: 16 - headerText: qsTr("New connection") + headerText: qsTr("Add this connection?") } RowLayout { @@ -204,7 +204,7 @@ PageType { Layout.rightMargin: 16 Layout.leftMargin: 16 - text: qsTr("Connect") + text: qsTr("Add") clickedFunc: function() { const headerItem = listView.headerItem; if (!headerItem) { From 2850f05be00c54124264c4fb69e8d3f056eec880 Mon Sep 17 00:00:00 2001 From: dranik Date: Thu, 14 May 2026 13:41:59 +0300 Subject: [PATCH 2/8] add lsregister deep --- deploy/data/linux/AmneziaVPN.desktop | 3 ++- deploy/data/linux/post_install.sh | 4 ++++ deploy/data/macos/post_install.sh | 9 +++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/deploy/data/linux/AmneziaVPN.desktop b/deploy/data/linux/AmneziaVPN.desktop index 03ab570c3..057675ce7 100755 --- a/deploy/data/linux/AmneziaVPN.desktop +++ b/deploy/data/linux/AmneziaVPN.desktop @@ -4,7 +4,8 @@ Type=Application Name=AmneziaVPN Version=1.0 Comment=Client of your self-hosted VPN -Exec=AmneziaVPN +Exec=AmneziaVPN %u Icon=/usr/share/pixmaps/AmneziaVPN.png Categories=Network;Qt;Security; +MimeType=x-scheme-handler/vpn; Terminal=false diff --git a/deploy/data/linux/post_install.sh b/deploy/data/linux/post_install.sh index baf02b719..f03371334 100755 --- a/deploy/data/linux/post_install.sh +++ b/deploy/data/linux/post_install.sh @@ -44,6 +44,10 @@ sudo cp $APP_PATH/$APP_NAME.desktop /usr/share/applications/ >> $LOG_FILE sudo cp $APP_PATH/$APP_NAME.png /usr/share/pixmaps/ >> $LOG_FILE sudo chmod 555 /usr/share/applications/$APP_NAME.desktop >> $LOG_FILE +if command -v xdg-mime &> /dev/null; then + xdg-mime default $APP_NAME.desktop x-scheme-handler/vpn >> $LOG_FILE 2>&1 || true +fi + echo "user desktop creation loop ended" >> $LOG_FILE if command -v steamos-readonly &> /dev/null; then diff --git a/deploy/data/macos/post_install.sh b/deploy/data/macos/post_install.sh index 2d5fe0857..45f76f6db 100755 --- a/deploy/data/macos/post_install.sh +++ b/deploy/data/macos/post_install.sh @@ -51,6 +51,15 @@ run_cmd sudo chmod -R a-w "$APP_PATH/" run_cmd sudo chown -R root "$APP_PATH/" run_cmd sudo chgrp -R wheel "$APP_PATH/" +# Refresh Launch Services so CFBundleURLTypes (e.g. vpn://) is picked up without a manual lsregister. +LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" +if [ -d "$APP_PATH" ]; then + log "Launch Services: lsregister -f -R $APP_PATH" + run_cmd "$LSREGISTER" -f -R "$APP_PATH" || true +else + log "WARN: $APP_PATH missing, skipping lsregister" +fi + log "Requesting ${APP_NAME} to quit gracefully" run_cmd osascript -e 'tell application "AmneziaVPN" to quit' || true From 9dc7e430a3c14464979525daf9fb056515423311 Mon Sep 17 00:00:00 2001 From: dranik Date: Thu, 14 May 2026 14:19:42 +0300 Subject: [PATCH 3/8] add Info.plist.in --- client/cmake/macos.cmake | 4 ++++ deploy/data/macos/post_install.sh | 10 +++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/client/cmake/macos.cmake b/client/cmake/macos.cmake index 2d7aed7b7..1ae622f2e 100644 --- a/client/cmake/macos.cmake +++ b/client/cmake/macos.cmake @@ -20,6 +20,10 @@ set(LIBS ${LIBS} set_target_properties(${PROJECT} PROPERTIES MACOSX_BUNDLE TRUE + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/macos/app/Info.plist.in" + MACOSX_BUNDLE_GUI_IDENTIFIER "${BUILD_OSX_APP_IDENTIFIER}" + MACOSX_BUNDLE_BUNDLE_NAME "AmneziaVPN" + MACOSX_BUNDLE_COPYRIGHT "" MACOSX_BUNDLE_SHORT_VERSION_STRING "${CMAKE_PROJECT_VERSION_MAJOR}.${CMAKE_PROJECT_VERSION_MINOR}.${CMAKE_PROJECT_VERSION_PATCH}" MACOSX_BUNDLE_BUNDLE_VERSION "${CMAKE_PROJECT_VERSION_TWEAK}" ) diff --git a/deploy/data/macos/post_install.sh b/deploy/data/macos/post_install.sh index 45f76f6db..5262fad4a 100755 --- a/deploy/data/macos/post_install.sh +++ b/deploy/data/macos/post_install.sh @@ -35,12 +35,10 @@ fi run_cmd launchctl bootout system "$LAUNCH_DAEMONS_PLIST_NAME" || run_cmd launchctl unload "$LAUNCH_DAEMONS_PLIST_NAME" run_cmd rm -f "$LAUNCH_DAEMONS_PLIST_NAME" -# Add separate group for xray filtering +# Add separate group for xray filtering (do not exit the script if the group already exists) if dscl . -read "/Groups/$SERVICE_GROUP" >/dev/null 2>&1; then log "Group $SERVICE_GROUP already exists" - return 0 else - local next_gid next_gid=$(dscl . -list /Groups PrimaryGroupID 2>/dev/null | awk '{print $2}' | sort -n | awk '$1>=500{g=$1} END{print (g?g+1:501)}') run_cmd dscl . -create "/Groups/$SERVICE_GROUP" run_cmd dscl . -create "/Groups/$SERVICE_GROUP" PrimaryGroupID "$next_gid" @@ -56,6 +54,12 @@ LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchS if [ -d "$APP_PATH" ]; then log "Launch Services: lsregister -f -R $APP_PATH" run_cmd "$LSREGISTER" -f -R "$APP_PATH" || true + INFO_PLIST="$APP_PATH/Contents/Info.plist" + if [ -f "$INFO_PLIST" ] && plutil -p "$INFO_PLIST" 2>/dev/null | grep -q 'CFBundleURLTypes'; then + log "Info.plist: CFBundleURLTypes present (vpn:// can be registered with Launch Services)" + else + log "ERROR: Info.plist has no CFBundleURLTypes — open vpn:// will fail (-10814). Fix the app bundle Info.plist at build time; lsregister cannot invent URL schemes." + fi else log "WARN: $APP_PATH missing, skipping lsregister" fi From 7c8613f19a98febd95b356442e890e6daccffb92 Mon Sep 17 00:00:00 2001 From: dranik Date: Thu, 14 May 2026 14:52:04 +0300 Subject: [PATCH 4/8] fixed icon & fix send link app --- client/amneziaApplication.cpp | 36 ++++++++++++++++++++++++++++++++++ client/cmake/macos.cmake | 2 +- client/macos/app/Info.plist.in | 4 +++- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/client/amneziaApplication.cpp b/client/amneziaApplication.cpp index c0f5053b5..8af526aa3 100644 --- a/client/amneziaApplication.cpp +++ b/client/amneziaApplication.cpp @@ -355,17 +355,53 @@ void AmneziaApplication::deliverVpnDeepLink(const QString &payload) m_coreController->openVpnKeyImportPreview(trimmed); } +#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) && !defined(MACOS_NE) +namespace { +bool forwardVpnPayloadToPrimaryInstance(const QString &payload) +{ + if (payload.trimmed().isEmpty()) { + return false; + } + QLocalSocket socket; + socket.connectToServer(QStringLiteral("AmneziaVPNInstance")); + if (!socket.waitForConnected(800)) { + return false; + } + const QByteArray msg = QByteArrayLiteral("VPN\n") + payload.toUtf8() + '\n'; + socket.write(msg); + socket.waitForBytesWritten(3000); + socket.flush(); + return true; +} +} // namespace +#endif + bool AmneziaApplication::event(QEvent *event) { +#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) if (event->type() == QEvent::FileOpen) { auto *foe = static_cast(event); const QUrl url = foe->url(); if (url.scheme().compare(QLatin1String("vpn"), Qt::CaseInsensitive) == 0) { const QString payload = url.toString(QUrl::PrettyDecoded); +#if !defined(MACOS_NE) + // Secondary instance: main() exits before init(), so m_coreController is null; browsers often + // pass the URL only via QFileOpenEvent (not argv). Forward to the running primary process. + if (!m_coreController) { + if (forwardVpnPayloadToPrimaryInstance(payload)) { + qInfo().noquote() << "Forwarded vpn deep link to primary instance, bytes:" << payload.size(); + QTimer::singleShot(0, qApp, &QCoreApplication::quit); + return true; + } + qWarning() << "vpn FileOpen: no CoreController and could not reach primary instance (socket)"; + return true; + } +#endif QTimer::singleShot(0, this, [this, payload]() { deliverVpnDeepLink(payload); }); return true; } } +#endif return AMNEZIA_BASE_CLASS::event(event); } #endif diff --git a/client/cmake/macos.cmake b/client/cmake/macos.cmake index 1ae622f2e..9ac7eccac 100644 --- a/client/cmake/macos.cmake +++ b/client/cmake/macos.cmake @@ -24,6 +24,7 @@ set_target_properties(${PROJECT} PROPERTIES MACOSX_BUNDLE_GUI_IDENTIFIER "${BUILD_OSX_APP_IDENTIFIER}" MACOSX_BUNDLE_BUNDLE_NAME "AmneziaVPN" MACOSX_BUNDLE_COPYRIGHT "" + MACOSX_BUNDLE_ICON_FILE "app.icns" MACOSX_BUNDLE_SHORT_VERSION_STRING "${CMAKE_PROJECT_VERSION_MAJOR}.${CMAKE_PROJECT_VERSION_MINOR}.${CMAKE_PROJECT_VERSION_PATCH}" MACOSX_BUNDLE_BUNDLE_VERSION "${CMAKE_PROJECT_VERSION_TWEAK}" ) @@ -39,7 +40,6 @@ set(SOURCES ${SOURCES} set(ICON_FILE ${CMAKE_CURRENT_SOURCE_DIR}/images/app.icns) -set(MACOSX_BUNDLE_ICON_FILE app.icns) set_source_files_properties(${ICON_FILE} PROPERTIES MACOSX_PACKAGE_LOCATION Resources) set(SOURCES ${SOURCES} ${ICON_FILE}) diff --git a/client/macos/app/Info.plist.in b/client/macos/app/Info.plist.in index dccbafa60..822346229 100644 --- a/client/macos/app/Info.plist.in +++ b/client/macos/app/Info.plist.in @@ -10,6 +10,8 @@ ${QT_INTERNAL_DOLLAR_VAR}{PRODUCT_NAME} CFBundleExecutable ${MACOSX_BUNDLE_EXECUTABLE_NAME} + CFBundleIconFile + ${MACOSX_BUNDLE_ICON_FILE} CFBundleIdentifier ${MACOSX_BUNDLE_GUI_IDENTIFIER} CFBundleInfoDictionaryVersion @@ -50,7 +52,7 @@ CFBundleURLName - org.amnezia.AmneziaVPN.vpn-deeplink + ${MACOSX_BUNDLE_GUI_IDENTIFIER}.vpn-deeplink CFBundleURLSchemes vpn From 865880d50281818bcdb23eba8fa93b04d7c10cb2 Mon Sep 17 00:00:00 2001 From: dranik Date: Thu, 14 May 2026 16:28:31 +0300 Subject: [PATCH 5/8] fixed send vpn:// close app --- client/CMakeLists.txt | 14 +++ client/amneziaApplication.cpp | 60 ++++++++--- client/amneziaApplication.h | 2 + client/main.cpp | 1 + tools/deeplink/README.md | 20 ++++ tools/deeplink/vpn-deeplink-demo.html | 143 ++++++++++++++++++++++++++ 6 files changed, 227 insertions(+), 13 deletions(-) create mode 100644 tools/deeplink/README.md create mode 100644 tools/deeplink/vpn-deeplink-demo.html diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 2f138157d..486c4d230 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -214,6 +214,20 @@ else() qt_finalize_target(${PROJECT}) endif() +if(IS_LSREGISTER_MACOS) + if(APPLE AND NOT IOS AND CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin") + add_custom_command( + TARGET ${PROJECT} POST_BUILD + COMMAND + /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister + -f + -R + $ + COMMENT "lsregister: register $ with Launch Services" + ) + endif() +endif() + install(TARGETS ${PROJECT} DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT AmneziaVPN diff --git a/client/amneziaApplication.cpp b/client/amneziaApplication.cpp index 8af526aa3..01e81a9ba 100644 --- a/client/amneziaApplication.cpp +++ b/client/amneziaApplication.cpp @@ -32,6 +32,17 @@ bool AmneziaApplication::m_forceQuit = false; +#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) && !defined(MACOS_NE) +namespace { +bool g_secondaryInstanceForDeepLink = false; +} + +void AmneziaApplication::markSecondaryInstanceForDeepLink() +{ + g_secondaryInstanceForDeepLink = true; +} +#endif + AmneziaApplication::AmneziaApplication(int &argc, char *argv[]) : AMNEZIA_BASE_CLASS(argc, argv), m_optAutostart({QStringLiteral("a"), QStringLiteral("autostart")}, QStringLiteral("System autostart")), m_optCleanup ({QStringLiteral("c"), QStringLiteral("cleanup")}, QStringLiteral("Cleanup logs")), @@ -239,9 +250,14 @@ void AmneziaApplication::init() if (!m_parser.isSet(m_optImport)) { const QString vpnArg = vpnUrlFromArguments(QCoreApplication::arguments()); if (!vpnArg.isEmpty()) { + m_pendingVpnDeepLink.clear(); QTimer::singleShot(0, this, [this, vpnArg]() { deliverVpnDeepLink(vpnArg); }); } } + if (!m_pendingVpnDeepLink.isEmpty()) { + const QString pending = std::move(m_pendingVpnDeepLink); + QTimer::singleShot(0, this, [this, pending]() { deliverVpnDeepLink(pending); }); + } #endif } @@ -355,8 +371,22 @@ void AmneziaApplication::deliverVpnDeepLink(const QString &payload) m_coreController->openVpnKeyImportPreview(trimmed); } -#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) && !defined(MACOS_NE) -namespace { +QString vpnPayloadFromFileOpenUrl(const QUrl &url) +{ + const QString decoded = url.toString(QUrl::PrettyDecoded); + const int idx = decoded.indexOf(QLatin1String("vpn://"), 0, Qt::CaseInsensitive); + if (idx >= 0) { + qDebug() << "vpn://" << decoded; + return decoded.mid(idx).trimmed(); + } + if (url.scheme().compare(QLatin1String("vpn"), Qt::CaseInsensitive) == 0) { + qDebug() << "vpn://" << decoded; + return decoded.trimmed(); + } + return {}; +} + +#if !defined(MACOS_NE) bool forwardVpnPayloadToPrimaryInstance(const QString &payload) { if (payload.trimmed().isEmpty()) { @@ -373,35 +403,39 @@ bool forwardVpnPayloadToPrimaryInstance(const QString &payload) socket.flush(); return true; } -} // namespace #endif bool AmneziaApplication::event(QEvent *event) { -#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) if (event->type() == QEvent::FileOpen) { auto *foe = static_cast(event); - const QUrl url = foe->url(); - if (url.scheme().compare(QLatin1String("vpn"), Qt::CaseInsensitive) == 0) { - const QString payload = url.toString(QUrl::PrettyDecoded); + const QUrl &url = foe->url(); + qDebug() << "url:" << url; + const QString payload = vpnPayloadFromFileOpenUrl(url); + qDebug() << "payload" << payload; + if (!payload.isEmpty()) { + if (m_coreController) { + QTimer::singleShot(0, this, [this, payload]() { deliverVpnDeepLink(payload); }); + return true; + } #if !defined(MACOS_NE) - // Secondary instance: main() exits before init(), so m_coreController is null; browsers often - // pass the URL only via QFileOpenEvent (not argv). Forward to the running primary process. - if (!m_coreController) { + // True secondary process (main skipped init): forward to the primary instance. + if (g_secondaryInstanceForDeepLink) { if (forwardVpnPayloadToPrimaryInstance(payload)) { qInfo().noquote() << "Forwarded vpn deep link to primary instance, bytes:" << payload.size(); QTimer::singleShot(0, qApp, &QCoreApplication::quit); return true; } - qWarning() << "vpn FileOpen: no CoreController and could not reach primary instance (socket)"; + qWarning() << "vpn FileOpen: secondary instance could not reach primary (socket)"; return true; } #endif - QTimer::singleShot(0, this, [this, payload]() { deliverVpnDeepLink(payload); }); + // Cold start: FileOpen can arrive while init() is still running (CoreController not ready yet). + // Do not forward to our own local server — queue and flush at end of init(). + m_pendingVpnDeepLink = payload; return true; } } -#endif return AMNEZIA_BASE_CLASS::event(event); } #endif diff --git a/client/amneziaApplication.h b/client/amneziaApplication.h index 9220b665f..fed7e8ac1 100644 --- a/client/amneziaApplication.h +++ b/client/amneziaApplication.h @@ -44,6 +44,7 @@ public: #if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) && !defined(MACOS_NE) void startLocalServer(); + static void markSecondaryInstanceForDeepLink(); #endif QQmlApplicationEngine *qmlEngine() const; @@ -72,6 +73,7 @@ private: #if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) void deliverVpnDeepLink(const QString &payload); + QString m_pendingVpnDeepLink; #endif QSharedPointer m_vpnConnection; diff --git a/client/main.cpp b/client/main.cpp index 3239b01b9..c87069e76 100644 --- a/client/main.cpp +++ b/client/main.cpp @@ -75,6 +75,7 @@ int main(int argc, char *argv[]) #if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) && !defined(MACOS_NE) const QString vpnFromArgv = findVpnDeepLinkInArguments(QCoreApplication::arguments()); if (notifyRunningInstanceOrExit(app, vpnFromArgv)) { + AmneziaApplication::markSecondaryInstanceForDeepLink(); return app.exec(); } app.startLocalServer(); diff --git a/tools/deeplink/README.md b/tools/deeplink/README.md new file mode 100644 index 000000000..aff52413d --- /dev/null +++ b/tools/deeplink/README.md @@ -0,0 +1,20 @@ +# Демо: `vpn://` из браузера (как `tg://`) + +Браузер показывает диалог «разрешить сайту открывать ссылки **vpn** через приложение» только если: + +1. Страница открыта по **HTTPS** (или `localhost` в части конфигураций; для надёжного сценария как у `tlgrm.ru` — **настоящий TLS**). +2. В ОС зарегистрирован обработчик схемы **`vpn`** → AmneziaVPN (см. [AH-355-deep-link-approval-and-operations.md](../../docs/plans/AH-355-deep-link-approval-and-operations.md)). +3. Переход на `vpn://` сделан **жестом пользователя** (клик по ссылке / кнопке), а не только автозапуск при загрузке страницы (политики браузера могут блокировать). + +## Файлы + +- [vpn-deeplink-demo.html](vpn-deeplink-demo.html) — поле для вставки/редактирования `vpn://…`, кнопки открытия и **лог на странице** (+ дублирование в консоль браузера). Открывайте **по HTTPS**. + +## Windows: регистрация `vpn` + +Один раз запустите установленный AmneziaVPN — при первом запуске клиент записывает обработчик в реестр пользователя (`HKCU\Software\Classes\vpn`). Без этого шага Браузер может не предложить Amnezia. + +## Проверка конкурента за схему `vpn` + +- **Windows:** «Параметры приложений по умолчанию» → протоколы / сопоставления URI, или `regedit` → `HKEY_CURRENT_USER\Software\Classes\vpn`. +- **macOS:** при конфликте система откроет не то приложение; проверьте «Открыть с помощью» для тестового `vpn://` в Safari. diff --git a/tools/deeplink/vpn-deeplink-demo.html b/tools/deeplink/vpn-deeplink-demo.html new file mode 100644 index 000000000..602f8ebab --- /dev/null +++ b/tools/deeplink/vpn-deeplink-demo.html @@ -0,0 +1,143 @@ + + + + + + Amnezia — тест vpn:// из браузера + + + +

Тест deep link vpn://

+ +
+ Откройте эту страницу по HTTPS (см. docs/deeplink/README.md). + Клик по ссылке / кнопке должен быть жестом пользователя (как у tg:// с https-страницы). +
+ + + +

Если строка не начинается с vpn://, префикс будет добавлен автоматически.

+ +
+ Открыть (ссылка <a>) + + + +
+ +

Ожидание: Chrome спросит разрешение на схему vpn → AmneziaVPN → экран предпросмотра импорта.

+ + +
+ + + + From 4ae2b32083668033b72455bb2ae9ebccc3bef7c9 Mon Sep 17 00:00:00 2001 From: "Yaumenau Pavel (Vir-Win10)" Date: Fri, 15 May 2026 01:15:32 +0300 Subject: [PATCH 6/8] fixed crash close app (tray) & add vpn url --- client/CMakeLists.txt | 10 +++++++++- client/amneziaApplication.cpp | 20 ++++---------------- client/amneziaApplication.h | 2 -- cmake/register_vpn_url_win.cmake | 26 ++++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 19 deletions(-) create mode 100644 cmake/register_vpn_url_win.cmake diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 486c4d230..8d55b279b 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -214,7 +214,7 @@ else() qt_finalize_target(${PROJECT}) endif() -if(IS_LSREGISTER_MACOS) +if(IS_REGISTER_VPN_URL) if(APPLE AND NOT IOS AND CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin") add_custom_command( TARGET ${PROJECT} POST_BUILD @@ -225,6 +225,14 @@ if(IS_LSREGISTER_MACOS) $ COMMENT "lsregister: register $ with Launch Services" ) + elseif(WIN32) + add_custom_command( + TARGET ${PROJECT} POST_BUILD + COMMAND ${CMAKE_COMMAND} + -DEXE_PATH=$ + -P "${CMAKE_SOURCE_DIR}/cmake/register_vpn_url_win.cmake" + COMMENT "HKCU: register vpn\\shell\\open\\command -> $" + ) endif() endif() diff --git a/client/amneziaApplication.cpp b/client/amneziaApplication.cpp index 01e81a9ba..4910b5a51 100644 --- a/client/amneziaApplication.cpp +++ b/client/amneziaApplication.cpp @@ -75,21 +75,12 @@ AmneziaApplication::AmneziaApplication(int &argc, char *argv[]) : AMNEZIA_BASE_C AmneziaApplication::~AmneziaApplication() { #ifdef AMNEZIA_DESKTOP - if (m_vpnConnection && m_vpnConnectionThread.isRunning()) { - QMetaObject::invokeMethod(m_vpnConnection.get(), "disconnectSlots", Qt::BlockingQueuedConnection); - - QMetaObject::invokeMethod(m_vpnConnection.get(), "disconnectFromVpn", Qt::BlockingQueuedConnection); + if (m_vpnConnection) { + m_vpnConnection->disconnectSlots(); + m_vpnConnection->disconnectFromVpn(); } #endif - m_vpnConnectionThread.requestInterruption(); - m_vpnConnectionThread.quit(); - - if (!m_vpnConnectionThread.wait(3000)) { - m_vpnConnectionThread.terminate(); - m_vpnConnectionThread.wait(500); - } - if (m_engine) { delete m_engine; } @@ -185,9 +176,6 @@ void AmneziaApplication::init() #endif m_vpnConnection.reset(new VpnConnection(nullptr, nullptr)); - m_vpnConnection->moveToThread(&m_vpnConnectionThread); - m_vpnConnectionThread.start(); - m_coreController.reset(new CoreController(m_vpnConnection, m_settings, m_engine)); m_engine->addImportPath("qrc:/ui/qml/Modules/"); @@ -354,7 +342,7 @@ void AmneziaApplication::startLocalServer() emit m_coreController->pageController()->raiseMainWindow(); } }); - }); + }, Qt::QueuedConnection); } #endif diff --git a/client/amneziaApplication.h b/client/amneziaApplication.h index fed7e8ac1..a92ade3cf 100644 --- a/client/amneziaApplication.h +++ b/client/amneziaApplication.h @@ -5,7 +5,6 @@ #include #include #include -#include #if defined(Q_OS_ANDROID) || defined(Q_OS_IOS) #include #else @@ -77,7 +76,6 @@ private: #endif QSharedPointer m_vpnConnection; - QThread m_vpnConnectionThread; QNetworkAccessManager *m_nam; protected: diff --git a/cmake/register_vpn_url_win.cmake b/cmake/register_vpn_url_win.cmake new file mode 100644 index 000000000..17a87bb1b --- /dev/null +++ b/cmake/register_vpn_url_win.cmake @@ -0,0 +1,26 @@ +# POST_BUILD helper: HKCU\Software\Classes\vpn\shell\open\command -> "app.exe" "%1" +# Invoke: cmake -DEXE_PATH="..." -P register_vpn_url_win.cmake +if(NOT DEFINED EXE_PATH OR EXE_PATH STREQUAL "") + message(FATAL_ERROR "register_vpn_url_win.cmake: EXE_PATH is empty") +endif() +if(NOT EXISTS "${EXE_PATH}") + message(WARNING "register_vpn_url_win.cmake: EXE not found (POST_BUILD order?): ${EXE_PATH}") +endif() + +file(TO_NATIVE_PATH "${EXE_PATH}" _exe_native) +string(REPLACE "/" "\\" _exe_native "${_exe_native}") + +# reg.exe: "C:\path\app.exe" "%1" — %1 is the full vpn://... string +set(_reg_dval "\"${_exe_native}\" \"%1\"") + +execute_process( + COMMAND reg ADD "HKCU\\Software\\Classes\\vpn\\shell\\open\\command" + /ve /t REG_SZ /d "${_reg_dval}" /f + RESULT_VARIABLE _reg_res + ERROR_VARIABLE _reg_err +) +if(NOT _reg_res EQUAL 0) + message(WARNING "register_vpn_url_win.cmake: reg ADD failed (code ${_reg_res}): ${_reg_err}") +else() + message(STATUS "vpn:// HKCU shell\\open\\command = ${_reg_dval}") +endif() From c90107bb7a85716f3a309f06f3b4eba9d3718802 Mon Sep 17 00:00:00 2001 From: dranik Date: Fri, 15 May 2026 10:27:47 +0300 Subject: [PATCH 7/8] fixed linux open link --- deploy/data/linux/AmneziaVPN.desktop | 3 +-- deploy/data/linux/post_install.sh | 36 ++++++++++++++++++++++++++-- tools/deeplink/README.md | 20 ---------------- 3 files changed, 35 insertions(+), 24 deletions(-) delete mode 100644 tools/deeplink/README.md diff --git a/deploy/data/linux/AmneziaVPN.desktop b/deploy/data/linux/AmneziaVPN.desktop index 057675ce7..f44b7a807 100755 --- a/deploy/data/linux/AmneziaVPN.desktop +++ b/deploy/data/linux/AmneziaVPN.desktop @@ -1,10 +1,9 @@ -#!/usr/bin/env xdg-open [Desktop Entry] Type=Application Name=AmneziaVPN Version=1.0 Comment=Client of your self-hosted VPN -Exec=AmneziaVPN %u +Exec=/opt/AmneziaVPN/bin/AmneziaVPN %u Icon=/usr/share/pixmaps/AmneziaVPN.png Categories=Network;Qt;Security; MimeType=x-scheme-handler/vpn; diff --git a/deploy/data/linux/post_install.sh b/deploy/data/linux/post_install.sh index f03371334..3b0b37e51 100755 --- a/deploy/data/linux/post_install.sh +++ b/deploy/data/linux/post_install.sh @@ -30,6 +30,13 @@ if sudo systemctl is-active --quiet $APP_NAME; then sudo rm -rf /etc/systemd/system/$APP_NAME.service >> $LOG_FILE fi +# Absolute Exec= in .desktop: Firefox/portal invoke handlers with a minimal PATH. +DESKTOP_IN_APP="$APP_PATH/$APP_NAME.desktop" +if [ -f "$DESKTOP_IN_APP" ]; then + sudo sed -i "s|^Exec=.*|Exec=$APP_PATH/bin/$APP_NAME %u|" "$DESKTOP_IN_APP" >> $LOG_FILE 2>&1 || true + sudo sed -i '1{/^#!\/usr\/bin\/env xdg-open$/d;}' "$DESKTOP_IN_APP" >> $LOG_FILE 2>&1 || true +fi + sudo chmod -R a-w $APP_PATH/ sudo cp $APP_PATH/$APP_NAME.service /etc/systemd/system/ >> $LOG_FILE @@ -44,8 +51,33 @@ sudo cp $APP_PATH/$APP_NAME.desktop /usr/share/applications/ >> $LOG_FILE sudo cp $APP_PATH/$APP_NAME.png /usr/share/pixmaps/ >> $LOG_FILE sudo chmod 555 /usr/share/applications/$APP_NAME.desktop >> $LOG_FILE -if command -v xdg-mime &> /dev/null; then - xdg-mime default $APP_NAME.desktop x-scheme-handler/vpn >> $LOG_FILE 2>&1 || true +if command -v update-desktop-database &> /dev/null; then + sudo update-desktop-database /usr/share/applications >> $LOG_FILE 2>&1 || true +fi + +register_vpn_scheme_for_user() { + local user="$1" + if [ -z "$user" ] || [ "$user" = "root" ]; then + return + fi + if ! command -v xdg-mime &> /dev/null; then + return + fi + local home + home=$(getent passwd "$user" | cut -d: -f6) + if [ -z "$home" ] || [ ! -d "$home" ]; then + echo "skip xdg-mime for $user: no home" >> $LOG_FILE + return + fi + echo "xdg-mime default for user $user" >> $LOG_FILE + sudo -u "$user" env HOME="$home" \ + xdg-mime default "$APP_NAME.desktop" x-scheme-handler/vpn >> $LOG_FILE 2>&1 || true +} + +if [ -n "$SUDO_USER" ] && [ "$SUDO_USER" != "root" ]; then + register_vpn_scheme_for_user "$SUDO_USER" +elif [ -n "$USER" ] && [ "$USER" != "root" ]; then + register_vpn_scheme_for_user "$USER" fi echo "user desktop creation loop ended" >> $LOG_FILE diff --git a/tools/deeplink/README.md b/tools/deeplink/README.md deleted file mode 100644 index aff52413d..000000000 --- a/tools/deeplink/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# Демо: `vpn://` из браузера (как `tg://`) - -Браузер показывает диалог «разрешить сайту открывать ссылки **vpn** через приложение» только если: - -1. Страница открыта по **HTTPS** (или `localhost` в части конфигураций; для надёжного сценария как у `tlgrm.ru` — **настоящий TLS**). -2. В ОС зарегистрирован обработчик схемы **`vpn`** → AmneziaVPN (см. [AH-355-deep-link-approval-and-operations.md](../../docs/plans/AH-355-deep-link-approval-and-operations.md)). -3. Переход на `vpn://` сделан **жестом пользователя** (клик по ссылке / кнопке), а не только автозапуск при загрузке страницы (политики браузера могут блокировать). - -## Файлы - -- [vpn-deeplink-demo.html](vpn-deeplink-demo.html) — поле для вставки/редактирования `vpn://…`, кнопки открытия и **лог на странице** (+ дублирование в консоль браузера). Открывайте **по HTTPS**. - -## Windows: регистрация `vpn` - -Один раз запустите установленный AmneziaVPN — при первом запуске клиент записывает обработчик в реестр пользователя (`HKCU\Software\Classes\vpn`). Без этого шага Браузер может не предложить Amnezia. - -## Проверка конкурента за схему `vpn` - -- **Windows:** «Параметры приложений по умолчанию» → протоколы / сопоставления URI, или `regedit` → `HKEY_CURRENT_USER\Software\Classes\vpn`. -- **macOS:** при конфликте система откроет не то приложение; проверьте «Открыть с помощью» для тестового `vpn://` в Safari. From 02e55424ad0e94903556ea7aee4abfe4a078fa2a Mon Sep 17 00:00:00 2001 From: dranik Date: Fri, 15 May 2026 16:07:53 +0300 Subject: [PATCH 8/8] update html --- tools/deeplink/vpn-deeplink-demo.html | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/tools/deeplink/vpn-deeplink-demo.html b/tools/deeplink/vpn-deeplink-demo.html index 602f8ebab..f953df94c 100644 --- a/tools/deeplink/vpn-deeplink-demo.html +++ b/tools/deeplink/vpn-deeplink-demo.html @@ -43,8 +43,6 @@
Открыть (ссылка <a>) - -
@@ -109,23 +107,6 @@ } }); - document.getElementById("vpnBtn").addEventListener("click", function () { - var href = buildHref(); - if (!href) return; - log("Кнопка: переход window.location.href, длина=" + href.length); - window.location.href = href; - }); - - document.getElementById("copyBtn").addEventListener("click", function () { - var href = buildHref(); - if (!href) return; - navigator.clipboard.writeText(href).then(function () { - log("Скопировано в буфер обмена."); - }).catch(function (err) { - log("Копирование не удалось: " + (err && err.message ? err.message : String(err))); - }); - }); - document.getElementById("clearLogBtn").addEventListener("click", function () { logEl.textContent = ""; log("Лог очищен.");