Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 82 additions & 1 deletion client/amneziaApplication.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,50 @@
#include "version.h"

#include "platforms/ios/QRCodeReaderBase.h"


#if defined(Q_OS_IOS)
#include <QThread>

extern "C" {
void set_intent_callbacks(void (*reloadCallback)(), void (*connectCallback)(const char*), const char* (*getCountriesCallback)());
}

static AmneziaApplication *g_amnApp = nullptr;

static void intent_reload() {
if (g_amnApp) {
QMetaObject::invokeMethod(g_amnApp, "handleIntentReload", Qt::QueuedConnection);
}
}

static void intent_connect(const char* c_str) {
if (g_amnApp && c_str) {
QString countryCode = QString::fromUtf8(c_str);
QMetaObject::invokeMethod(g_amnApp, "handleIntentConnect", Qt::QueuedConnection, Q_ARG(QString, countryCode));
}
}

static const char* intent_get_countries() {
thread_local static QByteArray lastJson;
if (g_amnApp) {
// Query synchronously from g_amnApp
// But since this might be called on a background thread by AppIntents, we can safely just fetch it if data is protected, or use invokeMethod with BlockingQueuedConnection
QString jsonStr;
if (QThread::currentThread() == g_amnApp->thread()) {
jsonStr = g_amnApp->handleIntentGetCountries();
} else {
bool ok = QMetaObject::invokeMethod(g_amnApp, "handleIntentGetCountries", Qt::BlockingQueuedConnection, Q_RETURN_ARG(QString, jsonStr));
if (!ok) {
return "[]";
}
}
lastJson = jsonStr.toUtf8();
return lastJson.constData();
}
return "[]";
}

#endif

bool AmneziaApplication::m_forceQuit = false;

Expand All @@ -35,6 +78,10 @@ AmneziaApplication::AmneziaApplication(int &argc, char *argv[]) : AMNEZIA_BASE_C
m_optConnect ({QStringLiteral("connect")}, QStringLiteral("Connect to server by index on startup"), QStringLiteral("index")),
m_optImport ({QStringLiteral("import")}, QStringLiteral("Import configuration from data string"), QStringLiteral("data"))
{
#if defined(Q_OS_IOS)
g_amnApp = this;
#endif

setDesktopFileName(QStringLiteral(APPLICATION_NAME));
setQuitOnLastWindowClosed(false);

Expand All @@ -60,6 +107,11 @@ AmneziaApplication::AmneziaApplication(int &argc, char *argv[]) : AMNEZIA_BASE_C

AmneziaApplication::~AmneziaApplication()
{
#if defined(Q_OS_IOS)
if (g_amnApp == this) {
g_amnApp = nullptr;
}
#endif
#ifdef AMNEZIA_DESKTOP
if (m_vpnConnection && m_vpnConnectionThread.isRunning()) {
QMetaObject::invokeMethod(m_vpnConnection.get(), "disconnectSlots", Qt::BlockingQueuedConnection);
Expand Down Expand Up @@ -138,6 +190,10 @@ void AmneziaApplication::init()

m_coreController.reset(new CoreController(m_vpnConnection, m_settings, m_engine));

#if defined(Q_OS_IOS)
set_intent_callbacks(intent_reload, intent_connect, intent_get_countries);
#endif

m_engine->addImportPath("qrc:/ui/qml/Modules/");

if (m_parser.isSet(m_optImport)) {
Expand Down Expand Up @@ -307,3 +363,28 @@ QClipboard *AmneziaApplication::getClipboard()
{
return this->clipboard();
}

#if defined(Q_OS_IOS)
void AmneziaApplication::handleIntentReload()
{
if (m_coreController) {
m_coreController->intentReload();
}
}

void AmneziaApplication::handleIntentConnect(const QString &countryCode)
{
if (m_coreController) {
m_coreController->intentConnect(countryCode);
}
}

QString AmneziaApplication::handleIntentGetCountries()
{
if (m_coreController) {
return m_coreController->intentGetCountries();
}
return "[]";
}
#endif

6 changes: 6 additions & 0 deletions client/amneziaApplication.h
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ class AmneziaApplication : public AMNEZIA_BASE_CLASS

public slots:
void forceQuit();
#if defined(Q_OS_IOS)
void handleIntentReload();
void handleIntentConnect(const QString &countryCode);
QString handleIntentGetCountries();
#endif


private:
static bool m_forceQuit;
Expand Down
1 change: 1 addition & 0 deletions client/cmake/ios.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ target_sources(${PROJECT} PRIVATE
${CLIENT_ROOT_DIR}/platforms/ios/ScreenProtection.swift
${CLIENT_ROOT_DIR}/platforms/ios/VPNCController.swift
${CLIENT_ROOT_DIR}/platforms/ios/StoreKit2Helper.swift
${CLIENT_ROOT_DIR}/platforms/ios/AmneziaAppIntents.swift
)
target_sources(${PROJECT} PRIVATE
Expand Down
52 changes: 52 additions & 0 deletions client/core/controllers/coreController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -359,3 +359,55 @@ void CoreController::importConfigFromData(const QString &data)
m_importController->importConfig();
}
}

#if defined(Q_OS_IOS)
#include <QJsonDocument>
#include <QJsonArray>

void CoreController::intentReload()
{
if (m_subscriptionUiController && m_serversUiController && m_serversRepository) {
QString serverId = m_serversUiController->processedServerId();
if (serverId.isEmpty()) {
serverId = m_serversRepository->defaultServerId();
}
if (!serverId.isEmpty()) {
m_subscriptionUiController->updateServiceFromGateway(serverId, "", "", true);
}
}
}

void CoreController::intentConnect(const QString &countryCode)
{
if (m_subscriptionUiController && m_serversUiController && m_serversRepository) {
QString serverId = m_serversUiController->processedServerId();
if (serverId.isEmpty()) {
serverId = m_serversRepository->defaultServerId();
}
if (!serverId.isEmpty()) {
m_subscriptionUiController->updateServiceFromGateway(serverId, countryCode, "", false);
}
}
}

QString CoreController::intentGetCountries()
{
QJsonArray countriesList;
if (m_serversRepository && m_serversUiController) {
QString serverId = m_serversUiController->processedServerId();
if (serverId.isEmpty()) {
serverId = m_serversRepository->defaultServerId();
}

if (!serverId.isEmpty()) {
std::optional<ApiV2ServerConfig> server = m_serversRepository->apiV2Config(serverId);
if (server.has_value() && !server->apiConfig.availableCountries.isEmpty()) {
countriesList = server->apiConfig.availableCountries;
}
}
}
QJsonDocument doc(countriesList);
return QString::fromUtf8(doc.toJson(QJsonDocument::Compact));
}
#endif

7 changes: 7 additions & 0 deletions client/core/controllers/coreController.h
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ class CoreController : public QObject
void importConfigFromData(const QString &data);
void updateTranslator(const QLocale &locale);

#if defined(Q_OS_IOS)
void intentReload();
void intentConnect(const QString &countryCode);
QString intentGetCountries();
#endif


signals:
void translationsUpdated();
void websiteUrlChanged(const QString &newUrl);
Expand Down
131 changes: 131 additions & 0 deletions client/platforms/ios/AmneziaAppIntents.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import Foundation
import AppIntents

@available(iOS 16.0, *)
public struct IntentCallbacks {
public static var reload: (@convention(c) () -> Void)? = nil
public static var connect: (@convention(c) (UnsafePointer<CChar>) -> Void)? = nil
public static var getCountries: (@convention(c) () -> UnsafePointer<CChar>)? = nil
}

@_cdecl("set_intent_callbacks")
public func setIntentCallbacks(
reloadCallback: @escaping @convention(c) () -> Void,
connectCallback: @escaping @convention(c) (UnsafePointer<CChar>) -> Void,
getCountriesCallback: @escaping @convention(c) () -> UnsafePointer<CChar>
) {
if #available(iOS 16.0, *) {
IntentCallbacks.connect = connectCallback
IntentCallbacks.getCountries = getCountriesCallback
IntentCallbacks.reload = reloadCallback
}
}

@available(iOS 16.0, *)
func waitForCallbacks() async {
// Wait up to 10 seconds for Qt app to initialize
for _ in 0..<100 {
if IntentCallbacks.reload != nil { return }
try? await Task.sleep(nanoseconds: 100_000_000)
}
}

@available(iOS 16.0, *)
struct ReloadApiConfigIntent: AppIntent {
static var title: LocalizedStringResource = "Reload API Config"
static var description = IntentDescription("Reloads Amnezia VPN API Configuration.")

func perform() async throws -> some IntentResult {
await waitForCallbacks()
if let reload = IntentCallbacks.reload {
reload()
}
return .result()
}
}

@available(iOS 16.0, *)
struct CountryOption: AppEntity {
var id: String
var name: String

static var typeDisplayRepresentation: TypeDisplayRepresentation = "Country"

var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(name)")
}

static var defaultQuery = CountryOptionQuery()
}

@available(iOS 16.0, *)
struct CountryOptionQuery: EntityQuery {
func entities(for identifiers: [String]) async throws -> [CountryOption] {
let all = try await suggestedEntities()
return all.filter { identifiers.contains($0.id) }
}

func suggestedEntities() async throws -> [CountryOption] {
await waitForCallbacks()
var options: [CountryOption] = []
if let getCountries = IntentCallbacks.getCountries {
let jsonStringPtr = getCountries()
let jsonString = String(cString: jsonStringPtr)
if let data = jsonString.data(using: .utf8),
let array = try? JSONSerialization.jsonObject(with: data, options: []) as? [[String: String]] {
for item in array {
if let code = item["server_country_code"] ?? item["code"],
let name = item["server_country_name"] ?? item["name"] {
options.append(CountryOption(id: code, name: name))
}
}
}
}
return options
}
}

@available(iOS 16.0, *)
struct ConnectToCountryIntent: AppIntent {
static var title: LocalizedStringResource = "Connect to Country"
static var description = IntentDescription("Connects Amnezia VPN to a specific country.")

@Parameter(title: "Country")
var country: CountryOption

func perform() async throws -> some IntentResult {
await waitForCallbacks()
if let connect = IntentCallbacks.connect {
let code = country.id
code.withCString { cStr in
connect(cStr)
}
}
return .result()
}
}

@available(iOS 16.0, *)
struct AmneziaShortcutsProvider: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: ReloadApiConfigIntent(),
phrases: [
"Reload \(.applicationName) API Config",
"Refresh \(.applicationName) Configuration"
],
shortTitle: "Reload API Config",
systemImageName: "arrow.clockwise"
)

AppShortcut(
intent: ConnectToCountryIntent(),
phrases: [
"Connect to Country in \(.applicationName)",
"Connect \(.applicationName) to Country"
],
shortTitle: "Connect to Country",
systemImageName: "network"
)
}
}