diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 2f138157d9..8d55b279bf 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -214,6 +214,28 @@ else() qt_finalize_target(${PROJECT}) endif() +if(IS_REGISTER_VPN_URL) + 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" + ) + 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() + install(TARGETS ${PROJECT} DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT AmneziaVPN diff --git a/client/amneziaApplication.cpp b/client/amneziaApplication.cpp index 008cc345d2..4910b5a51b 100644 --- a/client/amneziaApplication.cpp +++ b/client/amneziaApplication.cpp @@ -15,6 +15,9 @@ #include #include #include +#include +#include +#include #include #include @@ -29,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")), @@ -61,25 +75,54 @@ 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_engine) { + delete m_engine; + } +} - if (!m_vpnConnectionThread.wait(3000)) { - m_vpnConnectionThread.terminate(); - m_vpnConnectionThread.wait(500); +#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 (m_engine) { - delete m_engine; +#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 { @@ -133,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/"); @@ -190,6 +230,23 @@ 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()) { + 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 } void AmneziaApplication::registerTypes() @@ -250,20 +307,124 @@ 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(); + } + }); + }, Qt::QueuedConnection); +} +#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); +} + +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()) { + 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; +} +#endif + +bool AmneziaApplication::event(QEvent *event) +{ + if (event->type() == QEvent::FileOpen) { + auto *foe = static_cast(event); + 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) + // 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: secondary instance could not reach primary (socket)"; + return true; + } +#endif + // 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; + } + } + return AMNEZIA_BASE_CLASS::event(event); } #endif diff --git a/client/amneziaApplication.h b/client/amneziaApplication.h index 33b262c7fa..a92ade3cf8 100644 --- a/client/amneziaApplication.h +++ b/client/amneziaApplication.h @@ -5,13 +5,15 @@ #include #include #include -#include #if defined(Q_OS_ANDROID) || defined(Q_OS_IOS) #include #else #include #endif #include +#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) +#include +#endif #include "core/controllers/coreController.h" #include "secureQSettings.h" @@ -41,6 +43,7 @@ class AmneziaApplication : public AMNEZIA_BASE_CLASS #if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) && !defined(MACOS_NE) void startLocalServer(); + static void markSecondaryInstanceForDeepLink(); #endif QQmlApplicationEngine *qmlEngine() const; @@ -67,12 +70,19 @@ public slots: QCommandLineOption m_optConnect; QCommandLineOption m_optImport; +#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) + void deliverVpnDeepLink(const QString &payload); + QString m_pendingVpnDeepLink; +#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 dca70ee5cf..b993344f9f 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 49823a3643..75f8f23c87 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 86df23d25f..11bb32d40f 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/cmake/macos.cmake b/client/cmake/macos.cmake index 2d7aed7b72..9ac7eccacc 100644 --- a/client/cmake/macos.cmake +++ b/client/cmake/macos.cmake @@ -20,6 +20,11 @@ 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_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}" ) @@ -35,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/core/controllers/coreController.cpp b/client/core/controllers/coreController.cpp index 77b951f9e3..a5606bffb7 100644 --- a/client/core/controllers/coreController.cpp +++ b/client/core/controllers/coreController.cpp @@ -360,3 +360,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 70033d61b1..ed6ca16997 100644 --- a/client/core/controllers/coreController.h +++ b/client/core/controllers/coreController.h @@ -119,6 +119,8 @@ class CoreController : public QObject 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 934f20f6ae..e809f4891a 100644 --- a/client/core/controllers/coreSignalHandlers.cpp +++ b/client/core/controllers/coreSignalHandlers.cpp @@ -377,10 +377,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 } @@ -389,9 +387,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/ios/app/Info.plist.in b/client/ios/app/Info.plist.in index 6165daf32e..decca2ca55 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 1c9ad48eb0..8223462298 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 @@ -46,6 +48,19 @@ CFBundleIcons + CFBundleURLTypes + + + CFBundleURLName + ${MACOSX_BUNDLE_GUI_IDENTIFIER}.vpn-deeplink + CFBundleURLSchemes + + vpn + + CFBundleTypeRole + Editor + + UTImportedTypeDeclarations diff --git a/client/main.cpp b/client/main.cpp index 621692bd74..c87069e76a 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; + } + qWarning() << "AmneziaVPN is already running"; + if (!vpnPayload.isEmpty()) { + const QByteArray msg = QByteArrayLiteral("VPN\n") + vpnPayload.toUtf8() + '\n'; + socket.write(msg); + socket.waitForBytesWritten(3000); } - return false; + 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,9 @@ 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)) { + AmneziaApplication::markSecondaryInstanceForDeepLink(); return app.exec(); } app.startLocalServer(); diff --git a/client/platforms/ios/AmneziaOpenUrlImport.h b/client/platforms/ios/AmneziaOpenUrlImport.h new file mode 100644 index 0000000000..dcf6aeb83a --- /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 0000000000..3f661ca9b4 --- /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 60cbbe0fa9..c1d7111d37 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 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 64ee9425fc..301736f1eb 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 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( [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 @@ -(void)application:(UIApplication *)application performFetchWithCompletionHandle - (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/ui/controllers/api/subscriptionUiController.cpp b/client/ui/controllers/api/subscriptionUiController.cpp index 581a10a2d7..c4b4b233a0 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 #include diff --git a/client/ui/qml/Pages2/PageSetupWizardViewConfig.qml b/client/ui/qml/Pages2/PageSetupWizardViewConfig.qml index 778d5bfa03..4711d47d40 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) { diff --git a/cmake/register_vpn_url_win.cmake b/cmake/register_vpn_url_win.cmake new file mode 100644 index 0000000000..17a87bb1b9 --- /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() diff --git a/deploy/data/linux/AmneziaVPN.desktop b/deploy/data/linux/AmneziaVPN.desktop index 03ab570c31..f44b7a807b 100755 --- a/deploy/data/linux/AmneziaVPN.desktop +++ b/deploy/data/linux/AmneziaVPN.desktop @@ -1,10 +1,10 @@ -#!/usr/bin/env xdg-open [Desktop Entry] Type=Application Name=AmneziaVPN Version=1.0 Comment=Client of your self-hosted VPN -Exec=AmneziaVPN +Exec=/opt/AmneziaVPN/bin/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 baf02b719e..3b0b37e517 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,6 +51,35 @@ 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 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 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 2d5fe08573..5262fad4a3 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" @@ -51,6 +49,21 @@ 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 + 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 + log "Requesting ${APP_NAME} to quit gracefully" run_cmd osascript -e 'tell application "AmneziaVPN" to quit' || true diff --git a/tools/deeplink/vpn-deeplink-demo.html b/tools/deeplink/vpn-deeplink-demo.html new file mode 100644 index 0000000000..f953df94cc --- /dev/null +++ b/tools/deeplink/vpn-deeplink-demo.html @@ -0,0 +1,124 @@ + + + + + + Amnezia — тест vpn:// из браузера + + + +

Тест deep link vpn://

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

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

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

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

+ + +
+ + + +