Feat: Add deep link

This commit is contained in:
dranik
2026-05-13 17:41:26 +03:00
parent 009ca981d5
commit e2108a28ea
19 changed files with 292 additions and 77 deletions
+111 -8
View File
@@ -15,6 +15,9 @@
#include <QEvent>
#include <QDir>
#include <QSettings>
#include <QFileOpenEvent>
#include <QUrl>
#include <QCoreApplication>
#include <QtQuick/QQuickWindow>
#include <QWindow>
@@ -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<QFileOpenEvent *>(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) {
+10
View File
@@ -12,6 +12,9 @@
#include <QApplication>
#endif
#include <QClipboard>
#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS)
#include <QEvent>
#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<VpnConnection> 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
@@ -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) {
@@ -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)
}
+2
View File
@@ -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
)
@@ -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
}
+2
View File
@@ -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:
@@ -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();
@@ -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;
+13
View File
@@ -86,6 +86,19 @@
<dict/>
<key>CFBundleIcons~ipad</key>
<dict/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>org.amnezia.AmneziaVPN.vpn-deeplink</string>
<key>CFBundleURLSchemes</key>
<array>
<string>vpn</string>
</array>
<key>CFBundleTypeRole</key>
<string>Editor</string>
</dict>
</array>
<key>UTImportedTypeDeclarations</key>
<array>
<dict>
+13
View File
@@ -46,6 +46,19 @@
</dict>
<key>CFBundleIcons</key>
<dict/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>org.amnezia.AmneziaVPN.vpn-deeplink</string>
<key>CFBundleURLSchemes</key>
<array>
<string>vpn</string>
</array>
<key>CFBundleTypeRole</key>
<string>Editor</string>
</dict>
</array>
<key>UTImportedTypeDeclarations</key>
<array>
<dict>
+34 -9
View File
@@ -1,3 +1,5 @@
#include <QByteArray>
#include <QCoreApplication>
#include <QDebug>
#include <QTimer>
@@ -6,7 +8,7 @@
#include "core/utils/migrations.h"
#include "version.h"
#include <QTimer>
#include <QLocalSocket>
#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();
@@ -0,0 +1,14 @@
#pragma once
#import <Foundation/Foundation.h>
#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
@@ -0,0 +1,51 @@
#import "AmneziaOpenUrlImport.h"
#include "ios_controller.h"
#include <QFile>
#include <QString>
#include <dispatch/dispatch.h>
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));
});
}
@@ -1,12 +1,7 @@
#import <UIKit/UIKit.h>
#import <objc/runtime.h>
#include <dispatch/dispatch.h>
#include <QByteArray>
#include <QFile>
#include <QString>
#include "ios_controller.h"
#import "AmneziaOpenUrlImport.h"
using SceneOpenURLContexts = void (*)(id, SEL, UIScene *, NSSet<UIOpenURLContext *> *);
@@ -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<UIOpenURLContext *> *contexts)
+9 -21
View File
@@ -1,8 +1,5 @@
#import "QtAppDelegate.h"
#import "ios_controller.h"
#include <QFile>
#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<UIApplicationOpenURLOptionsKey, id> *)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;
+1 -1
View File
@@ -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);
@@ -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 <QThread>
#endif
#include "version.h"
#include "core/models/serverConfig.h"
#include <QClipboard>
@@ -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) {