fixed send vpn:// close app

This commit is contained in:
dranik
2026-05-14 16:28:31 +03:00
parent 7c8613f19a
commit 865880d502
6 changed files with 227 additions and 13 deletions
+14
View File
@@ -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
$<TARGET_BUNDLE_DIR:${PROJECT}>
COMMENT "lsregister: register $<TARGET_BUNDLE_DIR:${PROJECT}> with Launch Services"
)
endif()
endif()
install(TARGETS ${PROJECT}
DESTINATION ${CMAKE_INSTALL_BINDIR}
COMPONENT AmneziaVPN
+47 -13
View File
@@ -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<QFileOpenEvent *>(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
+2
View File
@@ -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<VpnConnection> m_vpnConnection;
+1
View File
@@ -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();
+20
View File
@@ -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.
+143
View File
@@ -0,0 +1,143 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Amnezia — тест vpn:// из браузера</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 48rem; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; }
code { background: #eee; padding: 0.1rem 0.35rem; border-radius: 4px; }
.warn { background: #fff3cd; padding: 1rem; border-radius: 8px; margin: 1rem 0; }
label { display: block; font-weight: 600; margin-top: 1rem; }
textarea {
width: 100%; min-height: 6rem; box-sizing: border-box; font-family: ui-monospace, monospace;
font-size: 0.85rem; padding: 0.5rem; border: 1px solid #ccc; border-radius: 6px; resize: vertical;
}
.row { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.75rem 0; align-items: center; }
a.button, button {
display: inline-block; padding: 0.55rem 1rem;
background: #1a73e8; color: #fff; text-decoration: none; border-radius: 6px; border: none; font-size: 0.95rem; cursor: pointer;
}
button.secondary { background: #333; }
button.muted { background: #666; }
#log {
margin-top: 1rem; padding: 0.75rem; background: #1e1e1e; color: #d4d4d4; border-radius: 6px;
font-family: ui-monospace, monospace; font-size: 0.75rem; max-height: 14rem; overflow-y: auto; white-space: pre-wrap; word-break: break-all;
}
#log .line { border-bottom: 1px solid #333; padding: 0.25rem 0; }
#log .time { color: #6a9955; margin-right: 0.5rem; }
.hint { color: #555; font-size: 0.9rem; margin-top: 0.25rem; }
</style>
</head>
<body>
<h1>Тест deep link <code>vpn://</code></h1>
<div class="warn">
Откройте эту страницу по <strong>HTTPS</strong> (см. <code>docs/deeplink/README.md</code>).
Клик по ссылке / кнопке должен быть <strong>жестом пользователя</strong> (как у <code>tg://</code> с https-страницы).
</div>
<label for="vpnInput">Строка ключа (целиком <code>vpn://…</code> или только хвост после <code>vpn://</code>)</label>
<textarea id="vpnInput" spellcheck="false" placeholder="vpn://AAAA_… или AAAA_…"></textarea>
<p class="hint">Если строка не начинается с <code>vpn://</code>, префикс будет добавлен автоматически.</p>
<div class="row">
<a class="button" id="vpnLink" href="#">Открыть (ссылка &lt;a&gt;)</a>
<button type="button" id="vpnBtn">Открыть (кнопка → location)</button>
<button type="button" class="secondary" id="copyBtn">Копировать URL</button>
<button type="button" class="muted" id="clearLogBtn">Очистить лог</button>
</div>
<p>Ожидание: Chrome спросит разрешение на схему <code>vpn</code> → AmneziaVPN → экран предпросмотра импорта.</p>
<label>Лог (на странице; дублируется в консоль DevTools)</label>
<div id="log" aria-live="polite"></div>
<script>
(function () {
var input = document.getElementById("vpnInput");
var link = document.getElementById("vpnLink");
var logEl = document.getElementById("log");
function ts() {
return new Date().toISOString();
}
function log(msg) {
var line = document.createElement("div");
line.className = "line";
var t = document.createElement("span");
t.className = "time";
t.textContent = ts();
line.appendChild(t);
line.appendChild(document.createTextNode(msg));
logEl.insertBefore(line, logEl.firstChild);
try { console.log(ts(), msg); } catch (e) {}
}
function buildHref() {
var raw = (input.value || "").trim();
if (!raw) {
log("Пустое поле — вставьте ключ.");
return "";
}
var href = raw.indexOf("vpn://") === 0 ? raw : "vpn://" + raw;
log("Собран URL, длина символов: " + href.length);
return href;
}
function syncLinkHref() {
var href = buildHref();
if (href) {
link.setAttribute("href", href);
log("href ссылки обновлён (первые 80 символов): " + href.slice(0, 80) + (href.length > 80 ? "…" : ""));
} else {
link.setAttribute("href", "#");
}
}
input.addEventListener("input", function () {
syncLinkHref();
});
link.addEventListener("click", function (ev) {
var href = link.getAttribute("href");
log("Клик по ссылке, href.length=" + (href ? href.length : 0));
if (!href || href === "#") {
ev.preventDefault();
log("Отмена: нет валидного href.");
}
});
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("Лог очищен.");
});
window.addEventListener("pagehide", function () {
log("pagehide (страница уходит / навигация)");
});
log("Страница загружена. Вставьте ключ и нажмите ссылку или кнопку.");
syncLinkHref();
})();
</script>
</body>
</html>