diff --git a/client/amneziaApplication.cpp b/client/amneziaApplication.cpp index 008cc345d2..25885c832a 100644 --- a/client/amneziaApplication.cpp +++ b/client/amneziaApplication.cpp @@ -25,7 +25,50 @@ #include "version.h" #include "platforms/ios/QRCodeReaderBase.h" - + +#if defined(Q_OS_IOS) +#include + +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; @@ -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); @@ -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); @@ -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)) { @@ -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 + diff --git a/client/amneziaApplication.h b/client/amneziaApplication.h index 33b262c7fa..ee3d9614e8 100644 --- a/client/amneziaApplication.h +++ b/client/amneziaApplication.h @@ -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; diff --git a/client/cmake/ios.cmake b/client/cmake/ios.cmake index 85f53f32ed..79c7ae9abb 100644 --- a/client/cmake/ios.cmake +++ b/client/cmake/ios.cmake @@ -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 diff --git a/client/core/controllers/coreController.cpp b/client/core/controllers/coreController.cpp index 878f4babc3..a9db4e831d 100644 --- a/client/core/controllers/coreController.cpp +++ b/client/core/controllers/coreController.cpp @@ -359,3 +359,55 @@ void CoreController::importConfigFromData(const QString &data) m_importController->importConfig(); } } + +#if defined(Q_OS_IOS) +#include +#include + +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 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 + diff --git a/client/core/controllers/coreController.h b/client/core/controllers/coreController.h index b8a3d16928..467233ebfe 100644 --- a/client/core/controllers/coreController.h +++ b/client/core/controllers/coreController.h @@ -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); diff --git a/client/platforms/ios/AmneziaAppIntents.swift b/client/platforms/ios/AmneziaAppIntents.swift new file mode 100644 index 0000000000..bb5fffaa5a --- /dev/null +++ b/client/platforms/ios/AmneziaAppIntents.swift @@ -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) -> Void)? = nil + public static var getCountries: (@convention(c) () -> UnsafePointer)? = nil +} + +@_cdecl("set_intent_callbacks") +public func setIntentCallbacks( + reloadCallback: @escaping @convention(c) () -> Void, + connectCallback: @escaping @convention(c) (UnsafePointer) -> Void, + getCountriesCallback: @escaping @convention(c) () -> UnsafePointer +) { + 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" + ) + } +}