mirror of
https://github.com/amnezia-vpn/amnezia-client.git
synced 2026-06-22 02:01:08 +07:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0cb1c02ab | |||
| 39d4c6f1ec | |||
| 129ae44edc | |||
| 16fc44f989 | |||
| ef909d3605 | |||
| b9ca3315c6 | |||
| e9ed5b59a4 | |||
| 047dbb2677 | |||
| e9efe32f9b | |||
| 2dd3531e78 | |||
| 129f79ca2c | |||
| 50769f231d | |||
| 2edd7de413 | |||
| f0da2b003f | |||
| 650c1c6ebb | |||
| 8dbded1624 | |||
| cebfcc846e | |||
| 4c18ceaa50 | |||
| ebe3a5dac6 | |||
| 92deee5f67 | |||
| a75bd0cf5e | |||
| 46f5b3894b | |||
| 493ee22883 | |||
| ad14847eb5 | |||
| cd50e0b8a5 | |||
| 78f504e35c | |||
| bf3d11e5c4 | |||
| 9a0222aee3 | |||
| f0f0f7c5be | |||
| 36b1a863bf | |||
| 4103c5bbcf | |||
| fa69da6d56 | |||
| aaf2c9ddeb | |||
| dbbc7119ec | |||
| c57162c4cc |
@@ -17,6 +17,7 @@ jobs:
|
|||||||
QIF_VERSION: 4.7
|
QIF_VERSION: 4.7
|
||||||
PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }}
|
PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }}
|
||||||
PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }}
|
PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }}
|
||||||
|
FALLBACK_S3_ENDPOINT: ${{ secrets.FALLBACK_S3_ENDPOINT }}
|
||||||
DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }}
|
DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }}
|
||||||
DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }}
|
DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }}
|
||||||
DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }}
|
DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }}
|
||||||
@@ -98,6 +99,7 @@ jobs:
|
|||||||
BUILD_ARCH: 64
|
BUILD_ARCH: 64
|
||||||
PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }}
|
PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }}
|
||||||
PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }}
|
PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }}
|
||||||
|
FALLBACK_S3_ENDPOINT: ${{ secrets.FALLBACK_S3_ENDPOINT }}
|
||||||
DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }}
|
DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }}
|
||||||
DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }}
|
DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }}
|
||||||
DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }}
|
DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }}
|
||||||
@@ -204,6 +206,7 @@ jobs:
|
|||||||
CXX: c++
|
CXX: c++
|
||||||
PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }}
|
PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }}
|
||||||
PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }}
|
PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }}
|
||||||
|
FALLBACK_S3_ENDPOINT: ${{ secrets.FALLBACK_S3_ENDPOINT }}
|
||||||
DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }}
|
DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }}
|
||||||
DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }}
|
DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }}
|
||||||
DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }}
|
DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }}
|
||||||
@@ -318,6 +321,7 @@ jobs:
|
|||||||
|
|
||||||
PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }}
|
PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }}
|
||||||
PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }}
|
PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }}
|
||||||
|
FALLBACK_S3_ENDPOINT: ${{ secrets.FALLBACK_S3_ENDPOINT }}
|
||||||
DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }}
|
DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }}
|
||||||
DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }}
|
DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }}
|
||||||
DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }}
|
DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }}
|
||||||
@@ -395,6 +399,7 @@ jobs:
|
|||||||
|
|
||||||
PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }}
|
PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }}
|
||||||
PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }}
|
PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }}
|
||||||
|
FALLBACK_S3_ENDPOINT: ${{ secrets.FALLBACK_S3_ENDPOINT }}
|
||||||
DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }}
|
DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }}
|
||||||
DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }}
|
DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }}
|
||||||
DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }}
|
DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }}
|
||||||
@@ -477,6 +482,7 @@ jobs:
|
|||||||
|
|
||||||
PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }}
|
PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }}
|
||||||
PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }}
|
PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }}
|
||||||
|
FALLBACK_S3_ENDPOINT: ${{ secrets.FALLBACK_S3_ENDPOINT }}
|
||||||
DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }}
|
DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }}
|
||||||
DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }}
|
DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }}
|
||||||
DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }}
|
DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }}
|
||||||
@@ -541,10 +547,11 @@ jobs:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
ANDROID_BUILD_PLATFORM: android-36
|
ANDROID_BUILD_PLATFORM: android-36
|
||||||
QT_VERSION: 6.10.1
|
QT_VERSION: 6.11.1
|
||||||
QT_MODULES: 'qtremoteobjects qt5compat qtimageformats qtshadertools'
|
QT_MODULES: 'qtremoteobjects qt5compat qtimageformats qtshadertools'
|
||||||
PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }}
|
PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }}
|
||||||
PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }}
|
PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }}
|
||||||
|
FALLBACK_S3_ENDPOINT: ${{ secrets.FALLBACK_S3_ENDPOINT }}
|
||||||
DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }}
|
DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }}
|
||||||
DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }}
|
DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }}
|
||||||
DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }}
|
DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ jobs:
|
|||||||
QIF_VERSION: 4.5
|
QIF_VERSION: 4.5
|
||||||
PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }}
|
PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }}
|
||||||
PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }}
|
PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }}
|
||||||
|
FALLBACK_S3_ENDPOINT: ${{ secrets.FALLBACK_S3_ENDPOINT }}
|
||||||
DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }}
|
DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }}
|
||||||
DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }}
|
DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }}
|
||||||
DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }}
|
DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }}
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
cmake_minimum_required(VERSION 3.25.0 FATAL_ERROR)
|
cmake_minimum_required(VERSION 3.25.0 FATAL_ERROR)
|
||||||
|
|
||||||
set(PROJECT AmneziaVPN)
|
set(PROJECT AmneziaVPN)
|
||||||
set(AMNEZIAVPN_VERSION 4.8.14.5)
|
set(AMNEZIAVPN_VERSION 4.8.19.0)
|
||||||
|
|
||||||
project(${PROJECT} VERSION ${AMNEZIAVPN_VERSION}
|
project(${PROJECT} VERSION ${AMNEZIAVPN_VERSION}
|
||||||
DESCRIPTION "AmneziaVPN"
|
DESCRIPTION "AmneziaVPN"
|
||||||
@@ -12,7 +12,7 @@ string(TIMESTAMP CURRENT_DATE "%Y-%m-%d")
|
|||||||
set(RELEASE_DATE "${CURRENT_DATE}")
|
set(RELEASE_DATE "${CURRENT_DATE}")
|
||||||
|
|
||||||
set(APP_MAJOR_VERSION ${CMAKE_PROJECT_VERSION_MAJOR}.${CMAKE_PROJECT_VERSION_MINOR}.${CMAKE_PROJECT_VERSION_PATCH})
|
set(APP_MAJOR_VERSION ${CMAKE_PROJECT_VERSION_MAJOR}.${CMAKE_PROJECT_VERSION_MINOR}.${CMAKE_PROJECT_VERSION_PATCH})
|
||||||
set(APP_ANDROID_VERSION_CODE 2118)
|
set(APP_ANDROID_VERSION_CODE 2129)
|
||||||
|
|
||||||
if(${CMAKE_SYSTEM_NAME} STREQUAL "Linux")
|
if(${CMAKE_SYSTEM_NAME} STREQUAL "Linux")
|
||||||
set(MZ_PLATFORM_NAME "linux")
|
set(MZ_PLATFORM_NAME "linux")
|
||||||
|
|||||||
+1
-1
Submodule client/3rd-prebuilt updated: 51bb4703a4...4680bd8fb4
@@ -25,6 +25,7 @@ add_definitions(-DGIT_COMMIT_HASH="${GIT_COMMIT_HASH}")
|
|||||||
|
|
||||||
add_definitions(-DPROD_AGW_PUBLIC_KEY="$ENV{PROD_AGW_PUBLIC_KEY}")
|
add_definitions(-DPROD_AGW_PUBLIC_KEY="$ENV{PROD_AGW_PUBLIC_KEY}")
|
||||||
add_definitions(-DPROD_S3_ENDPOINT="$ENV{PROD_S3_ENDPOINT}")
|
add_definitions(-DPROD_S3_ENDPOINT="$ENV{PROD_S3_ENDPOINT}")
|
||||||
|
add_definitions(-DFALLBACK_S3_ENDPOINT="$ENV{FALLBACK_S3_ENDPOINT}")
|
||||||
|
|
||||||
add_definitions(-DDEV_AGW_PUBLIC_KEY="$ENV{DEV_AGW_PUBLIC_KEY}")
|
add_definitions(-DDEV_AGW_PUBLIC_KEY="$ENV{DEV_AGW_PUBLIC_KEY}")
|
||||||
add_definitions(-DDEV_AGW_ENDPOINT="$ENV{DEV_AGW_ENDPOINT}")
|
add_definitions(-DDEV_AGW_ENDPOINT="$ENV{DEV_AGW_ENDPOINT}")
|
||||||
|
|||||||
@@ -296,9 +296,25 @@ class AmneziaActivity : QtActivity() {
|
|||||||
hasWindowFocus = hasFocus
|
hasWindowFocus = hasFocus
|
||||||
Log.d(TAG, "Window focus changed: hasFocus=$hasFocus")
|
Log.d(TAG, "Window focus changed: hasFocus=$hasFocus")
|
||||||
|
|
||||||
// Cancel pending operations if window loses focus
|
|
||||||
if (!hasFocus) {
|
if (!hasFocus) {
|
||||||
|
// Cancel pending operations if window loses focus
|
||||||
resumeHandler.removeCallbacksAndMessages(null)
|
resumeHandler.removeCallbacksAndMessages(null)
|
||||||
|
} else if (isActivityResumed && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
window.decorView.apply {
|
||||||
|
invalidate()
|
||||||
|
resumeHandler.postDelayed({
|
||||||
|
if (isActivityResumed && hasWindowFocus && !isFinishing && !isDestroyed) {
|
||||||
|
sendTouch(1f, 1f)
|
||||||
|
}
|
||||||
|
}, 50)
|
||||||
|
resumeHandler.postDelayed({
|
||||||
|
if (isActivityResumed && hasWindowFocus && !isFinishing && !isDestroyed) {
|
||||||
|
sendTouch(2f, 2f)
|
||||||
|
requestLayout()
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,6 +353,13 @@ class AmneziaActivity : QtActivity() {
|
|||||||
private external fun nativeGamepadKeyEvent(deviceId: Int, keyCode: Int, pressed: Boolean)
|
private external fun nativeGamepadKeyEvent(deviceId: Int, keyCode: Int, pressed: Boolean)
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
|
// Notify Qt to stop rendering BEFORE super.onPause() destroys the EGL surface.
|
||||||
|
// Using a coroutine here would be too late — the surface is gone by the time
|
||||||
|
// the coroutine runs. A direct synchronous call gives Qt's render thread the
|
||||||
|
// best chance to process visible=false before surface destruction.
|
||||||
|
if (qtInitialized.isCompleted) {
|
||||||
|
QtAndroidController.onActivityPaused()
|
||||||
|
}
|
||||||
super.onPause()
|
super.onPause()
|
||||||
isActivityResumed = false
|
isActivityResumed = false
|
||||||
// Cancel all pending operations when activity pauses
|
// Cancel all pending operations when activity pauses
|
||||||
@@ -349,6 +372,9 @@ class AmneziaActivity : QtActivity() {
|
|||||||
super.onResume()
|
super.onResume()
|
||||||
isActivityResumed = true
|
isActivityResumed = true
|
||||||
Log.d(TAG, "Resume Amnezia activity")
|
Log.d(TAG, "Resume Amnezia activity")
|
||||||
|
if (qtInitialized.isCompleted) {
|
||||||
|
QtAndroidController.onActivityResumed()
|
||||||
|
}
|
||||||
|
|
||||||
if (pendingOpenFileUri != null && !openFileDeliveryScheduled) {
|
if (pendingOpenFileUri != null && !openFileDeliveryScheduled) {
|
||||||
val uri = pendingOpenFileUri!!
|
val uri = pendingOpenFileUri!!
|
||||||
@@ -766,6 +792,16 @@ class AmneziaActivity : QtActivity() {
|
|||||||
else -> type = "*/*"
|
else -> type = "*/*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Force system document picker to avoid third-party file managers
|
||||||
|
// that may lack storage permissions (common on Android TV devices)
|
||||||
|
val systemPickerPackage = listOf("com.google.android.documentsui", "com.android.documentsui")
|
||||||
|
.firstOrNull { pkg ->
|
||||||
|
try { packageManager.getPackageInfo(pkg, 0); true }
|
||||||
|
catch (_: PackageManager.NameNotFoundException) { false }
|
||||||
|
}
|
||||||
|
if (systemPickerPackage != null) {
|
||||||
|
`package` = systemPickerPackage
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Intent(this@AmneziaActivity, TvFilePicker::class.java)
|
Intent(this@AmneziaActivity, TvFilePicker::class.java)
|
||||||
@@ -1038,13 +1074,11 @@ class AmneziaActivity : QtActivity() {
|
|||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
fun sendTouch(x: Float, y: Float) {
|
fun sendTouch(x: Float, y: Float) {
|
||||||
Log.v(TAG, "Send touch: $x, $y")
|
Log.v(TAG, "Send touch: $x, $y")
|
||||||
blockingCall {
|
|
||||||
findQtWindow(window.decorView)?.let {
|
findQtWindow(window.decorView)?.let {
|
||||||
Log.v(TAG, "Send touch to $it")
|
Log.v(TAG, "Send touch to $it")
|
||||||
it.dispatchTouchEvent(createEvent(x, y, SystemClock.uptimeMillis(), MotionEvent.ACTION_DOWN))
|
it.dispatchTouchEvent(createEvent(x, y, SystemClock.uptimeMillis(), MotionEvent.ACTION_DOWN))
|
||||||
it.dispatchTouchEvent(createEvent(x, y, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP))
|
it.dispatchTouchEvent(createEvent(x, y, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun findQtWindow(view: View): View? {
|
private fun findQtWindow(view: View): View? {
|
||||||
|
|||||||
@@ -31,4 +31,7 @@ object QtAndroidController {
|
|||||||
|
|
||||||
external fun onImeInsetsChanged(heightDp: Int)
|
external fun onImeInsetsChanged(heightDp: Int)
|
||||||
external fun onSystemBarsInsetsChanged(navBarHeightDp: Int, statusBarHeightDp: Int)
|
external fun onSystemBarsInsetsChanged(navBarHeightDp: Int, statusBarHeightDp: Int)
|
||||||
|
|
||||||
|
external fun onActivityPaused()
|
||||||
|
external fun onActivityResumed()
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,9 @@ import android.content.Context
|
|||||||
import android.net.VpnService.Builder
|
import android.net.VpnService.Builder
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.net.ServerSocket
|
||||||
|
import java.util.UUID
|
||||||
import go.Seq
|
import go.Seq
|
||||||
import org.amnezia.vpn.protocol.BadConfigException
|
import org.amnezia.vpn.protocol.BadConfigException
|
||||||
import org.amnezia.vpn.protocol.Protocol
|
import org.amnezia.vpn.protocol.Protocol
|
||||||
@@ -19,11 +22,32 @@ import org.amnezia.vpn.util.Log
|
|||||||
import org.amnezia.vpn.util.net.InetNetwork
|
import org.amnezia.vpn.util.net.InetNetwork
|
||||||
import org.amnezia.vpn.util.net.ip
|
import org.amnezia.vpn.util.net.ip
|
||||||
import org.amnezia.vpn.util.net.parseInetAddress
|
import org.amnezia.vpn.util.net.parseInetAddress
|
||||||
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
||||||
private const val TAG = "Xray"
|
private const val TAG = "Xray"
|
||||||
private const val LIBXRAY_TAG = "libXray"
|
private const val LIBXRAY_TAG = "libXray"
|
||||||
|
|
||||||
|
private fun findSocksInboundIndex(inbounds: JSONArray): Int {
|
||||||
|
for (i in 0 until inbounds.length()) {
|
||||||
|
val o = inbounds.optJSONObject(i) ?: continue
|
||||||
|
if (o.optString("protocol").equals("socks", ignoreCase = true)) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun acquireFreeLocalPort(): Int {
|
||||||
|
try {
|
||||||
|
ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")).use { return it.localPort }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw VpnStartException(
|
||||||
|
"Failed to acquire free TCP port on 127.0.0.1 for SOCKS inbound: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class Xray : Protocol() {
|
class Xray : Protocol() {
|
||||||
|
|
||||||
private var isRunning: Boolean = false
|
private var isRunning: Boolean = false
|
||||||
@@ -56,6 +80,10 @@ class Xray : Protocol() {
|
|||||||
val xrayJsonConfig = config.optJSONObject("xray_config_data")
|
val xrayJsonConfig = config.optJSONObject("xray_config_data")
|
||||||
?: config.optJSONObject("ssxray_config_data")
|
?: config.optJSONObject("ssxray_config_data")
|
||||||
?: throw BadConfigException("config_data not found")
|
?: throw BadConfigException("config_data not found")
|
||||||
|
|
||||||
|
// Inject SOCKS5 auth before starting xray. Re-uses existing credentials if present.
|
||||||
|
ensureInboundAuth(xrayJsonConfig)
|
||||||
|
|
||||||
val xrayConfig = parseConfig(config, xrayJsonConfig)
|
val xrayConfig = parseConfig(config, xrayJsonConfig)
|
||||||
|
|
||||||
(xrayJsonConfig.optJSONObject("log") ?: JSONObject().also { xrayJsonConfig.put("log", it) })
|
(xrayJsonConfig.optJSONObject("log") ?: JSONObject().also { xrayJsonConfig.put("log", it) })
|
||||||
@@ -97,9 +125,22 @@ class Xray : Protocol() {
|
|||||||
if (it.isNotBlank()) setMtu(it.toInt())
|
if (it.isNotBlank()) setMtu(it.toInt())
|
||||||
}
|
}
|
||||||
|
|
||||||
val socksConfig = xrayJsonConfig.getJSONArray("inbounds")[0] as JSONObject
|
val inbounds = xrayJsonConfig.getJSONArray("inbounds")
|
||||||
|
val socksIdx = findSocksInboundIndex(inbounds)
|
||||||
|
if (socksIdx < 0) {
|
||||||
|
throw BadConfigException("socks inbound not found")
|
||||||
|
}
|
||||||
|
val socksConfig = inbounds.getJSONObject(socksIdx)
|
||||||
socksConfig.getInt("port").let { setSocksPort(it) }
|
socksConfig.getInt("port").let { setSocksPort(it) }
|
||||||
|
|
||||||
|
val socksSettings = socksConfig.optJSONObject("settings")
|
||||||
|
val accounts = socksSettings?.optJSONArray("accounts")
|
||||||
|
if (accounts != null && accounts.length() > 0) {
|
||||||
|
val account = accounts.getJSONObject(0)
|
||||||
|
setSocksUser(account.optString("user"))
|
||||||
|
setSocksPass(account.optString("pass"))
|
||||||
|
}
|
||||||
|
|
||||||
configSplitTunneling(config)
|
configSplitTunneling(config)
|
||||||
configAppSplitTunneling(config)
|
configAppSplitTunneling(config)
|
||||||
}
|
}
|
||||||
@@ -162,9 +203,10 @@ class Xray : Protocol() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun runTun2Socks(config: XrayConfig, fd: Int) {
|
private fun runTun2Socks(config: XrayConfig, fd: Int) {
|
||||||
|
val proxyUrl = "socks5://${config.socksUser}:${config.socksPass}@127.0.0.1:${config.socksPort}"
|
||||||
val tun2SocksConfig = Tun2SocksConfig().apply {
|
val tun2SocksConfig = Tun2SocksConfig().apply {
|
||||||
mtu = config.mtu.toLong()
|
mtu = config.mtu.toLong()
|
||||||
proxy = "socks5://127.0.0.1:${config.socksPort}"
|
proxy = proxyUrl
|
||||||
device = "fd://$fd"
|
device = "fd://$fd"
|
||||||
logLevel = "warn"
|
logLevel = "warn"
|
||||||
}
|
}
|
||||||
@@ -173,6 +215,37 @@ class Xray : Protocol() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensures SOCKS5 auth is present on the socks inbound settings.
|
||||||
|
// Re-uses existing credentials if already configured; otherwise generates random ones.
|
||||||
|
private fun ensureInboundAuth(xrayConfig: JSONObject) {
|
||||||
|
val inbounds = xrayConfig.optJSONArray("inbounds") ?: return
|
||||||
|
val socksIdx = findSocksInboundIndex(inbounds)
|
||||||
|
if (socksIdx < 0) return
|
||||||
|
|
||||||
|
val inbound = inbounds.getJSONObject(socksIdx)
|
||||||
|
inbound.put("port", acquireFreeLocalPort())
|
||||||
|
val settings = inbound.optJSONObject("settings") ?: JSONObject().also { inbound.put("settings", it) }
|
||||||
|
val accounts = settings.optJSONArray("accounts")
|
||||||
|
if (accounts != null && accounts.length() > 0) {
|
||||||
|
val account = accounts.getJSONObject(0)
|
||||||
|
if (account.optString("user").isNotEmpty() && account.optString("pass").isNotEmpty()) {
|
||||||
|
// Ensure auth mode is enforced even for imported configs that had accounts
|
||||||
|
// but auth: "noauth" (or no auth field).
|
||||||
|
settings.put("auth", "password")
|
||||||
|
inbound.put("settings", settings)
|
||||||
|
inbounds.put(socksIdx, inbound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val user = UUID.randomUUID().toString().replace("-", "").substring(0, 16)
|
||||||
|
val pass = UUID.randomUUID().toString().replace("-", "")
|
||||||
|
settings.put("auth", "password")
|
||||||
|
settings.put("accounts", JSONArray().put(JSONObject().put("user", user).put("pass", pass)))
|
||||||
|
inbound.put("settings", settings)
|
||||||
|
inbounds.put(socksIdx, inbound)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val instance: Xray by lazy { Xray() }
|
val instance: Xray by lazy { Xray() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,16 @@ private const val XRAY_DEFAULT_MAX_MEMORY: Long = 50 shl 20 // 50 MB
|
|||||||
class XrayConfig protected constructor(
|
class XrayConfig protected constructor(
|
||||||
protocolConfigBuilder: ProtocolConfig.Builder,
|
protocolConfigBuilder: ProtocolConfig.Builder,
|
||||||
val socksPort: Int,
|
val socksPort: Int,
|
||||||
|
val socksUser: String,
|
||||||
|
val socksPass: String,
|
||||||
val maxMemory: Long,
|
val maxMemory: Long,
|
||||||
) : ProtocolConfig(protocolConfigBuilder) {
|
) : ProtocolConfig(protocolConfigBuilder) {
|
||||||
|
|
||||||
protected constructor(builder: Builder) : this(
|
protected constructor(builder: Builder) : this(
|
||||||
builder,
|
builder,
|
||||||
builder.socksPort,
|
builder.socksPort,
|
||||||
|
builder.socksUser,
|
||||||
|
builder.socksPass,
|
||||||
builder.maxMemory
|
builder.maxMemory
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,6 +26,12 @@ class XrayConfig protected constructor(
|
|||||||
internal var socksPort: Int = 0
|
internal var socksPort: Int = 0
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
internal var socksUser: String = ""
|
||||||
|
private set
|
||||||
|
|
||||||
|
internal var socksPass: String = ""
|
||||||
|
private set
|
||||||
|
|
||||||
internal var maxMemory: Long = XRAY_DEFAULT_MAX_MEMORY
|
internal var maxMemory: Long = XRAY_DEFAULT_MAX_MEMORY
|
||||||
private set
|
private set
|
||||||
|
|
||||||
@@ -29,6 +39,10 @@ class XrayConfig protected constructor(
|
|||||||
|
|
||||||
fun setSocksPort(port: Int) = apply { socksPort = port }
|
fun setSocksPort(port: Int) = apply { socksPort = port }
|
||||||
|
|
||||||
|
fun setSocksUser(user: String) = apply { socksUser = user }
|
||||||
|
|
||||||
|
fun setSocksPass(pass: String) = apply { socksPass = pass }
|
||||||
|
|
||||||
fun setMaxMemory(maxMemory: Long) = apply { this.maxMemory = maxMemory }
|
fun setMaxMemory(maxMemory: Long) = apply { this.maxMemory = maxMemory }
|
||||||
|
|
||||||
override fun build(): XrayConfig = configBuild().run { XrayConfig(this@Builder) }
|
override fun build(): XrayConfig = configBuild().run { XrayConfig(this@Builder) }
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ target_sources(${PROJECT} PRIVATE
|
|||||||
${CLIENT_ROOT_DIR}/platforms/ios/LogRecord.swift
|
${CLIENT_ROOT_DIR}/platforms/ios/LogRecord.swift
|
||||||
${CLIENT_ROOT_DIR}/platforms/ios/ScreenProtection.swift
|
${CLIENT_ROOT_DIR}/platforms/ios/ScreenProtection.swift
|
||||||
${CLIENT_ROOT_DIR}/platforms/ios/VPNCController.swift
|
${CLIENT_ROOT_DIR}/platforms/ios/VPNCController.swift
|
||||||
|
${CLIENT_ROOT_DIR}/platforms/ios/StoreKit2Helper.swift
|
||||||
)
|
)
|
||||||
|
|
||||||
target_sources(${PROJECT} PRIVATE
|
target_sources(${PROJECT} PRIVATE
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ target_sources(${PROJECT} PRIVATE
|
|||||||
${CLIENT_ROOT_DIR}/platforms/ios/LogRecord.swift
|
${CLIENT_ROOT_DIR}/platforms/ios/LogRecord.swift
|
||||||
${CLIENT_ROOT_DIR}/platforms/ios/ScreenProtection.swift
|
${CLIENT_ROOT_DIR}/platforms/ios/ScreenProtection.swift
|
||||||
${CLIENT_ROOT_DIR}/platforms/ios/VPNCController.swift
|
${CLIENT_ROOT_DIR}/platforms/ios/VPNCController.swift
|
||||||
|
${CLIENT_ROOT_DIR}/platforms/ios/StoreKit2Helper.swift
|
||||||
)
|
)
|
||||||
|
|
||||||
target_sources(${PROJECT} PRIVATE
|
target_sources(${PROJECT} PRIVATE
|
||||||
|
|||||||
@@ -298,14 +298,14 @@ bool ContainerProps::isSupportedByCurrentPlatform(DockerContainer c)
|
|||||||
}
|
}
|
||||||
|
|
||||||
#elif defined(MACOS_NE)
|
#elif defined(MACOS_NE)
|
||||||
// macOS build using Network Extension – hide OpenVPN-based containers
|
// macOS build using Network Extension – allow OpenVPN for parity with iOS.
|
||||||
switch (c) {
|
switch (c) {
|
||||||
|
case DockerContainer::OpenVpn: return true;
|
||||||
case DockerContainer::WireGuard: return true;
|
case DockerContainer::WireGuard: return true;
|
||||||
case DockerContainer::Awg2: return true;
|
case DockerContainer::Awg2: return true;
|
||||||
case DockerContainer::Awg: return true;
|
case DockerContainer::Awg: return true;
|
||||||
case DockerContainer::Xray: return true;
|
case DockerContainer::Xray: return true;
|
||||||
case DockerContainer::SSXray: return true;
|
case DockerContainer::SSXray: return true;
|
||||||
case DockerContainer::OpenVpn:
|
|
||||||
case DockerContainer::Cloak:
|
case DockerContainer::Cloak:
|
||||||
case DockerContainer::ShadowSocks:
|
case DockerContainer::ShadowSocks:
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ namespace apiDefs
|
|||||||
AmneziaPremiumV1,
|
AmneziaPremiumV1,
|
||||||
AmneziaPremiumV2,
|
AmneziaPremiumV2,
|
||||||
SelfHosted,
|
SelfHosted,
|
||||||
ExternalPremium
|
ExternalPremium,
|
||||||
|
ExternalTrial
|
||||||
};
|
};
|
||||||
|
|
||||||
enum ConfigSource {
|
enum ConfigSource {
|
||||||
@@ -32,6 +33,7 @@ namespace apiDefs
|
|||||||
constexpr QLatin1String stackType("stack_type");
|
constexpr QLatin1String stackType("stack_type");
|
||||||
constexpr QLatin1String serviceType("service_type");
|
constexpr QLatin1String serviceType("service_type");
|
||||||
constexpr QLatin1String cliVersion("cli_version");
|
constexpr QLatin1String cliVersion("cli_version");
|
||||||
|
constexpr QLatin1String cliName("cli_name");
|
||||||
constexpr QLatin1String supportedProtocols("supported_protocols");
|
constexpr QLatin1String supportedProtocols("supported_protocols");
|
||||||
|
|
||||||
constexpr QLatin1String vpnKey("vpn_key");
|
constexpr QLatin1String vpnKey("vpn_key");
|
||||||
@@ -53,8 +55,14 @@ namespace apiDefs
|
|||||||
constexpr QLatin1String activeDeviceCount("active_device_count");
|
constexpr QLatin1String activeDeviceCount("active_device_count");
|
||||||
constexpr QLatin1String maxDeviceCount("max_device_count");
|
constexpr QLatin1String maxDeviceCount("max_device_count");
|
||||||
constexpr QLatin1String subscriptionEndDate("subscription_end_date");
|
constexpr QLatin1String subscriptionEndDate("subscription_end_date");
|
||||||
|
constexpr QLatin1String subscriptionExpiredByServer("subscription_expired_by_server");
|
||||||
|
constexpr QLatin1String subscriptionStatus("subscription_status");
|
||||||
|
constexpr QLatin1String subscription("subscription");
|
||||||
|
constexpr QLatin1String endDate("end_date");
|
||||||
constexpr QLatin1String issuedConfigs("issued_configs");
|
constexpr QLatin1String issuedConfigs("issued_configs");
|
||||||
constexpr QLatin1String subscriptionDescription("subscription_description");
|
constexpr QLatin1String subscriptionDescription("subscription_description");
|
||||||
|
constexpr QLatin1String termsOfUseUrl("terms_of_use_url");
|
||||||
|
constexpr QLatin1String privacyPolicyUrl("privacy_policy_url");
|
||||||
|
|
||||||
constexpr QLatin1String supportInfo("support_info");
|
constexpr QLatin1String supportInfo("support_info");
|
||||||
constexpr QLatin1String email("email");
|
constexpr QLatin1String email("email");
|
||||||
@@ -69,11 +77,13 @@ namespace apiDefs
|
|||||||
|
|
||||||
constexpr QLatin1String transactionId("transaction_id");
|
constexpr QLatin1String transactionId("transaction_id");
|
||||||
constexpr QLatin1String isTestPurchase("is_test_purchase");
|
constexpr QLatin1String isTestPurchase("is_test_purchase");
|
||||||
|
constexpr QLatin1String isInAppPurchase("is_in_app_purchase");
|
||||||
|
|
||||||
constexpr QLatin1String userCountryCode("user_country_code");
|
constexpr QLatin1String userCountryCode("user_country_code");
|
||||||
|
|
||||||
constexpr QLatin1String serviceInfo("service_info");
|
constexpr QLatin1String serviceInfo("service_info");
|
||||||
constexpr QLatin1String isAdVisible("is_ad_visible");
|
constexpr QLatin1String isAdVisible("is_ad_visible");
|
||||||
|
constexpr QLatin1String isRenewalAvailable("is_renewal_available");
|
||||||
constexpr QLatin1String adHeader("ad_header");
|
constexpr QLatin1String adHeader("ad_header");
|
||||||
constexpr QLatin1String adDescription("ad_description");
|
constexpr QLatin1String adDescription("ad_description");
|
||||||
constexpr QLatin1String adEndpoint("ad_endpoint");
|
constexpr QLatin1String adEndpoint("ad_endpoint");
|
||||||
|
|||||||
@@ -3,11 +3,33 @@
|
|||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
|
#include <QJsonValue>
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
const QByteArray AMNEZIA_CONFIG_SIGNATURE = QByteArray::fromHex("000000ff");
|
const QByteArray AMNEZIA_CONFIG_SIGNATURE = QByteArray::fromHex("000000ff");
|
||||||
|
|
||||||
|
constexpr QLatin1String unprocessableSubscriptionMessage("Failed to retrieve subscription information. Is it activated?");
|
||||||
|
constexpr QLatin1String trialAlreadyUsedMessage("trial subscription already used");
|
||||||
|
|
||||||
|
QDateTime subscriptionEndUtcFromString(const QString &subscriptionEndDate)
|
||||||
|
{
|
||||||
|
if (subscriptionEndDate.isEmpty()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
QDateTime endDate = QDateTime::fromString(subscriptionEndDate, Qt::ISODateWithMs).toUTC();
|
||||||
|
if (!endDate.isValid()) {
|
||||||
|
endDate = QDateTime::fromString(subscriptionEndDate, Qt::ISODate).toUTC();
|
||||||
|
}
|
||||||
|
return endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString apiErrorMessageFromJson(const QJsonObject &jsonObj)
|
||||||
|
{
|
||||||
|
const QJsonValue value = jsonObj.value(QStringLiteral("message"));
|
||||||
|
return value.isString() ? value.toString().trimmed() : QString();
|
||||||
|
}
|
||||||
|
|
||||||
QString escapeUnicode(const QString &input)
|
QString escapeUnicode(const QString &input)
|
||||||
{
|
{
|
||||||
QString output;
|
QString output;
|
||||||
@@ -24,9 +46,30 @@ namespace
|
|||||||
|
|
||||||
bool apiUtils::isSubscriptionExpired(const QString &subscriptionEndDate)
|
bool apiUtils::isSubscriptionExpired(const QString &subscriptionEndDate)
|
||||||
{
|
{
|
||||||
QDateTime now = QDateTime::currentDateTimeUtc();
|
if (subscriptionEndDate.isEmpty()) {
|
||||||
QDateTime endDate = QDateTime::fromString(subscriptionEndDate, Qt::ISODateWithMs);
|
return false;
|
||||||
return endDate < now;
|
}
|
||||||
|
const QDateTime endDate = subscriptionEndUtcFromString(subscriptionEndDate);
|
||||||
|
if (!endDate.isValid()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return endDate <= QDateTime::currentDateTimeUtc();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool apiUtils::isSubscriptionExpiringSoon(const QString &subscriptionEndDate, int withinDays)
|
||||||
|
{
|
||||||
|
if (subscriptionEndDate.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const QDateTime endDate = subscriptionEndUtcFromString(subscriptionEndDate);
|
||||||
|
if (!endDate.isValid()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const QDateTime nowUtc = QDateTime::currentDateTimeUtc();
|
||||||
|
if (endDate <= nowUtc) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return endDate <= nowUtc.addDays(withinDays);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool apiUtils::isServerFromApi(const QJsonObject &serverConfigObject)
|
bool apiUtils::isServerFromApi(const QJsonObject &serverConfigObject)
|
||||||
@@ -60,6 +103,7 @@ apiDefs::ConfigType apiUtils::getConfigType(const QJsonObject &serverConfigObjec
|
|||||||
constexpr QLatin1String servicePremium("amnezia-premium");
|
constexpr QLatin1String servicePremium("amnezia-premium");
|
||||||
constexpr QLatin1String serviceFree("amnezia-free");
|
constexpr QLatin1String serviceFree("amnezia-free");
|
||||||
constexpr QLatin1String serviceExternalPremium("external-premium");
|
constexpr QLatin1String serviceExternalPremium("external-premium");
|
||||||
|
constexpr QLatin1String serviceExternalTrial("external-trial");
|
||||||
|
|
||||||
auto apiConfigObject = serverConfigObject.value(apiDefs::key::apiConfig).toObject();
|
auto apiConfigObject = serverConfigObject.value(apiDefs::key::apiConfig).toObject();
|
||||||
auto serviceType = apiConfigObject.value(apiDefs::key::serviceType).toString();
|
auto serviceType = apiConfigObject.value(apiDefs::key::serviceType).toString();
|
||||||
@@ -70,6 +114,8 @@ apiDefs::ConfigType apiUtils::getConfigType(const QJsonObject &serverConfigObjec
|
|||||||
return apiDefs::ConfigType::AmneziaFreeV3;
|
return apiDefs::ConfigType::AmneziaFreeV3;
|
||||||
} else if (serviceType == serviceExternalPremium) {
|
} else if (serviceType == serviceExternalPremium) {
|
||||||
return apiDefs::ConfigType::ExternalPremium;
|
return apiDefs::ConfigType::ExternalPremium;
|
||||||
|
} else if (serviceType == serviceExternalTrial) {
|
||||||
|
return apiDefs::ConfigType::ExternalTrial;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
@@ -90,50 +136,66 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList<QSslError> &ssl
|
|||||||
const int httpStatusCodeConflict = 409;
|
const int httpStatusCodeConflict = 409;
|
||||||
const int httpStatusCodeNotFound = 404;
|
const int httpStatusCodeNotFound = 404;
|
||||||
const int httpStatusCodeNotImplemented = 501;
|
const int httpStatusCodeNotImplemented = 501;
|
||||||
|
const int httpStatusCodePaymentRequired = 402;
|
||||||
|
const int httpStatusCodeUnprocessableEntity = 422;
|
||||||
|
|
||||||
if (!sslErrors.empty()) {
|
if (!sslErrors.empty()) {
|
||||||
qDebug().noquote() << sslErrors;
|
qDebug().noquote() << sslErrors;
|
||||||
return amnezia::ErrorCode::ApiConfigSslError;
|
return amnezia::ErrorCode::ApiConfigSslError;
|
||||||
} else if (replyError == QNetworkReply::NoError) {
|
}
|
||||||
|
if (replyError == QNetworkReply::NoError) {
|
||||||
return amnezia::ErrorCode::NoError;
|
return amnezia::ErrorCode::NoError;
|
||||||
} else if (replyError == QNetworkReply::NetworkError::OperationCanceledError
|
}
|
||||||
|| replyError == QNetworkReply::NetworkError::TimeoutError) {
|
if (replyError == QNetworkReply::NetworkError::OperationCanceledError
|
||||||
|
|| replyError == QNetworkReply::NetworkError::TimeoutError) {
|
||||||
qDebug() << replyError;
|
qDebug() << replyError;
|
||||||
return amnezia::ErrorCode::ApiConfigTimeoutError;
|
return amnezia::ErrorCode::ApiConfigTimeoutError;
|
||||||
} else if (replyError == QNetworkReply::NetworkError::OperationNotImplementedError) {
|
}
|
||||||
|
if (replyError == QNetworkReply::NetworkError::OperationNotImplementedError) {
|
||||||
qDebug() << replyError;
|
qDebug() << replyError;
|
||||||
return amnezia::ErrorCode::ApiUpdateRequestError;
|
return amnezia::ErrorCode::ApiUpdateRequestError;
|
||||||
} else {
|
}
|
||||||
qDebug() << QString::fromUtf8(responseBody);
|
|
||||||
qDebug() << replyError;
|
|
||||||
qDebug() << replyErrorString;
|
|
||||||
qDebug() << httpStatusCode;
|
|
||||||
|
|
||||||
int httpStatusFromBody = -1;
|
qDebug() << QString::fromUtf8(responseBody);
|
||||||
QJsonDocument jsonDoc = QJsonDocument::fromJson(responseBody);
|
qDebug() << replyError;
|
||||||
if (jsonDoc.isObject()) {
|
qDebug() << httpStatusCode;
|
||||||
QJsonObject jsonObj = jsonDoc.object();
|
|
||||||
httpStatusFromBody = jsonObj.value("http_status").toInt(-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
QJsonDocument jsonDoc = QJsonDocument::fromJson(responseBody);
|
||||||
|
if (jsonDoc.isObject()) {
|
||||||
|
QJsonObject jsonObj = jsonDoc.object();
|
||||||
|
const int httpStatusFromBody = jsonObj.value(QStringLiteral("http_status")).toInt(-1);
|
||||||
if (httpStatusFromBody == httpStatusCodeConflict) {
|
if (httpStatusFromBody == httpStatusCodeConflict) {
|
||||||
|
if (apiErrorMessageFromJson(jsonObj).contains(trialAlreadyUsedMessage, Qt::CaseInsensitive)) {
|
||||||
|
return amnezia::ErrorCode::ApiTrialAlreadyUsedError;
|
||||||
|
}
|
||||||
return amnezia::ErrorCode::ApiConfigLimitError;
|
return amnezia::ErrorCode::ApiConfigLimitError;
|
||||||
} else if (httpStatusFromBody == httpStatusCodeNotFound) {
|
}
|
||||||
|
if (httpStatusFromBody == httpStatusCodeNotFound) {
|
||||||
return amnezia::ErrorCode::ApiNotFoundError;
|
return amnezia::ErrorCode::ApiNotFoundError;
|
||||||
} else if (httpStatusFromBody == httpStatusCodeNotImplemented) {
|
}
|
||||||
|
if (httpStatusFromBody == httpStatusCodeNotImplemented) {
|
||||||
return amnezia::ErrorCode::ApiUpdateRequestError;
|
return amnezia::ErrorCode::ApiUpdateRequestError;
|
||||||
}
|
}
|
||||||
|
if (httpStatusFromBody == httpStatusCodeUnprocessableEntity) {
|
||||||
|
if (apiErrorMessageFromJson(jsonObj) == unprocessableSubscriptionMessage) {
|
||||||
|
return amnezia::ErrorCode::ApiSubscriptionExpiredError;
|
||||||
|
}
|
||||||
|
return amnezia::ErrorCode::ApiConfigDownloadError;
|
||||||
|
}
|
||||||
|
if (httpStatusFromBody == httpStatusCodePaymentRequired) {
|
||||||
|
return amnezia::ErrorCode::ApiSubscriptionNotActiveError;
|
||||||
|
}
|
||||||
return amnezia::ErrorCode::ApiConfigDownloadError;
|
return amnezia::ErrorCode::ApiConfigDownloadError;
|
||||||
}
|
}
|
||||||
|
|
||||||
qDebug() << "something went wrong";
|
qDebug() << "something went wrong";
|
||||||
return amnezia::ErrorCode::InternalError;
|
return amnezia::ErrorCode::ApiConfigDownloadError;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool apiUtils::isPremiumServer(const QJsonObject &serverConfigObject)
|
bool apiUtils::isPremiumServer(const QJsonObject &serverConfigObject)
|
||||||
{
|
{
|
||||||
static const QSet<apiDefs::ConfigType> premiumTypes = { apiDefs::ConfigType::AmneziaPremiumV1, apiDefs::ConfigType::AmneziaPremiumV2,
|
static const QSet<apiDefs::ConfigType> premiumTypes = { apiDefs::ConfigType::AmneziaPremiumV1, apiDefs::ConfigType::AmneziaPremiumV2,
|
||||||
apiDefs::ConfigType::ExternalPremium };
|
apiDefs::ConfigType::ExternalPremium, apiDefs::ConfigType::ExternalTrial };
|
||||||
return premiumTypes.contains(getConfigType(serverConfigObject));
|
return premiumTypes.contains(getConfigType(serverConfigObject));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +239,9 @@ QString apiUtils::getPremiumV1VpnKey(const QJsonObject &serverConfigObject)
|
|||||||
|
|
||||||
QString apiUtils::getPremiumV2VpnKey(const QJsonObject &serverConfigObject)
|
QString apiUtils::getPremiumV2VpnKey(const QJsonObject &serverConfigObject)
|
||||||
{
|
{
|
||||||
if (apiUtils::getConfigType(serverConfigObject) != apiDefs::ConfigType::AmneziaPremiumV2) {
|
auto configType = apiUtils::getConfigType(serverConfigObject);
|
||||||
|
if (configType != apiDefs::ConfigType::AmneziaPremiumV2 && configType != apiDefs::ConfigType::ExternalPremium
|
||||||
|
&& configType != apiDefs::ConfigType::ExternalTrial) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ namespace apiUtils
|
|||||||
|
|
||||||
bool isSubscriptionExpired(const QString &subscriptionEndDate);
|
bool isSubscriptionExpired(const QString &subscriptionEndDate);
|
||||||
|
|
||||||
|
bool isSubscriptionExpiringSoon(const QString &subscriptionEndDate, int withinDays = 30);
|
||||||
|
|
||||||
bool isPremiumServer(const QJsonObject &serverConfigObject);
|
bool isPremiumServer(const QJsonObject &serverConfigObject);
|
||||||
|
|
||||||
apiDefs::ConfigType getConfigType(const QJsonObject &serverConfigObject);
|
apiDefs::ConfigType getConfigType(const QJsonObject &serverConfigObject);
|
||||||
|
|||||||
@@ -91,6 +91,12 @@ void CoreController::initModels()
|
|||||||
m_apiServicesModel.reset(new ApiServicesModel(this));
|
m_apiServicesModel.reset(new ApiServicesModel(this));
|
||||||
m_engine->rootContext()->setContextProperty("ApiServicesModel", m_apiServicesModel.get());
|
m_engine->rootContext()->setContextProperty("ApiServicesModel", m_apiServicesModel.get());
|
||||||
|
|
||||||
|
m_apiSubscriptionPlansModel.reset(new ApiSubscriptionPlansModel(this));
|
||||||
|
m_engine->rootContext()->setContextProperty("ApiSubscriptionPlansModel", m_apiSubscriptionPlansModel.get());
|
||||||
|
|
||||||
|
m_apiBenefitsModel.reset(new ApiBenefitsModel(this));
|
||||||
|
m_engine->rootContext()->setContextProperty("ApiBenefitsModel", m_apiBenefitsModel.get());
|
||||||
|
|
||||||
m_apiCountryModel.reset(new ApiCountryModel(this));
|
m_apiCountryModel.reset(new ApiCountryModel(this));
|
||||||
m_engine->rootContext()->setContextProperty("ApiCountryModel", m_apiCountryModel.get());
|
m_engine->rootContext()->setContextProperty("ApiCountryModel", m_apiCountryModel.get());
|
||||||
|
|
||||||
@@ -151,8 +157,11 @@ void CoreController::initControllers()
|
|||||||
new ApiSettingsController(m_serversModel, m_apiAccountInfoModel, m_apiCountryModel, m_apiDevicesModel, m_settings));
|
new ApiSettingsController(m_serversModel, m_apiAccountInfoModel, m_apiCountryModel, m_apiDevicesModel, m_settings));
|
||||||
m_engine->rootContext()->setContextProperty("ApiSettingsController", m_apiSettingsController.get());
|
m_engine->rootContext()->setContextProperty("ApiSettingsController", m_apiSettingsController.get());
|
||||||
|
|
||||||
m_apiConfigsController.reset(new ApiConfigsController(m_serversModel, m_apiServicesModel, m_settings));
|
m_apiConfigsController.reset(
|
||||||
|
new ApiConfigsController(m_serversModel, m_apiServicesModel, m_apiSubscriptionPlansModel, m_apiBenefitsModel, m_settings));
|
||||||
m_engine->rootContext()->setContextProperty("ApiConfigsController", m_apiConfigsController.get());
|
m_engine->rootContext()->setContextProperty("ApiConfigsController", m_apiConfigsController.get());
|
||||||
|
connect(m_apiConfigsController.get(), &ApiConfigsController::subscriptionRefreshNeeded,
|
||||||
|
this, [this]() { m_apiSettingsController->getAccountInfo(false); });
|
||||||
|
|
||||||
m_apiNewsController.reset(new ApiNewsController(m_newsModel, m_settings, m_serversModel, this));
|
m_apiNewsController.reset(new ApiNewsController(m_newsModel, m_settings, m_serversModel, this));
|
||||||
m_engine->rootContext()->setContextProperty("ApiNewsController", m_apiNewsController.get());
|
m_engine->rootContext()->setContextProperty("ApiNewsController", m_apiNewsController.get());
|
||||||
|
|||||||
@@ -32,9 +32,11 @@
|
|||||||
#include "ui/models/protocols/ikev2ConfigModel.h"
|
#include "ui/models/protocols/ikev2ConfigModel.h"
|
||||||
#endif
|
#endif
|
||||||
#include "ui/models/api/apiAccountInfoModel.h"
|
#include "ui/models/api/apiAccountInfoModel.h"
|
||||||
|
#include "ui/models/api/apiBenefitsModel.h"
|
||||||
#include "ui/models/api/apiCountryModel.h"
|
#include "ui/models/api/apiCountryModel.h"
|
||||||
#include "ui/models/api/apiDevicesModel.h"
|
#include "ui/models/api/apiDevicesModel.h"
|
||||||
#include "ui/models/api/apiServicesModel.h"
|
#include "ui/models/api/apiServicesModel.h"
|
||||||
|
#include "ui/models/api/apiSubscriptionPlansModel.h"
|
||||||
#include "ui/models/appSplitTunnelingModel.h"
|
#include "ui/models/appSplitTunnelingModel.h"
|
||||||
#include "ui/models/clientManagementModel.h"
|
#include "ui/models/clientManagementModel.h"
|
||||||
#include "ui/models/protocols/awgConfigModel.h"
|
#include "ui/models/protocols/awgConfigModel.h"
|
||||||
@@ -133,6 +135,8 @@ private:
|
|||||||
QSharedPointer<ClientManagementModel> m_clientManagementModel;
|
QSharedPointer<ClientManagementModel> m_clientManagementModel;
|
||||||
|
|
||||||
QSharedPointer<ApiServicesModel> m_apiServicesModel;
|
QSharedPointer<ApiServicesModel> m_apiServicesModel;
|
||||||
|
QSharedPointer<ApiSubscriptionPlansModel> m_apiSubscriptionPlansModel;
|
||||||
|
QSharedPointer<ApiBenefitsModel> m_apiBenefitsModel;
|
||||||
QSharedPointer<ApiCountryModel> m_apiCountryModel;
|
QSharedPointer<ApiCountryModel> m_apiCountryModel;
|
||||||
QSharedPointer<ApiAccountInfoModel> m_apiAccountInfoModel;
|
QSharedPointer<ApiAccountInfoModel> m_apiAccountInfoModel;
|
||||||
QSharedPointer<ApiDevicesModel> m_apiDevicesModel;
|
QSharedPointer<ApiDevicesModel> m_apiDevicesModel;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
#include "amnezia_application.h"
|
#include "amnezia_application.h"
|
||||||
#include "core/api/apiUtils.h"
|
#include "core/api/apiUtils.h"
|
||||||
#include "core/networkUtilities.h"
|
#include "core/networkUtilities.h"
|
||||||
|
#include "settings.h"
|
||||||
#include "utilities.h"
|
#include "utilities.h"
|
||||||
|
|
||||||
#ifdef AMNEZIA_DESKTOP
|
#ifdef AMNEZIA_DESKTOP
|
||||||
@@ -44,17 +45,85 @@ namespace
|
|||||||
|
|
||||||
constexpr int httpStatusCodeNotFound = 404;
|
constexpr int httpStatusCodeNotFound = 404;
|
||||||
constexpr int httpStatusCodeConflict = 409;
|
constexpr int httpStatusCodeConflict = 409;
|
||||||
|
|
||||||
constexpr int httpStatusCodeNotImplemented = 501;
|
constexpr int httpStatusCodeNotImplemented = 501;
|
||||||
|
constexpr int httpStatusCodePaymentRequired = 402;
|
||||||
|
constexpr int httpStatusCodeUnprocessableEntity = 422;
|
||||||
|
|
||||||
|
constexpr QLatin1String unprocessableSubscriptionMessage("Failed to retrieve subscription information. Is it activated?");
|
||||||
|
|
||||||
|
constexpr int proxyStorageRequestTimeoutMsecs = 3000;
|
||||||
|
|
||||||
|
QStringList shuffledProxyUrls(const QStringList &proxyUrls)
|
||||||
|
{
|
||||||
|
QStringList shuffled = proxyUrls;
|
||||||
|
std::random_device randomDevice;
|
||||||
|
std::mt19937 generator(randomDevice());
|
||||||
|
std::shuffle(shuffled.begin(), shuffled.end(), generator);
|
||||||
|
return shuffled;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString getProxyUrlsCacheKey(const QString &serviceType, const QString &userCountryCode)
|
||||||
|
{
|
||||||
|
return QStringLiteral("service_%1_country_%2").arg(serviceType, userCountryCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool decryptProxyUrlsPayload(const QByteArray &encryptedPayload, bool isDevEnvironment, QByteArray &decryptedPayload)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
QByteArray key = isDevEnvironment ? DEV_AGW_PUBLIC_KEY : PROD_AGW_PUBLIC_KEY;
|
||||||
|
if (!isDevEnvironment) {
|
||||||
|
QCryptographicHash hash(QCryptographicHash::Sha512);
|
||||||
|
hash.addData(key);
|
||||||
|
QByteArray h = hash.result().toHex();
|
||||||
|
|
||||||
|
QByteArray decKey = QByteArray::fromHex(h.left(64));
|
||||||
|
QByteArray iv = QByteArray::fromHex(h.mid(64, 32));
|
||||||
|
QByteArray ba = QByteArray::fromBase64(encryptedPayload);
|
||||||
|
|
||||||
|
QSimpleCrypto::QBlockCipher cipher;
|
||||||
|
decryptedPayload = cipher.decryptAesBlockCipher(ba, decKey, iv);
|
||||||
|
} else {
|
||||||
|
decryptedPayload = encryptedPayload;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (...) {
|
||||||
|
Utils::logException();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList readCachedProxyUrls(const QByteArray &cachedProxyUrlsEncrypted, bool isDevEnvironment)
|
||||||
|
{
|
||||||
|
if (cachedProxyUrlsEncrypted.isEmpty()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray cachedProxyUrlsDecrypted;
|
||||||
|
if (!decryptProxyUrlsPayload(cachedProxyUrlsEncrypted, isDevEnvironment, cachedProxyUrlsDecrypted)) {
|
||||||
|
qCritical() << "error decrypting cached proxy urls payload";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonArray endpointsArray = QJsonDocument::fromJson(cachedProxyUrlsDecrypted).array();
|
||||||
|
QStringList endpoints;
|
||||||
|
endpoints.reserve(endpointsArray.size());
|
||||||
|
for (const QJsonValue &endpoint : endpointsArray) {
|
||||||
|
endpoints.push_back(endpoint.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return endpoints;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
GatewayController::GatewayController(const QString &gatewayEndpoint, const bool isDevEnvironment, const int requestTimeoutMsecs,
|
GatewayController::GatewayController(const QString &gatewayEndpoint, const bool isDevEnvironment, const int requestTimeoutMsecs,
|
||||||
const bool isStrictKillSwitchEnabled, QObject *parent)
|
const bool isStrictKillSwitchEnabled, const std::shared_ptr<Settings> &settings,
|
||||||
|
QObject *parent)
|
||||||
: QObject(parent),
|
: QObject(parent),
|
||||||
m_gatewayEndpoint(gatewayEndpoint),
|
m_gatewayEndpoint(gatewayEndpoint),
|
||||||
m_isDevEnvironment(isDevEnvironment),
|
m_isDevEnvironment(isDevEnvironment),
|
||||||
m_requestTimeoutMsecs(requestTimeoutMsecs),
|
m_requestTimeoutMsecs(requestTimeoutMsecs),
|
||||||
m_isStrictKillSwitchEnabled(isStrictKillSwitchEnabled)
|
m_isStrictKillSwitchEnabled(isStrictKillSwitchEnabled),
|
||||||
|
m_settings(settings)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,25 +350,33 @@ QFuture<QPair<ErrorCode, QByteArray>> GatewayController::postAsync(const QString
|
|||||||
auto serviceType = apiPayload.value(apiDefs::key::serviceType).toString("");
|
auto serviceType = apiPayload.value(apiDefs::key::serviceType).toString("");
|
||||||
auto userCountryCode = apiPayload.value(apiDefs::key::userCountryCode).toString("");
|
auto userCountryCode = apiPayload.value(apiDefs::key::userCountryCode).toString("");
|
||||||
|
|
||||||
QStringList baseUrls;
|
QStringList primaryBaseUrls;
|
||||||
|
QStringList fallbackBaseUrls;
|
||||||
if (m_isDevEnvironment) {
|
if (m_isDevEnvironment) {
|
||||||
baseUrls = QString(DEV_S3_ENDPOINT).split(", ");
|
primaryBaseUrls = QString(DEV_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts);
|
||||||
} else {
|
} else {
|
||||||
baseUrls = QString(PROD_S3_ENDPOINT).split(", ");
|
primaryBaseUrls = QString(PROD_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts);
|
||||||
|
fallbackBaseUrls = QString(FALLBACK_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto appendStorageUrls = [&serviceType, &userCountryCode](const QStringList &baseUrls, QStringList &target) {
|
||||||
|
if (!serviceType.isEmpty()) {
|
||||||
|
for (const auto &baseUrl : baseUrls) {
|
||||||
|
QByteArray path = ("endpoints-" + serviceType + "-" + userCountryCode).toUtf8();
|
||||||
|
target.push_back(baseUrl + path.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals) + ".json");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const auto &baseUrl : baseUrls) {
|
||||||
|
target.push_back(baseUrl + "endpoints.json");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
QStringList proxyStorageUrls;
|
QStringList proxyStorageUrls;
|
||||||
if (!serviceType.isEmpty()) {
|
appendStorageUrls(primaryBaseUrls, proxyStorageUrls);
|
||||||
for (const auto &baseUrl : baseUrls) {
|
appendStorageUrls(fallbackBaseUrls, proxyStorageUrls);
|
||||||
QByteArray path = ("endpoints-" + serviceType + "-" + userCountryCode).toUtf8();
|
const QString proxyUrlsCacheKey = getProxyUrlsCacheKey(serviceType, userCountryCode);
|
||||||
proxyStorageUrls.push_back(baseUrl + path.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals)
|
|
||||||
+ ".json");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const auto &baseUrl : baseUrls)
|
|
||||||
proxyStorageUrls.push_back(baseUrl + "endpoints.json");
|
|
||||||
|
|
||||||
getProxyUrlsAsync(proxyStorageUrls, 0, [this, encRequestData, endpoint, processResponse](const QStringList &proxyUrls) {
|
getProxyUrlsAsync(proxyStorageUrls, 0, proxyUrlsCacheKey, [this, encRequestData, endpoint, processResponse](const QStringList &proxyUrls) {
|
||||||
getProxyUrlAsync(proxyUrls, 0, [this, encRequestData, endpoint, processResponse](const QString &proxyUrl) {
|
getProxyUrlAsync(proxyUrls, 0, [this, encRequestData, endpoint, processResponse](const QString &proxyUrl) {
|
||||||
bypassProxyAsync(endpoint, proxyUrl, encRequestData,
|
bypassProxyAsync(endpoint, proxyUrl, encRequestData,
|
||||||
[processResponse, this](const QByteArray &decryptedBody, bool isDecryptionSuccessful,
|
[processResponse, this](const QByteArray &decryptedBody, bool isDecryptionSuccessful,
|
||||||
@@ -324,34 +401,48 @@ QFuture<QPair<ErrorCode, QByteArray>> GatewayController::postAsync(const QString
|
|||||||
QStringList GatewayController::getProxyUrls(const QString &serviceType, const QString &userCountryCode)
|
QStringList GatewayController::getProxyUrls(const QString &serviceType, const QString &userCountryCode)
|
||||||
{
|
{
|
||||||
QNetworkRequest request;
|
QNetworkRequest request;
|
||||||
request.setTransferTimeout(m_requestTimeoutMsecs);
|
request.setTransferTimeout(proxyStorageRequestTimeoutMsecs);
|
||||||
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
|
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
|
||||||
|
|
||||||
QEventLoop wait;
|
QEventLoop wait;
|
||||||
QList<QSslError> sslErrors;
|
QList<QSslError> sslErrors;
|
||||||
QNetworkReply *reply;
|
QNetworkReply *reply;
|
||||||
|
|
||||||
QStringList baseUrls;
|
QStringList primaryBaseUrls;
|
||||||
|
QStringList fallbackBaseUrls;
|
||||||
if (m_isDevEnvironment) {
|
if (m_isDevEnvironment) {
|
||||||
baseUrls = QString(DEV_S3_ENDPOINT).split(", ");
|
primaryBaseUrls = QString(DEV_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts);
|
||||||
} else {
|
} else {
|
||||||
baseUrls = QString(PROD_S3_ENDPOINT).split(", ");
|
primaryBaseUrls = QString(PROD_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts);
|
||||||
|
fallbackBaseUrls = QString(FALLBACK_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::random_device randomDevice;
|
std::random_device randomDevice;
|
||||||
std::mt19937 generator(randomDevice());
|
std::mt19937 generator(randomDevice());
|
||||||
std::shuffle(baseUrls.begin(), baseUrls.end(), generator);
|
std::shuffle(primaryBaseUrls.begin(), primaryBaseUrls.end(), generator);
|
||||||
|
std::shuffle(fallbackBaseUrls.begin(), fallbackBaseUrls.end(), generator);
|
||||||
|
|
||||||
QByteArray key = m_isDevEnvironment ? DEV_AGW_PUBLIC_KEY : PROD_AGW_PUBLIC_KEY;
|
auto appendStorageUrls = [&serviceType, &userCountryCode](const QStringList &baseUrls, QStringList &target) {
|
||||||
|
if (!serviceType.isEmpty()) {
|
||||||
|
for (const auto &baseUrl : baseUrls) {
|
||||||
|
QByteArray path = ("endpoints-" + serviceType + "-" + userCountryCode).toUtf8();
|
||||||
|
target.push_back(baseUrl + path.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals) + ".json");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const auto &baseUrl : baseUrls) {
|
||||||
|
target.push_back(baseUrl + "endpoints.json");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
QStringList proxyStorageUrls;
|
QStringList proxyStorageUrls;
|
||||||
if (!serviceType.isEmpty()) {
|
appendStorageUrls(primaryBaseUrls, proxyStorageUrls);
|
||||||
for (const auto &baseUrl : baseUrls) {
|
appendStorageUrls(fallbackBaseUrls, proxyStorageUrls);
|
||||||
QByteArray path = ("endpoints-" + serviceType + "-" + userCountryCode).toUtf8();
|
const QString proxyUrlsCacheKey = getProxyUrlsCacheKey(serviceType, userCountryCode);
|
||||||
proxyStorageUrls.push_back(baseUrl + path.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals) + ".json");
|
const QByteArray cachedProxyUrlsEncrypted = m_settings->readGatewayProxyUrls(proxyUrlsCacheKey);
|
||||||
}
|
|
||||||
}
|
if (proxyStorageUrls.empty()) {
|
||||||
for (const auto &baseUrl : baseUrls) {
|
qDebug() << "empty storage endpoint list";
|
||||||
proxyStorageUrls.push_back(baseUrl + "endpoints.json");
|
return readCachedProxyUrls(cachedProxyUrlsEncrypted, m_isDevEnvironment);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const auto &proxyStorageUrl : proxyStorageUrls) {
|
for (const auto &proxyStorageUrl : proxyStorageUrls) {
|
||||||
@@ -366,26 +457,8 @@ QStringList GatewayController::getProxyUrls(const QString &serviceType, const QS
|
|||||||
auto encryptedResponseBody = reply->readAll();
|
auto encryptedResponseBody = reply->readAll();
|
||||||
reply->deleteLater();
|
reply->deleteLater();
|
||||||
|
|
||||||
EVP_PKEY *privateKey = nullptr;
|
|
||||||
QByteArray responseBody;
|
QByteArray responseBody;
|
||||||
try {
|
if (!decryptProxyUrlsPayload(encryptedResponseBody, m_isDevEnvironment, responseBody)) {
|
||||||
if (!m_isDevEnvironment) {
|
|
||||||
QCryptographicHash hash(QCryptographicHash::Sha512);
|
|
||||||
hash.addData(key);
|
|
||||||
QByteArray hashResult = hash.result().toHex();
|
|
||||||
|
|
||||||
QByteArray key = QByteArray::fromHex(hashResult.left(64));
|
|
||||||
QByteArray iv = QByteArray::fromHex(hashResult.mid(64, 32));
|
|
||||||
|
|
||||||
QByteArray ba = QByteArray::fromBase64(encryptedResponseBody);
|
|
||||||
|
|
||||||
QSimpleCrypto::QBlockCipher blockCipher;
|
|
||||||
responseBody = blockCipher.decryptAesBlockCipher(ba, key, iv);
|
|
||||||
} else {
|
|
||||||
responseBody = encryptedResponseBody;
|
|
||||||
}
|
|
||||||
} catch (...) {
|
|
||||||
Utils::logException();
|
|
||||||
qCritical() << "error loading private key from environment variables or decrypting payload" << encryptedResponseBody;
|
qCritical() << "error loading private key from environment variables or decrypting payload" << encryptedResponseBody;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -396,6 +469,8 @@ QStringList GatewayController::getProxyUrls(const QString &serviceType, const QS
|
|||||||
for (const auto &endpoint : endpointsArray) {
|
for (const auto &endpoint : endpointsArray) {
|
||||||
endpoints.push_back(endpoint.toString());
|
endpoints.push_back(endpoint.toString());
|
||||||
}
|
}
|
||||||
|
m_settings->writeGatewayProxyUrls(proxyUrlsCacheKey, encryptedResponseBody);
|
||||||
|
|
||||||
return endpoints;
|
return endpoints;
|
||||||
} else {
|
} else {
|
||||||
auto replyError = reply->error();
|
auto replyError = reply->error();
|
||||||
@@ -407,7 +482,7 @@ QStringList GatewayController::getProxyUrls(const QString &serviceType, const QS
|
|||||||
reply->deleteLater();
|
reply->deleteLater();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {};
|
return readCachedProxyUrls(cachedProxyUrlsEncrypted, m_isDevEnvironment);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool GatewayController::shouldBypassProxy(const QNetworkReply::NetworkError &replyError, const QByteArray &decryptedResponseBody,
|
bool GatewayController::shouldBypassProxy(const QNetworkReply::NetworkError &replyError, const QByteArray &decryptedResponseBody,
|
||||||
@@ -415,12 +490,14 @@ bool GatewayController::shouldBypassProxy(const QNetworkReply::NetworkError &rep
|
|||||||
{
|
{
|
||||||
const QByteArray &responseBody = decryptedResponseBody;
|
const QByteArray &responseBody = decryptedResponseBody;
|
||||||
|
|
||||||
int httpStatus = -1;
|
int apiHttpStatus = -1;
|
||||||
|
QString apiErrorMessage;
|
||||||
if (isDecryptionSuccessful) {
|
if (isDecryptionSuccessful) {
|
||||||
QJsonDocument jsonDoc = QJsonDocument::fromJson(responseBody);
|
QJsonDocument jsonDoc = QJsonDocument::fromJson(responseBody);
|
||||||
if (jsonDoc.isObject()) {
|
if (jsonDoc.isObject()) {
|
||||||
QJsonObject jsonObj = jsonDoc.object();
|
QJsonObject jsonObj = jsonDoc.object();
|
||||||
httpStatus = jsonObj.value("http_status").toInt(-1);
|
apiHttpStatus = jsonObj.value("http_status").toInt(-1);
|
||||||
|
apiErrorMessage = jsonObj.value(QStringLiteral("message")).toString().trimmed();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
qDebug() << "failed to decrypt the data";
|
qDebug() << "failed to decrypt the data";
|
||||||
@@ -431,10 +508,12 @@ bool GatewayController::shouldBypassProxy(const QNetworkReply::NetworkError &rep
|
|||||||
qDebug() << "timeout occurred";
|
qDebug() << "timeout occurred";
|
||||||
qDebug() << replyError;
|
qDebug() << replyError;
|
||||||
return true;
|
return true;
|
||||||
} else if (responseBody.contains("html")) {
|
}
|
||||||
|
if (responseBody.contains("html")) {
|
||||||
qDebug() << "the response contains an html tag";
|
qDebug() << "the response contains an html tag";
|
||||||
return true;
|
return true;
|
||||||
} else if (httpStatus == httpStatusCodeNotFound) {
|
}
|
||||||
|
if (apiHttpStatus == httpStatusCodeNotFound) {
|
||||||
if (responseBody.contains(errorResponsePattern1) || responseBody.contains(errorResponsePattern2)
|
if (responseBody.contains(errorResponsePattern1) || responseBody.contains(errorResponsePattern2)
|
||||||
|| responseBody.contains(errorResponsePattern3)) {
|
|| responseBody.contains(errorResponsePattern3)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -442,16 +521,25 @@ bool GatewayController::shouldBypassProxy(const QNetworkReply::NetworkError &rep
|
|||||||
qDebug() << replyError;
|
qDebug() << replyError;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} else if (httpStatus == httpStatusCodeNotImplemented) {
|
}
|
||||||
|
if (apiHttpStatus == httpStatusCodeNotImplemented) {
|
||||||
if (responseBody.contains(updateRequestResponsePattern)) {
|
if (responseBody.contains(updateRequestResponsePattern)) {
|
||||||
return false;
|
return false;
|
||||||
} else {
|
} else {
|
||||||
qDebug() << replyError;
|
qDebug() << replyError;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} else if (httpStatus == httpStatusCodeConflict) {
|
}
|
||||||
|
if (apiHttpStatus == httpStatusCodeConflict) {
|
||||||
return false;
|
return false;
|
||||||
} else if (replyError != QNetworkReply::NetworkError::NoError) {
|
}
|
||||||
|
if (apiHttpStatus == httpStatusCodePaymentRequired) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (apiHttpStatus == httpStatusCodeUnprocessableEntity) {
|
||||||
|
return apiErrorMessage != unprocessableSubscriptionMessage;
|
||||||
|
}
|
||||||
|
if (replyError != QNetworkReply::NetworkError::NoError) {
|
||||||
qDebug() << replyError;
|
qDebug() << replyError;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -532,15 +620,17 @@ void GatewayController::bypassProxy(const QString &endpoint, const QString &serv
|
|||||||
}
|
}
|
||||||
|
|
||||||
void GatewayController::getProxyUrlsAsync(const QStringList proxyStorageUrls, const int currentProxyStorageIndex,
|
void GatewayController::getProxyUrlsAsync(const QStringList proxyStorageUrls, const int currentProxyStorageIndex,
|
||||||
std::function<void(const QStringList &)> onComplete)
|
const QString &proxyUrlsCacheKey, std::function<void(const QStringList &)> onComplete)
|
||||||
{
|
{
|
||||||
|
const QByteArray cachedProxyUrlsEncrypted = m_settings->readGatewayProxyUrls(proxyUrlsCacheKey);
|
||||||
|
|
||||||
if (currentProxyStorageIndex >= proxyStorageUrls.size()) {
|
if (currentProxyStorageIndex >= proxyStorageUrls.size()) {
|
||||||
onComplete({});
|
onComplete(shuffledProxyUrls(readCachedProxyUrls(cachedProxyUrlsEncrypted, m_isDevEnvironment)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
QNetworkRequest request;
|
QNetworkRequest request;
|
||||||
request.setTransferTimeout(m_requestTimeoutMsecs);
|
request.setTransferTimeout(proxyStorageRequestTimeoutMsecs);
|
||||||
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
|
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
|
||||||
request.setUrl(proxyStorageUrls[currentProxyStorageIndex]);
|
request.setUrl(proxyStorageUrls[currentProxyStorageIndex]);
|
||||||
|
|
||||||
@@ -548,33 +638,17 @@ void GatewayController::getProxyUrlsAsync(const QStringList proxyStorageUrls, co
|
|||||||
|
|
||||||
// connect(reply, &QNetworkReply::sslErrors, this, [state](const QList<QSslError> &e) { *(state->sslErrors) = e; });
|
// connect(reply, &QNetworkReply::sslErrors, this, [state](const QList<QSslError> &e) { *(state->sslErrors) = e; });
|
||||||
|
|
||||||
connect(reply, &QNetworkReply::finished, this, [this, proxyStorageUrls, currentProxyStorageIndex, onComplete, reply]() {
|
connect(reply, &QNetworkReply::finished, this,
|
||||||
|
[this, proxyStorageUrls, currentProxyStorageIndex, proxyUrlsCacheKey, onComplete, reply]() {
|
||||||
if (reply->error() == QNetworkReply::NoError) {
|
if (reply->error() == QNetworkReply::NoError) {
|
||||||
QByteArray encrypted = reply->readAll();
|
QByteArray encrypted = reply->readAll();
|
||||||
reply->deleteLater();
|
reply->deleteLater();
|
||||||
|
|
||||||
QByteArray responseBody;
|
QByteArray responseBody;
|
||||||
try {
|
if (!decryptProxyUrlsPayload(encrypted, m_isDevEnvironment, responseBody)) {
|
||||||
QByteArray key = m_isDevEnvironment ? DEV_AGW_PUBLIC_KEY : PROD_AGW_PUBLIC_KEY;
|
|
||||||
if (!m_isDevEnvironment) {
|
|
||||||
QCryptographicHash hash(QCryptographicHash::Sha512);
|
|
||||||
hash.addData(key);
|
|
||||||
QByteArray h = hash.result().toHex();
|
|
||||||
|
|
||||||
QByteArray decKey = QByteArray::fromHex(h.left(64));
|
|
||||||
QByteArray iv = QByteArray::fromHex(h.mid(64, 32));
|
|
||||||
QByteArray ba = QByteArray::fromBase64(encrypted);
|
|
||||||
|
|
||||||
QSimpleCrypto::QBlockCipher cipher;
|
|
||||||
responseBody = cipher.decryptAesBlockCipher(ba, decKey, iv);
|
|
||||||
} else {
|
|
||||||
responseBody = encrypted;
|
|
||||||
}
|
|
||||||
} catch (...) {
|
|
||||||
Utils::logException();
|
|
||||||
qCritical() << "error decrypting payload";
|
qCritical() << "error decrypting payload";
|
||||||
QMetaObject::invokeMethod(
|
QMetaObject::invokeMethod(
|
||||||
this, [=]() { getProxyUrlsAsync(proxyStorageUrls, currentProxyStorageIndex + 1, onComplete); }, Qt::QueuedConnection);
|
this, [=]() { getProxyUrlsAsync(proxyStorageUrls, currentProxyStorageIndex + 1, proxyUrlsCacheKey, onComplete); }, Qt::QueuedConnection);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,13 +656,9 @@ void GatewayController::getProxyUrlsAsync(const QStringList proxyStorageUrls, co
|
|||||||
QStringList endpoints;
|
QStringList endpoints;
|
||||||
for (const QJsonValue &endpoint : endpointsArray)
|
for (const QJsonValue &endpoint : endpointsArray)
|
||||||
endpoints.push_back(endpoint.toString());
|
endpoints.push_back(endpoint.toString());
|
||||||
|
m_settings->writeGatewayProxyUrls(proxyUrlsCacheKey, encrypted);
|
||||||
|
|
||||||
QStringList shuffled = endpoints;
|
onComplete(shuffledProxyUrls(endpoints));
|
||||||
std::random_device randomDevice;
|
|
||||||
std::mt19937 generator(randomDevice());
|
|
||||||
std::shuffle(shuffled.begin(), shuffled.end(), generator);
|
|
||||||
|
|
||||||
onComplete(shuffled);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,7 +667,7 @@ void GatewayController::getProxyUrlsAsync(const QStringList proxyStorageUrls, co
|
|||||||
qDebug() << "go to the next storage endpoint";
|
qDebug() << "go to the next storage endpoint";
|
||||||
reply->deleteLater();
|
reply->deleteLater();
|
||||||
QMetaObject::invokeMethod(
|
QMetaObject::invokeMethod(
|
||||||
this, [=]() { getProxyUrlsAsync(proxyStorageUrls, currentProxyStorageIndex + 1, onComplete); }, Qt::QueuedConnection);
|
this, [=]() { getProxyUrlsAsync(proxyStorageUrls, currentProxyStorageIndex + 1, proxyUrlsCacheKey, onComplete); }, Qt::QueuedConnection);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
#include <QPair>
|
#include <QPair>
|
||||||
#include <QPromise>
|
#include <QPromise>
|
||||||
#include <QSharedPointer>
|
#include <QSharedPointer>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
#include "core/defs.h"
|
#include "core/defs.h"
|
||||||
|
|
||||||
@@ -14,13 +17,16 @@
|
|||||||
#include "platforms/ios/ios_controller.h"
|
#include "platforms/ios/ios_controller.h"
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
class Settings;
|
||||||
|
|
||||||
class GatewayController : public QObject
|
class GatewayController : public QObject
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit GatewayController(const QString &gatewayEndpoint, const bool isDevEnvironment, const int requestTimeoutMsecs,
|
explicit GatewayController(const QString &gatewayEndpoint, const bool isDevEnvironment, const int requestTimeoutMsecs,
|
||||||
const bool isStrictKillSwitchEnabled, QObject *parent = nullptr);
|
const bool isStrictKillSwitchEnabled, const std::shared_ptr<Settings> &settings,
|
||||||
|
QObject *parent = nullptr);
|
||||||
|
|
||||||
amnezia::ErrorCode post(const QString &endpoint, const QJsonObject apiPayload, QByteArray &responseBody);
|
amnezia::ErrorCode post(const QString &endpoint, const QJsonObject apiPayload, QByteArray &responseBody);
|
||||||
QFuture<QPair<amnezia::ErrorCode, QByteArray>> postAsync(const QString &endpoint, const QJsonObject apiPayload);
|
QFuture<QPair<amnezia::ErrorCode, QByteArray>> postAsync(const QString &endpoint, const QJsonObject apiPayload);
|
||||||
@@ -53,7 +59,7 @@ private:
|
|||||||
std::function<bool(QNetworkReply *reply, const QList<QSslError> &sslErrors)> replyProcessingFunction);
|
std::function<bool(QNetworkReply *reply, const QList<QSslError> &sslErrors)> replyProcessingFunction);
|
||||||
|
|
||||||
void getProxyUrlsAsync(const QStringList proxyStorageUrls, const int currentProxyStorageIndex,
|
void getProxyUrlsAsync(const QStringList proxyStorageUrls, const int currentProxyStorageIndex,
|
||||||
std::function<void(const QStringList &)> onComplete);
|
const QString &proxyUrlsCacheKey, std::function<void(const QStringList &)> onComplete);
|
||||||
void getProxyUrlAsync(const QStringList proxyUrls, const int currentProxyIndex, std::function<void(const QString &)> onComplete);
|
void getProxyUrlAsync(const QStringList proxyUrls, const int currentProxyIndex, std::function<void(const QString &)> onComplete);
|
||||||
void bypassProxyAsync(
|
void bypassProxyAsync(
|
||||||
const QString &endpoint, const QString &proxyUrl, EncryptedRequestData encRequestData,
|
const QString &endpoint, const QString &proxyUrl, EncryptedRequestData encRequestData,
|
||||||
@@ -63,6 +69,7 @@ private:
|
|||||||
QString m_gatewayEndpoint;
|
QString m_gatewayEndpoint;
|
||||||
bool m_isDevEnvironment = false;
|
bool m_isDevEnvironment = false;
|
||||||
bool m_isStrictKillSwitchEnabled = false;
|
bool m_isStrictKillSwitchEnabled = false;
|
||||||
|
std::shared_ptr<Settings> m_settings;
|
||||||
|
|
||||||
inline static QString m_proxyUrl;
|
inline static QString m_proxyUrl;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -123,6 +123,9 @@ namespace amnezia
|
|||||||
ApiUpdateRequestError = 1111,
|
ApiUpdateRequestError = 1111,
|
||||||
ApiSubscriptionExpiredError = 1112,
|
ApiSubscriptionExpiredError = 1112,
|
||||||
ApiPurchaseError = 1113,
|
ApiPurchaseError = 1113,
|
||||||
|
ApiSubscriptionNotActiveError = 1114,
|
||||||
|
ApiNoPurchasedSubscriptionsError = 1115,
|
||||||
|
ApiTrialAlreadyUsedError = 1116,
|
||||||
|
|
||||||
// QFile errors
|
// QFile errors
|
||||||
OpenError = 1200,
|
OpenError = 1200,
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ QString errorString(ErrorCode code) {
|
|||||||
case (ErrorCode::ApiUpdateRequestError): errorMessage = QObject::tr("Please update the application to use this feature"); break;
|
case (ErrorCode::ApiUpdateRequestError): errorMessage = QObject::tr("Please update the application to use this feature"); break;
|
||||||
case (ErrorCode::ApiSubscriptionExpiredError): errorMessage = QObject::tr("Your Amnezia Premium subscription has expired.\n Please check your email for renewal instructions.\n If you haven't received an email, please contact our support."); break;
|
case (ErrorCode::ApiSubscriptionExpiredError): errorMessage = QObject::tr("Your Amnezia Premium subscription has expired.\n Please check your email for renewal instructions.\n If you haven't received an email, please contact our support."); break;
|
||||||
case (ErrorCode::ApiPurchaseError): errorMessage = QObject::tr("Unable to process purchase"); break;
|
case (ErrorCode::ApiPurchaseError): errorMessage = QObject::tr("Unable to process purchase"); break;
|
||||||
|
case (ErrorCode::ApiSubscriptionNotActiveError): errorMessage = QObject::tr("No active subscription found"); break;
|
||||||
|
case (ErrorCode::ApiNoPurchasedSubscriptionsError): errorMessage = QObject::tr("No purchased subscriptions found. Please purchase a subscription first"); break;
|
||||||
|
case (ErrorCode::ApiTrialAlreadyUsedError): errorMessage = QObject::tr("This email address has already been used to activate a trial"); break;
|
||||||
|
|
||||||
// QFile errors
|
// QFile errors
|
||||||
case(ErrorCode::OpenError): errorMessage = QObject::tr("QFile error: The file could not be opened"); break;
|
case(ErrorCode::OpenError): errorMessage = QObject::tr("QFile error: The file could not be opened"); break;
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ QPair<QString, QNetworkInterface> NetworkUtilities::getGatewayAndIface()
|
|||||||
return { resGateway, QNetworkInterface::interfaceFromIndex(resIndex) };
|
return { resGateway, QNetworkInterface::interfaceFromIndex(resIndex) };
|
||||||
#endif
|
#endif
|
||||||
#ifdef Q_OS_LINUX
|
#ifdef Q_OS_LINUX
|
||||||
constexpr int BUFFER_SIZE = 100;
|
constexpr int BUFFER_SIZE = 8192;
|
||||||
int received_bytes = 0, msg_len = 0, route_attribute_len = 0;
|
int received_bytes = 0, msg_len = 0, route_attribute_len = 0;
|
||||||
int sock = -1, msgseq = 0;
|
int sock = -1, msgseq = 0;
|
||||||
struct nlmsghdr *nlh, *nlmsg;
|
struct nlmsghdr *nlh, *nlmsg;
|
||||||
@@ -300,7 +300,7 @@ QPair<QString, QNetworkInterface> NetworkUtilities::getGatewayAndIface()
|
|||||||
// This struct contain route attributes (route type)
|
// This struct contain route attributes (route type)
|
||||||
struct rtattr *route_attribute;
|
struct rtattr *route_attribute;
|
||||||
char gateway_address[INET_ADDRSTRLEN], interface[IF_NAMESIZE];
|
char gateway_address[INET_ADDRSTRLEN], interface[IF_NAMESIZE];
|
||||||
char msgbuf[BUFFER_SIZE], buffer[BUFFER_SIZE];
|
char msgbuf[100], buffer[BUFFER_SIZE];
|
||||||
char *ptr = buffer;
|
char *ptr = buffer;
|
||||||
struct timeval tv;
|
struct timeval tv;
|
||||||
|
|
||||||
@@ -345,8 +345,8 @@ QPair<QString, QNetworkInterface> NetworkUtilities::getGatewayAndIface()
|
|||||||
nlh = (struct nlmsghdr *) ptr;
|
nlh = (struct nlmsghdr *) ptr;
|
||||||
|
|
||||||
/* Check if the header is valid */
|
/* Check if the header is valid */
|
||||||
if((NLMSG_OK(nlmsg, received_bytes) == 0) ||
|
if((NLMSG_OK(nlh, received_bytes) == 0) ||
|
||||||
(nlmsg->nlmsg_type == NLMSG_ERROR))
|
(nlh->nlmsg_type == NLMSG_ERROR))
|
||||||
{
|
{
|
||||||
perror("Error in received packet");
|
perror("Error in received packet");
|
||||||
return {};
|
return {};
|
||||||
@@ -361,13 +361,15 @@ QPair<QString, QNetworkInterface> NetworkUtilities::getGatewayAndIface()
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Break if its not a multi part message */
|
/* Break if its not a multi part message */
|
||||||
if ((nlmsg->nlmsg_flags & NLM_F_MULTI) == 0)
|
if ((nlh->nlmsg_flags & NLM_F_MULTI) == 0)
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
while ((nlmsg->nlmsg_seq != msgseq) || (nlmsg->nlmsg_pid != getpid()));
|
while ((nlh->nlmsg_seq != msgseq) || (nlh->nlmsg_pid != getpid()));
|
||||||
|
|
||||||
/* parse response */
|
/* parse response */
|
||||||
for ( ; NLMSG_OK(nlh, received_bytes); nlh = NLMSG_NEXT(nlh, received_bytes))
|
int remaining = msg_len + received_bytes;
|
||||||
|
nlh = (struct nlmsghdr *) buffer;
|
||||||
|
for ( ; NLMSG_OK(nlh, remaining); nlh = NLMSG_NEXT(nlh, remaining))
|
||||||
{
|
{
|
||||||
/* Get the route data */
|
/* Get the route data */
|
||||||
route_entry = (struct rtmsg *) NLMSG_DATA(nlh);
|
route_entry = (struct rtmsg *) NLMSG_DATA(nlh);
|
||||||
@@ -376,6 +378,10 @@ QPair<QString, QNetworkInterface> NetworkUtilities::getGatewayAndIface()
|
|||||||
if (route_entry->rtm_table != RT_TABLE_MAIN)
|
if (route_entry->rtm_table != RT_TABLE_MAIN)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
/* Reset per-route to avoid cross-route state pollution */
|
||||||
|
memset(gateway_address, 0, sizeof(gateway_address));
|
||||||
|
memset(interface, 0, sizeof(interface));
|
||||||
|
|
||||||
route_attribute = (struct rtattr *) RTM_RTA(route_entry);
|
route_attribute = (struct rtattr *) RTM_RTA(route_entry);
|
||||||
route_attribute_len = RTM_PAYLOAD(nlh);
|
route_attribute_len = RTM_PAYLOAD(nlh);
|
||||||
|
|
||||||
@@ -401,6 +407,8 @@ QPair<QString, QNetworkInterface> NetworkUtilities::getGatewayAndIface()
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!(*gateway_address) || !(*interface))
|
||||||
|
qDebug() << "getGatewayAndIface: no gateway found";
|
||||||
close(sock);
|
close(sock);
|
||||||
return { gateway_address, QNetworkInterface::interfaceFromName(interface) };
|
return { gateway_address, QNetworkInterface::interfaceFromName(interface) };
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
#include <QString>
|
#include <QString>
|
||||||
|
#include <QJsonArray>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
#include <QList>
|
#include <QList>
|
||||||
|
#include <QHostAddress>
|
||||||
|
#include <QRandomGenerator>
|
||||||
|
#include <QTcpServer>
|
||||||
|
#include <stdexcept>
|
||||||
#include "3rd/QJsonStruct/QJsonIO.hpp"
|
#include "3rd/QJsonStruct/QJsonIO.hpp"
|
||||||
#include "transfer.h"
|
#include "transfer.h"
|
||||||
#include "serialization.h"
|
#include "serialization.h"
|
||||||
@@ -14,25 +19,125 @@ namespace amnezia::serialization::inbounds
|
|||||||
// "port": 10808,
|
// "port": 10808,
|
||||||
// "protocol": "socks",
|
// "protocol": "socks",
|
||||||
// "settings": {
|
// "settings": {
|
||||||
|
// "auth": "password",
|
||||||
|
// "accounts": [{"user": "...", "pass": "..."}],
|
||||||
// "udp": true
|
// "udp": true
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
//],
|
//],
|
||||||
|
|
||||||
const static QString listen = "127.0.0.1";
|
const static QString listen = "127.0.0.1";
|
||||||
const static int port = 10808;
|
const static int defaultPort = 10808;
|
||||||
const static QString protocol = "socks";
|
const static QString protocol = "socks";
|
||||||
|
|
||||||
|
static int indexOfSocksInbound(const QJsonArray &inbounds)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < inbounds.size(); ++i) {
|
||||||
|
const QString p = inbounds.at(i).toObject().value(QLatin1String("protocol")).toString();
|
||||||
|
if (p.compare(QLatin1String("socks"), Qt::CaseInsensitive) == 0)
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask the OS for a free TCP port on loopback (same stack as inbound "listen": "127.0.0.1").
|
||||||
|
static int acquireFreeLocalPort()
|
||||||
|
{
|
||||||
|
QTcpServer probe;
|
||||||
|
if (!probe.listen(QHostAddress(QStringLiteral("127.0.0.1")), 0)) {
|
||||||
|
throw std::runtime_error(
|
||||||
|
"Failed to bind a local TCP port on 127.0.0.1 for SOCKS inbound "
|
||||||
|
"(QTcpServer::listen failed; possible permission or OS network error).");
|
||||||
|
}
|
||||||
|
return static_cast<int>(probe.serverPort());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generates a hex string of `byteCount` random bytes (URL-safe, no special chars).
|
||||||
|
static QString generateRandomHex(int byteCount)
|
||||||
|
{
|
||||||
|
if (byteCount <= 0)
|
||||||
|
return {};
|
||||||
|
// fillRange writes full quint32 words; size the buffer to a multiple of 4 bytes to avoid
|
||||||
|
// overrunning a short buffer when byteCount is not divisible by 4.
|
||||||
|
const int numUint32 = (byteCount + int(sizeof(quint32)) - 1) / int(sizeof(quint32));
|
||||||
|
QByteArray buf(numUint32 * int(sizeof(quint32)), '\0');
|
||||||
|
QRandomGenerator::system()->fillRange(reinterpret_cast<quint32 *>(buf.data()), numUint32);
|
||||||
|
return QString::fromLatin1(buf.left(byteCount).toHex());
|
||||||
|
}
|
||||||
|
|
||||||
QJsonObject GenerateInboundEntry()
|
QJsonObject GenerateInboundEntry()
|
||||||
{
|
{
|
||||||
QJsonObject root;
|
QJsonObject root;
|
||||||
QJsonIO::SetValue(root, listen, "listen");
|
QJsonIO::SetValue(root, listen, "listen");
|
||||||
QJsonIO::SetValue(root, port, "port");
|
QJsonIO::SetValue(root, defaultPort, "port");
|
||||||
QJsonIO::SetValue(root, protocol, "protocol");
|
QJsonIO::SetValue(root, protocol, "protocol");
|
||||||
QJsonIO::SetValue(root, true, "settings", "udp");
|
QJsonIO::SetValue(root, true, "settings", "udp");
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
InboundCredentials GetInboundCredentials(const QJsonObject &xrayConfig)
|
||||||
|
{
|
||||||
|
InboundCredentials creds;
|
||||||
|
creds.port = defaultPort;
|
||||||
|
|
||||||
|
const QJsonArray inbounds = xrayConfig.value("inbounds").toArray();
|
||||||
|
const int socksIdx = indexOfSocksInbound(inbounds);
|
||||||
|
if (socksIdx < 0)
|
||||||
|
return creds;
|
||||||
|
|
||||||
|
const QJsonObject inbound = inbounds.at(socksIdx).toObject();
|
||||||
|
creds.port = inbound.value("port").toInt(defaultPort);
|
||||||
|
|
||||||
|
const QJsonObject settings = inbound.value("settings").toObject();
|
||||||
|
const QJsonArray accounts = settings.value("accounts").toArray();
|
||||||
|
if (accounts.isEmpty())
|
||||||
|
return creds;
|
||||||
|
|
||||||
|
const QJsonObject account = accounts.first().toObject();
|
||||||
|
creds.username = account.value("user").toString();
|
||||||
|
creds.password = account.value("pass").toString();
|
||||||
|
return creds;
|
||||||
|
}
|
||||||
|
|
||||||
|
InboundCredentials EnsureInboundAuth(QJsonObject &xrayConfig)
|
||||||
|
{
|
||||||
|
QJsonArray inbounds = xrayConfig.value("inbounds").toArray();
|
||||||
|
const int socksIdx = indexOfSocksInbound(inbounds);
|
||||||
|
if (socksIdx < 0)
|
||||||
|
return GetInboundCredentials(xrayConfig); // no SOCKS inbound to patch
|
||||||
|
|
||||||
|
QJsonObject inbound = inbounds.at(socksIdx).toObject();
|
||||||
|
InboundCredentials creds;
|
||||||
|
creds.port = acquireFreeLocalPort();
|
||||||
|
inbound["port"] = creds.port;
|
||||||
|
|
||||||
|
QJsonObject settings = inbound.value("settings").toObject();
|
||||||
|
const QJsonArray accounts = settings.value("accounts").toArray();
|
||||||
|
if (!accounts.isEmpty()) {
|
||||||
|
const QJsonObject account = accounts.first().toObject();
|
||||||
|
creds.username = account.value("user").toString();
|
||||||
|
creds.password = account.value("pass").toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (creds.username.isEmpty() || creds.password.isEmpty()) {
|
||||||
|
// Generate fresh credentials for this session (never persisted)
|
||||||
|
creds.username = generateRandomHex(8); // 16 hex chars
|
||||||
|
creds.password = generateRandomHex(16); // 32 hex chars
|
||||||
|
QJsonObject account;
|
||||||
|
account["user"] = creds.username;
|
||||||
|
account["pass"] = creds.password;
|
||||||
|
settings["accounts"] = QJsonArray{ account };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always ensure auth mode is enforced, even for imported configs that had
|
||||||
|
// accounts but auth: "noauth" (or no auth field at all).
|
||||||
|
settings["auth"] = QStringLiteral("password");
|
||||||
|
inbound["settings"] = settings;
|
||||||
|
inbounds[socksIdx] = inbound;
|
||||||
|
xrayConfig["inbounds"] = inbounds;
|
||||||
|
|
||||||
|
return creds;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace amnezia::serialization::inbounds
|
} // namespace amnezia::serialization::inbounds
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,24 @@ namespace amnezia::serialization
|
|||||||
|
|
||||||
namespace inbounds
|
namespace inbounds
|
||||||
{
|
{
|
||||||
|
struct InboundCredentials {
|
||||||
|
QString username;
|
||||||
|
QString password;
|
||||||
|
int port;
|
||||||
|
};
|
||||||
|
|
||||||
QJsonObject GenerateInboundEntry();
|
QJsonObject GenerateInboundEntry();
|
||||||
|
|
||||||
|
// Reads existing SOCKS5 auth from the first inbound with protocol "socks"
|
||||||
|
// (.settings.accounts[0]). Returns empty username/password if none.
|
||||||
|
InboundCredentials GetInboundCredentials(const QJsonObject &xrayConfig);
|
||||||
|
|
||||||
|
// Ensures SOCKS5 auth is present on the inbound whose protocol is "socks".
|
||||||
|
// Re-uses existing credentials if already set; otherwise generates random ones
|
||||||
|
// and writes them into the config. Assigns a free loopback TCP port each session
|
||||||
|
// (OS-assigned). Throws std::runtime_error if a SOCKS inbound exists but binding
|
||||||
|
// a local port on 127.0.0.1 fails (e.g. permissions or OS error).
|
||||||
|
InboundCredentials EnsureInboundAuth(QJsonObject &xrayConfig);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-33
@@ -390,55 +390,55 @@ bool Daemon::parseConfig(const QJsonObject& obj, InterfaceConfig& config) {
|
|||||||
|
|
||||||
config.m_killSwitchEnabled = QVariant(obj.value("killSwitchOption").toString()).toBool();
|
config.m_killSwitchEnabled = QVariant(obj.value("killSwitchOption").toString()).toBool();
|
||||||
|
|
||||||
if (!obj.value("Jc").isNull()) {
|
if (const auto jc = obj.value("Jc"); !jc.isUndefined()) {
|
||||||
config.m_junkPacketCount = obj.value("Jc").toString();
|
config.m_junkPacketCount = jc.toString();
|
||||||
}
|
}
|
||||||
if (!obj.value("Jmin").isNull()) {
|
if (const auto jmin = obj.value("Jmin"); !jmin.isUndefined()) {
|
||||||
config.m_junkPacketMinSize = obj.value("Jmin").toString();
|
config.m_junkPacketMinSize = jmin.toString();
|
||||||
}
|
}
|
||||||
if (!obj.value("Jmax").isNull()) {
|
if (const auto jmax = obj.value("Jmax"); !jmax.isUndefined()) {
|
||||||
config.m_junkPacketMaxSize = obj.value("Jmax").toString();
|
config.m_junkPacketMaxSize = jmax.toString();
|
||||||
}
|
}
|
||||||
if (!obj.value("S1").isNull()) {
|
if (const auto s1 = obj.value("S1"); !s1.isUndefined()) {
|
||||||
config.m_initPacketJunkSize = obj.value("S1").toString();
|
config.m_initPacketJunkSize = s1.toString();
|
||||||
}
|
}
|
||||||
if (!obj.value("S2").isNull()) {
|
if (const auto s2 = obj.value("S2"); !s2.isUndefined()) {
|
||||||
config.m_responsePacketJunkSize = obj.value("S2").toString();
|
config.m_responsePacketJunkSize = s2.toString();
|
||||||
}
|
}
|
||||||
if (!obj.value("S3").isNull()) {
|
if (const auto s3 = obj.value("S3"); !s3.isUndefined()) {
|
||||||
config.m_cookieReplyPacketJunkSize = obj.value("S3").toString();
|
config.m_cookieReplyPacketJunkSize = s3.toString();
|
||||||
}
|
}
|
||||||
if (!obj.value("S4").isNull()) {
|
if (const auto s4 = obj.value("S4"); !s4.isUndefined()) {
|
||||||
config.m_transportPacketJunkSize = obj.value("S4").toString();
|
config.m_transportPacketJunkSize = s4.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!obj.value("H1").isNull()) {
|
if (const auto h1 = obj.value("H1"); !h1.isUndefined()) {
|
||||||
config.m_initPacketMagicHeader = obj.value("H1").toString();
|
config.m_initPacketMagicHeader = h1.toString();
|
||||||
}
|
}
|
||||||
if (!obj.value("H2").isNull()) {
|
if (const auto h2 = obj.value("H2"); !h2.isUndefined()) {
|
||||||
config.m_responsePacketMagicHeader = obj.value("H2").toString();
|
config.m_responsePacketMagicHeader = h2.toString();
|
||||||
}
|
}
|
||||||
if (!obj.value("H3").isNull()) {
|
if (const auto h3 = obj.value("H3"); !h3.isUndefined()) {
|
||||||
config.m_underloadPacketMagicHeader = obj.value("H3").toString();
|
config.m_underloadPacketMagicHeader = h3.toString();
|
||||||
}
|
}
|
||||||
if (!obj.value("H4").isNull()) {
|
if (const auto h4 = obj.value("H4"); !h4.isUndefined()) {
|
||||||
config.m_transportPacketMagicHeader = obj.value("H4").toString();
|
config.m_transportPacketMagicHeader = h4.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!obj.value("I1").isNull()) {
|
if (const auto i1 = obj.value("I1"); !i1.isUndefined()) {
|
||||||
config.m_specialJunk["I1"] = obj.value("I1").toString();
|
config.m_specialJunk["I1"] = i1.toString();
|
||||||
}
|
}
|
||||||
if (!obj.value("I2").isNull()) {
|
if (const auto i2 = obj.value("I2"); !i2.isUndefined()) {
|
||||||
config.m_specialJunk["I2"] = obj.value("I2").toString();
|
config.m_specialJunk["I2"] = i2.toString();
|
||||||
}
|
}
|
||||||
if (!obj.value("I3").isNull()) {
|
if (const auto i3 = obj.value("I3"); !i3.isUndefined()) {
|
||||||
config.m_specialJunk["I3"] = obj.value("I3").toString();
|
config.m_specialJunk["I3"] = i3.toString();
|
||||||
}
|
}
|
||||||
if (!obj.value("I4").isNull()) {
|
if (const auto i4 = obj.value("I4"); !i4.isUndefined()) {
|
||||||
config.m_specialJunk["I4"] = obj.value("I4").toString();
|
config.m_specialJunk["I4"] = i4.toString();
|
||||||
}
|
}
|
||||||
if (!obj.value("I5").isNull()) {
|
if (const auto i5 = obj.value("I5"); !i5.isUndefined()) {
|
||||||
config.m_specialJunk["I5"] = obj.value("I5").toString();
|
config.m_specialJunk["I5"] = i5.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -613,7 +613,7 @@ void Daemon::checkHandshake() {
|
|||||||
pendingHandshakes++;
|
pendingHandshakes++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check again if there were connections that haven't completed a handshake.
|
// Check again if there were connections that haven't completed a handshake.
|
||||||
if (pendingHandshakes > 0) {
|
if (pendingHandshakes > 0) {
|
||||||
m_handshakeTimer.start(HANDSHAKE_POLL_MSEC);
|
m_handshakeTimer.start(HANDSHAKE_POLL_MSEC);
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M15 21V17C15 16.4696 15.2107 15.9609 15.5858 15.5858C15.9609 15.2107 16.4696 15 17 15H21" stroke="#D7D8DB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M7 4V6C7.21572 6.61347 7.62494 7.14024 8.16602 7.50096C8.7071 7.86168 9.35075 8.03682 10 8V8C10.5304 8 11.0391 8.21071 11.4142 8.58579C11.7893 8.96086 12 9.46957 12 10C12 10.5304 12.2107 11.0391 12.5858 11.4142C12.9609 11.7893 13.4696 12 14 12C14.5304 12 15.0391 11.7893 15.4142 11.4142C15.7893 11.0391 16 10.5304 16 10C16 9.46957 16.2107 8.96086 16.5858 8.58579C16.9609 8.21071 17.4696 8 18 8H21" stroke="#D7D8DB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M3 11H5C5.53043 11 6.03914 11.2107 6.41421 11.5858C6.78929 11.9609 7 12.4696 7 13V14C7 14.5304 7.21071 15.0391 7.58579 15.4142C7.96086 15.7893 8.46957 16 9 16C9.53043 16 10.0391 16.2107 10.4142 16.5858C10.7893 16.9609 11 17.4696 11 18V22" stroke="#D7D8DB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="#D7D8DB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18.1777 8C23.2737 8 23.2737 16 18.1777 16C13.0827 16 11.0447 8 5.43875 8C0.85375 8 0.85375 16 5.43875 16C11.0447 16 13.0828 8 18.1788 8H18.1777Z" stroke="#D7D8DB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 342 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M17 2H7C5.89543 2 5 2.89543 5 4V20C5 21.1046 5.89543 22 7 22H17C18.1046 22 19 21.1046 19 20V4C19 2.89543 18.1046 2 17 2Z" stroke="#D7D8DB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 18H12.01" stroke="#D7D8DB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 423 B |
@@ -16,7 +16,7 @@ set_target_properties(AmneziaVPNNetworkExtension PROPERTIES
|
|||||||
XCODE_ATTRIBUTE_PRODUCT_BUNDLE_NAME "${BUILD_IOS_APP_IDENTIFIER}.network-extension"
|
XCODE_ATTRIBUTE_PRODUCT_BUNDLE_NAME "${BUILD_IOS_APP_IDENTIFIER}.network-extension"
|
||||||
XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS ${CMAKE_CURRENT_SOURCE_DIR}/AmneziaVPNNetworkExtension.entitlements
|
XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS ${CMAKE_CURRENT_SOURCE_DIR}/AmneziaVPNNetworkExtension.entitlements
|
||||||
XCODE_ATTRIBUTE_MARKETING_VERSION "${APP_MAJOR_VERSION}"
|
XCODE_ATTRIBUTE_MARKETING_VERSION "${APP_MAJOR_VERSION}"
|
||||||
XCODE_ATTRIBUTE_CURRENT_PROJECT_VERSION "${BUILD_ID}"
|
XCODE_ATTRIBUTE_CURRENT_PROJECT_VERSION "${CMAKE_PROJECT_VERSION_TWEAK}"
|
||||||
XCODE_ATTRIBUTE_PRODUCT_NAME "AmneziaVPNNetworkExtension"
|
XCODE_ATTRIBUTE_PRODUCT_NAME "AmneziaVPNNetworkExtension"
|
||||||
|
|
||||||
XCODE_ATTRIBUTE_APPLICATION_EXTENSION_API_ONLY "YES"
|
XCODE_ATTRIBUTE_APPLICATION_EXTENSION_API_ONLY "YES"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<string>AmneziaVPNNetworkExtension</string>
|
<string>AmneziaVPNNetworkExtension</string>
|
||||||
|
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>org.amnezia.AmneziaVPN.network-extension</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
@@ -16,9 +16,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>${APPLE_PROJECT_VERSION}</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>${CMAKE_PROJECT_VERSION_TWEAK}</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<false/>
|
<false/>
|
||||||
@@ -41,6 +41,6 @@
|
|||||||
<string>group.org.amnezia.AmneziaVPN</string>
|
<string>group.org.amnezia.AmneziaVPN</string>
|
||||||
|
|
||||||
<key>com.wireguard.macos.app_group_id</key>
|
<key>com.wireguard.macos.app_group_id</key>
|
||||||
<string>${BUILD_VPN_DEVELOPMENT_TEAM}.group.org.amnezia.AmneziaVPN</string>
|
<string>$(DEVELOPMENT_TEAM).group.org.amnezia.AmneziaVPN</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -101,7 +101,9 @@ bool AndroidController::initialize()
|
|||||||
{"onAuthResult", "(Z)V", reinterpret_cast<void *>(onAuthResult)},
|
{"onAuthResult", "(Z)V", reinterpret_cast<void *>(onAuthResult)},
|
||||||
{"decodeQrCode", "(Ljava/lang/String;)Z", reinterpret_cast<bool *>(decodeQrCode)},
|
{"decodeQrCode", "(Ljava/lang/String;)Z", reinterpret_cast<bool *>(decodeQrCode)},
|
||||||
{"onImeInsetsChanged", "(I)V", reinterpret_cast<void *>(onImeInsetsChanged)},
|
{"onImeInsetsChanged", "(I)V", reinterpret_cast<void *>(onImeInsetsChanged)},
|
||||||
{"onSystemBarsInsetsChanged", "(II)V", reinterpret_cast<void *>(onSystemBarsInsetsChanged)}
|
{"onSystemBarsInsetsChanged", "(II)V", reinterpret_cast<void *>(onSystemBarsInsetsChanged)},
|
||||||
|
{"onActivityPaused", "()V", reinterpret_cast<void *>(onActivityPaused)},
|
||||||
|
{"onActivityResumed", "()V", reinterpret_cast<void *>(onActivityResumed)}
|
||||||
};
|
};
|
||||||
|
|
||||||
QJniEnvironment env;
|
QJniEnvironment env;
|
||||||
@@ -558,3 +560,22 @@ void AndroidController::onSystemBarsInsetsChanged(JNIEnv *env, jobject thiz, jin
|
|||||||
emit AndroidController::instance()->systemBarsInsetsChanged(navBarHeightDp, statusBarHeightDp);
|
emit AndroidController::instance()->systemBarsInsetsChanged(navBarHeightDp, statusBarHeightDp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// static
|
||||||
|
void AndroidController::onActivityPaused(JNIEnv *env, jobject thiz)
|
||||||
|
{
|
||||||
|
Q_UNUSED(env);
|
||||||
|
Q_UNUSED(thiz);
|
||||||
|
|
||||||
|
emit AndroidController::instance()->activityPaused();
|
||||||
|
}
|
||||||
|
|
||||||
|
// static
|
||||||
|
void AndroidController::onActivityResumed(JNIEnv *env, jobject thiz)
|
||||||
|
{
|
||||||
|
Q_UNUSED(env);
|
||||||
|
Q_UNUSED(thiz);
|
||||||
|
|
||||||
|
emit AndroidController::instance()->activityResumed();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ signals:
|
|||||||
void authenticationResult(bool result);
|
void authenticationResult(bool result);
|
||||||
void imeInsetsChanged(int heightDp);
|
void imeInsetsChanged(int heightDp);
|
||||||
void systemBarsInsetsChanged(int navBarHeightDp, int statusBarHeightDp);
|
void systemBarsInsetsChanged(int navBarHeightDp, int statusBarHeightDp);
|
||||||
|
void activityPaused();
|
||||||
|
void activityResumed();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
bool isWaitingStatus = true;
|
bool isWaitingStatus = true;
|
||||||
@@ -105,6 +107,8 @@ private:
|
|||||||
static bool decodeQrCode(JNIEnv *env, jobject thiz, jstring data);
|
static bool decodeQrCode(JNIEnv *env, jobject thiz, jstring data);
|
||||||
static void onImeInsetsChanged(JNIEnv *env, jobject thiz, jint heightDp);
|
static void onImeInsetsChanged(JNIEnv *env, jobject thiz, jint heightDp);
|
||||||
static void onSystemBarsInsetsChanged(JNIEnv *env, jobject thiz, jint navBarHeightDp, jint statusBarHeightDp);
|
static void onSystemBarsInsetsChanged(JNIEnv *env, jobject thiz, jint navBarHeightDp, jint statusBarHeightDp);
|
||||||
|
static void onActivityPaused(JNIEnv *env, jobject thiz);
|
||||||
|
static void onActivityResumed(JNIEnv *env, jobject thiz);
|
||||||
|
|
||||||
template <typename Ret, typename ...Args>
|
template <typename Ret, typename ...Args>
|
||||||
static auto callActivityMethod(const char *methodName, const char *signature, Args &&...args);
|
static auto callActivityMethod(const char *methodName, const char *signature, Args &&...args);
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ struct OpenVPNConfig: Decodable {
|
|||||||
|
|
||||||
extension PacketTunnelProvider {
|
extension PacketTunnelProvider {
|
||||||
func startOpenVPN(completionHandler: @escaping (Error?) -> Void) {
|
func startOpenVPN(completionHandler: @escaping (Error?) -> Void) {
|
||||||
|
// Reset session-derived state so reconnects never reuse stale gateway/address data.
|
||||||
|
openVpnGatewayAddress = nil
|
||||||
|
openVpnLocalAddress = nil
|
||||||
|
openVpnLocalMask = nil
|
||||||
|
lastOpenVPNSettings = nil
|
||||||
|
|
||||||
guard let protocolConfiguration = self.protocolConfiguration as? NETunnelProviderProtocol,
|
guard let protocolConfiguration = self.protocolConfiguration as? NETunnelProviderProtocol,
|
||||||
let providerConfiguration = protocolConfiguration.providerConfiguration,
|
let providerConfiguration = protocolConfiguration.providerConfiguration,
|
||||||
let openVPNConfigData = providerConfiguration[Constants.ovpnConfigKey] as? Data else {
|
let openVPNConfigData = providerConfiguration[Constants.ovpnConfigKey] as? Data else {
|
||||||
@@ -25,7 +31,25 @@ extension PacketTunnelProvider {
|
|||||||
do {
|
do {
|
||||||
let openVPNConfig = try JSONDecoder().decode(OpenVPNConfig.self, from: openVPNConfigData)
|
let openVPNConfig = try JSONDecoder().decode(OpenVPNConfig.self, from: openVPNConfigData)
|
||||||
ovpnLog(.info, title: "config: ", message: openVPNConfig.str)
|
ovpnLog(.info, title: "config: ", message: openVPNConfig.str)
|
||||||
|
let wrapperPreview = String(decoding: openVPNConfigData.prefix(512), as: UTF8.self)
|
||||||
|
let ovpnPreview = String(openVPNConfig.config.prefix(512))
|
||||||
|
ovpnLog(.info, title: "config wrapper", message: "bytes=\(openVPNConfigData.count) preview=\(wrapperPreview)")
|
||||||
|
ovpnLog(.info, title: "config raw", message: "chars=\(openVPNConfig.config.count) preview=\(ovpnPreview)")
|
||||||
let ovpnConfiguration = Data(openVPNConfig.config.utf8)
|
let ovpnConfiguration = Data(openVPNConfig.config.utf8)
|
||||||
|
splitTunnelType = openVPNConfig.splitTunnelType
|
||||||
|
splitTunnelSites = openVPNConfig.splitTunnelSites
|
||||||
|
openVpnDnsServers = Self.extractDnsServers(from: openVPNConfig.config)
|
||||||
|
openVpnRemoteAddress = Self.extractRemoteHost(from: openVPNConfig.config)
|
||||||
|
openVpnRedirectGatewayDef1 = Self.hasRedirectGatewayDef1(in: openVPNConfig.config)
|
||||||
|
if let openVpnRemoteAddress {
|
||||||
|
ovpnLog(.info, title: "Remote", message: "host=\(openVpnRemoteAddress)")
|
||||||
|
}
|
||||||
|
if !openVpnDnsServers.isEmpty {
|
||||||
|
ovpnLog(.info, title: "DNS", message: "servers=\(openVpnDnsServers)")
|
||||||
|
}
|
||||||
|
if openVpnRedirectGatewayDef1 {
|
||||||
|
ovpnLog(.info, title: "IPv4Routes", message: "redirect-gateway def1 detected")
|
||||||
|
}
|
||||||
setupAndlaunchOpenVPN(withConfig: ovpnConfiguration, completionHandler: completionHandler)
|
setupAndlaunchOpenVPN(withConfig: ovpnConfiguration, completionHandler: completionHandler)
|
||||||
} catch {
|
} catch {
|
||||||
ovpnLog(.error, message: "Can't parse OpenVPN config: \(error.localizedDescription)")
|
ovpnLog(.error, message: "Can't parse OpenVPN config: \(error.localizedDescription)")
|
||||||
@@ -73,6 +97,11 @@ extension PacketTunnelProvider {
|
|||||||
let digestString = digest.map { String(format: "%02x", $0) }.joined()
|
let digestString = digest.map { String(format: "%02x", $0) }.joined()
|
||||||
ovpnLog(.info, title: "ConfigDigest", message: digestString)
|
ovpnLog(.info, title: "ConfigDigest", message: digestString)
|
||||||
|
|
||||||
|
let hasCertTag = configString.contains("<cert>") && configString.contains("</cert>")
|
||||||
|
let hasKeyTag = configString.contains("<key>") && configString.contains("</key>")
|
||||||
|
let hasAuthUserPass = configString.contains("auth-user-pass")
|
||||||
|
ovpnLog(.info, title: "ConfigCreds", message: "inlineCert=\(hasCertTag) inlineKey=\(hasKeyTag) authUserPass=\(hasAuthUserPass)")
|
||||||
|
|
||||||
let hasTlsAuthOpen = configString.contains("<tls-auth>")
|
let hasTlsAuthOpen = configString.contains("<tls-auth>")
|
||||||
let hasTlsAuthClose = configString.contains("</tls-auth>")
|
let hasTlsAuthClose = configString.contains("</tls-auth>")
|
||||||
ovpnLog(.info, title: "ConfigFlags", message: "tls-auth open=\(hasTlsAuthOpen) close=\(hasTlsAuthClose)")
|
ovpnLog(.info, title: "ConfigFlags", message: "tls-auth open=\(hasTlsAuthOpen) close=\(hasTlsAuthClose)")
|
||||||
@@ -83,27 +112,98 @@ extension PacketTunnelProvider {
|
|||||||
ovpnLog(.debug, title: "ConfigHead", message: head)
|
ovpnLog(.debug, title: "ConfigHead", message: head)
|
||||||
ovpnLog(.debug, title: "ConfigTail", message: tail)
|
ovpnLog(.debug, title: "ConfigTail", message: tail)
|
||||||
|
|
||||||
if let start = configString.range(of: "<tls-auth>"),
|
if hasTlsAuthOpen && hasTlsAuthClose {
|
||||||
let end = configString.range(of: "</tls-auth>", range: start.upperBound..<configString.endIndex) {
|
ovpnLog(.info, title: "TLSAuthSanitized", message: "preserve original tls-auth block")
|
||||||
let keyBody = String(configString[start.upperBound..<end.lowerBound])
|
|
||||||
ovpnLog(.debug, title: "TLSAuthInline", message: keyBody)
|
|
||||||
let sanitizedLines = keyBody
|
|
||||||
.split(whereSeparator: { $0.isNewline })
|
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
.filter { !$0.hasPrefix("#") }
|
|
||||||
|
|
||||||
let sanitizedKey = sanitizedLines.joined(separator: "\n")
|
|
||||||
ovpnLog(.debug, title: "TLSAuthSanitized", message: sanitizedKey)
|
|
||||||
let sanitizedBlock = "<tls-auth>\n\(sanitizedKey)\n</tls-auth>"
|
|
||||||
configString.replaceSubrange(start.lowerBound..<end.upperBound, with: sanitizedBlock)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let normalizedConfig = configString.replacingOccurrences(of: "\r\n", with: "\n")
|
var normalizedConfig = configString.replacingOccurrences(of: "\r\n", with: "\n")
|
||||||
|
normalizedConfig = Self.normalizeInlineBlock(
|
||||||
|
in: normalizedConfig,
|
||||||
|
tag: "ca",
|
||||||
|
beginMarkers: ["-----BEGIN CERTIFICATE-----"],
|
||||||
|
endMarkers: ["-----END CERTIFICATE-----"]
|
||||||
|
)
|
||||||
|
normalizedConfig = Self.normalizeInlineBlock(
|
||||||
|
in: normalizedConfig,
|
||||||
|
tag: "cert",
|
||||||
|
beginMarkers: ["-----BEGIN CERTIFICATE-----"],
|
||||||
|
endMarkers: ["-----END CERTIFICATE-----"]
|
||||||
|
)
|
||||||
|
normalizedConfig = Self.normalizeInlineBlock(
|
||||||
|
in: normalizedConfig,
|
||||||
|
tag: "key",
|
||||||
|
beginMarkers: [
|
||||||
|
"-----BEGIN PRIVATE KEY-----",
|
||||||
|
"-----BEGIN RSA PRIVATE KEY-----",
|
||||||
|
"-----BEGIN EC PRIVATE KEY-----",
|
||||||
|
"-----BEGIN ENCRYPTED PRIVATE KEY-----"
|
||||||
|
],
|
||||||
|
endMarkers: [
|
||||||
|
"-----END PRIVATE KEY-----",
|
||||||
|
"-----END RSA PRIVATE KEY-----",
|
||||||
|
"-----END EC PRIVATE KEY-----",
|
||||||
|
"-----END ENCRYPTED PRIVATE KEY-----"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
normalizedConfig = Self.normalizeInlineBlock(
|
||||||
|
in: normalizedConfig,
|
||||||
|
tag: "tls-auth",
|
||||||
|
beginMarkers: ["-----BEGIN OpenVPN Static key V1-----"],
|
||||||
|
endMarkers: ["-----END OpenVPN Static key V1-----"]
|
||||||
|
)
|
||||||
|
normalizedConfig = Self.stripUnsupportedOptions(forOpenVPNAdapter: normalizedConfig)
|
||||||
|
if !normalizedConfig.hasSuffix("\n") {
|
||||||
|
normalizedConfig.append("\n")
|
||||||
|
}
|
||||||
|
let normalizedLines = normalizedConfig.split(whereSeparator: \.isNewline)
|
||||||
|
let normalizedTail = normalizedLines.suffix(10).joined(separator: "\n")
|
||||||
|
ovpnLog(.debug, title: "ConfigTailSanitized", message: normalizedTail)
|
||||||
|
let redirectLines = normalizedLines
|
||||||
|
.map(String.init)
|
||||||
|
.filter { $0.lowercased().contains("redirect-gateway") }
|
||||||
|
if !redirectLines.isEmpty {
|
||||||
|
ovpnLog(.info, title: "ConfigRedirect", message: redirectLines.joined(separator: " | "))
|
||||||
|
}
|
||||||
|
let controlScalars = normalizedConfig.unicodeScalars.filter {
|
||||||
|
($0.value < 0x20 && $0 != "\n" && $0 != "\r" && $0 != "\t")
|
||||||
|
}
|
||||||
|
if !controlScalars.isEmpty {
|
||||||
|
ovpnLog(.error, title: "ConfigChars", message: "nonPrintableControlCount=\(controlScalars.count)")
|
||||||
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
let dumpBaseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
||||||
|
?? FileManager.default.temporaryDirectory
|
||||||
|
let dumpURL = dumpBaseURL.appendingPathComponent("amnezia_ovpn_adapter_config.conf")
|
||||||
|
do {
|
||||||
|
try normalizedConfig.write(to: dumpURL, atomically: true, encoding: .utf8)
|
||||||
|
ovpnLog(.info, title: "ConfigDump", message: "path=\(dumpURL.path) bytes=\(normalizedConfig.utf8.count)")
|
||||||
|
} catch {
|
||||||
|
ovpnLog(.error, title: "ConfigDump", message: "write failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
let sanitizedData = Data(normalizedConfig.utf8)
|
let sanitizedData = Data(normalizedConfig.utf8)
|
||||||
|
|
||||||
let configuration = OpenVPNConfiguration()
|
let configuration = OpenVPNConfiguration()
|
||||||
configuration.fileContent = sanitizedData
|
configuration.fileContent = sanitizedData
|
||||||
|
// Be explicit: enum default is 0 (enabled), we need stubs-only behavior.
|
||||||
|
configuration.compressionMode = .disabled
|
||||||
|
// A-012: emulate OpenVPN2 CLI capability advertisement as closely as possible.
|
||||||
|
configuration.peerInfo = [
|
||||||
|
"IV_VER": "2.6.10",
|
||||||
|
"IV_PLAT": "mac",
|
||||||
|
"IV_TCPNL": "1",
|
||||||
|
"IV_MTU": "1600",
|
||||||
|
"IV_NCP": "2",
|
||||||
|
"IV_CIPHERS": "AES-256-GCM:AES-128-GCM:CHACHA20-POLY1305",
|
||||||
|
"IV_PROTO": "990",
|
||||||
|
"IV_LZO_STUB": "1",
|
||||||
|
"IV_COMP_STUB": "1",
|
||||||
|
"IV_COMP_STUBv2": "1"
|
||||||
|
]
|
||||||
|
if let peerInfo = configuration.peerInfo {
|
||||||
|
let peerInfoSummary = peerInfo.keys.sorted().map { "\($0)=\(peerInfo[$0] ?? "")" }.joined(separator: " ")
|
||||||
|
ovpnLog(.info, title: "PeerInfoOverride", message: peerInfoSummary)
|
||||||
|
}
|
||||||
if configString.contains("cloak") {
|
if configString.contains("cloak") {
|
||||||
configuration.setPTCloak()
|
configuration.setPTCloak()
|
||||||
}
|
}
|
||||||
@@ -124,17 +224,16 @@ extension PacketTunnelProvider {
|
|||||||
if evaluation?.autologin == false {
|
if evaluation?.autologin == false {
|
||||||
ovpnLog(.info, message: "Implement login with user credentials")
|
ovpnLog(.info, message: "Implement login with user credentials")
|
||||||
}
|
}
|
||||||
|
if let evaluation {
|
||||||
vpnReachability.startTracking { [weak self] status in
|
ovpnLog(.info, title: "ConfigEval", message: "autologin=\(evaluation.autologin) externalPki=\(evaluation.externalPki)")
|
||||||
switch status {
|
|
||||||
case .reachableViaWiFi, .reachableViaWWAN:
|
|
||||||
ovpnLog(.info, message: "Reachability changed, reconnecting OpenVPN session")
|
|
||||||
self?.ovpnAdapter?.reconnect(afterTimeInterval: 1)
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if !os(macOS)
|
||||||
|
vpnReachability.startTracking { [weak self] status in
|
||||||
|
self?.handleOpenVPNReachabilityChange(status)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
startHandler = completionHandler
|
startHandler = completionHandler
|
||||||
ovpnAdapter?.connect(using: openVPNPacketFlow())
|
ovpnAdapter?.connect(using: openVPNPacketFlow())
|
||||||
}
|
}
|
||||||
@@ -149,6 +248,8 @@ extension PacketTunnelProvider {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ovpnLog(.info, title: "Transport", message: "bytesIn=\(bytesin) bytesOut=\(bytesout)")
|
||||||
|
|
||||||
let response: [String: Any] = [
|
let response: [String: Any] = [
|
||||||
"rx_bytes": bytesin,
|
"rx_bytes": bytesin,
|
||||||
"tx_bytes": bytesout
|
"tx_bytes": bytesout
|
||||||
@@ -161,6 +262,10 @@ extension PacketTunnelProvider {
|
|||||||
ovpnLog(.info, message: "Stopping tunnel: reason: \(reason.amneziaDescription)")
|
ovpnLog(.info, message: "Stopping tunnel: reason: \(reason.amneziaDescription)")
|
||||||
|
|
||||||
stopHandler = completionHandler
|
stopHandler = completionHandler
|
||||||
|
openVpnGatewayAddress = nil
|
||||||
|
openVpnLocalAddress = nil
|
||||||
|
openVpnLocalMask = nil
|
||||||
|
lastOpenVPNSettings = nil
|
||||||
if vpnReachability.isTracking {
|
if vpnReachability.isTracking {
|
||||||
vpnReachability.stopTracking()
|
vpnReachability.stopTracking()
|
||||||
}
|
}
|
||||||
@@ -180,11 +285,99 @@ extension PacketTunnelProvider: OpenVPNAdapterDelegate {
|
|||||||
configureTunnelWithNetworkSettings networkSettings: NEPacketTunnelNetworkSettings?,
|
configureTunnelWithNetworkSettings networkSettings: NEPacketTunnelNetworkSettings?,
|
||||||
completionHandler: @escaping (Error?) -> Void
|
completionHandler: @escaping (Error?) -> Void
|
||||||
) {
|
) {
|
||||||
|
guard var effectiveSettings = networkSettings else {
|
||||||
|
ovpnLog(.info, title: "SetTunnelNetworkSettings", message: "nil settings; skipping update")
|
||||||
|
completionHandler(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let splitType = splitTunnelType ?? 0
|
||||||
|
|
||||||
|
if let ipv4Settings = effectiveSettings.ipv4Settings {
|
||||||
|
openVpnLocalAddress = ipv4Settings.addresses.first
|
||||||
|
openVpnLocalMask = ipv4Settings.subnetMasks.first
|
||||||
|
}
|
||||||
|
|
||||||
|
let serverIP = openVPNAdapter.connectionInformation?.serverIP
|
||||||
|
let configRemote = openVpnRemoteAddress
|
||||||
|
let serverEndpoint: String? = {
|
||||||
|
if let ip = serverIP, Self.isIPv4Address(ip) { return ip }
|
||||||
|
if let ip = configRemote, Self.isIPv4Address(ip) { return ip }
|
||||||
|
return effectiveSettings.tunnelRemoteAddress
|
||||||
|
}()
|
||||||
|
|
||||||
|
if let serverEndpoint,
|
||||||
|
Self.isIPv4Address(serverEndpoint),
|
||||||
|
effectiveSettings.tunnelRemoteAddress != serverEndpoint {
|
||||||
|
let updatedSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: serverEndpoint)
|
||||||
|
updatedSettings.ipv4Settings = effectiveSettings.ipv4Settings
|
||||||
|
updatedSettings.ipv6Settings = effectiveSettings.ipv6Settings
|
||||||
|
updatedSettings.dnsSettings = effectiveSettings.dnsSettings
|
||||||
|
updatedSettings.proxySettings = effectiveSettings.proxySettings
|
||||||
|
updatedSettings.mtu = effectiveSettings.mtu
|
||||||
|
effectiveSettings = updatedSettings
|
||||||
|
ovpnLog(.info, title: "Remote", message: "tunnelRemoteAddress set to server=\(serverEndpoint)")
|
||||||
|
} else if let serverEndpoint, !Self.isIPv4Address(serverEndpoint) {
|
||||||
|
ovpnLog(.info, title: "Remote", message: "skip tunnelRemoteAddress override; non-ip serverEndpoint=\(serverEndpoint)")
|
||||||
|
}
|
||||||
|
|
||||||
// In order to direct all DNS queries first to the VPN DNS servers before the primary DNS servers
|
// In order to direct all DNS queries first to the VPN DNS servers before the primary DNS servers
|
||||||
// send empty string to NEDNSSettings.matchDomains
|
// send empty string to NEDNSSettings.matchDomains
|
||||||
networkSettings?.dnsSettings?.matchDomains = [""]
|
if let dnsSettings = effectiveSettings.dnsSettings {
|
||||||
|
if dnsSettings.servers.isEmpty, !openVpnDnsServers.isEmpty {
|
||||||
|
let newSettings = NEDNSSettings(servers: openVpnDnsServers)
|
||||||
|
newSettings.matchDomains = dnsSettings.matchDomains
|
||||||
|
effectiveSettings.dnsSettings = newSettings
|
||||||
|
}
|
||||||
|
} else if !openVpnDnsServers.isEmpty {
|
||||||
|
let newSettings = NEDNSSettings(servers: openVpnDnsServers)
|
||||||
|
effectiveSettings.dnsSettings = newSettings
|
||||||
|
}
|
||||||
|
|
||||||
if splitTunnelType == 1 {
|
effectiveSettings.dnsSettings?.matchDomains = [""]
|
||||||
|
if let dnsSettings = effectiveSettings.dnsSettings {
|
||||||
|
let servers = dnsSettings.servers.joined(separator: ",")
|
||||||
|
let domains = dnsSettings.matchDomains?.joined(separator: ",") ?? ""
|
||||||
|
ovpnLog(.info, title: "DNS", message: "servers=[\(servers)] matchDomains=[\(domains)]")
|
||||||
|
} else {
|
||||||
|
ovpnLog(.error, title: "DNS", message: "dnsSettings is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
let tunnelRemote = effectiveSettings.tunnelRemoteAddress
|
||||||
|
if !tunnelRemote.isEmpty {
|
||||||
|
ovpnLog(.info, title: "Remote", message: "tunnelRemoteAddress=\(tunnelRemote)")
|
||||||
|
} else if let remoteAddress = openVpnRemoteAddress {
|
||||||
|
ovpnLog(.info, title: "Remote", message: "tunnelRemoteAddress is empty, configRemote=\(remoteAddress)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let ipv4Settings = effectiveSettings.ipv4Settings {
|
||||||
|
let included = (ipv4Settings.includedRoutes ?? []).map { "\($0.destinationAddress)/\($0.destinationSubnetMask)" }
|
||||||
|
let excluded = (ipv4Settings.excludedRoutes ?? []).map { "\($0.destinationAddress)/\($0.destinationSubnetMask)" }
|
||||||
|
let addresses = ipv4Settings.addresses.joined(separator: ",")
|
||||||
|
let masks = ipv4Settings.subnetMasks.joined(separator: ",")
|
||||||
|
let router: String
|
||||||
|
#if os(macOS)
|
||||||
|
if #available(macOS 13.0, *) {
|
||||||
|
router = ipv4Settings.router ?? ""
|
||||||
|
} else {
|
||||||
|
router = ""
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
router = ""
|
||||||
|
#endif
|
||||||
|
ovpnLog(.info, title: "IPv4RoutesPre", message: "addresses=[\(addresses)] masks=[\(masks)] router=\(router) included=\(included) excluded=\(excluded)")
|
||||||
|
} else {
|
||||||
|
ovpnLog(.error, title: "IPv4RoutesPre", message: "ipv4Settings is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let ipv6Settings = effectiveSettings.ipv6Settings {
|
||||||
|
let included = (ipv6Settings.includedRoutes ?? []).map { "\($0.destinationAddress)/\($0.destinationNetworkPrefixLength)" }
|
||||||
|
let excluded = (ipv6Settings.excludedRoutes ?? []).map { "\($0.destinationAddress)/\($0.destinationNetworkPrefixLength)" }
|
||||||
|
let addresses = ipv6Settings.addresses.joined(separator: ",")
|
||||||
|
let prefixes = ipv6Settings.networkPrefixLengths.map { "\($0)" }.joined(separator: ",")
|
||||||
|
ovpnLog(.info, title: "IPv6RoutesPre", message: "addresses=[\(addresses)] prefixes=[\(prefixes)] included=\(included) excluded=\(excluded)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if splitType == 1 {
|
||||||
var ipv4IncludedRoutes = [NEIPv4Route]()
|
var ipv4IncludedRoutes = [NEIPv4Route]()
|
||||||
|
|
||||||
guard let splitTunnelSites else {
|
guard let splitTunnelSites else {
|
||||||
@@ -200,9 +393,8 @@ extension PacketTunnelProvider: OpenVPNAdapterDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
networkSettings?.ipv4Settings?.includedRoutes = ipv4IncludedRoutes
|
effectiveSettings.ipv4Settings?.includedRoutes = ipv4IncludedRoutes
|
||||||
} else {
|
} else if splitType == 2 {
|
||||||
if splitTunnelType == 2 {
|
|
||||||
var ipv4ExcludedRoutes = [NEIPv4Route]()
|
var ipv4ExcludedRoutes = [NEIPv4Route]()
|
||||||
var ipv4IncludedRoutes = [NEIPv4Route]()
|
var ipv4IncludedRoutes = [NEIPv4Route]()
|
||||||
var ipv6IncludedRoutes = [NEIPv6Route]()
|
var ipv6IncludedRoutes = [NEIPv6Route]()
|
||||||
@@ -230,14 +422,418 @@ extension PacketTunnelProvider: OpenVPNAdapterDelegate {
|
|||||||
destinationAddress: "\(allIPv6.address)",
|
destinationAddress: "\(allIPv6.address)",
|
||||||
networkPrefixLength: NSNumber(value: allIPv6.networkPrefixLength)))
|
networkPrefixLength: NSNumber(value: allIPv6.networkPrefixLength)))
|
||||||
}
|
}
|
||||||
networkSettings?.ipv4Settings?.includedRoutes = ipv4IncludedRoutes
|
effectiveSettings.ipv4Settings?.includedRoutes = ipv4IncludedRoutes
|
||||||
networkSettings?.ipv6Settings?.includedRoutes = ipv6IncludedRoutes
|
effectiveSettings.ipv6Settings?.includedRoutes = ipv6IncludedRoutes
|
||||||
networkSettings?.ipv4Settings?.excludedRoutes = ipv4ExcludedRoutes
|
effectiveSettings.ipv4Settings?.excludedRoutes = ipv4ExcludedRoutes
|
||||||
|
} else {
|
||||||
|
// Full tunnel: rely on adapter-provided routes.
|
||||||
|
}
|
||||||
|
|
||||||
|
if let serverEndpoint,
|
||||||
|
Self.isIPv4Address(serverEndpoint),
|
||||||
|
let ipv4Settings = effectiveSettings.ipv4Settings {
|
||||||
|
let hostMask = "255.255.255.255"
|
||||||
|
var excluded = ipv4Settings.excludedRoutes ?? []
|
||||||
|
let alreadyExcluded = excluded.contains {
|
||||||
|
$0.destinationAddress == serverEndpoint && $0.destinationSubnetMask == hostMask
|
||||||
|
}
|
||||||
|
if !alreadyExcluded {
|
||||||
|
excluded.append(NEIPv4Route(destinationAddress: serverEndpoint, subnetMask: hostMask))
|
||||||
|
ipv4Settings.excludedRoutes = excluded
|
||||||
|
ovpnLog(.info, title: "IPv4Routes", message: "excluded remoteAddress=\(serverEndpoint)")
|
||||||
|
}
|
||||||
|
} else if let serverEndpoint {
|
||||||
|
ovpnLog(.info, title: "IPv4Routes", message: "skip explicit remote exclude; non-ip server=\(serverEndpoint)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let localAddr = openVpnLocalAddress
|
||||||
|
var net30Gateway: String?
|
||||||
|
if let localAddr, let mask = openVpnLocalMask {
|
||||||
|
net30Gateway = Self.net30Peer(for: localAddr, mask: mask)
|
||||||
|
}
|
||||||
|
var gateway = net30Gateway
|
||||||
|
if let adapterGateway = openVPNAdapter.connectionInformation?.gatewayIPv4, !adapterGateway.isEmpty {
|
||||||
|
if let localAddr, adapterGateway == localAddr {
|
||||||
|
ovpnLog(.info, title: "IPv4Gateway", message: "ignore adapter gateway equal to local address=\(adapterGateway)")
|
||||||
|
} else if let net30Gateway, net30Gateway != adapterGateway {
|
||||||
|
ovpnLog(.info, title: "IPv4Gateway", message: "ignore mismatched adapter gateway=\(adapterGateway), using net30 peer=\(net30Gateway)")
|
||||||
|
} else {
|
||||||
|
gateway = adapterGateway
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openVpnGatewayAddress = gateway
|
||||||
|
if let gateway, !gateway.isEmpty {
|
||||||
|
ovpnLog(.info, title: "IPv4Gateway", message: "gateway=\(gateway)")
|
||||||
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
if splitType == 0, let gateway, !gateway.isEmpty, effectiveSettings.tunnelRemoteAddress != gateway {
|
||||||
|
let updatedSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: gateway)
|
||||||
|
updatedSettings.ipv4Settings = effectiveSettings.ipv4Settings
|
||||||
|
updatedSettings.ipv6Settings = effectiveSettings.ipv6Settings
|
||||||
|
updatedSettings.dnsSettings = effectiveSettings.dnsSettings
|
||||||
|
updatedSettings.proxySettings = effectiveSettings.proxySettings
|
||||||
|
updatedSettings.mtu = effectiveSettings.mtu
|
||||||
|
effectiveSettings = updatedSettings
|
||||||
|
ovpnLog(.info, title: "Remote", message: "tunnelRemoteAddress set to gateway=\(gateway) on macOS full-tunnel")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
#if os(macOS)
|
||||||
|
if var ipv4Settings = effectiveSettings.ipv4Settings {
|
||||||
|
if splitType == 0 {
|
||||||
|
let hasNet30Mask = ipv4Settings.subnetMasks.contains("255.255.255.252")
|
||||||
|
if hasNet30Mask {
|
||||||
|
let normalizedMasks = Array(repeating: "255.255.255.255",
|
||||||
|
count: ipv4Settings.subnetMasks.count)
|
||||||
|
let normalized = NEIPv4Settings(addresses: ipv4Settings.addresses,
|
||||||
|
subnetMasks: normalizedMasks)
|
||||||
|
normalized.includedRoutes = ipv4Settings.includedRoutes
|
||||||
|
normalized.excludedRoutes = ipv4Settings.excludedRoutes
|
||||||
|
if #available(macOS 13.0, *) {
|
||||||
|
normalized.router = ipv4Settings.router
|
||||||
|
}
|
||||||
|
ipv4Settings = normalized
|
||||||
|
ovpnLog(.info, title: "IPv4Routes", message: "normalized net30 /30 masks to /32 on macOS full-tunnel")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let gateway, !gateway.isEmpty {
|
||||||
|
if #available(macOS 13.0, *) {
|
||||||
|
ipv4Settings.router = gateway
|
||||||
|
ovpnLog(.info, title: "IPv4Routes", message: "set ipv4 router=\(gateway) on macOS full-tunnel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var included = ipv4Settings.includedRoutes ?? []
|
||||||
|
let hasDefault = included.contains {
|
||||||
|
$0.destinationAddress == "0.0.0.0" && $0.destinationSubnetMask == "0.0.0.0"
|
||||||
|
}
|
||||||
|
if hasDefault {
|
||||||
|
included.removeAll {
|
||||||
|
$0.destinationAddress == "0.0.0.0" && $0.destinationSubnetMask == "0.0.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let hasDef1Low = included.contains {
|
||||||
|
$0.destinationAddress == "0.0.0.0" && $0.destinationSubnetMask == "128.0.0.0"
|
||||||
|
}
|
||||||
|
let hasDef1High = included.contains {
|
||||||
|
$0.destinationAddress == "128.0.0.0" && $0.destinationSubnetMask == "128.0.0.0"
|
||||||
|
}
|
||||||
|
if (hasDefault || openVpnRedirectGatewayDef1) && !(hasDef1Low && hasDef1High) {
|
||||||
|
if !hasDef1Low {
|
||||||
|
let route = NEIPv4Route(destinationAddress: "0.0.0.0", subnetMask: "128.0.0.0")
|
||||||
|
if let gateway, !gateway.isEmpty {
|
||||||
|
route.gatewayAddress = gateway
|
||||||
|
}
|
||||||
|
included.append(route)
|
||||||
|
}
|
||||||
|
if !hasDef1High {
|
||||||
|
let route = NEIPv4Route(destinationAddress: "128.0.0.0", subnetMask: "128.0.0.0")
|
||||||
|
if let gateway, !gateway.isEmpty {
|
||||||
|
route.gatewayAddress = gateway
|
||||||
|
}
|
||||||
|
included.append(route)
|
||||||
|
}
|
||||||
|
ovpnLog(.info, title: "IPv4Routes", message: "ensured def1 routes (/1 + /1) on macOS full-tunnel")
|
||||||
|
}
|
||||||
|
if let gateway, !gateway.isEmpty {
|
||||||
|
included = included.map { route in
|
||||||
|
let isDef1 =
|
||||||
|
(route.destinationAddress == "0.0.0.0" && route.destinationSubnetMask == "128.0.0.0") ||
|
||||||
|
(route.destinationAddress == "128.0.0.0" && route.destinationSubnetMask == "128.0.0.0")
|
||||||
|
guard isDef1 else { return route }
|
||||||
|
if route.gatewayAddress == gateway {
|
||||||
|
return route
|
||||||
|
}
|
||||||
|
let updatedRoute = NEIPv4Route(destinationAddress: route.destinationAddress,
|
||||||
|
subnetMask: route.destinationSubnetMask)
|
||||||
|
updatedRoute.gatewayAddress = gateway
|
||||||
|
return updatedRoute
|
||||||
|
}
|
||||||
|
ovpnLog(.info, title: "IPv4Routes", message: "set gateway=\(gateway) on macOS def1 routes")
|
||||||
|
}
|
||||||
|
ipv4Settings.includedRoutes = included
|
||||||
|
effectiveSettings.ipv4Settings = ipv4Settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
if let ipv4Settings = effectiveSettings.ipv4Settings {
|
||||||
|
let included = (ipv4Settings.includedRoutes ?? []).map {
|
||||||
|
let gw = $0.gatewayAddress ?? ""
|
||||||
|
return "\($0.destinationAddress)/\($0.destinationSubnetMask) gw=\(gw)"
|
||||||
|
}
|
||||||
|
let excluded = (ipv4Settings.excludedRoutes ?? []).map { "\($0.destinationAddress)/\($0.destinationSubnetMask)" }
|
||||||
|
let addresses = ipv4Settings.addresses.joined(separator: ",")
|
||||||
|
let masks = ipv4Settings.subnetMasks.joined(separator: ",")
|
||||||
|
let router: String
|
||||||
|
#if os(macOS)
|
||||||
|
if #available(macOS 13.0, *) {
|
||||||
|
router = ipv4Settings.router ?? ""
|
||||||
|
} else {
|
||||||
|
router = ""
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
router = ""
|
||||||
|
#endif
|
||||||
|
ovpnLog(.info, title: "IPv4Routes", message: "addresses=[\(addresses)] masks=[\(masks)] router=\(router) included=\(included) excluded=\(excluded)")
|
||||||
|
} else {
|
||||||
|
ovpnLog(.error, title: "IPv4Routes", message: "ipv4Settings is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let ipv6Settings = effectiveSettings.ipv6Settings {
|
||||||
|
let included = (ipv6Settings.includedRoutes ?? []).map { "\($0.destinationAddress)/\($0.destinationNetworkPrefixLength)" }
|
||||||
|
let excluded = (ipv6Settings.excludedRoutes ?? []).map { "\($0.destinationAddress)/\($0.destinationNetworkPrefixLength)" }
|
||||||
|
let addresses = ipv6Settings.addresses.joined(separator: ",")
|
||||||
|
let prefixes = ipv6Settings.networkPrefixLengths.map { "\($0)" }.joined(separator: ",")
|
||||||
|
ovpnLog(.info, title: "IPv6Routes", message: "addresses=[\(addresses)] prefixes=[\(prefixes)] included=\(included) excluded=\(excluded)")
|
||||||
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
if effectiveSettings.ipv6Settings != nil {
|
||||||
|
effectiveSettings.ipv6Settings = nil
|
||||||
|
ovpnLog(.info, title: "IPv6", message: "cleared ipv6Settings on macOS")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
lastOpenVPNSettings = effectiveSettings
|
||||||
|
|
||||||
// Set the network settings for the current tunneling session.
|
// Set the network settings for the current tunneling session.
|
||||||
setTunnelNetworkSettings(networkSettings, completionHandler: completionHandler)
|
setTunnelNetworkSettings(effectiveSettings) { error in
|
||||||
|
if let error {
|
||||||
|
ovpnLog(.error, title: "SetTunnelNetworkSettings", message: error.localizedDescription)
|
||||||
|
} else {
|
||||||
|
ovpnLog(.info, title: "SetTunnelNetworkSettings", message: "ok")
|
||||||
|
}
|
||||||
|
completionHandler(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func extractDnsServers(from config: String) -> [String] {
|
||||||
|
let lines = config.split(whereSeparator: \.isNewline)
|
||||||
|
var servers: [String] = []
|
||||||
|
for line in lines {
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.hasPrefix("dhcp-option DNS ") {
|
||||||
|
let parts = trimmed.split(separator: " ")
|
||||||
|
if let last = parts.last {
|
||||||
|
servers.append(String(last))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return servers
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func extractRemoteHost(from config: String) -> String? {
|
||||||
|
let lines = config.split(whereSeparator: \.isNewline)
|
||||||
|
for line in lines {
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.hasPrefix("remote ") {
|
||||||
|
let parts = trimmed.split(separator: " ")
|
||||||
|
if parts.count >= 2 {
|
||||||
|
return String(parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func hasRedirectGatewayDef1(in config: String) -> Bool {
|
||||||
|
let lines = config.split(whereSeparator: \.isNewline)
|
||||||
|
for line in lines {
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.hasPrefix("redirect-gateway") {
|
||||||
|
return trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).contains("def1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func net30Peer(for address: String, mask: String) -> String? {
|
||||||
|
guard mask == "255.255.255.252" else { return nil }
|
||||||
|
let parts = address.split(separator: ".")
|
||||||
|
guard parts.count == 4 else { return nil }
|
||||||
|
var octets: [Int] = []
|
||||||
|
for part in parts {
|
||||||
|
guard let num = Int(part), num >= 0 && num <= 255 else { return nil }
|
||||||
|
octets.append(num)
|
||||||
|
}
|
||||||
|
let ip = (octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]
|
||||||
|
let network = ip & ~3
|
||||||
|
let host = ip - network
|
||||||
|
let peerHost: Int
|
||||||
|
switch host {
|
||||||
|
case 1: peerHost = 2
|
||||||
|
case 2: peerHost = 1
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
let peerIP = network + peerHost
|
||||||
|
return "\((peerIP >> 24) & 0xff).\((peerIP >> 16) & 0xff).\((peerIP >> 8) & 0xff).\(peerIP & 0xff)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func logOpenVPNConnectionInfo() {
|
||||||
|
guard let info = ovpnAdapter?.connectionInformation else { return }
|
||||||
|
let message = "vpnIPv4=\(info.vpnIPv4 ?? "") gatewayIPv4=\(info.gatewayIPv4 ?? "") serverIP=\(info.serverIP ?? "") tun=\(info.tunName ?? "")"
|
||||||
|
ovpnLog(.info, title: "ConnInfo", message: message)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizeInlineBlock(
|
||||||
|
in config: String,
|
||||||
|
tag: String,
|
||||||
|
beginMarkers: [String],
|
||||||
|
endMarkers: [String]
|
||||||
|
) -> String {
|
||||||
|
guard !beginMarkers.isEmpty, !endMarkers.isEmpty else { return config }
|
||||||
|
|
||||||
|
var normalizedConfig = config
|
||||||
|
let openTag = "<\(tag)>"
|
||||||
|
let closeTag = "</\(tag)>"
|
||||||
|
var searchStart = normalizedConfig.startIndex
|
||||||
|
|
||||||
|
while let openRange = normalizedConfig.range(of: openTag, range: searchStart..<normalizedConfig.endIndex),
|
||||||
|
let closeRange = normalizedConfig.range(of: closeTag, range: openRange.upperBound..<normalizedConfig.endIndex) {
|
||||||
|
let rawBody = String(normalizedConfig[openRange.upperBound..<closeRange.lowerBound])
|
||||||
|
let lines = rawBody
|
||||||
|
.split(whereSeparator: \.isNewline)
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
|
||||||
|
var beginIndex: Int?
|
||||||
|
var endIndex: Int?
|
||||||
|
for (idx, line) in lines.enumerated() {
|
||||||
|
if beginIndex == nil,
|
||||||
|
beginMarkers.contains(where: { line.contains($0) }) {
|
||||||
|
beginIndex = idx
|
||||||
|
}
|
||||||
|
if beginIndex != nil,
|
||||||
|
endMarkers.contains(where: { line.contains($0) }) {
|
||||||
|
endIndex = idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let beginIndex,
|
||||||
|
let endIndex,
|
||||||
|
endIndex >= beginIndex {
|
||||||
|
let extracted = lines[beginIndex...endIndex].joined(separator: "\n")
|
||||||
|
let replacement = "<\(tag)>\n\(extracted)\n</\(tag)>"
|
||||||
|
normalizedConfig.replaceSubrange(openRange.lowerBound..<closeRange.upperBound, with: replacement)
|
||||||
|
ovpnLog(.info, title: "ConfigInline", message: "tag=<\(tag)> linesIn=\(lines.count) linesOut=\(endIndex - beginIndex + 1)")
|
||||||
|
searchStart = normalizedConfig.index(openRange.lowerBound, offsetBy: replacement.count)
|
||||||
|
} else {
|
||||||
|
ovpnLog(.error, title: "ConfigInline", message: "tag=<\(tag)> missing markers, keeping original body")
|
||||||
|
searchStart = closeRange.upperBound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private static func stripUnsupportedOptions(forOpenVPNAdapter config: String) -> String {
|
||||||
|
let unsupportedTokens: Set<String> = [
|
||||||
|
"block-ipv6",
|
||||||
|
"script-security",
|
||||||
|
"up",
|
||||||
|
"down",
|
||||||
|
"resolv-retry",
|
||||||
|
"persist-key",
|
||||||
|
"persist-tun",
|
||||||
|
"compat-mode",
|
||||||
|
"disable-dco"
|
||||||
|
]
|
||||||
|
let inlineBlockTags: Set<String> = [
|
||||||
|
"ca",
|
||||||
|
"cert",
|
||||||
|
"key",
|
||||||
|
"pkcs12",
|
||||||
|
"tls-auth",
|
||||||
|
"tls-crypt",
|
||||||
|
"tls-crypt-v2",
|
||||||
|
"secret",
|
||||||
|
"crl-verify",
|
||||||
|
"extra-certs"
|
||||||
|
]
|
||||||
|
|
||||||
|
var removed: [String: Int] = [:]
|
||||||
|
var normalized: [String: Int] = [:]
|
||||||
|
var output: [String] = []
|
||||||
|
var activeInlineTag: String?
|
||||||
|
|
||||||
|
for rawLine in config.split(whereSeparator: \.isNewline) {
|
||||||
|
let line = String(rawLine)
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.isEmpty {
|
||||||
|
output.append(line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmedLowercased = trimmed.lowercased()
|
||||||
|
|
||||||
|
if let currentInlineTag = activeInlineTag {
|
||||||
|
output.append(line)
|
||||||
|
if trimmedLowercased == "</\(currentInlineTag)>" {
|
||||||
|
activeInlineTag = nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmedLowercased.hasPrefix("<"),
|
||||||
|
trimmedLowercased.hasSuffix(">"),
|
||||||
|
!trimmedLowercased.hasPrefix("</") {
|
||||||
|
let tagContent = String(trimmedLowercased.dropFirst().dropLast())
|
||||||
|
let tagName = tagContent
|
||||||
|
.split(whereSeparator: { $0 == " " || $0 == "\t" })
|
||||||
|
.first
|
||||||
|
.map(String.init) ?? ""
|
||||||
|
if inlineBlockTags.contains(tagName) {
|
||||||
|
activeInlineTag = tagName
|
||||||
|
output.append(line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmed.hasPrefix("#") || trimmed.hasPrefix(";") {
|
||||||
|
output.append(line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let parts = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" })
|
||||||
|
let token = parts.first.map(String.init)?.lowercased() ?? ""
|
||||||
|
if trimmedLowercased.hasPrefix("redirect-gateway") || token.hasPrefix("redirect-gateway") {
|
||||||
|
let hasDef1 = parts.dropFirst().contains { String($0).lowercased().hasPrefix("def1") }
|
||||||
|
if hasDef1 {
|
||||||
|
output.append("redirect-gateway def1")
|
||||||
|
normalized["redirect-gateway", default: 0] += 1
|
||||||
|
} else {
|
||||||
|
removed["redirect-gateway", default: 0] += 1
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if let matchedUnsupported = unsupportedTokens.first(where: { token.hasPrefix($0) }) {
|
||||||
|
removed[matchedUnsupported, default: 0] += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
output.append(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !removed.isEmpty {
|
||||||
|
let summary = removed.keys.sorted().map { "\($0)=\(removed[$0] ?? 0)" }.joined(separator: " ")
|
||||||
|
ovpnLog(.info, title: "ConfigStrip", message: summary)
|
||||||
|
}
|
||||||
|
if !normalized.isEmpty {
|
||||||
|
let summary = normalized.keys.sorted().map { "\($0)=\(normalized[$0] ?? 0)" }.joined(separator: " ")
|
||||||
|
ovpnLog(.info, title: "ConfigNormalize", message: summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isIPv4Address(_ value: String) -> Bool {
|
||||||
|
let parts = value.split(separator: ".")
|
||||||
|
if parts.count != 4 { return false }
|
||||||
|
for part in parts {
|
||||||
|
guard let num = Int(part), num >= 0 && num <= 255 else { return false }
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process events returned by the OpenVPN library
|
// Process events returned by the OpenVPN library
|
||||||
@@ -255,6 +851,9 @@ extension PacketTunnelProvider: OpenVPNAdapterDelegate {
|
|||||||
|
|
||||||
startHandler(nil)
|
startHandler(nil)
|
||||||
self.startHandler = nil
|
self.startHandler = nil
|
||||||
|
|
||||||
|
logOpenVPNConnectionInfo()
|
||||||
|
refreshOpenVPNSettingsAfterConnect()
|
||||||
case .disconnected:
|
case .disconnected:
|
||||||
guard let stopHandler = stopHandler else { return }
|
guard let stopHandler = stopHandler else { return }
|
||||||
|
|
||||||
@@ -297,4 +896,41 @@ extension PacketTunnelProvider: OpenVPNAdapterDelegate {
|
|||||||
// Handle log messages
|
// Handle log messages
|
||||||
ovpnLog(.info, message: logMessage)
|
ovpnLog(.info, message: logMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func openVPNAdapterDidReceiveClockTick(_ openVPNAdapter: OpenVPNAdapter) {
|
||||||
|
let now = Date()
|
||||||
|
if now.timeIntervalSince(lastOpenVPNStatsLogTime) < 5 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastOpenVPNStatsLogTime = now
|
||||||
|
|
||||||
|
let transport = openVPNAdapter.transportStatistics
|
||||||
|
let iface = openVPNAdapter.interfaceStatistics
|
||||||
|
let transportLine = "transport bytesIn=\(transport.bytesIn) bytesOut=\(transport.bytesOut) packetsIn=\(transport.packetsIn) packetsOut=\(transport.packetsOut)"
|
||||||
|
let ifaceLine = "iface bytesIn=\(iface.bytesIn) bytesOut=\(iface.bytesOut) packetsIn=\(iface.packetsIn) packetsOut=\(iface.packetsOut) errorsIn=\(iface.errorsIn) errorsOut=\(iface.errorsOut)"
|
||||||
|
ovpnLog(.info, title: "Stats", message: "\(transportLine) | \(ifaceLine)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshOpenVPNSettingsAfterConnect() {
|
||||||
|
let localAddr = openVpnLocalAddress
|
||||||
|
var net30Gateway: String?
|
||||||
|
if let localAddr, let mask = openVpnLocalMask {
|
||||||
|
net30Gateway = Self.net30Peer(for: localAddr, mask: mask)
|
||||||
|
}
|
||||||
|
var gateway = net30Gateway
|
||||||
|
if let adapterGateway = ovpnAdapter?.connectionInformation?.gatewayIPv4, !adapterGateway.isEmpty {
|
||||||
|
if let localAddr, adapterGateway == localAddr {
|
||||||
|
ovpnLog(.info, title: "IPv4Gateway", message: "post-connect ignoring adapter gateway equal to local address=\(adapterGateway)")
|
||||||
|
} else if let net30Gateway, net30Gateway != adapterGateway {
|
||||||
|
ovpnLog(.info, title: "IPv4Gateway", message: "post-connect keeping net30 peer=\(net30Gateway), adapter gateway=\(adapterGateway)")
|
||||||
|
} else {
|
||||||
|
gateway = adapterGateway
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let gateway, !gateway.isEmpty else { return }
|
||||||
|
openVpnGatewayAddress = gateway
|
||||||
|
ovpnLog(.info, title: "IPv4Gateway", message: "post-connect gateway=\(gateway)")
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Darwin
|
||||||
import Foundation
|
import Foundation
|
||||||
import NetworkExtension
|
import NetworkExtension
|
||||||
|
|
||||||
@@ -6,6 +7,7 @@ enum XrayErrors: Error {
|
|||||||
case xrayConfigIsWrong
|
case xrayConfigIsWrong
|
||||||
case cantSaveXrayConfig
|
case cantSaveXrayConfig
|
||||||
case cantParseListenAndPort
|
case cantParseListenAndPort
|
||||||
|
case cantAcquireLocalPort
|
||||||
case cantSaveHevSocksConfig
|
case cantSaveHevSocksConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +23,80 @@ extension Constants {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension PacketTunnelProvider {
|
extension PacketTunnelProvider {
|
||||||
|
/// TCP port chosen by the OS on IPv6 loopback (::1), matching inbound listen address.
|
||||||
|
private func acquireFreeLocalPort() throws -> Int {
|
||||||
|
let fd = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP)
|
||||||
|
guard fd != -1 else {
|
||||||
|
throw XrayErrors.cantAcquireLocalPort
|
||||||
|
}
|
||||||
|
defer { close(fd) }
|
||||||
|
var reuse: Int32 = 1
|
||||||
|
_ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout<Int32>.size))
|
||||||
|
var addr = sockaddr_in6()
|
||||||
|
addr.sin6_len = UInt8(MemoryLayout<sockaddr_in6>.size)
|
||||||
|
addr.sin6_family = sa_family_t(AF_INET6)
|
||||||
|
addr.sin6_port = in_port_t(0).bigEndian
|
||||||
|
addr.sin6_addr = in6addr_loopback
|
||||||
|
addr.sin6_scope_id = 0
|
||||||
|
let bindResult = withUnsafePointer(to: &addr) { ptr in
|
||||||
|
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { p in
|
||||||
|
bind(fd, p, socklen_t(MemoryLayout<sockaddr_in6>.size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard bindResult == 0 else {
|
||||||
|
throw XrayErrors.cantAcquireLocalPort
|
||||||
|
}
|
||||||
|
var bound = sockaddr_in6()
|
||||||
|
var len = socklen_t(MemoryLayout<sockaddr_in6>.size)
|
||||||
|
let gr = withUnsafeMutablePointer(to: &bound) { p in
|
||||||
|
p.withMemoryRebound(to: sockaddr.self, capacity: 1) { bp in
|
||||||
|
getsockname(fd, bp, &len)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard gr == 0 else {
|
||||||
|
throw XrayErrors.cantAcquireLocalPort
|
||||||
|
}
|
||||||
|
return Int(bound.sin6_port.byteSwapped)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyXraySplitTunnel(_ xrayConfig: XrayConfig,
|
||||||
|
settings: NEPacketTunnelNetworkSettings) {
|
||||||
|
guard let splitTunnelType = xrayConfig.splitTunnelType else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let splitTunnelSites = xrayConfig.splitTunnelSites else {
|
||||||
|
xrayLog(.error, message: "Split tunnel sites are not set")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if splitTunnelType == 1 {
|
||||||
|
var ipv4IncludedRoutes = [NEIPv4Route]()
|
||||||
|
|
||||||
|
for allowedIPString in splitTunnelSites {
|
||||||
|
if let allowedIP = IPAddressRange(from: allowedIPString) {
|
||||||
|
ipv4IncludedRoutes.append(NEIPv4Route(
|
||||||
|
destinationAddress: "\(allowedIP.address)",
|
||||||
|
subnetMask: "\(allowedIP.subnetMask())"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.ipv4Settings?.includedRoutes = ipv4IncludedRoutes
|
||||||
|
} else if splitTunnelType == 2 {
|
||||||
|
var ipv4ExcludedRoutes = [NEIPv4Route]()
|
||||||
|
|
||||||
|
for excludedIPString in splitTunnelSites {
|
||||||
|
if let excludedIP = IPAddressRange(from: excludedIPString) {
|
||||||
|
ipv4ExcludedRoutes.append(NEIPv4Route(
|
||||||
|
destinationAddress: "\(excludedIP.address)",
|
||||||
|
subnetMask: "\(excludedIP.subnetMask())"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.ipv4Settings?.excludedRoutes = ipv4ExcludedRoutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func startXray(completionHandler: @escaping (Error?) -> Void) {
|
func startXray(completionHandler: @escaping (Error?) -> Void) {
|
||||||
|
|
||||||
// Xray configuration
|
// Xray configuration
|
||||||
@@ -72,6 +148,7 @@ extension PacketTunnelProvider {
|
|||||||
settings.dnsSettings = !dnsArray.isEmpty
|
settings.dnsSettings = !dnsArray.isEmpty
|
||||||
? NEDNSSettings(servers: dnsArray)
|
? NEDNSSettings(servers: dnsArray)
|
||||||
: NEDNSSettings(servers: ["1.1.1.1"])
|
: NEDNSSettings(servers: ["1.1.1.1"])
|
||||||
|
applyXraySplitTunnel(xrayConfig, settings: settings)
|
||||||
|
|
||||||
let xrayConfigData = xrayConfig.config.data(using: .utf8)
|
let xrayConfigData = xrayConfig.config.data(using: .utf8)
|
||||||
|
|
||||||
@@ -90,14 +167,11 @@ extension PacketTunnelProvider {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let port = 10808
|
let port = try acquireFreeLocalPort()
|
||||||
let address = "::1"
|
let address = "::1"
|
||||||
|
|
||||||
if var inboundsArray = jsonDict["inbounds"] as? [[String: Any]], !inboundsArray.isEmpty {
|
// Extract existing SOCKS5 credentials or generate new ones per session.
|
||||||
inboundsArray[0]["port"] = port
|
let socksCredentials = ensureInboundAuth(jsonDict: &jsonDict, port: port, address: address)
|
||||||
inboundsArray[0]["listen"] = address
|
|
||||||
jsonDict["inbounds"] = inboundsArray
|
|
||||||
}
|
|
||||||
|
|
||||||
let updatedData = try JSONSerialization.data(withJSONObject: jsonDict, options: [])
|
let updatedData = try JSONSerialization.data(withJSONObject: jsonDict, options: [])
|
||||||
|
|
||||||
@@ -120,6 +194,8 @@ extension PacketTunnelProvider {
|
|||||||
self?.setupAndRunTun2socks(configData: updatedData,
|
self?.setupAndRunTun2socks(configData: updatedData,
|
||||||
address: address,
|
address: address,
|
||||||
port: port,
|
port: port,
|
||||||
|
username: socksCredentials.username,
|
||||||
|
password: socksCredentials.password,
|
||||||
completionHandler: completionHandler)
|
completionHandler: completionHandler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,6 +220,62 @@ extension PacketTunnelProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct SocksCredentials {
|
||||||
|
let username: String
|
||||||
|
let password: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private func indexOfSocksInbound(in inboundsArray: [[String: Any]]) -> Int? {
|
||||||
|
for (i, inbound) in inboundsArray.enumerated() {
|
||||||
|
guard let proto = inbound["protocol"] as? String else { continue }
|
||||||
|
if proto.caseInsensitiveCompare("socks") == .orderedSame {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns existing SOCKS5 credentials from the inbound config, or generates and injects
|
||||||
|
// new random ones. Also sets port and address on the socks inbound entry.
|
||||||
|
private func ensureInboundAuth(jsonDict: inout [String: Any], port: Int, address: String) -> SocksCredentials {
|
||||||
|
var inboundsArray = jsonDict["inbounds"] as? [[String: Any]] ?? []
|
||||||
|
|
||||||
|
if let socksIdx = indexOfSocksInbound(in: inboundsArray) {
|
||||||
|
var inbound = inboundsArray[socksIdx]
|
||||||
|
inbound["port"] = port
|
||||||
|
inbound["listen"] = address
|
||||||
|
|
||||||
|
var settings = inbound["settings"] as? [String: Any] ?? [:]
|
||||||
|
if let accounts = settings["accounts"] as? [[String: Any]],
|
||||||
|
let first = accounts.first,
|
||||||
|
let user = first["user"] as? String, !user.isEmpty,
|
||||||
|
let pass = first["pass"] as? String, !pass.isEmpty {
|
||||||
|
// Re-use existing credentials, but always enforce auth mode in case the
|
||||||
|
// imported config had accounts but auth: "noauth" (or no auth field).
|
||||||
|
settings["auth"] = "password"
|
||||||
|
inbound["settings"] = settings
|
||||||
|
inboundsArray[socksIdx] = inbound
|
||||||
|
jsonDict["inbounds"] = inboundsArray
|
||||||
|
return SocksCredentials(username: user, password: pass)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new random credentials for this session
|
||||||
|
let user = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased().prefix(16)
|
||||||
|
let pass = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
|
||||||
|
settings["auth"] = "password"
|
||||||
|
settings["accounts"] = [["user": String(user), "pass": pass]]
|
||||||
|
inbound["settings"] = settings
|
||||||
|
inboundsArray[socksIdx] = inbound
|
||||||
|
jsonDict["inbounds"] = inboundsArray
|
||||||
|
return SocksCredentials(username: String(user), password: pass)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: no socks inbound — generate credentials but can't inject
|
||||||
|
let user = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased().prefix(16)
|
||||||
|
let pass = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
|
||||||
|
return SocksCredentials(username: String(user), password: pass)
|
||||||
|
}
|
||||||
|
|
||||||
private func setupAndStartXray(configData: Data,
|
private func setupAndStartXray(configData: Data,
|
||||||
completionHandler: @escaping (Error?) -> Void) {
|
completionHandler: @escaping (Error?) -> Void) {
|
||||||
let path = Constants.cachesDirectory.appendingPathComponent("config.json", isDirectory: false).path
|
let path = Constants.cachesDirectory.appendingPathComponent("config.json", isDirectory: false).path
|
||||||
@@ -175,6 +307,8 @@ extension PacketTunnelProvider {
|
|||||||
private func setupAndRunTun2socks(configData: Data,
|
private func setupAndRunTun2socks(configData: Data,
|
||||||
address: String,
|
address: String,
|
||||||
port: Int,
|
port: Int,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
completionHandler: @escaping (Error?) -> Void) {
|
completionHandler: @escaping (Error?) -> Void) {
|
||||||
let config = """
|
let config = """
|
||||||
tunnel:
|
tunnel:
|
||||||
@@ -182,6 +316,8 @@ extension PacketTunnelProvider {
|
|||||||
socks5:
|
socks5:
|
||||||
port: \(port)
|
port: \(port)
|
||||||
address: \(address)
|
address: \(address)
|
||||||
|
username: \(username)
|
||||||
|
password: \(password)
|
||||||
udp: 'udp'
|
udp: 'udp'
|
||||||
misc:
|
misc:
|
||||||
task-stack-size: 20480
|
task-stack-size: 20480
|
||||||
|
|||||||
@@ -46,11 +46,21 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
|||||||
private var didReceiveInitialPathUpdate = false
|
private var didReceiveInitialPathUpdate = false
|
||||||
private var currentPath: Network.NWPath?
|
private var currentPath: Network.NWPath?
|
||||||
private var currentPathSignature: String?
|
private var currentPathSignature: String?
|
||||||
|
private var pendingOpenVPNReconnectWorkItem: DispatchWorkItem?
|
||||||
private var pendingNetworkChangeWorkItem: DispatchWorkItem?
|
private var pendingNetworkChangeWorkItem: DispatchWorkItem?
|
||||||
private var isApplyingNetworkChange = false
|
private var isApplyingNetworkChange = false
|
||||||
|
private var lastOpenVPNReachabilityStatus: OpenVPNReachabilityStatus?
|
||||||
|
|
||||||
var splitTunnelType: Int?
|
var splitTunnelType: Int?
|
||||||
var splitTunnelSites: [String]?
|
var splitTunnelSites: [String]?
|
||||||
|
var openVpnDnsServers: [String] = []
|
||||||
|
var openVpnRemoteAddress: String?
|
||||||
|
var openVpnRedirectGatewayDef1 = false
|
||||||
|
var openVpnLocalAddress: String?
|
||||||
|
var openVpnLocalMask: String?
|
||||||
|
var openVpnGatewayAddress: String?
|
||||||
|
var lastOpenVPNSettings: NEPacketTunnelNetworkSettings?
|
||||||
|
var lastOpenVPNStatsLogTime = Date.distantPast
|
||||||
|
|
||||||
let vpnReachability = OpenVPNReachability()
|
let vpnReachability = OpenVPNReachability()
|
||||||
|
|
||||||
@@ -81,12 +91,21 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
|||||||
|
|
||||||
guard hasMeaningfulChange, let proto = self.protoType else { return }
|
guard hasMeaningfulChange, let proto = self.protoType else { return }
|
||||||
|
|
||||||
// OpenVPN and WireGuard/AWG handle network changes internally.
|
// WireGuard/AWG and OpenVPN manages network changes internally in its own adapter.
|
||||||
// Restarting them here can race their own reconnect logic and break tunnel setup.
|
|
||||||
if proto == .wireguard || proto == .openvpn {
|
if proto == .wireguard || proto == .openvpn {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if proto == .openvpn {
|
||||||
|
self.scheduleOpenVPNReconnect(reason: "NWPath changed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.isApplyingNetworkChange || self.reasserting {
|
||||||
|
xrayLog(.debug, message: "Ignoring path change while xray restart is in progress")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
self.scheduleNetworkChangeHandling(for: proto, path: path)
|
self.scheduleNetworkChangeHandling(for: proto, path: path)
|
||||||
}
|
}
|
||||||
pathMonitor.start(queue: pathMonitorQueue)
|
pathMonitor.start(queue: pathMonitorQueue)
|
||||||
@@ -181,9 +200,26 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
|||||||
let errorNotifier = ErrorNotifier(activationAttemptId: activationAttemptId)
|
let errorNotifier = ErrorNotifier(activationAttemptId: activationAttemptId)
|
||||||
|
|
||||||
neLog(.info, message: "Start tunnel")
|
neLog(.info, message: "Start tunnel")
|
||||||
|
if let vpnProto = protocolConfiguration as? NEVPNProtocol {
|
||||||
|
if #available(iOS 14.0, macOS 11.0, *) {
|
||||||
|
var details = "includeAllNetworks=\(vpnProto.includeAllNetworks)"
|
||||||
|
if #available(iOS 14.2, macOS 11.0, *) {
|
||||||
|
details += " excludeLocalNetworks=\(vpnProto.excludeLocalNetworks)"
|
||||||
|
}
|
||||||
|
neLog(.info, title: "Protocol", message: details)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let protocolConfiguration = protocolConfiguration as? NETunnelProviderProtocol {
|
if let protocolConfiguration = protocolConfiguration as? NETunnelProviderProtocol {
|
||||||
let providerConfiguration = protocolConfiguration.providerConfiguration
|
let providerConfiguration = protocolConfiguration.providerConfiguration
|
||||||
|
let providerKeys = providerConfiguration?.keys.sorted().joined(separator: ",") ?? ""
|
||||||
|
var protocolDetails = "bundleId=\(protocolConfiguration.providerBundleIdentifier ?? "") keys=[\(providerKeys)]"
|
||||||
|
if let ovpnData = providerConfiguration?[Constants.ovpnConfigKey] as? Data {
|
||||||
|
let preview = String(decoding: ovpnData.prefix(512), as: UTF8.self)
|
||||||
|
protocolDetails += " ovpnBytes=\(ovpnData.count) ovpnPreview=\(preview)"
|
||||||
|
}
|
||||||
|
neLog(.info, title: "Protocol", message: protocolDetails)
|
||||||
|
|
||||||
if (providerConfiguration?[Constants.ovpnConfigKey] as? Data) != nil {
|
if (providerConfiguration?[Constants.ovpnConfigKey] as? Data) != nil {
|
||||||
protoType = .openvpn
|
protoType = .openvpn
|
||||||
} else if (providerConfiguration?[Constants.wireGuardConfigKey] as? Data) != nil {
|
} else if (providerConfiguration?[Constants.wireGuardConfigKey] as? Data) != nil {
|
||||||
@@ -199,6 +235,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cancelPendingOpenVPNReconnect()
|
||||||
|
cancelPendingNetworkChangeHandling()
|
||||||
didReceiveInitialPathUpdate = false
|
didReceiveInitialPathUpdate = false
|
||||||
updateActiveInterfaceIndexForCurrentPath()
|
updateActiveInterfaceIndexForCurrentPath()
|
||||||
|
|
||||||
@@ -217,6 +255,9 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
|||||||
|
|
||||||
|
|
||||||
override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
|
override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
|
||||||
|
cancelPendingOpenVPNReconnect()
|
||||||
|
cancelPendingNetworkChangeHandling()
|
||||||
|
|
||||||
guard let protoType else {
|
guard let protoType else {
|
||||||
completionHandler()
|
completionHandler()
|
||||||
return
|
return
|
||||||
@@ -284,8 +325,9 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
|||||||
|
|
||||||
let workItem = DispatchWorkItem { [weak self] in
|
let workItem = DispatchWorkItem { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
self.pendingNetworkChangeWorkItem = nil
|
||||||
|
|
||||||
if self.isApplyingNetworkChange {
|
if self.isApplyingNetworkChange || self.reasserting {
|
||||||
xrayLog(.debug, message: "Skipping network change while restart is already in progress")
|
xrayLog(.debug, message: "Skipping network change while restart is already in progress")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -303,6 +345,69 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
|||||||
pendingNetworkChangeWorkItem = workItem
|
pendingNetworkChangeWorkItem = workItem
|
||||||
networkChangeQueue.asyncAfter(deadline: .now() + 1.0, execute: workItem)
|
networkChangeQueue.asyncAfter(deadline: .now() + 1.0, execute: workItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func scheduleOpenVPNReconnect(reason: String) {
|
||||||
|
guard protoType == .openvpn else { return }
|
||||||
|
|
||||||
|
pendingOpenVPNReconnectWorkItem?.cancel()
|
||||||
|
|
||||||
|
let workItem = DispatchWorkItem { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.pendingOpenVPNReconnectWorkItem = nil
|
||||||
|
|
||||||
|
guard self.protoType == .openvpn else { return }
|
||||||
|
|
||||||
|
if self.reasserting {
|
||||||
|
ovpnLog(.debug, message: "Skipping OpenVPN reconnect while session is already reasserting")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
guard !self.reasserting else {
|
||||||
|
ovpnLog(.debug, message: "Skipping OpenVPN reconnect while session is already reasserting")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ovpnLog(.info, message: "\(reason), reconnecting OpenVPN session")
|
||||||
|
self.ovpnAdapter?.reconnect(afterTimeInterval: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingOpenVPNReconnectWorkItem = workItem
|
||||||
|
networkChangeQueue.asyncAfter(deadline: .now() + 1.0, execute: workItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleOpenVPNReachabilityChange(_ status: OpenVPNReachabilityStatus) {
|
||||||
|
defer { lastOpenVPNReachabilityStatus = status }
|
||||||
|
|
||||||
|
guard let previousStatus = lastOpenVPNReachabilityStatus else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard previousStatus != status else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case .reachableViaWiFi, .reachableViaWWAN:
|
||||||
|
scheduleOpenVPNReconnect(reason: "Reachability changed")
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelPendingOpenVPNReconnect() {
|
||||||
|
pendingOpenVPNReconnectWorkItem?.cancel()
|
||||||
|
pendingOpenVPNReconnectWorkItem = nil
|
||||||
|
lastOpenVPNReachabilityStatus = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelPendingNetworkChangeHandling() {
|
||||||
|
pendingNetworkChangeWorkItem?.cancel()
|
||||||
|
pendingNetworkChangeWorkItem = nil
|
||||||
|
isApplyingNetworkChange = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension PacketTunnelProvider {
|
private extension PacketTunnelProvider {
|
||||||
@@ -311,8 +416,14 @@ private extension PacketTunnelProvider {
|
|||||||
signatureComponents.append(path.isExpensive ? "exp" : "noexp")
|
signatureComponents.append(path.isExpensive ? "exp" : "noexp")
|
||||||
signatureComponents.append(path.isConstrained ? "con" : "nocon")
|
signatureComponents.append(path.isConstrained ? "con" : "nocon")
|
||||||
|
|
||||||
let preferredTypes: [NWInterface.InterfaceType] = [.wiredEthernet, .wifi, .cellular, .loopback, .other]
|
// Ignore loopback and tunnel-style `.other` interfaces so Xray does not
|
||||||
let sortedInterfaces = path.availableInterfaces.sorted { lhs, rhs in
|
// react to its own utun lifecycle as if the physical uplink changed.
|
||||||
|
let preferredTypes: [NWInterface.InterfaceType] = [.wiredEthernet, .wifi, .cellular]
|
||||||
|
let externalInterfaces = path.availableInterfaces.filter { interface in
|
||||||
|
interface.type == .wiredEthernet || interface.type == .wifi || interface.type == .cellular
|
||||||
|
}
|
||||||
|
|
||||||
|
let sortedInterfaces = externalInterfaces.sorted { lhs, rhs in
|
||||||
if lhs.type == rhs.type {
|
if lhs.type == rhs.type {
|
||||||
return lhs.index < rhs.index
|
return lhs.index < rhs.index
|
||||||
}
|
}
|
||||||
@@ -333,8 +444,8 @@ private extension PacketTunnelProvider {
|
|||||||
case .wiredEthernet: typeName = "ethernet"
|
case .wiredEthernet: typeName = "ethernet"
|
||||||
case .wifi: typeName = "wifi"
|
case .wifi: typeName = "wifi"
|
||||||
case .cellular: typeName = "cellular"
|
case .cellular: typeName = "cellular"
|
||||||
case .loopback: typeName = "loopback"
|
case .loopback, .other:
|
||||||
case .other: typeName = "other"
|
continue
|
||||||
@unknown default: typeName = "unknown"
|
@unknown default: typeName = "unknown"
|
||||||
}
|
}
|
||||||
signatureComponents.append("\(typeName):\(interface.index)")
|
signatureComponents.append("\(typeName):\(interface.index)")
|
||||||
@@ -363,6 +474,8 @@ extension WireGuardLogLevel {
|
|||||||
|
|
||||||
final class PacketTunnelFlowAdapter: NSObject, OpenVPNAdapterPacketFlow {
|
final class PacketTunnelFlowAdapter: NSObject, OpenVPNAdapterPacketFlow {
|
||||||
private let flow: NEPacketTunnelFlow
|
private let flow: NEPacketTunnelFlow
|
||||||
|
private var readLogCounter = 0
|
||||||
|
private var writeLogCounter = 0
|
||||||
|
|
||||||
init(flow: NEPacketTunnelFlow) {
|
init(flow: NEPacketTunnelFlow) {
|
||||||
self.flow = flow
|
self.flow = flow
|
||||||
@@ -371,15 +484,98 @@ final class PacketTunnelFlowAdapter: NSObject, OpenVPNAdapterPacketFlow {
|
|||||||
|
|
||||||
@objc(readPacketsWithCompletionHandler:)
|
@objc(readPacketsWithCompletionHandler:)
|
||||||
func readPackets(completionHandler: @escaping ([Data], [NSNumber]) -> Void) {
|
func readPackets(completionHandler: @escaping ([Data], [NSNumber]) -> Void) {
|
||||||
flow.readPackets(completionHandler: completionHandler)
|
flow.readPackets { packets, protocols in
|
||||||
|
#if os(macOS)
|
||||||
|
if self.readLogCounter < 20, let firstPacket = packets.first, let firstProtocol = protocols.first {
|
||||||
|
let prefix = firstPacket.prefix(12).map { String(format: "%02x", $0) }.joined()
|
||||||
|
let header = Self.describePacketHeader(firstPacket)
|
||||||
|
ovpnLog(.info, title: "FlowRead", message: "count=\(packets.count) proto0=\(firstProtocol) len0=\(firstPacket.count) prefix=\(prefix) \(header)")
|
||||||
|
self.readLogCounter += 1
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
completionHandler(packets, protocols)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc(writePackets:withProtocols:)
|
@objc(writePackets:withProtocols:)
|
||||||
func writePackets(_ packets: [Data], withProtocols protocols: [NSNumber]) -> Bool {
|
func writePackets(_ packets: [Data], withProtocols protocols: [NSNumber]) -> Bool {
|
||||||
flow.writePackets(packets, withProtocols: protocols)
|
#if os(macOS)
|
||||||
|
if writeLogCounter < 20, let firstPacket = packets.first, let firstProtocol = protocols.first {
|
||||||
|
let prefix = firstPacket.prefix(12).map { String(format: "%02x", $0) }.joined()
|
||||||
|
let header = Self.describePacketHeader(firstPacket)
|
||||||
|
ovpnLog(.info, title: "FlowWrite", message: "count=\(packets.count) proto0=\(firstProtocol) len0=\(firstPacket.count) prefix=\(prefix) \(header)")
|
||||||
|
writeLogCounter += 1
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return flow.writePackets(packets, withProtocols: protocols)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func describePacketHeader(_ packet: Data) -> String {
|
||||||
|
guard let versionNibble = packet.first.map({ Int($0 >> 4) }) else {
|
||||||
|
return "ip=unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
if versionNibble == 4, packet.count >= 20 {
|
||||||
|
let ihl = Int(packet[0] & 0x0f) * 4
|
||||||
|
guard ihl >= 20, packet.count >= ihl else {
|
||||||
|
return "ip=ipv4 malformed"
|
||||||
|
}
|
||||||
|
|
||||||
|
let proto = packet[9]
|
||||||
|
let src = "\(packet[12]).\(packet[13]).\(packet[14]).\(packet[15])"
|
||||||
|
let dst = "\(packet[16]).\(packet[17]).\(packet[18]).\(packet[19])"
|
||||||
|
let l4Offset = ihl
|
||||||
|
let ports: String
|
||||||
|
if (proto == 6 || proto == 17) && packet.count >= l4Offset + 4 {
|
||||||
|
let srcPort = (UInt16(packet[l4Offset]) << 8) | UInt16(packet[l4Offset + 1])
|
||||||
|
let dstPort = (UInt16(packet[l4Offset + 2]) << 8) | UInt16(packet[l4Offset + 3])
|
||||||
|
ports = "sport=\(srcPort) dport=\(dstPort)"
|
||||||
|
} else {
|
||||||
|
ports = "sport=- dport=-"
|
||||||
|
}
|
||||||
|
let protoName: String
|
||||||
|
switch proto {
|
||||||
|
case 1: protoName = "ICMP"
|
||||||
|
case 6: protoName = "TCP"
|
||||||
|
case 17: protoName = "UDP"
|
||||||
|
default: protoName = "P\(proto)"
|
||||||
|
}
|
||||||
|
return "ip=ipv4 src=\(src) dst=\(dst) proto=\(protoName) \(ports)"
|
||||||
|
}
|
||||||
|
|
||||||
|
if versionNibble == 6, packet.count >= 40 {
|
||||||
|
let proto = packet[6]
|
||||||
|
func hex16(_ start: Int) -> String {
|
||||||
|
let value = (UInt16(packet[start]) << 8) | UInt16(packet[start + 1])
|
||||||
|
return String(format: "%x", value)
|
||||||
|
}
|
||||||
|
let src = stride(from: 8, to: 24, by: 2).map(hex16).joined(separator: ":")
|
||||||
|
let dst = stride(from: 24, to: 40, by: 2).map(hex16).joined(separator: ":")
|
||||||
|
let l4Offset = 40
|
||||||
|
let ports: String
|
||||||
|
if (proto == 6 || proto == 17) && packet.count >= l4Offset + 4 {
|
||||||
|
let srcPort = (UInt16(packet[l4Offset]) << 8) | UInt16(packet[l4Offset + 1])
|
||||||
|
let dstPort = (UInt16(packet[l4Offset + 2]) << 8) | UInt16(packet[l4Offset + 3])
|
||||||
|
ports = "sport=\(srcPort) dport=\(dstPort)"
|
||||||
|
} else {
|
||||||
|
ports = "sport=- dport=-"
|
||||||
|
}
|
||||||
|
let protoName: String
|
||||||
|
switch proto {
|
||||||
|
case 58: protoName = "ICMPv6"
|
||||||
|
case 6: protoName = "TCP"
|
||||||
|
case 17: protoName = "UDP"
|
||||||
|
default: protoName = "P\(proto)"
|
||||||
|
}
|
||||||
|
return "ip=ipv6 src=\(src) dst=\(dst) proto=\(protoName) \(ports)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "ip=v\(versionNibble) len=\(packet.count)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension NEPacketTunnelFlow: OpenVPNAdapterPacketFlow {}
|
||||||
|
|
||||||
extension NEProviderStopReason {
|
extension NEProviderStopReason {
|
||||||
var amneziaDescription: String {
|
var amneziaDescription: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import Foundation
|
||||||
|
import StoreKit
|
||||||
|
|
||||||
|
@available(iOS 15.0, macOS 12.0, *)
|
||||||
|
@objcMembers
|
||||||
|
public class StoreKit2Helper: NSObject {
|
||||||
|
|
||||||
|
public static let shared = StoreKit2Helper()
|
||||||
|
private static let errorDomain = "StoreKit2Helper"
|
||||||
|
|
||||||
|
private struct EntitlementInfo {
|
||||||
|
let transactionId: UInt64
|
||||||
|
let originalTransactionId: UInt64
|
||||||
|
let productId: String
|
||||||
|
let purchaseDate: Date
|
||||||
|
|
||||||
|
var dictionary: NSDictionary {
|
||||||
|
[
|
||||||
|
"transactionId": String(transactionId),
|
||||||
|
"originalTransactionId": String(originalTransactionId),
|
||||||
|
"productId": productId
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func fetchCurrentEntitlements(completion: @escaping (Bool, [NSDictionary]?, NSError?) -> Void) {
|
||||||
|
Task { @MainActor in
|
||||||
|
do {
|
||||||
|
try await AppStore.sync()
|
||||||
|
|
||||||
|
var entitlements: [EntitlementInfo] = []
|
||||||
|
for await result in Transaction.currentEntitlements {
|
||||||
|
switch result {
|
||||||
|
case .verified(let transaction):
|
||||||
|
entitlements.append(EntitlementInfo(transactionId: transaction.id,
|
||||||
|
originalTransactionId: transaction.originalID,
|
||||||
|
productId: transaction.productID,
|
||||||
|
purchaseDate: transaction.purchaseDate))
|
||||||
|
case .unverified(_, let error):
|
||||||
|
print("[IAP][StoreKit2] Unverified transaction skipped: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let sortedEntitlements = entitlements.sorted { lhs, rhs in
|
||||||
|
if lhs.purchaseDate != rhs.purchaseDate {
|
||||||
|
return lhs.purchaseDate > rhs.purchaseDate
|
||||||
|
}
|
||||||
|
return lhs.transactionId > rhs.transactionId
|
||||||
|
}.map { $0.dictionary }
|
||||||
|
completion(true, sortedEntitlements, nil)
|
||||||
|
} catch {
|
||||||
|
completion(false, nil, error as NSError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func purchaseProduct(productIdentifier: String, completion: @escaping (Bool, String?, String?, String?, NSError?) -> Void) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let products = try await Product.products(for: [productIdentifier])
|
||||||
|
guard let product = products.first else {
|
||||||
|
completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil,
|
||||||
|
error: makeError(code: 0, description: "Product not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let result = try await product.purchase()
|
||||||
|
switch result {
|
||||||
|
case .success(let verification):
|
||||||
|
switch verification {
|
||||||
|
case .verified(let transaction):
|
||||||
|
await transaction.finish()
|
||||||
|
completePurchase(completion: completion, success: true, transactionId: String(transaction.id),
|
||||||
|
productId: transaction.productID, originalTransactionId: String(transaction.originalID), error: nil)
|
||||||
|
case .unverified(_, let error):
|
||||||
|
completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil,
|
||||||
|
error: error as NSError)
|
||||||
|
}
|
||||||
|
case .userCancelled:
|
||||||
|
completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil,
|
||||||
|
error: makeError(code: 1, description: "Purchase cancelled"))
|
||||||
|
case .pending:
|
||||||
|
completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil,
|
||||||
|
error: makeError(code: 2, description: "Purchase pending"))
|
||||||
|
@unknown default:
|
||||||
|
completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil,
|
||||||
|
error: makeError(code: 3, description: "Unknown purchase result"))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil,
|
||||||
|
error: error as NSError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func storefrontCurrencyCode(for product: Product) -> String {
|
||||||
|
product.priceFormatStyle.locale.currencyCode ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func subscriptionBillingMonths(_ period: Product.SubscriptionPeriod) -> Double {
|
||||||
|
let periodValue = Double(period.value)
|
||||||
|
switch period.unit {
|
||||||
|
case .day:
|
||||||
|
return periodValue / 30.0
|
||||||
|
case .week:
|
||||||
|
return periodValue * 7.0 / 30.0
|
||||||
|
case .month:
|
||||||
|
return periodValue
|
||||||
|
case .year:
|
||||||
|
return periodValue * 12.0
|
||||||
|
@unknown default:
|
||||||
|
return periodValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func fetchProducts(identifiers: Set<String>, completion: @escaping ([NSDictionary], [String], NSError?) -> Void) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let products = try await Product.products(for: identifiers)
|
||||||
|
let productDicts = products.map { product in productDictionary(for: product) }
|
||||||
|
let fetchedIds = Set(products.map { $0.id })
|
||||||
|
let invalidIdentifiers = identifiers.filter { !fetchedIds.contains($0) }
|
||||||
|
DispatchQueue.main.async { completion(productDicts, Array(invalidIdentifiers), nil) }
|
||||||
|
} catch {
|
||||||
|
DispatchQueue.main.async { completion([], Array(identifiers), error as NSError) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeError(code: Int, description: String) -> NSError {
|
||||||
|
NSError(domain: Self.errorDomain, code: code, userInfo: [NSLocalizedDescriptionKey: description])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func completePurchase(completion: @escaping (Bool, String?, String?, String?, NSError?) -> Void,
|
||||||
|
success: Bool,
|
||||||
|
transactionId: String?,
|
||||||
|
productId: String?,
|
||||||
|
originalTransactionId: String?,
|
||||||
|
error: NSError?) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
completion(success, transactionId, productId, originalTransactionId, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func productDictionary(for product: Product) -> NSDictionary {
|
||||||
|
let currencyCode = storefrontCurrencyCode(for: product)
|
||||||
|
var productData: [String: Any] = [
|
||||||
|
"productId": product.id,
|
||||||
|
"title": product.displayName,
|
||||||
|
"description": product.description,
|
||||||
|
"price": "\(product.price)",
|
||||||
|
"displayPrice": product.displayPrice,
|
||||||
|
"currencyCode": currencyCode,
|
||||||
|
"priceAmount": NSDecimalNumber(decimal: product.price).doubleValue
|
||||||
|
]
|
||||||
|
if let subscription = product.subscription {
|
||||||
|
let billingMonths = subscriptionBillingMonths(subscription.subscriptionPeriod)
|
||||||
|
productData["subscriptionBillingMonths"] = billingMonths
|
||||||
|
if let perMonthPrice = displayPricePerMonth(for: product, billingMonths: billingMonths, currencyCode: currencyCode) {
|
||||||
|
productData["displayPricePerMonth"] = perMonthPrice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return productData as NSDictionary
|
||||||
|
}
|
||||||
|
|
||||||
|
private func displayPricePerMonth(for product: Product, billingMonths: Double, currencyCode: String) -> String? {
|
||||||
|
if billingMonths <= 1e-6 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let perMonthPrice = product.price / Decimal(billingMonths)
|
||||||
|
let formatter = NumberFormatter()
|
||||||
|
formatter.numberStyle = .currency
|
||||||
|
formatter.locale = product.priceFormatStyle.locale
|
||||||
|
if !currencyCode.isEmpty {
|
||||||
|
formatter.currencyCode = currencyCode
|
||||||
|
}
|
||||||
|
return formatter.string(from: NSDecimalNumber(decimal: perMonthPrice))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,27 +4,20 @@
|
|||||||
|
|
||||||
#import "StoreKitController.h"
|
#import "StoreKitController.h"
|
||||||
#import <StoreKit/StoreKit.h>
|
#import <StoreKit/StoreKit.h>
|
||||||
|
#import <AmneziaVPN-Swift.h>
|
||||||
|
|
||||||
#include <QtCore/QDebug>
|
#include <QtCore/QDebug>
|
||||||
#include <QtCore/QString>
|
#include <QtCore/QString>
|
||||||
|
|
||||||
API_AVAILABLE(ios(15.0), macos(12.0))
|
namespace
|
||||||
@interface StoreKitController () <SKProductsRequestDelegate, SKPaymentTransactionObserver>
|
{
|
||||||
@property (nonatomic, copy) void (^purchaseCompletion)(BOOL success,
|
QString toQString(NSString *value)
|
||||||
NSString *_Nullable transactionId,
|
{
|
||||||
NSString *_Nullable productId,
|
return QString::fromUtf8((value ?: @"").UTF8String);
|
||||||
NSString *_Nullable originalTransactionId,
|
}
|
||||||
NSError *_Nullable error);
|
}
|
||||||
@property (nonatomic, copy) void (^restoreCompletion)(BOOL success,
|
|
||||||
NSArray<NSDictionary *> *_Nullable restoredTransactions,
|
|
||||||
NSError *_Nullable error);
|
|
||||||
@property (nonatomic, copy) void (^productsFetchCompletion)(NSArray<NSDictionary *> *products,
|
|
||||||
NSArray<NSString *> *invalidIdentifiers,
|
|
||||||
NSError *_Nullable error);
|
|
||||||
@property (nonatomic, strong) SKProductsRequest *productsRequest;
|
|
||||||
@property (nonatomic, strong) NSMutableArray<NSDictionary *> *restoredTransactions;
|
|
||||||
@end
|
|
||||||
|
|
||||||
|
API_AVAILABLE(ios(15.0), macos(12.0))
|
||||||
@implementation StoreKitController
|
@implementation StoreKitController
|
||||||
|
|
||||||
+ (instancetype)sharedInstance
|
+ (instancetype)sharedInstance
|
||||||
@@ -42,17 +35,9 @@ API_AVAILABLE(ios(15.0), macos(12.0))
|
|||||||
- (instancetype)init API_AVAILABLE(ios(15.0), macos(12.0))
|
- (instancetype)init API_AVAILABLE(ios(15.0), macos(12.0))
|
||||||
{
|
{
|
||||||
self = [super init];
|
self = [super init];
|
||||||
if (self) {
|
|
||||||
[[SKPaymentQueue defaultQueue] addTransactionObserver:self];
|
|
||||||
}
|
|
||||||
return self;
|
return self;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)dealloc
|
|
||||||
{
|
|
||||||
[[SKPaymentQueue defaultQueue] removeTransactionObserver:self];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)purchaseProduct:(NSString *)productIdentifier
|
- (void)purchaseProduct:(NSString *)productIdentifier
|
||||||
completion:(void (^)(BOOL success,
|
completion:(void (^)(BOOL success,
|
||||||
NSString *_Nullable transactionId,
|
NSString *_Nullable transactionId,
|
||||||
@@ -60,41 +45,48 @@ API_AVAILABLE(ios(15.0), macos(12.0))
|
|||||||
NSString *_Nullable originalTransactionId,
|
NSString *_Nullable originalTransactionId,
|
||||||
NSError *_Nullable error))completion API_AVAILABLE(ios(15.0), macos(12.0))
|
NSError *_Nullable error))completion API_AVAILABLE(ios(15.0), macos(12.0))
|
||||||
{
|
{
|
||||||
self.purchaseCompletion = completion;
|
qInfo().noquote() << "[IAP][StoreKit2] Starting purchase for" << QString::fromUtf8(productIdentifier.UTF8String);
|
||||||
|
[[StoreKit2Helper shared] purchaseProductWithProductIdentifier:productIdentifier
|
||||||
qInfo().noquote() << "[IAP][StoreKit] Starting purchase for" << QString::fromUtf8(productIdentifier.UTF8String);
|
completion:^(BOOL success,
|
||||||
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
|
NSString *transactionId,
|
||||||
[self performPurchaseAsync:productIdentifier];
|
NSString *productId,
|
||||||
});
|
NSString *originalTransactionId,
|
||||||
}
|
NSError *error) {
|
||||||
|
if (success) {
|
||||||
- (void)performPurchaseAsync:(NSString *)productIdentifier API_AVAILABLE(ios(15.0), macos(12.0))
|
qInfo().noquote() << "[IAP][StoreKit2] Purchase success. transactionId =" << toQString(transactionId)
|
||||||
{
|
<< "originalTransactionId =" << toQString(originalTransactionId) << "productId =" << toQString(productId);
|
||||||
dispatch_async(dispatch_get_main_queue(), ^{
|
} else if (error) {
|
||||||
@try {
|
qWarning().noquote() << "[IAP][StoreKit2] Purchase failed:" << toQString(error.localizedDescription);
|
||||||
SKProductsRequest *request = [[SKProductsRequest alloc] initWithProductIdentifiers:[NSSet setWithObject:productIdentifier]];
|
|
||||||
request.delegate = self;
|
|
||||||
[request start];
|
|
||||||
|
|
||||||
} @catch (NSException *exception) {
|
|
||||||
NSError *error = [NSError errorWithDomain:@"StoreKitController"
|
|
||||||
code:1
|
|
||||||
userInfo:@{ NSLocalizedDescriptionKey : exception.reason ?: @"Purchase failed" }];
|
|
||||||
if (self.purchaseCompletion) {
|
|
||||||
self.purchaseCompletion(NO, nil, nil, nil, error);
|
|
||||||
self.purchaseCompletion = nil;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
if (completion) {
|
||||||
|
completion(success, transactionId, productId, originalTransactionId, error);
|
||||||
|
}
|
||||||
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)restorePurchasesWithCompletion:(void (^)(BOOL success,
|
- (void)restorePurchasesWithCompletion:(void (^)(BOOL success,
|
||||||
NSArray<NSDictionary *> *_Nullable restoredTransactions,
|
NSArray<NSDictionary *> *_Nullable restoredTransactions,
|
||||||
NSError *_Nullable error))completion API_AVAILABLE(ios(15.0), macos(12.0))
|
NSError *_Nullable error))completion API_AVAILABLE(ios(15.0), macos(12.0))
|
||||||
{
|
{
|
||||||
self.restoreCompletion = completion;
|
[[StoreKit2Helper shared] fetchCurrentEntitlementsWithCompletion:^(BOOL success,
|
||||||
self.restoredTransactions = [NSMutableArray array];
|
NSArray<NSDictionary *> *entitlements,
|
||||||
[[SKPaymentQueue defaultQueue] restoreCompletedTransactions];
|
NSError *error) {
|
||||||
|
if (success) {
|
||||||
|
qInfo().noquote() << "[IAP][StoreKit2] currentEntitlements returned"
|
||||||
|
<< (int)(entitlements ? entitlements.count : 0) << "active entitlements";
|
||||||
|
for (NSDictionary *entitlement in entitlements) {
|
||||||
|
qInfo().noquote() << "[IAP][StoreKit2] Active entitlement:"
|
||||||
|
<< "transactionId=" << toQString(entitlement[@"transactionId"])
|
||||||
|
<< "originalTransactionId=" << toQString(entitlement[@"originalTransactionId"])
|
||||||
|
<< "productId=" << toQString(entitlement[@"productId"]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
qWarning().noquote() << "[IAP][StoreKit2] fetchCurrentEntitlements failed:" << toQString(error.localizedDescription);
|
||||||
|
}
|
||||||
|
if (completion) {
|
||||||
|
completion(success, entitlements, error);
|
||||||
|
}
|
||||||
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)fetchProductsWithIdentifiers:(NSSet<NSString *> *)productIdentifiers
|
- (void)fetchProductsWithIdentifiers:(NSSet<NSString *> *)productIdentifiers
|
||||||
@@ -102,163 +94,21 @@ API_AVAILABLE(ios(15.0), macos(12.0))
|
|||||||
NSArray<NSString *> *invalidIdentifiers,
|
NSArray<NSString *> *invalidIdentifiers,
|
||||||
NSError *_Nullable error))completion API_AVAILABLE(ios(15.0), macos(12.0))
|
NSError *_Nullable error))completion API_AVAILABLE(ios(15.0), macos(12.0))
|
||||||
{
|
{
|
||||||
self.productsFetchCompletion = completion;
|
[[StoreKit2Helper shared] fetchProductsWithIdentifiers:productIdentifiers
|
||||||
self.productsRequest = [[SKProductsRequest alloc] initWithProductIdentifiers:productIdentifiers];
|
completion:^(NSArray<NSDictionary *> *products,
|
||||||
self.productsRequest.delegate = self;
|
NSArray<NSString *> *invalidIdentifiers,
|
||||||
[self.productsRequest start];
|
NSError *error) {
|
||||||
}
|
if (!error) {
|
||||||
|
for (NSDictionary *productInfo in products) {
|
||||||
#pragma mark - SKProductsRequestDelegate / SKRequestDelegate
|
qInfo().noquote() << "[IAP][StoreKit2] Fetched product info" << toQString(productInfo[@"productId"])
|
||||||
|
<< "price=" << toQString(productInfo[@"price"])
|
||||||
- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response
|
<< "currency=" << toQString(productInfo[@"currencyCode"]);
|
||||||
{
|
|
||||||
if (self.purchaseCompletion) {
|
|
||||||
SKProduct *product = response.products.firstObject;
|
|
||||||
if (!product) {
|
|
||||||
NSError *error = [NSError errorWithDomain:@"StoreKitController"
|
|
||||||
code:0
|
|
||||||
userInfo:@{ NSLocalizedDescriptionKey : @"Product not found" }];
|
|
||||||
self.purchaseCompletion(NO, nil, nil, nil, error);
|
|
||||||
self.purchaseCompletion = nil;
|
|
||||||
self.productsRequest = nil;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
NSString *currencyCode = [product.priceLocale objectForKey:NSLocaleCurrencyCode] ?: @"";
|
|
||||||
NSString *priceString = [product.price stringValue] ?: @"";
|
|
||||||
qInfo().noquote() << "[IAP][StoreKit] Received product" << QString::fromUtf8(product.productIdentifier.UTF8String)
|
|
||||||
<< "price=" << QString::fromUtf8(priceString.UTF8String)
|
|
||||||
<< "currency=" << QString::fromUtf8(currencyCode.UTF8String);
|
|
||||||
SKPayment *payment = [SKPayment paymentWithProduct:product];
|
|
||||||
[[SKPaymentQueue defaultQueue] addPayment:payment];
|
|
||||||
self.productsRequest = nil;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (self.productsFetchCompletion) {
|
|
||||||
NSMutableArray<NSDictionary *> *productDicts = [NSMutableArray array];
|
|
||||||
for (SKProduct *p in response.products) {
|
|
||||||
NSDictionary *productDict = @{
|
|
||||||
@"productId": p.productIdentifier,
|
|
||||||
@"title": p.localizedTitle,
|
|
||||||
@"description": p.localizedDescription,
|
|
||||||
@"price": p.price.stringValue,
|
|
||||||
@"currencyCode": [p.priceLocale objectForKey:NSLocaleCurrencyCode] ?: @""
|
|
||||||
};
|
|
||||||
[productDicts addObject:productDict];
|
|
||||||
NSString *productCurrency = [p.priceLocale objectForKey:NSLocaleCurrencyCode] ?: @"";
|
|
||||||
NSString *productPrice = [p.price stringValue] ?: @"";
|
|
||||||
qInfo().noquote() << "[IAP][StoreKit] Fetched product info" << QString::fromUtf8(p.productIdentifier.UTF8String)
|
|
||||||
<< "price=" << QString::fromUtf8(productPrice.UTF8String)
|
|
||||||
<< "currency=" << QString::fromUtf8(productCurrency.UTF8String);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.productsFetchCompletion(productDicts, response.invalidProductIdentifiers, nil);
|
|
||||||
self.productsFetchCompletion = nil;
|
|
||||||
self.productsRequest = nil;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)request:(SKRequest *)request didFailWithError:(NSError *)error
|
|
||||||
{
|
|
||||||
if (self.purchaseCompletion) {
|
|
||||||
self.purchaseCompletion(NO, nil, nil, nil, error);
|
|
||||||
self.purchaseCompletion = nil;
|
|
||||||
}
|
|
||||||
if (self.productsFetchCompletion) {
|
|
||||||
self.productsFetchCompletion(@[], @[], error);
|
|
||||||
self.productsFetchCompletion = nil;
|
|
||||||
}
|
|
||||||
self.productsRequest = nil;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - SKPaymentTransactionObserver
|
|
||||||
|
|
||||||
- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions
|
|
||||||
{
|
|
||||||
for (SKPaymentTransaction *transaction in transactions) {
|
|
||||||
switch (transaction.transactionState) {
|
|
||||||
case SKPaymentTransactionStatePurchased: {
|
|
||||||
NSString *originalTransactionId = transaction.originalTransaction.transactionIdentifier ?: transaction.transactionIdentifier;
|
|
||||||
qInfo().noquote() << "[IAP][StoreKit] Transaction purchased" << QString::fromUtf8(transaction.transactionIdentifier.UTF8String)
|
|
||||||
<< "original=" << QString::fromUtf8((originalTransactionId ?: @"").UTF8String)
|
|
||||||
<< "product=" << QString::fromUtf8(transaction.payment.productIdentifier.UTF8String);
|
|
||||||
|
|
||||||
if (self.purchaseCompletion) {
|
|
||||||
self.purchaseCompletion(YES,
|
|
||||||
transaction.transactionIdentifier,
|
|
||||||
transaction.payment.productIdentifier,
|
|
||||||
originalTransactionId,
|
|
||||||
nil);
|
|
||||||
self.purchaseCompletion = nil;
|
|
||||||
}
|
}
|
||||||
[[SKPaymentQueue defaultQueue] finishTransaction:transaction];
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
case SKPaymentTransactionStateFailed:
|
if (completion) {
|
||||||
qInfo().noquote() << "[IAP][StoreKit] Transaction failed" << QString::fromUtf8(transaction.transactionIdentifier.UTF8String)
|
completion(products ?: @[], invalidIdentifiers ?: @[], error);
|
||||||
<< "product=" << QString::fromUtf8(transaction.payment.productIdentifier.UTF8String)
|
|
||||||
<< "error=" << QString::fromUtf8(transaction.error.localizedDescription.UTF8String);
|
|
||||||
if (self.purchaseCompletion) {
|
|
||||||
self.purchaseCompletion(NO,
|
|
||||||
transaction.transactionIdentifier,
|
|
||||||
transaction.payment.productIdentifier,
|
|
||||||
nil,
|
|
||||||
transaction.error);
|
|
||||||
self.purchaseCompletion = nil;
|
|
||||||
}
|
|
||||||
[[SKPaymentQueue defaultQueue] finishTransaction:transaction];
|
|
||||||
break;
|
|
||||||
case SKPaymentTransactionStateRestored: {
|
|
||||||
if (self.restoreCompletion) {
|
|
||||||
NSString *transactionId = transaction.transactionIdentifier ?: @"";
|
|
||||||
NSString *originalTransactionId = transaction.originalTransaction.transactionIdentifier ?: transactionId;
|
|
||||||
NSString *productId = transaction.payment.productIdentifier ?: @"";
|
|
||||||
|
|
||||||
qInfo().noquote() << "[IAP][StoreKit] Transaction restored"
|
|
||||||
<< QString::fromUtf8(transactionId.UTF8String)
|
|
||||||
<< "original="
|
|
||||||
<< QString::fromUtf8((originalTransactionId ?: @"").UTF8String)
|
|
||||||
<< "product="
|
|
||||||
<< QString::fromUtf8((productId ?: @"").UTF8String);
|
|
||||||
|
|
||||||
NSDictionary *info = @{
|
|
||||||
@"transactionId": transactionId,
|
|
||||||
@"originalTransactionId": originalTransactionId ?: @"",
|
|
||||||
@"productId": productId ?: @""
|
|
||||||
};
|
|
||||||
if (!self.restoredTransactions) {
|
|
||||||
self.restoredTransactions = [NSMutableArray array];
|
|
||||||
}
|
|
||||||
[self.restoredTransactions addObject:info];
|
|
||||||
}
|
|
||||||
[[SKPaymentQueue defaultQueue] finishTransaction:transaction];
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
case SKPaymentTransactionStatePurchasing:
|
}];
|
||||||
case SKPaymentTransactionStateDeferred:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue *)queue
|
|
||||||
{
|
|
||||||
if (self.restoreCompletion) {
|
|
||||||
NSArray<NSDictionary *> *transactions = [self.restoredTransactions copy];
|
|
||||||
self.restoreCompletion(YES, transactions, nil);
|
|
||||||
self.restoreCompletion = nil;
|
|
||||||
self.restoredTransactions = nil;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)paymentQueue:(SKPaymentQueue *)queue restoreCompletedTransactionsFailedWithError:(NSError *)error
|
|
||||||
{
|
|
||||||
if (self.restoreCompletion) {
|
|
||||||
self.restoreCompletion(NO, nil, error);
|
|
||||||
self.restoreCompletion = nil;
|
|
||||||
self.restoredTransactions = nil;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -3,5 +3,7 @@ import Foundation
|
|||||||
struct XrayConfig: Decodable {
|
struct XrayConfig: Decodable {
|
||||||
let dns1: String?
|
let dns1: String?
|
||||||
let dns2: String?
|
let dns2: String?
|
||||||
|
let splitTunnelType: Int?
|
||||||
|
let splitTunnelSites: [String]?
|
||||||
let config: String
|
let config: String
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,8 +179,9 @@ bool IosController::initialize()
|
|||||||
[NETunnelProviderManager loadAllFromPreferencesWithCompletionHandler:^(NSArray<NETunnelProviderManager *> * _Nullable managers, NSError * _Nullable error) {
|
[NETunnelProviderManager loadAllFromPreferencesWithCompletionHandler:^(NSArray<NETunnelProviderManager *> * _Nullable managers, NSError * _Nullable error) {
|
||||||
@try {
|
@try {
|
||||||
if (error) {
|
if (error) {
|
||||||
qDebug() << "IosController::initialize : Error:" << [error.localizedDescription UTF8String];
|
qWarning() << "IosController::initialize : loadAllFromPreferences failed:"
|
||||||
emit connectionStateChanged(Vpn::ConnectionState::Error);
|
<< [error.localizedDescription UTF8String]
|
||||||
|
<< "domain:" << [error.domain UTF8String] << "code:" << error.code;
|
||||||
ok = false;
|
ok = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -217,16 +218,13 @@ bool IosController::connectVpn(amnezia::Proto proto, const QJsonObject& configur
|
|||||||
m_rawConfig = configuration;
|
m_rawConfig = configuration;
|
||||||
m_serverAddress = configuration.value(config_key::hostName).toString().toNSString();
|
m_serverAddress = configuration.value(config_key::hostName).toString().toNSString();
|
||||||
|
|
||||||
|
const QString serverDescription = configuration.value(config_key::description).toString().trimmed();
|
||||||
QString tunnelName;
|
QString tunnelName;
|
||||||
if (configuration.value(config_key::description).toString().isEmpty()) {
|
if (serverDescription.isEmpty()) {
|
||||||
|
tunnelName = ProtocolProps::protoToString(proto);
|
||||||
|
} else {
|
||||||
tunnelName = QString("%1 %2")
|
tunnelName = QString("%1 %2")
|
||||||
.arg(configuration.value(config_key::hostName).toString())
|
.arg(serverDescription)
|
||||||
.arg(ProtocolProps::protoToString(proto));
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
tunnelName = QString("%1 (%2) %3")
|
|
||||||
.arg(configuration.value(config_key::description).toString())
|
|
||||||
.arg(configuration.value(config_key::hostName).toString())
|
|
||||||
.arg(ProtocolProps::protoToString(proto));
|
.arg(ProtocolProps::protoToString(proto));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,8 +395,14 @@ void IosController::vpnStatusDidChange(void *pNotification)
|
|||||||
{
|
{
|
||||||
NETunnelProviderSession *session = (NETunnelProviderSession *)pNotification;
|
NETunnelProviderSession *session = (NETunnelProviderSession *)pNotification;
|
||||||
|
|
||||||
if (session /* && session == TunnelManager.session */ ) {
|
if (!session) {
|
||||||
qDebug() << "IosController::vpnStatusDidChange" << iosStatusToState(session.status) << session;
|
return;
|
||||||
|
}
|
||||||
|
if (!m_currentTunnel || (NETunnelProviderSession *)m_currentTunnel.connection != session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "IosController::vpnStatusDidChange" << iosStatusToState(session.status) << session;
|
||||||
|
|
||||||
if (session.status == NEVPNStatusDisconnected) {
|
if (session.status == NEVPNStatusDisconnected) {
|
||||||
if (@available(iOS 16.0, *)) {
|
if (@available(iOS 16.0, *)) {
|
||||||
@@ -512,7 +516,6 @@ void IosController::vpnStatusDidChange(void *pNotification)
|
|||||||
m_statusRequestInFlight = false;
|
m_statusRequestInFlight = false;
|
||||||
}
|
}
|
||||||
emitConnectionStateIfChanged(nextState);
|
emitConnectionStateIfChanged(nextState);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void IosController::vpnConfigurationDidChange(void *pNotification)
|
void IosController::vpnConfigurationDidChange(void *pNotification)
|
||||||
@@ -546,6 +549,16 @@ bool IosController::setupOpenVPN()
|
|||||||
|
|
||||||
QJsonDocument openVPNConfigDoc(openVPNConfig);
|
QJsonDocument openVPNConfigDoc(openVPNConfig);
|
||||||
QString openVPNConfigStr(openVPNConfigDoc.toJson(QJsonDocument::Compact));
|
QString openVPNConfigStr(openVPNConfigDoc.toJson(QJsonDocument::Compact));
|
||||||
|
QString openVPNConfigPreview = openVPNConfigStr.left(512);
|
||||||
|
QString ovpnPreview = ovpnConfig.left(512);
|
||||||
|
|
||||||
|
qDebug().noquote() << "IosController::setupOpenVPN payload"
|
||||||
|
<< "jsonBytes=" << openVPNConfigStr.toUtf8().size()
|
||||||
|
<< "ovpnChars=" << ovpnConfig.size()
|
||||||
|
<< "splitTunnelType=" << m_rawConfig[config_key::splitTunnelType].toInt()
|
||||||
|
<< "splitTunnelSites=" << splitTunnelSites;
|
||||||
|
qDebug().noquote() << "IosController::setupOpenVPN payload jsonPreview=" << openVPNConfigPreview;
|
||||||
|
qDebug().noquote() << "IosController::setupOpenVPN payload ovpnPreview=" << ovpnPreview;
|
||||||
|
|
||||||
return startOpenVPN(openVPNConfigStr);
|
return startOpenVPN(openVPNConfigStr);
|
||||||
}
|
}
|
||||||
@@ -684,6 +697,15 @@ bool IosController::setupXray()
|
|||||||
QJsonObject finalConfig;
|
QJsonObject finalConfig;
|
||||||
finalConfig.insert(config_key::dns1, m_rawConfig[config_key::dns1].toString());
|
finalConfig.insert(config_key::dns1, m_rawConfig[config_key::dns1].toString());
|
||||||
finalConfig.insert(config_key::dns2, m_rawConfig[config_key::dns2].toString());
|
finalConfig.insert(config_key::dns2, m_rawConfig[config_key::dns2].toString());
|
||||||
|
finalConfig.insert(config_key::splitTunnelType, m_rawConfig[config_key::splitTunnelType]);
|
||||||
|
|
||||||
|
QJsonArray splitTunnelSites = m_rawConfig[config_key::splitTunnelSites].toArray();
|
||||||
|
|
||||||
|
for(int index = 0; index < splitTunnelSites.count(); index++) {
|
||||||
|
splitTunnelSites[index] = splitTunnelSites[index].toString().remove(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
finalConfig.insert(config_key::splitTunnelSites, splitTunnelSites);
|
||||||
finalConfig.insert(config_key::config, xrayConfigStr);
|
finalConfig.insert(config_key::config, xrayConfigStr);
|
||||||
|
|
||||||
QJsonDocument finalConfigDoc(finalConfig);
|
QJsonDocument finalConfigDoc(finalConfig);
|
||||||
@@ -785,11 +807,59 @@ bool IosController::startOpenVPN(const QString &config)
|
|||||||
|
|
||||||
NETunnelProviderProtocol *tunnelProtocol = [[NETunnelProviderProtocol alloc] init];
|
NETunnelProviderProtocol *tunnelProtocol = [[NETunnelProviderProtocol alloc] init];
|
||||||
tunnelProtocol.providerBundleIdentifier = [NSString stringWithUTF8String:VPN_NE_BUNDLEID];
|
tunnelProtocol.providerBundleIdentifier = [NSString stringWithUTF8String:VPN_NE_BUNDLEID];
|
||||||
tunnelProtocol.providerConfiguration = @{@"ovpn": [[NSString stringWithUTF8String:config.toStdString().c_str()] dataUsingEncoding:NSUTF8StringEncoding]};
|
QByteArray configUtf8 = config.toUtf8();
|
||||||
|
NSData *ovpnConfigData = [NSData dataWithBytes:configUtf8.constData() length:configUtf8.size()];
|
||||||
|
tunnelProtocol.providerConfiguration = @{@"ovpn": ovpnConfigData};
|
||||||
tunnelProtocol.serverAddress = m_serverAddress;
|
tunnelProtocol.serverAddress = m_serverAddress;
|
||||||
|
if (@available(iOS 14.0, macOS 11.0, *)) {
|
||||||
|
int splitTunnelType = 0;
|
||||||
|
QJsonParseError parseError;
|
||||||
|
QJsonDocument doc = QJsonDocument::fromJson(config.toUtf8(), &parseError);
|
||||||
|
if (parseError.error == QJsonParseError::NoError && doc.isObject()) {
|
||||||
|
QJsonObject obj = doc.object();
|
||||||
|
splitTunnelType = obj.value(config_key::splitTunnelType).toInt(0);
|
||||||
|
}
|
||||||
|
#if defined(MACOS_NE)
|
||||||
|
// On macOS NE use route-based full tunnel. includeAllNetworks enables
|
||||||
|
// policy-based drop-all mode and causes enforceRoutes to be ignored.
|
||||||
|
tunnelProtocol.includeAllNetworks = NO;
|
||||||
|
if (splitTunnelType == 0) {
|
||||||
|
tunnelProtocol.enforceRoutes = YES;
|
||||||
|
if (@available(iOS 14.2, macOS 11.0, *)) {
|
||||||
|
tunnelProtocol.excludeLocalNetworks = YES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
tunnelProtocol.includeAllNetworks = (splitTunnelType == 0);
|
||||||
|
if (@available(iOS 14.2, macOS 11.0, *)) {
|
||||||
|
// Keep existing iOS behavior.
|
||||||
|
if (splitTunnelType == 0) {
|
||||||
|
tunnelProtocol.excludeLocalNetworks = NO;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
m_currentTunnel.protocolConfiguration = tunnelProtocol;
|
m_currentTunnel.protocolConfiguration = tunnelProtocol;
|
||||||
|
|
||||||
|
NETunnelProviderProtocol *appliedProtocol = (NETunnelProviderProtocol *)m_currentTunnel.protocolConfiguration;
|
||||||
|
NSData *ovpnPayload = appliedProtocol.providerConfiguration[@"ovpn"];
|
||||||
|
NSString *payloadPreview = @"";
|
||||||
|
if (ovpnPayload != nil) {
|
||||||
|
NSString *decodedPayload = [[NSString alloc] initWithData:ovpnPayload encoding:NSUTF8StringEncoding];
|
||||||
|
if (decodedPayload != nil) {
|
||||||
|
payloadPreview = [decodedPayload substringToIndex:MIN((NSUInteger)512, decodedPayload.length)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug().noquote() << "IosController::startOpenVPN protocolConfiguration"
|
||||||
|
<< "bundleId=" << QString::fromNSString(appliedProtocol.providerBundleIdentifier ?: @"")
|
||||||
|
<< "serverAddress=" << QString::fromNSString(appliedProtocol.serverAddress ?: @"")
|
||||||
|
<< "providerKeys=" << QString::fromNSString([[appliedProtocol.providerConfiguration.allKeys description] copy])
|
||||||
|
<< "ovpnBytes=" << (ovpnPayload != nil ? ovpnPayload.length : 0);
|
||||||
|
qDebug().noquote() << "IosController::startOpenVPN protocolConfiguration payloadPreview="
|
||||||
|
<< QString::fromNSString(payloadPreview);
|
||||||
|
|
||||||
startTunnel();
|
startTunnel();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -799,7 +869,9 @@ bool IosController::startWireGuard(const QString &config)
|
|||||||
|
|
||||||
NETunnelProviderProtocol *tunnelProtocol = [[NETunnelProviderProtocol alloc] init];
|
NETunnelProviderProtocol *tunnelProtocol = [[NETunnelProviderProtocol alloc] init];
|
||||||
tunnelProtocol.providerBundleIdentifier = [NSString stringWithUTF8String:VPN_NE_BUNDLEID];
|
tunnelProtocol.providerBundleIdentifier = [NSString stringWithUTF8String:VPN_NE_BUNDLEID];
|
||||||
tunnelProtocol.providerConfiguration = @{@"wireguard": [[NSString stringWithUTF8String:config.toStdString().c_str()] dataUsingEncoding:NSUTF8StringEncoding]};
|
QByteArray configUtf8 = config.toUtf8();
|
||||||
|
NSData *wgConfigData = [NSData dataWithBytes:configUtf8.constData() length:configUtf8.size()];
|
||||||
|
tunnelProtocol.providerConfiguration = @{@"wireguard": wgConfigData};
|
||||||
tunnelProtocol.serverAddress = m_serverAddress;
|
tunnelProtocol.serverAddress = m_serverAddress;
|
||||||
|
|
||||||
m_currentTunnel.protocolConfiguration = tunnelProtocol;
|
m_currentTunnel.protocolConfiguration = tunnelProtocol;
|
||||||
@@ -813,7 +885,9 @@ bool IosController::startXray(const QString &config)
|
|||||||
|
|
||||||
NETunnelProviderProtocol *tunnelProtocol = [[NETunnelProviderProtocol alloc] init];
|
NETunnelProviderProtocol *tunnelProtocol = [[NETunnelProviderProtocol alloc] init];
|
||||||
tunnelProtocol.providerBundleIdentifier = [NSString stringWithUTF8String:VPN_NE_BUNDLEID];
|
tunnelProtocol.providerBundleIdentifier = [NSString stringWithUTF8String:VPN_NE_BUNDLEID];
|
||||||
tunnelProtocol.providerConfiguration = @{@"xray": [[NSString stringWithUTF8String:config.toStdString().c_str()] dataUsingEncoding:NSUTF8StringEncoding]};
|
QByteArray configUtf8 = config.toUtf8();
|
||||||
|
NSData *xrayConfigData = [NSData dataWithBytes:configUtf8.constData() length:configUtf8.size()];
|
||||||
|
tunnelProtocol.providerConfiguration = @{@"xray": xrayConfigData};
|
||||||
tunnelProtocol.serverAddress = m_serverAddress;
|
tunnelProtocol.serverAddress = m_serverAddress;
|
||||||
|
|
||||||
m_currentTunnel.protocolConfiguration = tunnelProtocol;
|
m_currentTunnel.protocolConfiguration = tunnelProtocol;
|
||||||
@@ -835,39 +909,49 @@ void IosController::startTunnel()
|
|||||||
m_rxBytes = 0;
|
m_rxBytes = 0;
|
||||||
m_txBytes = 0;
|
m_txBytes = 0;
|
||||||
|
|
||||||
[m_currentTunnel setEnabled:YES];
|
NETunnelProviderManager *tunnel = m_currentTunnel;
|
||||||
|
[tunnel setEnabled:YES];
|
||||||
|
|
||||||
[m_currentTunnel saveToPreferencesWithCompletionHandler:^(NSError *saveError) {
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
|
[tunnel saveToPreferencesWithCompletionHandler:^(NSError *saveError) {
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
if (saveError) {
|
||||||
|
qDebug().nospace() << "IosController::startTunnel" << protocolName << ": Connect " << protocolName
|
||||||
|
<< " Tunnel Save Error" << saveError.localizedDescription.UTF8String << " domain:"
|
||||||
|
<< saveError.domain.UTF8String << " code:" << saveError.code;
|
||||||
|
emit connectionStateChanged(Vpn::ConnectionState::Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (saveError) {
|
[tunnel loadFromPreferencesWithCompletionHandler:^(NSError *loadError) {
|
||||||
qDebug().nospace() << "IosController::startTunnel" << protocolName << ": Connect " << protocolName << " Tunnel Save Error" << saveError.localizedDescription.UTF8String;
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
emit connectionStateChanged(Vpn::ConnectionState::Error);
|
if (loadError) {
|
||||||
return;
|
qDebug().nospace() << "IosController::startTunnel :" << tunnel.localizedDescription << protocolName
|
||||||
}
|
<< ": Connect " << protocolName << " Tunnel Load Error"
|
||||||
|
<< loadError.localizedDescription.UTF8String;
|
||||||
|
emit connectionStateChanged(Vpn::ConnectionState::Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
[m_currentTunnel loadFromPreferencesWithCompletionHandler:^(NSError *loadError) {
|
NSError *startError = nil;
|
||||||
if (loadError) {
|
qDebug() << iosStatusToState(tunnel.connection.status);
|
||||||
qDebug().nospace() << "IosController::startTunnel :" << m_currentTunnel.localizedDescription << protocolName << ": Connect " << protocolName << " Tunnel Load Error" << loadError.localizedDescription.UTF8String;
|
|
||||||
emit connectionStateChanged(Vpn::ConnectionState::Error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
NSError *startError = nil;
|
BOOL started = [tunnel.connection startVPNTunnelWithOptions:nil andReturnError:&startError];
|
||||||
qDebug() << iosStatusToState(m_currentTunnel.connection.status);
|
|
||||||
|
|
||||||
BOOL started = [m_currentTunnel.connection startVPNTunnelWithOptions:nil andReturnError:&startError];
|
if (!started || startError) {
|
||||||
|
qDebug().nospace() << "IosController::startTunnel :" << tunnel.localizedDescription << protocolName
|
||||||
if (!started || startError) {
|
<< " : Connect " << protocolName << " Tunnel Start Error"
|
||||||
qDebug().nospace() << "IosController::startTunnel :" << m_currentTunnel.localizedDescription << protocolName << " : Connect " << protocolName << " Tunnel Start Error"
|
<< (startError ? startError.localizedDescription.UTF8String : "");
|
||||||
<< (startError ? startError.localizedDescription.UTF8String : "");
|
emit connectionStateChanged(Vpn::ConnectionState::Error);
|
||||||
emit connectionStateChanged(Vpn::ConnectionState::Error);
|
} else {
|
||||||
} else {
|
qDebug().nospace() << "IosController::startTunnel :" << tunnel.localizedDescription << protocolName
|
||||||
qDebug().nospace() << "IosController::startTunnel :" << m_currentTunnel.localizedDescription << protocolName << " : Starting the tunnel succeeded";
|
<< " : Starting the tunnel succeeded";
|
||||||
}
|
}
|
||||||
}];
|
});
|
||||||
});
|
}];
|
||||||
}];
|
});
|
||||||
|
}];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
bool IosController::isOurManager(NETunnelProviderManager* manager) {
|
bool IosController::isOurManager(NETunnelProviderManager* manager) {
|
||||||
@@ -1122,14 +1206,26 @@ void IosController::fetchProducts(const QStringList &productIds,
|
|||||||
NSArray<NSString *> * _Nonnull invalidIdentifiers,
|
NSArray<NSString *> * _Nonnull invalidIdentifiers,
|
||||||
NSError * _Nullable error) {
|
NSError * _Nullable error) {
|
||||||
QList<QVariantMap> outProducts;
|
QList<QVariantMap> outProducts;
|
||||||
for (NSDictionary *p in products) {
|
for (NSDictionary *productInfo in products) {
|
||||||
QVariantMap m;
|
QVariantMap productData;
|
||||||
m["productId"] = QString::fromUtf8([p[@"productId"] UTF8String]);
|
productData["productId"] = QString::fromUtf8([productInfo[@"productId"] UTF8String]);
|
||||||
m["title"] = QString::fromUtf8([p[@"title"] UTF8String]);
|
productData["title"] = QString::fromUtf8([productInfo[@"title"] UTF8String]);
|
||||||
m["description"] = QString::fromUtf8([p[@"description"] UTF8String]);
|
productData["description"] = QString::fromUtf8([productInfo[@"description"] UTF8String]);
|
||||||
m["price"] = QString::fromUtf8([p[@"price"] UTF8String]);
|
productData["price"] = QString::fromUtf8([productInfo[@"price"] UTF8String]);
|
||||||
m["currencyCode"] = QString::fromUtf8([p[@"currencyCode"] UTF8String]);
|
if (productInfo[@"displayPrice"]) {
|
||||||
outProducts.push_back(m);
|
productData["displayPrice"] = QString::fromUtf8([productInfo[@"displayPrice"] UTF8String]);
|
||||||
|
}
|
||||||
|
productData["currencyCode"] = QString::fromUtf8([productInfo[@"currencyCode"] UTF8String]);
|
||||||
|
if (productInfo[@"priceAmount"]) {
|
||||||
|
productData["priceAmount"] = [productInfo[@"priceAmount"] doubleValue];
|
||||||
|
}
|
||||||
|
if (productInfo[@"subscriptionBillingMonths"]) {
|
||||||
|
productData["subscriptionBillingMonths"] = [productInfo[@"subscriptionBillingMonths"] doubleValue];
|
||||||
|
}
|
||||||
|
if (productInfo[@"displayPricePerMonth"]) {
|
||||||
|
productData["displayPricePerMonth"] = QString::fromUtf8([productInfo[@"displayPricePerMonth"] UTF8String]);
|
||||||
|
}
|
||||||
|
outProducts.push_back(productData);
|
||||||
}
|
}
|
||||||
|
|
||||||
QStringList invalid;
|
QStringList invalid;
|
||||||
|
|||||||
@@ -7,8 +7,11 @@
|
|||||||
#include <net/if.h>
|
#include <net/if.h>
|
||||||
|
|
||||||
#include <QDBusVariant>
|
#include <QDBusVariant>
|
||||||
|
#include <QNetworkInterface>
|
||||||
|
#include <QTimer>
|
||||||
#include <QtDBus/QtDBus>
|
#include <QtDBus/QtDBus>
|
||||||
|
|
||||||
|
#include "core/networkUtilities.h"
|
||||||
#include "leakdetector.h"
|
#include "leakdetector.h"
|
||||||
#include "logger.h"
|
#include "logger.h"
|
||||||
|
|
||||||
@@ -27,24 +30,56 @@ DnsUtilsLinux::DnsUtilsLinux(QObject* parent) : DnsUtils(parent) {
|
|||||||
logger.debug() << "DnsUtilsLinux created.";
|
logger.debug() << "DnsUtilsLinux created.";
|
||||||
|
|
||||||
QDBusConnection conn = QDBusConnection::systemBus();
|
QDBusConnection conn = QDBusConnection::systemBus();
|
||||||
m_resolver = new QDBusInterface(DBUS_RESOLVE_SERVICE, DBUS_RESOLVE_PATH,
|
auto* watcher = new QDBusServiceWatcher(
|
||||||
DBUS_RESOLVE_MANAGER, conn, this);
|
DBUS_RESOLVE_SERVICE, conn,
|
||||||
|
QDBusServiceWatcher::WatchForRegistration |
|
||||||
|
QDBusServiceWatcher::WatchForUnregistration, this);
|
||||||
|
|
||||||
|
connect(watcher, &QDBusServiceWatcher::serviceRegistered,
|
||||||
|
this, &DnsUtilsLinux::onResolverRegistered);
|
||||||
|
connect(watcher, &QDBusServiceWatcher::serviceUnregistered,
|
||||||
|
this, &DnsUtilsLinux::onResolverUnregistered);
|
||||||
|
|
||||||
|
if (conn.interface()->isServiceRegistered(DBUS_RESOLVE_SERVICE)) {
|
||||||
|
onResolverRegistered();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DnsUtilsLinux::onResolverRegistered() {
|
||||||
|
m_resolver.reset(new QDBusInterface(DBUS_RESOLVE_SERVICE, DBUS_RESOLVE_PATH,
|
||||||
|
DBUS_RESOLVE_MANAGER,
|
||||||
|
QDBusConnection::systemBus()));
|
||||||
|
logger.debug() << "systemd-resolved available, DNS resolver initialized";
|
||||||
|
|
||||||
|
if (!m_pendingIfname.isEmpty()) {
|
||||||
|
logger.debug() << "Re-applying DNS configuration for" << m_pendingIfname;
|
||||||
|
updateResolvers(m_pendingIfname, m_pendingResolvers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DnsUtilsLinux::onResolverUnregistered() {
|
||||||
|
logger.debug() << "systemd-resolved disappeared, dropping DNS resolver";
|
||||||
|
m_resolver.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
DnsUtilsLinux::~DnsUtilsLinux() {
|
DnsUtilsLinux::~DnsUtilsLinux() {
|
||||||
MZ_COUNT_DTOR(DnsUtilsLinux);
|
MZ_COUNT_DTOR(DnsUtilsLinux);
|
||||||
|
|
||||||
for (auto iterator = m_linkDomains.constBegin();
|
if (m_resolver) {
|
||||||
iterator != m_linkDomains.constEnd(); ++iterator) {
|
if (m_gatewayIfindex > 0)
|
||||||
QList<QVariant> argumentList;
|
setLinkDefaultRoute(m_gatewayIfindex, true);
|
||||||
argumentList << QVariant::fromValue(iterator.key());
|
|
||||||
argumentList << QVariant::fromValue(iterator.value());
|
|
||||||
m_resolver->asyncCallWithArgumentList(QStringLiteral("SetLinkDomains"),
|
|
||||||
argumentList);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (m_ifindex > 0) {
|
for (auto iterator = m_linkDomains.constBegin();
|
||||||
m_resolver->asyncCall(QStringLiteral("RevertLink"), m_ifindex);
|
iterator != m_linkDomains.constEnd(); ++iterator) {
|
||||||
|
QList<QVariant> argumentList;
|
||||||
|
argumentList << QVariant::fromValue(iterator.key());
|
||||||
|
argumentList << QVariant::fromValue(iterator.value());
|
||||||
|
m_resolver->asyncCallWithArgumentList(QStringLiteral("SetLinkDomains"),
|
||||||
|
argumentList);
|
||||||
|
}
|
||||||
|
if (m_ifindex > 0) {
|
||||||
|
m_resolver->asyncCall(QStringLiteral("RevertLink"), m_ifindex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug() << "DnsUtilsLinux destroyed.";
|
logger.debug() << "DnsUtilsLinux destroyed.";
|
||||||
@@ -52,12 +87,31 @@ DnsUtilsLinux::~DnsUtilsLinux() {
|
|||||||
|
|
||||||
bool DnsUtilsLinux::updateResolvers(const QString& ifname,
|
bool DnsUtilsLinux::updateResolvers(const QString& ifname,
|
||||||
const QList<QHostAddress>& resolvers) {
|
const QList<QHostAddress>& resolvers) {
|
||||||
|
if (m_gatewayIfindex > 0) {
|
||||||
|
setLinkDefaultRoute(m_gatewayIfindex, true);
|
||||||
|
m_gatewayIfindex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
m_ifindex = if_nametoindex(qPrintable(ifname));
|
m_ifindex = if_nametoindex(qPrintable(ifname));
|
||||||
if (m_ifindex <= 0) {
|
if (m_ifindex <= 0) {
|
||||||
logger.error() << "Unable to resolve ifindex for" << ifname;
|
logger.error() << "Unable to resolve ifindex for" << ifname;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m_pendingIfname = ifname;
|
||||||
|
m_pendingResolvers = resolvers;
|
||||||
|
|
||||||
|
if (!m_resolver) {
|
||||||
|
logger.debug() << "systemd-resolved not ready, queuing DNS configuration";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int gwIdx = NetworkUtilities::getGatewayAndIface().second.index();
|
||||||
|
if (gwIdx > 0 && gwIdx != m_ifindex && gwIdx != m_gatewayIfindex) {
|
||||||
|
m_gatewayIfindex = gwIdx;
|
||||||
|
setLinkDefaultRoute(gwIdx, false);
|
||||||
|
}
|
||||||
|
|
||||||
setLinkDNS(m_ifindex, resolvers);
|
setLinkDNS(m_ifindex, resolvers);
|
||||||
setLinkDefaultRoute(m_ifindex, true);
|
setLinkDefaultRoute(m_ifindex, true);
|
||||||
updateLinkDomains();
|
updateLinkDomains();
|
||||||
@@ -65,6 +119,14 @@ bool DnsUtilsLinux::updateResolvers(const QString& ifname,
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool DnsUtilsLinux::restoreResolvers() {
|
bool DnsUtilsLinux::restoreResolvers() {
|
||||||
|
m_pendingIfname.clear();
|
||||||
|
m_pendingResolvers.clear();
|
||||||
|
|
||||||
|
if (m_gatewayIfindex > 0) {
|
||||||
|
setLinkDefaultRoute(m_gatewayIfindex, true);
|
||||||
|
m_gatewayIfindex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
for (auto iterator = m_linkDomains.constBegin();
|
for (auto iterator = m_linkDomains.constBegin();
|
||||||
iterator != m_linkDomains.constEnd(); ++iterator) {
|
iterator != m_linkDomains.constEnd(); ++iterator) {
|
||||||
setLinkDomains(iterator.key(), iterator.value());
|
setLinkDomains(iterator.key(), iterator.value());
|
||||||
@@ -72,7 +134,7 @@ bool DnsUtilsLinux::restoreResolvers() {
|
|||||||
m_linkDomains.clear();
|
m_linkDomains.clear();
|
||||||
|
|
||||||
/* Revert the VPN interface's DNS configuration */
|
/* Revert the VPN interface's DNS configuration */
|
||||||
if (m_ifindex > 0) {
|
if (m_ifindex > 0 && m_resolver) {
|
||||||
QList<QVariant> argumentList = {QVariant::fromValue(m_ifindex)};
|
QList<QVariant> argumentList = {QVariant::fromValue(m_ifindex)};
|
||||||
QDBusPendingReply<> reply = m_resolver->asyncCallWithArgumentList(
|
QDBusPendingReply<> reply = m_resolver->asyncCallWithArgumentList(
|
||||||
QStringLiteral("RevertLink"), argumentList);
|
QStringLiteral("RevertLink"), argumentList);
|
||||||
@@ -90,13 +152,14 @@ bool DnsUtilsLinux::restoreResolvers() {
|
|||||||
void DnsUtilsLinux::dnsCallCompleted(QDBusPendingCallWatcher* call) {
|
void DnsUtilsLinux::dnsCallCompleted(QDBusPendingCallWatcher* call) {
|
||||||
QDBusPendingReply<> reply = *call;
|
QDBusPendingReply<> reply = *call;
|
||||||
if (reply.isError()) {
|
if (reply.isError()) {
|
||||||
logger.error() << "Error received from the DBus service";
|
logger.debug() << "DBus call failed (may be transient after systemd-resolved restart)";
|
||||||
}
|
}
|
||||||
delete call;
|
delete call;
|
||||||
}
|
}
|
||||||
|
|
||||||
void DnsUtilsLinux::setLinkDNS(int ifindex,
|
void DnsUtilsLinux::setLinkDNS(int ifindex,
|
||||||
const QList<QHostAddress>& resolvers) {
|
const QList<QHostAddress>& resolvers) {
|
||||||
|
if (!m_resolver) return;
|
||||||
QList<DnsResolver> resolverList;
|
QList<DnsResolver> resolverList;
|
||||||
char ifnamebuf[IF_NAMESIZE];
|
char ifnamebuf[IF_NAMESIZE];
|
||||||
const char* ifname = if_indextoname(ifindex, ifnamebuf);
|
const char* ifname = if_indextoname(ifindex, ifnamebuf);
|
||||||
@@ -121,6 +184,7 @@ void DnsUtilsLinux::setLinkDNS(int ifindex,
|
|||||||
|
|
||||||
void DnsUtilsLinux::setLinkDomains(int ifindex,
|
void DnsUtilsLinux::setLinkDomains(int ifindex,
|
||||||
const QList<DnsLinkDomain>& domains) {
|
const QList<DnsLinkDomain>& domains) {
|
||||||
|
if (!m_resolver) return;
|
||||||
char ifnamebuf[IF_NAMESIZE];
|
char ifnamebuf[IF_NAMESIZE];
|
||||||
const char* ifname = if_indextoname(ifindex, ifnamebuf);
|
const char* ifname = if_indextoname(ifindex, ifnamebuf);
|
||||||
if (ifname) {
|
if (ifname) {
|
||||||
@@ -144,6 +208,7 @@ void DnsUtilsLinux::setLinkDomains(int ifindex,
|
|||||||
}
|
}
|
||||||
|
|
||||||
void DnsUtilsLinux::setLinkDefaultRoute(int ifindex, bool enable) {
|
void DnsUtilsLinux::setLinkDefaultRoute(int ifindex, bool enable) {
|
||||||
|
if (!m_resolver) return;
|
||||||
QList<QVariant> argumentList;
|
QList<QVariant> argumentList;
|
||||||
argumentList << QVariant::fromValue(ifindex);
|
argumentList << QVariant::fromValue(ifindex);
|
||||||
argumentList << QVariant::fromValue(enable);
|
argumentList << QVariant::fromValue(enable);
|
||||||
@@ -156,6 +221,7 @@ void DnsUtilsLinux::setLinkDefaultRoute(int ifindex, bool enable) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void DnsUtilsLinux::updateLinkDomains() {
|
void DnsUtilsLinux::updateLinkDomains() {
|
||||||
|
if (!m_resolver) return;
|
||||||
/* Get the list of search domains, and remove any others that might conspire
|
/* Get the list of search domains, and remove any others that might conspire
|
||||||
* to satisfy DNS resolution. Unfortunately, this is a pain because Qt doesn't
|
* to satisfy DNS resolution. Unfortunately, this is a pain because Qt doesn't
|
||||||
* seem to be able to demarshall complex property types.
|
* seem to be able to demarshall complex property types.
|
||||||
@@ -174,11 +240,20 @@ void DnsUtilsLinux::updateLinkDomains() {
|
|||||||
|
|
||||||
void DnsUtilsLinux::dnsDomainsReceived(QDBusPendingCallWatcher* call) {
|
void DnsUtilsLinux::dnsDomainsReceived(QDBusPendingCallWatcher* call) {
|
||||||
QDBusPendingReply<QVariant> reply = *call;
|
QDBusPendingReply<QVariant> reply = *call;
|
||||||
|
call->deleteLater();
|
||||||
if (reply.isError()) {
|
if (reply.isError()) {
|
||||||
logger.error() << "Error retrieving the DNS domains from the DBus service";
|
// systemd-resolved may still be starting up after a restart — retry a few times
|
||||||
delete call;
|
if (m_ifindex > 0 && m_domainRetries++ < 5) {
|
||||||
|
logger.debug() << "systemd-resolved not ready yet, retrying DNS setup ("
|
||||||
|
<< m_domainRetries << "/5)";
|
||||||
|
QTimer::singleShot(500, this, &DnsUtilsLinux::updateLinkDomains);
|
||||||
|
} else {
|
||||||
|
logger.warning() << "Failed to configure DNS after 5 retries";
|
||||||
|
m_domainRetries = 0;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
m_domainRetries = 0;
|
||||||
|
|
||||||
/* Update the state of the DNS domains */
|
/* Update the state of the DNS domains */
|
||||||
m_linkDomains.clear();
|
m_linkDomains.clear();
|
||||||
@@ -204,9 +279,17 @@ void DnsUtilsLinux::dnsDomainsReceived(QDBusPendingCallWatcher* call) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Add a root search domain for the new interface. */
|
/* Add a root search domain for the new interface. */
|
||||||
QList<DnsLinkDomain> newlist = {root};
|
if (m_ifindex > 0) {
|
||||||
setLinkDomains(m_ifindex, newlist);
|
setLinkDomains(m_ifindex, {root});
|
||||||
delete call;
|
|
||||||
|
/* Disable DefaultRoute on the physical gateway so systemd-resolved
|
||||||
|
* routes all DNS through the VPN interface. */
|
||||||
|
const int gwIdx = NetworkUtilities::getGatewayAndIface().second.index();
|
||||||
|
if (gwIdx > 0 && gwIdx != m_ifindex && gwIdx != m_gatewayIfindex) {
|
||||||
|
m_gatewayIfindex = gwIdx;
|
||||||
|
setLinkDefaultRoute(gwIdx, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static DnsMetatypeRegistrationProxy s_dnsMetatypeProxy;
|
static DnsMetatypeRegistrationProxy s_dnsMetatypeProxy;
|
||||||
|
|||||||
@@ -6,7 +6,12 @@
|
|||||||
#define DNSUTILSLINUX_H
|
#define DNSUTILSLINUX_H
|
||||||
|
|
||||||
#include <QDBusInterface>
|
#include <QDBusInterface>
|
||||||
|
#include <QScopedPointer>
|
||||||
#include <QDBusPendingCallWatcher>
|
#include <QDBusPendingCallWatcher>
|
||||||
|
#include <QDBusServiceWatcher>
|
||||||
|
#include <QHostAddress>
|
||||||
|
#include <QList>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
#include "daemon/dnsutils.h"
|
#include "daemon/dnsutils.h"
|
||||||
#include "dbustypeslinux.h"
|
#include "dbustypeslinux.h"
|
||||||
@@ -29,13 +34,19 @@ class DnsUtilsLinux final : public DnsUtils {
|
|||||||
void updateLinkDomains();
|
void updateLinkDomains();
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
|
void onResolverRegistered();
|
||||||
|
void onResolverUnregistered();
|
||||||
void dnsCallCompleted(QDBusPendingCallWatcher*);
|
void dnsCallCompleted(QDBusPendingCallWatcher*);
|
||||||
void dnsDomainsReceived(QDBusPendingCallWatcher*);
|
void dnsDomainsReceived(QDBusPendingCallWatcher*);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
int m_ifindex = 0;
|
int m_ifindex = 0;
|
||||||
|
int m_gatewayIfindex = 0;
|
||||||
|
int m_domainRetries = 0;
|
||||||
QMap<int, DnsLinkDomainList> m_linkDomains;
|
QMap<int, DnsLinkDomainList> m_linkDomains;
|
||||||
QDBusInterface* m_resolver = nullptr;
|
QScopedPointer<QDBusInterface> m_resolver;
|
||||||
|
QString m_pendingIfname;
|
||||||
|
QList<QHostAddress> m_pendingResolvers;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // DNSUTILSLINUX_H
|
#endif // DNSUTILSLINUX_H
|
||||||
|
|||||||
@@ -448,16 +448,33 @@ void LinuxFirewall::updateDNSServers(const QStringList& servers)
|
|||||||
static QStringList existingServers {};
|
static QStringList existingServers {};
|
||||||
|
|
||||||
existingServers = servers;
|
existingServers = servers;
|
||||||
execute(QStringLiteral("iptables -F %1.320.allowDNS").arg(kAnchorName));
|
const QString chain = QStringLiteral("%1.320.allowDNS").arg(kAnchorName);
|
||||||
for (const QString& rule : getDNSRules(servers))
|
executeIptables(QStringLiteral("iptables"), {QStringLiteral("-F"), chain});
|
||||||
execute(QStringLiteral("iptables -A %1.320.allowDNS %2").arg(kAnchorName, rule));
|
const QStringList ifaces = {
|
||||||
|
QStringLiteral("amn0+"), QStringLiteral("tun0+"), QStringLiteral("tun2+")
|
||||||
|
};
|
||||||
|
for (const QString& server : servers) {
|
||||||
|
for (const QString& iface : ifaces) {
|
||||||
|
executeIptables(QStringLiteral("iptables"),
|
||||||
|
{QStringLiteral("-A"), chain, QStringLiteral("-o"), iface,
|
||||||
|
QStringLiteral("-d"), server, QStringLiteral("-p"), QStringLiteral("udp"),
|
||||||
|
QStringLiteral("--dport"), QStringLiteral("53"), QStringLiteral("-j"), QStringLiteral("ACCEPT")});
|
||||||
|
executeIptables(QStringLiteral("iptables"),
|
||||||
|
{QStringLiteral("-A"), chain, QStringLiteral("-o"), iface,
|
||||||
|
QStringLiteral("-d"), server, QStringLiteral("-p"), QStringLiteral("tcp"),
|
||||||
|
QStringLiteral("--dport"), QStringLiteral("53"), QStringLiteral("-j"), QStringLiteral("ACCEPT")});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void LinuxFirewall::updateAllowNets(const QStringList& servers)
|
void LinuxFirewall::updateAllowNets(const QStringList& servers)
|
||||||
{
|
{
|
||||||
execute(QStringLiteral("iptables -F %1.110.allowNets").arg(kAnchorName));
|
const QString chain = QStringLiteral("%1.110.allowNets").arg(kAnchorName);
|
||||||
for (const QString& rule : getAllowRule(servers))
|
executeIptables(QStringLiteral("iptables"), {QStringLiteral("-F"), chain});
|
||||||
execute(QStringLiteral("iptables -A %1.110.allowNets %2").arg(kAnchorName, rule));
|
for (const QString& server : servers)
|
||||||
|
executeIptables(QStringLiteral("iptables"),
|
||||||
|
{QStringLiteral("-A"), chain, QStringLiteral("-d"), server,
|
||||||
|
QStringLiteral("-j"), QStringLiteral("ACCEPT")});
|
||||||
}
|
}
|
||||||
|
|
||||||
void LinuxFirewall::updateBlockNets(const QStringList& servers)
|
void LinuxFirewall::updateBlockNets(const QStringList& servers)
|
||||||
@@ -465,9 +482,12 @@ void LinuxFirewall::updateBlockNets(const QStringList& servers)
|
|||||||
static QStringList existingServers {};
|
static QStringList existingServers {};
|
||||||
|
|
||||||
existingServers = servers;
|
existingServers = servers;
|
||||||
execute(QStringLiteral("iptables -F %1.120.blockNets").arg(kAnchorName));
|
const QString chain = QStringLiteral("%1.120.blockNets").arg(kAnchorName);
|
||||||
for (const QString& rule : getBlockRule(servers))
|
executeIptables(QStringLiteral("iptables"), {QStringLiteral("-F"), chain});
|
||||||
execute(QStringLiteral("iptables -A %1.120.blockNets %2").arg(kAnchorName, rule));
|
for (const QString& server : servers)
|
||||||
|
executeIptables(QStringLiteral("iptables"),
|
||||||
|
{QStringLiteral("-A"), chain, QStringLiteral("-d"), server,
|
||||||
|
QStringLiteral("-j"), QStringLiteral("REJECT")});
|
||||||
}
|
}
|
||||||
|
|
||||||
int waitForExitCode(QProcess& process)
|
int waitForExitCode(QProcess& process)
|
||||||
@@ -500,6 +520,24 @@ int LinuxFirewall::execute(const QString &command, bool ignoreErrors)
|
|||||||
return exitCode;
|
return exitCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int LinuxFirewall::executeIptables(const QString &program, const QStringList &args, bool ignoreErrors)
|
||||||
|
{
|
||||||
|
QProcess p;
|
||||||
|
p.start(program, args, QProcess::ReadOnly);
|
||||||
|
p.closeWriteChannel();
|
||||||
|
|
||||||
|
int exitCode = waitForExitCode(p);
|
||||||
|
auto out = p.readAllStandardOutput().trimmed();
|
||||||
|
auto err = p.readAllStandardError().trimmed();
|
||||||
|
if ((exitCode != 0 || !err.isEmpty()) && !ignoreErrors)
|
||||||
|
logger.warning() << "(" << exitCode << ") $ " << program << args.join(QLatin1Char(' '));
|
||||||
|
if (!out.isEmpty())
|
||||||
|
logger.info() << out;
|
||||||
|
if (!err.isEmpty())
|
||||||
|
logger.warning() << err;
|
||||||
|
return exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
void LinuxFirewall::setupTrafficSplitting()
|
void LinuxFirewall::setupTrafficSplitting()
|
||||||
{
|
{
|
||||||
auto cGroupDir = "/sys/fs/cgroup/net_cls/" BRAND_CODE "vpnexclusions/";
|
auto cGroupDir = "/sys/fs/cgroup/net_cls/" BRAND_CODE "vpnexclusions/";
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ private:
|
|||||||
static void setupTrafficSplitting();
|
static void setupTrafficSplitting();
|
||||||
static void teardownTrafficSplitting();
|
static void teardownTrafficSplitting();
|
||||||
static int execute(const QString& command, bool ignoreErrors = false);
|
static int execute(const QString& command, bool ignoreErrors = false);
|
||||||
|
static int executeIptables(const QString& program, const QStringList& args, bool ignoreErrors = false);
|
||||||
private:
|
private:
|
||||||
// Chain names
|
// Chain names
|
||||||
static QString kOutputChain, kRootChain, kPostRoutingChain, kPreRoutingChain;
|
static QString kOutputChain, kRootChain, kPostRoutingChain, kPreRoutingChain;
|
||||||
|
|||||||
@@ -164,8 +164,13 @@ bool LinuxRouteMonitor::rtmSendRoute(int action, int flags, int type,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (rtm->rtm_type == RTN_THROW) {
|
if (rtm->rtm_type == RTN_THROW) {
|
||||||
|
QString gateway = NetworkUtilities::getGatewayAndIface().first;
|
||||||
|
if (gateway.isEmpty()) {
|
||||||
|
logger.warning() << "No default gateway available, skipping exclusion route";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
struct in_addr ip4;
|
struct in_addr ip4;
|
||||||
inet_pton(AF_INET, NetworkUtilities::getGatewayAndIface().first.toUtf8(), &ip4);
|
inet_pton(AF_INET, gateway.toUtf8(), &ip4);
|
||||||
nlmsg_append_attr(nlmsg, sizeof(buf), RTA_GATEWAY, &ip4, sizeof(ip4));
|
nlmsg_append_attr(nlmsg, sizeof(buf), RTA_GATEWAY, &ip4, sizeof(ip4));
|
||||||
nlmsg_append_attr32(nlmsg, sizeof(buf), RTA_PRIORITY, 0);
|
nlmsg_append_attr32(nlmsg, sizeof(buf), RTA_PRIORITY, 0);
|
||||||
rtm->rtm_type = RTN_UNICAST;
|
rtm->rtm_type = RTN_UNICAST;
|
||||||
|
|||||||
@@ -237,7 +237,11 @@ bool WireguardUtilsLinux::updatePeer(const InterfaceConfig& config) {
|
|||||||
// Exclude the server address, except for multihop exit servers.
|
// Exclude the server address, except for multihop exit servers.
|
||||||
if ((config.m_hopType != InterfaceConfig::MultiHopExit) &&
|
if ((config.m_hopType != InterfaceConfig::MultiHopExit) &&
|
||||||
(m_rtmonitor != nullptr)) {
|
(m_rtmonitor != nullptr)) {
|
||||||
m_rtmonitor->addExclusionRoute(IPAddress(config.m_serverIpv4AddrIn));
|
if (!config.m_serverIpv4AddrIn.isEmpty() &&
|
||||||
|
!m_rtmonitor->addExclusionRoute(IPAddress(config.m_serverIpv4AddrIn))) {
|
||||||
|
logger.error() << "No gateway — cannot add server exclusion route";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
m_rtmonitor->addExclusionRoute(IPAddress(config.m_serverIpv6AddrIn));
|
m_rtmonitor->addExclusionRoute(IPAddress(config.m_serverIpv6AddrIn));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ void LinuxNetworkWatcher::initialize() {
|
|||||||
connect(m_worker, &LinuxNetworkWatcherWorker::wakeup, this,
|
connect(m_worker, &LinuxNetworkWatcherWorker::wakeup, this,
|
||||||
&NetworkWatcherImpl::wakeup);
|
&NetworkWatcherImpl::wakeup);
|
||||||
|
|
||||||
|
connect(m_worker, &LinuxNetworkWatcherWorker::networkChanged, this,
|
||||||
|
[this]() { emit networkChanged(""); });
|
||||||
|
|
||||||
// Let's wait a few seconds to allow the UI to be fully loaded and shown.
|
// Let's wait a few seconds to allow the UI to be fully loaded and shown.
|
||||||
// This is not strictly needed, but it's better for user experience because
|
// This is not strictly needed, but it's better for user experience because
|
||||||
// it makes the UI faster to appear, plus it gives a bit of delay between the
|
// it makes the UI faster to appear, plus it gives a bit of delay between the
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
enum NMState {
|
enum NMState {
|
||||||
NM_STATE_UNKNOWN = 0,
|
NM_STATE_UNKNOWN = 0,
|
||||||
NM_STATE_ASLEEP = 10,
|
NM_STATE_ASLEEP = 10,
|
||||||
|
NM_STATE_DISABLED = 10,
|
||||||
NM_STATE_DISCONNECTED = 20,
|
NM_STATE_DISCONNECTED = 20,
|
||||||
NM_STATE_DISCONNECTING = 30,
|
NM_STATE_DISCONNECTING = 30,
|
||||||
NM_STATE_CONNECTING = 40,
|
NM_STATE_CONNECTING = 40,
|
||||||
@@ -199,10 +200,11 @@ void LinuxNetworkWatcherWorker::checkDevices() {
|
|||||||
|
|
||||||
void LinuxNetworkWatcherWorker::NMStateChanged(quint32 state)
|
void LinuxNetworkWatcherWorker::NMStateChanged(quint32 state)
|
||||||
{
|
{
|
||||||
if (state == NM_STATE_ASLEEP) {
|
logger.debug() << "NMStateChanged " << state;
|
||||||
emit wakeup();
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug() << "NMStateChanged " << state;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (state == NM_STATE_ASLEEP || state == NM_STATE_DISABLED) {
|
||||||
|
emit wakeup();
|
||||||
|
} else if (state == NM_STATE_CONNECTED_GLOBAL) {
|
||||||
|
emit networkChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ class LinuxNetworkWatcherWorker final : public QObject {
|
|||||||
signals:
|
signals:
|
||||||
void unsecuredNetwork(const QString& networkName, const QString& networkId);
|
void unsecuredNetwork(const QString& networkName, const QString& networkId);
|
||||||
void wakeup();
|
void wakeup();
|
||||||
|
void networkChanged();
|
||||||
|
|
||||||
public slots:
|
public slots:
|
||||||
void initialize();
|
void initialize();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "xrayprotocol.h"
|
#include "xrayprotocol.h"
|
||||||
|
|
||||||
#include "core/ipcclient.h"
|
#include "core/ipcclient.h"
|
||||||
|
#include "core/serialization/serialization.h"
|
||||||
#include "ipc.h"
|
#include "ipc.h"
|
||||||
#include "utilities.h"
|
#include "utilities.h"
|
||||||
#include "core/networkUtilities.h"
|
#include "core/networkUtilities.h"
|
||||||
@@ -14,6 +15,8 @@
|
|||||||
#include <QtCore/qobjectdefs.h>
|
#include <QtCore/qobjectdefs.h>
|
||||||
#include <QtCore/qprocess.h>
|
#include <QtCore/qprocess.h>
|
||||||
|
|
||||||
|
#include <exception>
|
||||||
|
|
||||||
#ifdef Q_OS_MACOS
|
#ifdef Q_OS_MACOS
|
||||||
static const QString tunName = "utun22";
|
static const QString tunName = "utun22";
|
||||||
#else
|
#else
|
||||||
@@ -53,6 +56,19 @@ ErrorCode XrayProtocol::start()
|
|||||||
{
|
{
|
||||||
qDebug() << "XrayProtocol::start()";
|
qDebug() << "XrayProtocol::start()";
|
||||||
|
|
||||||
|
// Inject SOCKS5 auth into the inbound before starting xray.
|
||||||
|
// Re-uses existing credentials if the config already has them (e.g. imported config).
|
||||||
|
amnezia::serialization::inbounds::InboundCredentials creds;
|
||||||
|
try {
|
||||||
|
creds = amnezia::serialization::inbounds::EnsureInboundAuth(m_xrayConfig);
|
||||||
|
} catch (const std::exception &e) {
|
||||||
|
qCritical() << "EnsureInboundAuth failed:" << e.what();
|
||||||
|
return ErrorCode::InternalError;
|
||||||
|
}
|
||||||
|
m_socksUser = creds.username;
|
||||||
|
m_socksPassword = creds.password;
|
||||||
|
m_socksPort = creds.port;
|
||||||
|
|
||||||
return IpcClient::withInterface([&](QSharedPointer<IpcInterfaceReplica> iface) {
|
return IpcClient::withInterface([&](QSharedPointer<IpcInterfaceReplica> iface) {
|
||||||
auto xrayStart = iface->xrayStart(QJsonDocument(m_xrayConfig).toJson());
|
auto xrayStart = iface->xrayStart(QJsonDocument(m_xrayConfig).toJson());
|
||||||
if (!xrayStart.waitForFinished() || !xrayStart.returnValue()) {
|
if (!xrayStart.waitForFinished() || !xrayStart.returnValue()) {
|
||||||
@@ -121,8 +137,11 @@ ErrorCode XrayProtocol::startTun2Socks()
|
|||||||
return ErrorCode::AmneziaServiceConnectionFailed;
|
return ErrorCode::AmneziaServiceConnectionFailed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const QString proxyUrl = QString("socks5://%1:%2@127.0.0.1:%3")
|
||||||
|
.arg(m_socksUser, m_socksPassword, QString::number(m_socksPort));
|
||||||
|
|
||||||
m_tun2socksProcess->setProgram(PermittedProcess::Tun2Socks);
|
m_tun2socksProcess->setProgram(PermittedProcess::Tun2Socks);
|
||||||
m_tun2socksProcess->setArguments({"-device", QString("tun://%1").arg(tunName), "-proxy", "socks5://127.0.0.1:10808" });
|
m_tun2socksProcess->setArguments({"-device", QString("tun://%1").arg(tunName), "-proxy", proxyUrl});
|
||||||
|
|
||||||
connect(m_tun2socksProcess.data(), &IpcProcessInterfaceReplica::readyReadStandardOutput, this, [this]() {
|
connect(m_tun2socksProcess.data(), &IpcProcessInterfaceReplica::readyReadStandardOutput, this, [this]() {
|
||||||
auto readAllStandardOutput = m_tun2socksProcess->readAllStandardOutput();
|
auto readAllStandardOutput = m_tun2socksProcess->readAllStandardOutput();
|
||||||
@@ -136,7 +155,7 @@ ErrorCode XrayProtocol::startTun2Socks()
|
|||||||
if (!line.contains("[TCP]") && !line.contains("[UDP]"))
|
if (!line.contains("[TCP]") && !line.contains("[UDP]"))
|
||||||
qDebug() << "[tun2socks]:" << line;
|
qDebug() << "[tun2socks]:" << line;
|
||||||
|
|
||||||
if (line.contains("[STACK] tun://") && line.contains("<-> socks5://127.0.0.1")) {
|
if (line.contains("[STACK] tun://") && line.contains("<-> socks5://")) {
|
||||||
disconnect(m_tun2socksProcess.data(), &IpcProcessInterfaceReplica::readyReadStandardOutput, this, nullptr);
|
disconnect(m_tun2socksProcess.data(), &IpcProcessInterfaceReplica::readyReadStandardOutput, this, nullptr);
|
||||||
|
|
||||||
if (ErrorCode res = setupRouting(); res != ErrorCode::NoError) {
|
if (ErrorCode res = setupRouting(); res != ErrorCode::NoError) {
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ private:
|
|||||||
QList<QHostAddress> m_dnsServers;
|
QList<QHostAddress> m_dnsServers;
|
||||||
QString m_remoteAddress;
|
QString m_remoteAddress;
|
||||||
|
|
||||||
|
QString m_socksUser;
|
||||||
|
QString m_socksPassword;
|
||||||
|
int m_socksPort = 10808;
|
||||||
|
|
||||||
QSharedPointer<IpcProcessInterfaceReplica> m_tun2socksProcess;
|
QSharedPointer<IpcProcessInterfaceReplica> m_tun2socksProcess;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+12
-1
@@ -27,10 +27,12 @@
|
|||||||
<file>images/controls/folder-open.svg</file>
|
<file>images/controls/folder-open.svg</file>
|
||||||
<file>images/controls/folder-search-2.svg</file>
|
<file>images/controls/folder-search-2.svg</file>
|
||||||
<file>images/controls/gauge.svg</file>
|
<file>images/controls/gauge.svg</file>
|
||||||
|
<file>images/controls/globe-2.svg</file>
|
||||||
<file>images/controls/github.svg</file>
|
<file>images/controls/github.svg</file>
|
||||||
<file>images/controls/help-circle.svg</file>
|
<file>images/controls/help-circle.svg</file>
|
||||||
<file>images/controls/history.svg</file>
|
<file>images/controls/history.svg</file>
|
||||||
<file>images/controls/home.svg</file>
|
<file>images/controls/home.svg</file>
|
||||||
|
<file>images/controls/infinity.svg</file>
|
||||||
<file>images/controls/info.svg</file>
|
<file>images/controls/info.svg</file>
|
||||||
<file>images/controls/mail.svg</file>
|
<file>images/controls/mail.svg</file>
|
||||||
<file>images/controls/map-pin.svg</file>
|
<file>images/controls/map-pin.svg</file>
|
||||||
@@ -55,6 +57,7 @@
|
|||||||
<file>images/controls/settings-news.svg</file>
|
<file>images/controls/settings-news.svg</file>
|
||||||
<file>images/controls/share-2.svg</file>
|
<file>images/controls/share-2.svg</file>
|
||||||
<file>images/controls/split-tunneling.svg</file>
|
<file>images/controls/split-tunneling.svg</file>
|
||||||
|
<file>images/controls/smartphone.svg</file>
|
||||||
<file>images/controls/tag.svg</file>
|
<file>images/controls/tag.svg</file>
|
||||||
<file>images/controls/telegram.svg</file>
|
<file>images/controls/telegram.svg</file>
|
||||||
<file>images/controls/text-cursor.svg</file>
|
<file>images/controls/text-cursor.svg</file>
|
||||||
@@ -133,8 +136,13 @@
|
|||||||
<file>ui/qml/Components/HomeContainersListView.qml</file>
|
<file>ui/qml/Components/HomeContainersListView.qml</file>
|
||||||
<file>ui/qml/Components/HomeSplitTunnelingDrawer.qml</file>
|
<file>ui/qml/Components/HomeSplitTunnelingDrawer.qml</file>
|
||||||
<file>ui/qml/Components/InstalledAppsDrawer.qml</file>
|
<file>ui/qml/Components/InstalledAppsDrawer.qml</file>
|
||||||
|
<file>ui/qml/Components/BenefitRow.qml</file>
|
||||||
|
<file>ui/qml/Components/BenefitsPanel.qml</file>
|
||||||
|
<file>ui/qml/Components/SubscriptionPlanCard.qml</file>
|
||||||
|
<file>ui/qml/Components/TermsAndPrivacyText.qml</file>
|
||||||
<file>ui/qml/Components/QuestionDrawer.qml</file>
|
<file>ui/qml/Components/QuestionDrawer.qml</file>
|
||||||
<file>ui/qml/Components/SelectLanguageDrawer.qml</file>
|
<file>ui/qml/Components/SelectLanguageDrawer.qml</file>
|
||||||
|
<file>ui/qml/Components/SubscriptionExpiredDrawer.qml</file>
|
||||||
<file>ui/qml/Components/ServersListView.qml</file>
|
<file>ui/qml/Components/ServersListView.qml</file>
|
||||||
<file>ui/qml/Components/SettingsContainersListView.qml</file>
|
<file>ui/qml/Components/SettingsContainersListView.qml</file>
|
||||||
<file>ui/qml/Components/TransportProtoSelector.qml</file>
|
<file>ui/qml/Components/TransportProtoSelector.qml</file>
|
||||||
@@ -180,6 +188,7 @@
|
|||||||
<file>ui/qml/Controls2/TextTypes/LabelTextType.qml</file>
|
<file>ui/qml/Controls2/TextTypes/LabelTextType.qml</file>
|
||||||
<file>ui/qml/Controls2/TextTypes/ListItemTitleType.qml</file>
|
<file>ui/qml/Controls2/TextTypes/ListItemTitleType.qml</file>
|
||||||
<file>ui/qml/Controls2/TextTypes/ParagraphTextType.qml</file>
|
<file>ui/qml/Controls2/TextTypes/ParagraphTextType.qml</file>
|
||||||
|
<file>ui/qml/Controls2/TextTypes/BadgeTextType.qml</file>
|
||||||
<file>ui/qml/Controls2/TextTypes/SmallTextType.qml</file>
|
<file>ui/qml/Controls2/TextTypes/SmallTextType.qml</file>
|
||||||
<file>ui/qml/Controls2/TopCloseButtonType.qml</file>
|
<file>ui/qml/Controls2/TopCloseButtonType.qml</file>
|
||||||
<file>ui/qml/Controls2/VerticalRadioButton.qml</file>
|
<file>ui/qml/Controls2/VerticalRadioButton.qml</file>
|
||||||
@@ -225,7 +234,9 @@
|
|||||||
<file>ui/qml/Pages2/PageSettingsNewsDetail.qml</file>
|
<file>ui/qml/Pages2/PageSettingsNewsDetail.qml</file>
|
||||||
<file>ui/qml/Pages2/PageProtocolAwgClientSettings.qml</file>
|
<file>ui/qml/Pages2/PageProtocolAwgClientSettings.qml</file>
|
||||||
<file>ui/qml/Pages2/PageProtocolWireGuardClientSettings.qml</file>
|
<file>ui/qml/Pages2/PageProtocolWireGuardClientSettings.qml</file>
|
||||||
<file>ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml</file>
|
<file>ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml</file>
|
||||||
|
<file>ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml</file>
|
||||||
|
<file>ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml</file>
|
||||||
<file>ui/qml/Pages2/PageSetupWizardApiServicesList.qml</file>
|
<file>ui/qml/Pages2/PageSetupWizardApiServicesList.qml</file>
|
||||||
<file>ui/qml/Pages2/PageSetupWizardConfigSource.qml</file>
|
<file>ui/qml/Pages2/PageSetupWizardConfigSource.qml</file>
|
||||||
<file>ui/qml/Pages2/PageSetupWizardCredentials.qml</file>
|
<file>ui/qml/Pages2/PageSetupWizardCredentials.qml</file>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
sudo docker ps -a | grep amnezia | awk '{print $1}' | xargs sudo docker stop;\
|
sudo docker ps -a | grep amnezia | awk '{print $1}' | xargs sudo docker stop;\
|
||||||
sudo docker ps -a | grep amnezia | awk '{print $1}' | xargs sudo docker rm -fv;\
|
sudo docker ps -a | grep amnezia | awk '{print $1}' | xargs sudo docker rm -fv;\
|
||||||
sudo docker images -a --format table | grep amnezia | awk '{print $3}' | xargs sudo docker rmi;\
|
sudo docker images -a --format table | grep amnezia | awk '{print $3, $1 ":" $2}' | xargs sudo docker rmi;\
|
||||||
sudo docker network ls | grep amnezia-dns-net | awk '{print $1}' | xargs sudo docker network rm;\
|
sudo docker network ls | grep amnezia-dns-net | awk '{print $1}' | xargs sudo docker network rm;\
|
||||||
sudo rm -frd /opt/amnezia
|
sudo rm -frd /opt/amnezia
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM 3proxy/3proxy:latest
|
FROM 3proxy/3proxy:0.9.5
|
||||||
|
|
||||||
LABEL maintainer="AmneziaVPN"
|
LABEL maintainer="AmneziaVPN"
|
||||||
|
|
||||||
@@ -7,4 +7,4 @@ RUN echo -e "#!/bin/bash\ntail -f /dev/null" > /opt/amnezia/start.sh
|
|||||||
RUN chmod a+x /opt/amnezia/start.sh
|
RUN chmod a+x /opt/amnezia/start.sh
|
||||||
|
|
||||||
ENTRYPOINT [ "/bin/sh", "/opt/amnezia/start.sh" ]
|
ENTRYPOINT [ "/bin/sh", "/opt/amnezia/start.sh" ]
|
||||||
CMD [ "" ]
|
CMD [ "" ]
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ namespace
|
|||||||
const char cloudFlareNs2[] = "1.0.0.1";
|
const char cloudFlareNs2[] = "1.0.0.1";
|
||||||
|
|
||||||
constexpr char gatewayEndpoint[] = "http://gw.amnezia.org:80/";
|
constexpr char gatewayEndpoint[] = "http://gw.amnezia.org:80/";
|
||||||
|
constexpr char proxyUrlsKey[] = "Conf/proxyUrls/";
|
||||||
}
|
}
|
||||||
|
|
||||||
Settings::Settings(QObject *parent) : QObject(parent), m_settings(ORGANIZATION_NAME, APPLICATION_NAME, this)
|
Settings::Settings(QObject *parent) : QObject(parent), m_settings(ORGANIZATION_NAME, APPLICATION_NAME, this)
|
||||||
@@ -526,6 +527,24 @@ void Settings::toggleDevGatewayEnv(bool enabled)
|
|||||||
m_settings.setValue("Conf/devGatewayEnv", enabled);
|
m_settings.setValue("Conf/devGatewayEnv", enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QByteArray Settings::readGatewayProxyUrls(const QString &cacheKey) const
|
||||||
|
{
|
||||||
|
if (cacheKey.isEmpty()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return m_settings.value(QString(proxyUrlsKey) + cacheKey).toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Settings::writeGatewayProxyUrls(const QString &cacheKey, const QByteArray &proxyUrlsEncrypted)
|
||||||
|
{
|
||||||
|
if (cacheKey.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_settings.setValue(QString(proxyUrlsKey) + cacheKey, proxyUrlsEncrypted);
|
||||||
|
}
|
||||||
|
|
||||||
bool Settings::isHomeAdLabelVisible()
|
bool Settings::isHomeAdLabelVisible()
|
||||||
{
|
{
|
||||||
return m_settings.value("Conf/homeAdLabelVisible", true).toBool();
|
return m_settings.value("Conf/homeAdLabelVisible", true).toBool();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QSettings>
|
#include <QSettings>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
#include <QByteArray>
|
||||||
|
|
||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
@@ -234,6 +235,8 @@ public:
|
|||||||
QString getGatewayEndpoint(bool isTestPurchase = false);
|
QString getGatewayEndpoint(bool isTestPurchase = false);
|
||||||
bool isDevGatewayEnv(bool isTestPurchase = false);
|
bool isDevGatewayEnv(bool isTestPurchase = false);
|
||||||
void toggleDevGatewayEnv(bool enabled);
|
void toggleDevGatewayEnv(bool enabled);
|
||||||
|
QByteArray readGatewayProxyUrls(const QString &cacheKey) const;
|
||||||
|
void writeGatewayProxyUrls(const QString &cacheKey, const QByteArray &proxyUrlsEncrypted);
|
||||||
|
|
||||||
bool isHomeAdLabelVisible();
|
bool isHomeAdLabelVisible();
|
||||||
void disableHomeAdLabel();
|
void disableHomeAdLabel();
|
||||||
|
|||||||
@@ -52,18 +52,18 @@
|
|||||||
<context>
|
<context>
|
||||||
<name>ApiAccountInfoModel</name>
|
<name>ApiAccountInfoModel</name>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/models/api/apiAccountInfoModel.cpp" line="31"/>
|
<location filename="../ui/models/api/apiAccountInfoModel.cpp" line="32"/>
|
||||||
<location filename="../ui/models/api/apiAccountInfoModel.cpp" line="35"/>
|
<location filename="../ui/models/api/apiAccountInfoModel.cpp" line="37"/>
|
||||||
<source>Active</source>
|
<source>Active</source>
|
||||||
<translation>Активна</translation>
|
<translation>Активна</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/models/api/apiAccountInfoModel.cpp" line="34"/>
|
<location filename="../ui/models/api/apiAccountInfoModel.cpp" line="36"/>
|
||||||
<source><p><a style="color: #EB5757;">Inactive</a></source>
|
<source>Inactive</source>
|
||||||
<translation>Не активна</translation>
|
<translation>Не активна</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/models/api/apiAccountInfoModel.cpp" line="48"/>
|
<location filename="../ui/models/api/apiAccountInfoModel.cpp" line="50"/>
|
||||||
<source>%1 out of %2</source>
|
<source>%1 out of %2</source>
|
||||||
<translation>%1 из %2</translation>
|
<translation>%1 из %2</translation>
|
||||||
</message>
|
</message>
|
||||||
@@ -71,23 +71,51 @@
|
|||||||
<context>
|
<context>
|
||||||
<name>ApiConfigsController</name>
|
<name>ApiConfigsController</name>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="514"/>
|
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="859"/>
|
||||||
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="690"/>
|
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="929"/>
|
||||||
<source>%1 installed successfully.</source>
|
<source>%1 installed successfully.</source>
|
||||||
<translation>%1 успешно установлен.</translation>
|
<translation>%1 успешно установлен.</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="637"/>
|
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="810"/>
|
||||||
<source>Subscription restored successfully.</source>
|
<source>Subscription restored successfully.</source>
|
||||||
<translation>Подписка успешно восстановлена.</translation>
|
<translation>Подписка успешно восстановлена.</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="751"/>
|
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="391"/>
|
||||||
|
<source>%1/mo</source>
|
||||||
|
<comment>IAP: price per month in plan subtitle</comment>
|
||||||
|
<translation>%1/мес</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="410"/>
|
||||||
|
<source>from %1 per month</source>
|
||||||
|
<comment>IAP: card footer minimum monthly price from StoreKit</comment>
|
||||||
|
<translation>от %1 в месяц</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="664"/>
|
||||||
|
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="802"/>
|
||||||
|
<source>This subscription has already been added</source>
|
||||||
|
<translation>Эта подписка уже добавлена</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="672"/>
|
||||||
|
<source>%1 has been added to the app</source>
|
||||||
|
<translation>%1 добавлено в приложение</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="897"/>
|
||||||
|
<source>This email address has already been used to activate a trial. If you like the service, you can upgrade to Premium</source>
|
||||||
|
<translation>Этот адрес электронной почты уже использовался для активации пробного периода. Если вам понравился сервис, вы можете оформить подписку Premium</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="998"/>
|
||||||
<source>API config reloaded</source>
|
<source>API config reloaded</source>
|
||||||
<translation>Конфигурация API перезагружена</translation>
|
<translation>Конфигурация API перезагружена</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="755"/>
|
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="1002"/>
|
||||||
<source>Successfully changed the country of connection to %1</source>
|
<source>Successfully changed the country of connection to %1</source>
|
||||||
<translation>Страна подключения изменена на %1</translation>
|
<translation>Страна подключения изменена на %1</translation>
|
||||||
</message>
|
</message>
|
||||||
@@ -182,29 +210,24 @@
|
|||||||
<translation><p><a style="color: #EB5757;">Недоступно в вашем регионе. Если у вас включен VPN, отключите его, вернитесь на предыдущий экран и попробуйте снова.</a></translation>
|
<translation><p><a style="color: #EB5757;">Недоступно в вашем регионе. Если у вас включен VPN, отключите его, вернитесь на предыдущий экран и попробуйте снова.</a></translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/models/api/apiServicesModel.cpp" line="95"/>
|
|
||||||
<source>%1 MBit/s</source>
|
<source>%1 MBit/s</source>
|
||||||
<translation>%1 Мбит/с</translation>
|
<translation type="vanished">%1 Мбит/с</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/models/api/apiServicesModel.cpp" line="102"/>
|
|
||||||
<source>%1 days</source>
|
<source>%1 days</source>
|
||||||
<translation>%1 дней</translation>
|
<translation type="vanished">%1 дней</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/models/api/apiServicesModel.cpp" line="113"/>
|
|
||||||
<source>Free</source>
|
<source>Free</source>
|
||||||
<translation>Бесплатно</translation>
|
<translation type="vanished">Бесплатно</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/models/api/apiServicesModel.cpp" line="116"/>
|
|
||||||
<source>%1 $</source>
|
<source>%1 $</source>
|
||||||
<translation>%1 $</translation>
|
<translation type="vanished">%1 $</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/models/api/apiServicesModel.cpp" line="118"/>
|
|
||||||
<source>%1 $/month</source>
|
<source>%1 $/month</source>
|
||||||
<translation>%1 $/месяц</translation>
|
<translation type="vanished">%1 $/месяц</translation>
|
||||||
</message>
|
</message>
|
||||||
</context>
|
</context>
|
||||||
<context>
|
<context>
|
||||||
@@ -241,45 +264,45 @@
|
|||||||
<context>
|
<context>
|
||||||
<name>ConnectionController</name>
|
<name>ConnectionController</name>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/controllers/connectionController.cpp" line="81"/>
|
<location filename="../ui/controllers/connectionController.cpp" line="82"/>
|
||||||
<source>Connecting...</source>
|
<source>Connecting...</source>
|
||||||
<translation>Подключение...</translation>
|
<translation>Подключение...</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/controllers/connectionController.cpp" line="86"/>
|
<location filename="../ui/controllers/connectionController.cpp" line="89"/>
|
||||||
<source>Connected</source>
|
<source>Connected</source>
|
||||||
<translation>Подключено</translation>
|
<translation>Подключено</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/controllers/connectionController.cpp" line="110"/>
|
<location filename="../ui/controllers/connectionController.cpp" line="113"/>
|
||||||
<source>Preparing...</source>
|
<source>Preparing...</source>
|
||||||
<translation>Подготовка...</translation>
|
<translation>Подготовка...</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/controllers/connectionController.cpp" line="132"/>
|
<location filename="../ui/controllers/connectionController.cpp" line="135"/>
|
||||||
<source>Settings updated successfully, reconnnection...</source>
|
<source>Settings updated successfully, reconnnection...</source>
|
||||||
<translation>Настройки успешно обновлены, переподключение...</translation>
|
<translation>Настройки успешно обновлены, переподключение...</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/controllers/connectionController.cpp" line="135"/>
|
<location filename="../ui/controllers/connectionController.cpp" line="138"/>
|
||||||
<source>Settings updated successfully</source>
|
<source>Settings updated successfully</source>
|
||||||
<translation>Настройки успешно обновлены</translation>
|
<translation>Настройки успешно обновлены</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/controllers/connectionController.cpp" line="95"/>
|
<location filename="../ui/controllers/connectionController.cpp" line="98"/>
|
||||||
<source>Reconnecting...</source>
|
<source>Reconnecting...</source>
|
||||||
<translation>Переподключение...</translation>
|
<translation>Переподключение...</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/controllers/connectionController.h" line="70"/>
|
<location filename="../ui/controllers/connectionController.h" line="70"/>
|
||||||
<location filename="../ui/controllers/connectionController.cpp" line="100"/>
|
<location filename="../ui/controllers/connectionController.cpp" line="103"/>
|
||||||
<location filename="../ui/controllers/connectionController.cpp" line="115"/>
|
<location filename="../ui/controllers/connectionController.cpp" line="118"/>
|
||||||
<location filename="../ui/controllers/connectionController.cpp" line="121"/>
|
<location filename="../ui/controllers/connectionController.cpp" line="124"/>
|
||||||
<source>Connect</source>
|
<source>Connect</source>
|
||||||
<translation>Подключиться</translation>
|
<translation>Подключиться</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/controllers/connectionController.cpp" line="105"/>
|
<location filename="../ui/controllers/connectionController.cpp" line="108"/>
|
||||||
<source>Disconnecting...</source>
|
<source>Disconnecting...</source>
|
||||||
<translation>Отключение...</translation>
|
<translation>Отключение...</translation>
|
||||||
</message>
|
</message>
|
||||||
@@ -1697,17 +1720,32 @@ Thank you for staying with us!</source>
|
|||||||
<context>
|
<context>
|
||||||
<name>PageSettingsApiAvailableCountries</name>
|
<name>PageSettingsApiAvailableCountries</name>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiAvailableCountries.qml" line="84"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiAvailableCountries.qml" line="129"/>
|
||||||
|
<source>Subscription expired</source>
|
||||||
|
<translation>Подписка закончилась</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/qml/Pages2/PageSettingsApiAvailableCountries.qml" line="129"/>
|
||||||
|
<source>Subscription expiring soon</source>
|
||||||
|
<translation>Подписка скоро закончится</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/qml/Pages2/PageSettingsApiAvailableCountries.qml" line="148"/>
|
||||||
|
<source>Renew subscription</source>
|
||||||
|
<translation>Продлить подписку</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/qml/Pages2/PageSettingsApiAvailableCountries.qml" line="162"/>
|
||||||
<source>Location for connection</source>
|
<source>Location for connection</source>
|
||||||
<translation>Страны для подключения</translation>
|
<translation>Страны для подключения</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiAvailableCountries.qml" line="123"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiAvailableCountries.qml" line="191"/>
|
||||||
<source>Unable change server location while trying to make an active connection</source>
|
<source>Unable change server location while trying to make an active connection</source>
|
||||||
<translation>Невозможно изменить локацию во время попытки соединения</translation>
|
<translation>Невозможно изменить локацию во время попытки соединения</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiAvailableCountries.qml" line="127"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiAvailableCountries.qml" line="195"/>
|
||||||
<source>Unable change server location while there is an active connection</source>
|
<source>Unable change server location while there is an active connection</source>
|
||||||
<translation>Невозможно изменить локацию во время активного соединения</translation>
|
<translation>Невозможно изменить локацию во время активного соединения</translation>
|
||||||
</message>
|
</message>
|
||||||
@@ -1939,12 +1977,12 @@ Thank you for staying with us!</source>
|
|||||||
<context>
|
<context>
|
||||||
<name>PageSettingsApiServerInfo</name>
|
<name>PageSettingsApiServerInfo</name>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="190"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="298"/>
|
||||||
<source>Configurations have been updated for some countries. Download and install the updated configuration files</source>
|
<source>Configurations have been updated for some countries. Download and install the updated configuration files</source>
|
||||||
<translation>Сетевые адреса одного или нескольких серверов были обновлены. Пожалуйста, удалите старые конфигурацию и загрузите новые файлы</translation>
|
<translation>Сетевые адреса одного или нескольких серверов были обновлены. Пожалуйста, удалите старые конфигурацию и загрузите новые файлы</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="235"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="343"/>
|
||||||
<source>Manage configuration files</source>
|
<source>Manage configuration files</source>
|
||||||
<translation>Управление файлами конфигурации</translation>
|
<translation>Управление файлами конфигурации</translation>
|
||||||
</message>
|
</message>
|
||||||
@@ -1964,106 +2002,122 @@ Thank you for staying with us!</source>
|
|||||||
<translation>Активные соединения</translation>
|
<translation>Активные соединения</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="166"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="150"/>
|
||||||
|
<source>Subscription expired</source>
|
||||||
|
<translation>Подписка закончилась</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="151"/>
|
||||||
|
<source>Subscription expiring soon</source>
|
||||||
|
<translation>Подписка скоро закончится</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="181"/>
|
||||||
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="246"/>
|
||||||
|
<source>Renew subscription</source>
|
||||||
|
<translation>Продлить подписку</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="270"/>
|
||||||
<source>Use VLESS protocol</source>
|
<source>Use VLESS protocol</source>
|
||||||
<translation>Использовать протокол VLESS</translation>
|
<translation>Использовать протокол VLESS</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="170"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="274"/>
|
||||||
<source>Cannot change protocol during active connection</source>
|
<source>Cannot change protocol during active connection</source>
|
||||||
<translation>Невозможно изменить протокол во время активного соединения</translation>
|
<translation>Невозможно изменить протокол во время активного соединения</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="211"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="319"/>
|
||||||
<source>Subscription Key</source>
|
<source>Subscription Key</source>
|
||||||
<translation>Ключ для подключения</translation>
|
<translation>Ключ для подключения</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="233"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="341"/>
|
||||||
<source>Configuration Files</source>
|
<source>Configuration Files</source>
|
||||||
<translation>Файлы конфигурации</translation>
|
<translation>Файлы конфигурации</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="253"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="361"/>
|
||||||
<source>Active Devices</source>
|
<source>Active Devices</source>
|
||||||
<translation>Активные устройства</translation>
|
<translation>Активные устройства</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="255"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="363"/>
|
||||||
<source>Manage currently connected devices</source>
|
<source>Manage currently connected devices</source>
|
||||||
<translation>Управление подключенными устройствами</translation>
|
<translation>Управление подключенными устройствами</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="272"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="380"/>
|
||||||
<source>Support</source>
|
<source>Support</source>
|
||||||
<translation>Поддержка</translation>
|
<translation>Поддержка</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="287"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="395"/>
|
||||||
<source>How to connect on another device</source>
|
<source>How to connect on another device</source>
|
||||||
<translation>Как подключить другие устройства</translation>
|
<translation>Как подключить другие устройства</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="312"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="420"/>
|
||||||
<source>Reload API config</source>
|
<source>Reload API config</source>
|
||||||
<translation>Перезагрузить конфигурацию API</translation>
|
<translation>Перезагрузить конфигурацию API</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="315"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="423"/>
|
||||||
<source>Reload API config?</source>
|
<source>Reload API config?</source>
|
||||||
<translation>Перезагрузить конфигурацию API?</translation>
|
<translation>Перезагрузить конфигурацию API?</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="316"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="424"/>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="354"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="462"/>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="391"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="499"/>
|
||||||
<source>Continue</source>
|
<source>Continue</source>
|
||||||
<translation>Продолжить</translation>
|
<translation>Продолжить</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="317"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="425"/>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="355"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="463"/>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="392"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="500"/>
|
||||||
<source>Cancel</source>
|
<source>Cancel</source>
|
||||||
<translation>Отменить</translation>
|
<translation>Отменить</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="321"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="429"/>
|
||||||
<source>Cannot reload API config during active connection</source>
|
<source>Cannot reload API config during active connection</source>
|
||||||
<translation>Невозможно перзагрузить API конфигурацию при активном соединении</translation>
|
<translation>Невозможно перзагрузить API конфигурацию при активном соединении</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="349"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="457"/>
|
||||||
<source>Unlink this device</source>
|
<source>Unlink this device</source>
|
||||||
<translation>Отвязать это устройство</translation>
|
<translation>Отвязать это устройство</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="352"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="460"/>
|
||||||
<source>Are you sure you want to unlink this device?</source>
|
<source>Are you sure you want to unlink this device?</source>
|
||||||
<translation>Вы уверены, что хотите отвязать это устройство?</translation>
|
<translation>Вы уверены, что хотите отвязать это устройство?</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="353"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="461"/>
|
||||||
<source>This will unlink the device from your subscription. You can reconnect it anytime by pressing "Reload API config" in subscription settings on device.</source>
|
<source>This will unlink the device from your subscription. You can reconnect it anytime by pressing "Reload API config" in subscription settings on device.</source>
|
||||||
<translation>Это отключит устройство от вашей подписки. Вы можете повторно подключить его в любое время, нажав "Перезагрузить конфигурацию API" в настройках подписки на устройстве.</translation>
|
<translation>Это отключит устройство от вашей подписки. Вы можете повторно подключить его в любое время, нажав "Перезагрузить конфигурацию API" в настройках подписки на устройстве.</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="359"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="467"/>
|
||||||
<source>Cannot unlink device during active connection</source>
|
<source>Cannot unlink device during active connection</source>
|
||||||
<translation>Невозможно отвязать устройство во время активного соединения</translation>
|
<translation>Невозможно отвязать устройство во время активного соединения</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="387"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="495"/>
|
||||||
<source>Remove from application</source>
|
<source>Remove from application</source>
|
||||||
<translation>Удалить из приложения</translation>
|
<translation>Удалить из приложения</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="390"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="498"/>
|
||||||
<source>Remove from application?</source>
|
<source>Remove from application?</source>
|
||||||
<translation>Удалить из приложения?</translation>
|
<translation>Удалить из приложения?</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="396"/>
|
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="504"/>
|
||||||
<source>Cannot remove server during active connection</source>
|
<source>Cannot remove server during active connection</source>
|
||||||
<translation>Невозможно удалить сервер во время активного соединения</translation>
|
<translation>Невозможно удалить сервер во время активного соединения</translation>
|
||||||
</message>
|
</message>
|
||||||
@@ -3111,51 +3165,83 @@ Thank you for staying with us!</source>
|
|||||||
</message>
|
</message>
|
||||||
</context>
|
</context>
|
||||||
<context>
|
<context>
|
||||||
<name>PageSetupWizardApiServiceInfo</name>
|
<name>PageSetupWizardApiFreeInfo</name>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml" line="113"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml" line="74"/>
|
||||||
|
<source>Free features</source>
|
||||||
|
<translation>Возможности Free</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml" line="125"/>
|
||||||
|
<source>Continue</source>
|
||||||
|
<translation>Продолжить</translation>
|
||||||
|
</message>
|
||||||
|
</context>
|
||||||
|
<context>
|
||||||
|
<name>PageSetupWizardApiPremiumInfo</name>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml" line="91"/>
|
||||||
|
<source>Recommended</source>
|
||||||
|
<translation>Рекомендуется</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml" line="103"/>
|
||||||
|
<source>Premium features</source>
|
||||||
|
<translation>Возможности Premium</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml" line="132"/>
|
||||||
<source>Charged to your Apple ID at confirmation. Renews automatically unless auto-renew is turned off at least 24 hours before period end. Manage in Apple ID settings.</source>
|
<source>Charged to your Apple ID at confirmation. Renews automatically unless auto-renew is turned off at least 24 hours before period end. Manage in Apple ID settings.</source>
|
||||||
<translation>Списание с Apple ID при подтверждении. Продление автоматическое, если автопродление не отключено минимум за 24 часа до окончания периода. Управление в настройках Apple ID.</translation>
|
<translation>Списание с Apple ID при подтверждении. Продление автоматическое, если автопродление не отключено минимум за 24 часа до окончания периода. Управление в настройках Apple ID.</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml" line="125"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml" line="169"/>
|
||||||
|
<source>Continue</source>
|
||||||
|
<translation>Продолжить</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml" line="171"/>
|
||||||
|
<source>Subscribe — %1 for %2</source>
|
||||||
|
<translation>Подписаться — %1 за %2</translation>
|
||||||
|
</message>
|
||||||
|
</context>
|
||||||
|
<context>
|
||||||
|
<name>PageSetupWizardApiServiceInfo</name>
|
||||||
|
<message>
|
||||||
|
<source>Charged to your Apple ID at confirmation. Renews automatically unless auto-renew is turned off at least 24 hours before period end. Manage in Apple ID settings.</source>
|
||||||
|
<translation type="vanished">Списание с Apple ID при подтверждении. Продление автоматическое, если автопродление не отключено минимум за 24 часа до окончания периода. Управление в настройках Apple ID.</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
<source>Subscribe Now</source>
|
<source>Subscribe Now</source>
|
||||||
<translation>Подписаться сейчас</translation>
|
<translation type="vanished">Подписаться сейчас</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml" line="158"/>
|
|
||||||
<source>By continuing, you agree to the <a href="%1" style="color: #FBB26A;">Terms of Use</a> and <a href="%2" style="color: #FBB26A;">Privacy Policy</a></source>
|
<source>By continuing, you agree to the <a href="%1" style="color: #FBB26A;">Terms of Use</a> and <a href="%2" style="color: #FBB26A;">Privacy Policy</a></source>
|
||||||
<translation>Продолжая, вы соглашаетесь с <a href="%1" style="color: #FBB26A;">Условиями использования</a> и <a href="%2" style="color: #FBB26A;">Политикой конфиденциальности</a></translation>
|
<translation type="vanished">Продолжая, вы соглашаетесь с <a href="%1" style="color: #FBB26A;">Условиями использования</a> и <a href="%2" style="color: #FBB26A;">Политикой конфиденциальности</a></translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml" line="186"/>
|
|
||||||
<source>For the region</source>
|
<source>For the region</source>
|
||||||
<translation>Для региона</translation>
|
<translation type="vanished">Для региона</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml" line="195"/>
|
|
||||||
<source>Price</source>
|
<source>Price</source>
|
||||||
<translation>Цена</translation>
|
<translation type="vanished">Цена</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml" line="204"/>
|
|
||||||
<source>Work period</source>
|
<source>Work period</source>
|
||||||
<translation>Период работы</translation>
|
<translation type="vanished">Период работы</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml" line="213"/>
|
|
||||||
<source>Speed</source>
|
<source>Speed</source>
|
||||||
<translation>Скорость</translation>
|
<translation type="vanished">Скорость</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml" line="222"/>
|
|
||||||
<source>Features</source>
|
<source>Features</source>
|
||||||
<translation>Особенности</translation>
|
<translation type="vanished">Особенности</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml" line="125"/>
|
|
||||||
<source>Connect</source>
|
<source>Connect</source>
|
||||||
<translation>Подключиться</translation>
|
<translation type="vanished">Подключиться</translation>
|
||||||
</message>
|
</message>
|
||||||
</context>
|
</context>
|
||||||
<context>
|
<context>
|
||||||
@@ -3170,11 +3256,50 @@ Thank you for staying with us!</source>
|
|||||||
<source>Choose a VPN service that suits your needs.</source>
|
<source>Choose a VPN service that suits your needs.</source>
|
||||||
<translation>Выберите VPN-сервис, который подходит именно вам.</translation>
|
<translation>Выберите VPN-сервис, который подходит именно вам.</translation>
|
||||||
</message>
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/qml/Pages2/PageSetupWizardApiServicesList.qml" line="88"/>
|
||||||
|
<source>Recommended</source>
|
||||||
|
<translation>Рекомендуется</translation>
|
||||||
|
</message>
|
||||||
|
</context>
|
||||||
|
<context>
|
||||||
|
<name>PageSetupWizardApiTrialEmail</name>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml" line="65"/>
|
||||||
|
<source>Create an account</source>
|
||||||
|
<translation>Создайте учётную запись</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml" line="66"/>
|
||||||
|
<source>To manage your subscription</source>
|
||||||
|
<translation>Для управления подпиской</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml" line="77"/>
|
||||||
|
<location filename="../ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml" line="78"/>
|
||||||
|
<source>Email</source>
|
||||||
|
<translation>Электронная почта</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml" line="102"/>
|
||||||
|
<source>We will create an account for your trial subscription and send important subscription updates to this email address</source>
|
||||||
|
<translation>Мы создадим учётную запись для вашей пробной подписки и будем отправлять на этот адрес электронной почты важные уведомления о подписке</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml" line="118"/>
|
||||||
|
<source>Continue</source>
|
||||||
|
<translation>Продолжить</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml" line="126"/>
|
||||||
|
<source>Enter a valid email address</source>
|
||||||
|
<translation>Введите корректный адрес электронной почты</translation>
|
||||||
|
</message>
|
||||||
</context>
|
</context>
|
||||||
<context>
|
<context>
|
||||||
<name>PageSetupWizardConfigSource</name>
|
<name>PageSetupWizardConfigSource</name>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="324"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="331"/>
|
||||||
<source>File with connection settings</source>
|
<source>File with connection settings</source>
|
||||||
<translation>Файл с настройками подключения</translation>
|
<translation>Файл с настройками подключения</translation>
|
||||||
</message>
|
</message>
|
||||||
@@ -3249,71 +3374,80 @@ Thank you for staying with us!</source>
|
|||||||
<translation>Другие варианты подключения</translation>
|
<translation>Другие варианты подключения</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="253"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="226"/>
|
||||||
|
<source>Recommended</source>
|
||||||
|
<translation>Рекомендуется</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="256"/>
|
||||||
<source>Site Amnezia</source>
|
<source>Site Amnezia</source>
|
||||||
<translation>Сайт Amnezia</translation>
|
<translation>Сайт Amnezia</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="358"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="281"/>
|
||||||
|
<source>The easiest way to connect to the VPN</source>
|
||||||
|
<translation>Самый простой способ подключиться к VPN</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="367"/>
|
||||||
<source>Restore purchases</source>
|
<source>Restore purchases</source>
|
||||||
<translation>Восстановить покупки</translation>
|
<translation>Восстановить покупки</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="277"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="280"/>
|
||||||
<source>VPN by Amnezia</source>
|
<source>VPN by Amnezia</source>
|
||||||
<translation>VPN от Amnezia</translation>
|
<translation>VPN от Amnezia</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="278"/>
|
|
||||||
<source>Connect to classic paid and free VPN services from Amnezia</source>
|
<source>Connect to classic paid and free VPN services from Amnezia</source>
|
||||||
<translation>Подключайтесь к классическим платным и бесплатным VPN-сервисам от Amnezia</translation>
|
<translation type="vanished">Подключайтесь к классическим платным и бесплатным VPN-сервисам от Amnezia</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="294"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="299"/>
|
||||||
<source>Self-hosted VPN</source>
|
<source>Self-hosted VPN</source>
|
||||||
<translation>Self-hosted VPN</translation>
|
<translation>Self-hosted VPN</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="295"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="300"/>
|
||||||
<source>Configure Amnezia VPN on your own server</source>
|
<source>Configure Amnezia VPN on your own server</source>
|
||||||
<translation>Настроить VPN на собственном сервере</translation>
|
<translation>Настроить VPN на собственном сервере</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="306"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="312"/>
|
||||||
<source>Restore from backup</source>
|
<source>Restore from backup</source>
|
||||||
<translation>Восстановить из резервной копии</translation>
|
<translation>Восстановить из резервной копии</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="307"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="313"/>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="325"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="332"/>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="344"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="352"/>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="359"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="368"/>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="373"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="383"/>
|
||||||
<source></source>
|
<source></source>
|
||||||
<translation></translation>
|
<translation></translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="311"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="317"/>
|
||||||
<source>Open backup file</source>
|
<source>Open backup file</source>
|
||||||
<translation>Открыть резервную копию</translation>
|
<translation>Открыть резервную копию</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="312"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="318"/>
|
||||||
<source>Backup files (*.backup)</source>
|
<source>Backup files (*.backup)</source>
|
||||||
<translation>Файлы резервных копий (*.backup)</translation>
|
<translation>Файлы резервных копий (*.backup)</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="331"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="338"/>
|
||||||
<source>Open config file</source>
|
<source>Open config file</source>
|
||||||
<translation>Открыть файл с конфигурацией</translation>
|
<translation>Открыть файл с конфигурацией</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="343"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="351"/>
|
||||||
<source>QR code</source>
|
<source>QR code</source>
|
||||||
<translation>QR-код</translation>
|
<translation>QR-код</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="372"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="382"/>
|
||||||
<source>I have nothing</source>
|
<source>I have nothing</source>
|
||||||
<translation>У меня ничего нет</translation>
|
<translation>У меня ничего нет</translation>
|
||||||
</message>
|
</message>
|
||||||
@@ -3321,17 +3455,17 @@ Thank you for staying with us!</source>
|
|||||||
<context>
|
<context>
|
||||||
<name>PageSetupWizardCredentials</name>
|
<name>PageSetupWizardCredentials</name>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="194"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="206"/>
|
||||||
<source>Server IP address [:port]</source>
|
<source>Server IP address [:port]</source>
|
||||||
<translation>IP-адрес[:порт] сервера</translation>
|
<translation>IP-адрес[:порт] сервера</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="100"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="112"/>
|
||||||
<source>Continue</source>
|
<source>Continue</source>
|
||||||
<translation>Продолжить</translation>
|
<translation>Продолжить</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="167"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="179"/>
|
||||||
<source>Enter the address in the format 255.255.255.255:88</source>
|
<source>Enter the address in the format 255.255.255.255:88</source>
|
||||||
<translation>Введите адрес в формате 255.255.255.255:88</translation>
|
<translation>Введите адрес в формате 255.255.255.255:88</translation>
|
||||||
</message>
|
</message>
|
||||||
@@ -3341,48 +3475,54 @@ Thank you for staying with us!</source>
|
|||||||
<translation>Настроить ваш сервер</translation>
|
<translation>Настроить ваш сервер</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="195"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="207"/>
|
||||||
<source>255.255.255.255:22</source>
|
<source>255.255.255.255:22</source>
|
||||||
<translation>255.255.255.255:22</translation>
|
<translation>255.255.255.255:22</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="203"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="215"/>
|
||||||
<source>SSH Username</source>
|
<source>SSH Username</source>
|
||||||
<translation>Имя пользователя SSH</translation>
|
<translation>Имя пользователя SSH</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="82"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="82"/>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="212"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="94"/>
|
||||||
|
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="224"/>
|
||||||
<source>Password or SSH private key</source>
|
<source>Password or SSH private key</source>
|
||||||
<translation>Пароль или закрытый ключ SSH</translation>
|
<translation>Пароль или закрытый ключ SSH</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="132"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="97"/>
|
||||||
|
<source>SSH key requirements: supported key types are ED25519 and RSA in PEM format. Paste the private key, including the BEGIN/END lines. If your key doesn’t work, generate a compatible one</source>
|
||||||
|
<translation>Требования к SSH-ключу: поддерживаются ключи ED25519 и RSA в формате PEM. Вставьте закрытый ключ целиком, включая строки BEGIN/END. Если ваш ключ не подходит, создайте совместимый ключ</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="144"/>
|
||||||
<source>All data you enter will remain strictly confidential and will not be shared or disclosed to the Amnezia or any third parties</source>
|
<source>All data you enter will remain strictly confidential and will not be shared or disclosed to the Amnezia or any third parties</source>
|
||||||
<translation>Все данные, которые вы вводите, останутся строго конфиденциальными и не будут переданы или раскрыты Amnezia или каким-либо третьим лицам</translation>
|
<translation>Все данные, которые вы вводите, останутся строго конфиденциальными и не будут переданы или раскрыты Amnezia или каким-либо третьим лицам</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="143"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="155"/>
|
||||||
<source>How to run your VPN server</source>
|
<source>How to run your VPN server</source>
|
||||||
<translation>Как создать VPN на собственном сервере</translation>
|
<translation>Как создать VPN на собственном сервере</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="144"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="156"/>
|
||||||
<source>Where to get connection data, step-by-step instructions for buying a VPS</source>
|
<source>Where to get connection data, step-by-step instructions for buying a VPS</source>
|
||||||
<translation>Где взять данные для подключения, пошаговые инструкции по покупке VPS</translation>
|
<translation>Где взять данные для подключения, пошаговые инструкции по покупке VPS</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="164"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="176"/>
|
||||||
<source>Ip address cannot be empty</source>
|
<source>Ip address cannot be empty</source>
|
||||||
<translation>Поле с IP-адресом не может быть пустым</translation>
|
<translation>Поле с IP-адресом не может быть пустым</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="172"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="184"/>
|
||||||
<source>Login cannot be empty</source>
|
<source>Login cannot be empty</source>
|
||||||
<translation>Поле с логином не может быть пустым</translation>
|
<translation>Поле с логином не может быть пустым</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="178"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="190"/>
|
||||||
<source>Password/private key cannot be empty</source>
|
<source>Password/private key cannot be empty</source>
|
||||||
<translation>Поле с паролем/закрытым ключом не может быть пустым</translation>
|
<translation>Поле с паролем/закрытым ключом не может быть пустым</translation>
|
||||||
</message>
|
</message>
|
||||||
@@ -3516,7 +3656,7 @@ Thank you for staying with us!</source>
|
|||||||
<context>
|
<context>
|
||||||
<name>PageSetupWizardStart</name>
|
<name>PageSetupWizardStart</name>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Pages2/PageSetupWizardStart.qml" line="41"/>
|
<location filename="../ui/qml/Pages2/PageSetupWizardStart.qml" line="42"/>
|
||||||
<source>Let's get started</source>
|
<source>Let's get started</source>
|
||||||
<translation>Приступим</translation>
|
<translation>Приступим</translation>
|
||||||
</message>
|
</message>
|
||||||
@@ -4326,7 +4466,22 @@ Thank you for staying with us!</source>
|
|||||||
<translation>Не удалось обработать покупку</translation>
|
<translation>Не удалось обработать покупку</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../core/errorstrings.cpp" line="97"/>
|
<location filename="../core/errorstrings.cpp" line="83"/>
|
||||||
|
<source>No active subscription found</source>
|
||||||
|
<translation>Активная подписка не найдена</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../core/errorstrings.cpp" line="84"/>
|
||||||
|
<source>No purchased subscriptions found. Please purchase a subscription first</source>
|
||||||
|
<translation>Платные подписки не найдены. Сначала оформите подписку</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../core/errorstrings.cpp" line="85"/>
|
||||||
|
<source>This email address has already been used to activate a trial</source>
|
||||||
|
<translation>Этот адрес электронной почты уже использовался для активации пробного периода</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../core/errorstrings.cpp" line="100"/>
|
||||||
<source>ErrorCode: %1. </source>
|
<source>ErrorCode: %1. </source>
|
||||||
<translation>Код ошибки: %1. </translation>
|
<translation>Код ошибки: %1. </translation>
|
||||||
</message>
|
</message>
|
||||||
@@ -4426,37 +4581,37 @@ Thank you for staying with us!</source>
|
|||||||
<translation>Превышен лимит разрешенных конфигураций для одной подписки</translation>
|
<translation>Превышен лимит разрешенных конфигураций для одной подписки</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../core/errorstrings.cpp" line="85"/>
|
<location filename="../core/errorstrings.cpp" line="88"/>
|
||||||
<source>QFile error: The file could not be opened</source>
|
<source>QFile error: The file could not be opened</source>
|
||||||
<translation>Ошибка QFile: не удалось открыть файл</translation>
|
<translation>Ошибка QFile: не удалось открыть файл</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../core/errorstrings.cpp" line="86"/>
|
<location filename="../core/errorstrings.cpp" line="89"/>
|
||||||
<source>QFile error: An error occurred when reading from the file</source>
|
<source>QFile error: An error occurred when reading from the file</source>
|
||||||
<translation>Ошибка QFile: произошла ошибка при чтении из файла</translation>
|
<translation>Ошибка QFile: произошла ошибка при чтении из файла</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../core/errorstrings.cpp" line="87"/>
|
<location filename="../core/errorstrings.cpp" line="90"/>
|
||||||
<source>QFile error: The file could not be accessed</source>
|
<source>QFile error: The file could not be accessed</source>
|
||||||
<translation>Ошибка QFile: не удалось получить доступ к файлу</translation>
|
<translation>Ошибка QFile: не удалось получить доступ к файлу</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../core/errorstrings.cpp" line="88"/>
|
<location filename="../core/errorstrings.cpp" line="91"/>
|
||||||
<source>QFile error: An unspecified error occurred</source>
|
<source>QFile error: An unspecified error occurred</source>
|
||||||
<translation>Ошибка QFile: произошла неизвестная ошибка</translation>
|
<translation>Ошибка QFile: произошла неизвестная ошибка</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../core/errorstrings.cpp" line="89"/>
|
<location filename="../core/errorstrings.cpp" line="92"/>
|
||||||
<source>QFile error: A fatal error occurred</source>
|
<source>QFile error: A fatal error occurred</source>
|
||||||
<translation>Ошибка QFile: произошла фатальная ошибка</translation>
|
<translation>Ошибка QFile: произошла фатальная ошибка</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../core/errorstrings.cpp" line="90"/>
|
<location filename="../core/errorstrings.cpp" line="93"/>
|
||||||
<source>QFile error: The operation was aborted</source>
|
<source>QFile error: The operation was aborted</source>
|
||||||
<translation>Ошибка QFile: операция была прервана</translation>
|
<translation>Ошибка QFile: операция была прервана</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../core/errorstrings.cpp" line="94"/>
|
<location filename="../core/errorstrings.cpp" line="97"/>
|
||||||
<source>Internal error</source>
|
<source>Internal error</source>
|
||||||
<translation>Внутренняя ошибка</translation>
|
<translation>Внутренняя ошибка</translation>
|
||||||
</message>
|
</message>
|
||||||
@@ -4985,7 +5140,17 @@ FileZilla или другие SFTP-клиенты, а также смонтир
|
|||||||
<context>
|
<context>
|
||||||
<name>ServersListView</name>
|
<name>ServersListView</name>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/Components/ServersListView.qml" line="79"/>
|
<location filename="../ui/qml/Components/ServersListView.qml" line="71"/>
|
||||||
|
<source>Subscription expired. Please renew</source>
|
||||||
|
<translation>Подписка закончилась. Пожалуйста, продлите её</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/qml/Components/ServersListView.qml" line="71"/>
|
||||||
|
<source>Subscription expiring soon</source>
|
||||||
|
<translation>Подписка скоро закончится</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/qml/Components/ServersListView.qml" line="84"/>
|
||||||
<source>Unable change server while there is an active connection</source>
|
<source>Unable change server while there is an active connection</source>
|
||||||
<translation>Невозможно изменить сервер во время активного соединения</translation>
|
<translation>Невозможно изменить сервер во время активного соединения</translation>
|
||||||
</message>
|
</message>
|
||||||
@@ -5007,12 +5172,17 @@ FileZilla или другие SFTP-клиенты, а также смонтир
|
|||||||
<context>
|
<context>
|
||||||
<name>SettingsController</name>
|
<name>SettingsController</name>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/controllers/settingsController.cpp" line="270"/>
|
<location filename="../ui/controllers/settingsController.cpp" line="185"/>
|
||||||
|
<source>Can't open file: %1</source>
|
||||||
|
<translation>Невозможно открыть файл: %1</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/controllers/settingsController.cpp" line="271"/>
|
||||||
<source>All settings have been reset to default values</source>
|
<source>All settings have been reset to default values</source>
|
||||||
<translation>Все настройки сброшены до значений по умолчанию</translation>
|
<translation>Все настройки сброшены до значений по умолчанию</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/controllers/settingsController.cpp" line="247"/>
|
<location filename="../ui/controllers/settingsController.cpp" line="248"/>
|
||||||
<source>Backup file is corrupted</source>
|
<source>Backup file is corrupted</source>
|
||||||
<translation>Файл резервной копии поврежден</translation>
|
<translation>Файл резервной копии поврежден</translation>
|
||||||
</message>
|
</message>
|
||||||
@@ -5065,6 +5235,29 @@ FileZilla или другие SFTP-клиенты, а также смонтир
|
|||||||
<translation>Экспорт завершен</translation>
|
<translation>Экспорт завершен</translation>
|
||||||
</message>
|
</message>
|
||||||
</context>
|
</context>
|
||||||
|
<context>
|
||||||
|
<name>SubscriptionExpiredDrawer</name>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/qml/Components/SubscriptionExpiredDrawer.qml" line="47"/>
|
||||||
|
<source>Amnezia Premium subscription has expired</source>
|
||||||
|
<translation>Подписка Amnezia Premium закончилась</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/qml/Components/SubscriptionExpiredDrawer.qml" line="60"/>
|
||||||
|
<source>Renew to continue using VPN</source>
|
||||||
|
<translation>Продлите подписку, чтобы продолжить использовать VPN</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/qml/Components/SubscriptionExpiredDrawer.qml" line="72"/>
|
||||||
|
<source>Renew</source>
|
||||||
|
<translation>Продлить</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/qml/Components/SubscriptionExpiredDrawer.qml" line="96"/>
|
||||||
|
<source>Support</source>
|
||||||
|
<translation>Поддержка</translation>
|
||||||
|
</message>
|
||||||
|
</context>
|
||||||
<context>
|
<context>
|
||||||
<name>SystemTrayNotificationHandler</name>
|
<name>SystemTrayNotificationHandler</name>
|
||||||
<message>
|
<message>
|
||||||
@@ -5098,6 +5291,14 @@ FileZilla или другие SFTP-клиенты, а также смонтир
|
|||||||
<translation>Закрыть</translation>
|
<translation>Закрыть</translation>
|
||||||
</message>
|
</message>
|
||||||
</context>
|
</context>
|
||||||
|
<context>
|
||||||
|
<name>TermsAndPrivacyText</name>
|
||||||
|
<message>
|
||||||
|
<location filename="../ui/qml/Components/TermsAndPrivacyText.qml" line="23"/>
|
||||||
|
<source>By continuing, you agree to the <a href="%1" style="color: %3;">Terms of Use</a> and <a href="%2" style="color: %3;">Privacy Policy</a></source>
|
||||||
|
<translation>Продолжая, вы соглашаетесь с <a href="%1" style="color: %3;">Условиями использования</a> и <a href="%2" style="color: %3;">Политикой конфиденциальности</a></translation>
|
||||||
|
</message>
|
||||||
|
</context>
|
||||||
<context>
|
<context>
|
||||||
<name>TextFieldWithHeaderType</name>
|
<name>TextFieldWithHeaderType</name>
|
||||||
<message>
|
<message>
|
||||||
@@ -5173,12 +5374,12 @@ FileZilla или другие SFTP-клиенты, а также смонтир
|
|||||||
<context>
|
<context>
|
||||||
<name>main2</name>
|
<name>main2</name>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/main2.qml" line="230"/>
|
<location filename="../ui/qml/main2.qml" line="247"/>
|
||||||
<source>Private key passphrase</source>
|
<source>Private key passphrase</source>
|
||||||
<translation>Парольная фраза для закрытого ключа</translation>
|
<translation>Парольная фраза для закрытого ключа</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../ui/qml/main2.qml" line="251"/>
|
<location filename="../ui/qml/main2.qml" line="268"/>
|
||||||
<source>Save</source>
|
<source>Save</source>
|
||||||
<translation>Сохранить</translation>
|
<translation>Сохранить</translation>
|
||||||
</message>
|
</message>
|
||||||
|
|||||||
@@ -9,9 +9,14 @@
|
|||||||
#include "ui/controllers/systemController.h"
|
#include "ui/controllers/systemController.h"
|
||||||
#include "version.h"
|
#include "version.h"
|
||||||
#include <QClipboard>
|
#include <QClipboard>
|
||||||
|
#include <QCoreApplication>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QEventLoop>
|
#include <QEventLoop>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QJsonArray>
|
||||||
#include <QSet>
|
#include <QSet>
|
||||||
|
#include <QVariantMap>
|
||||||
|
#include <limits>
|
||||||
|
|
||||||
#include "platforms/ios/ios_controller.h"
|
#include "platforms/ios/ios_controller.h"
|
||||||
|
|
||||||
@@ -39,6 +44,15 @@ namespace
|
|||||||
constexpr char serviceInfo[] = "service_info";
|
constexpr char serviceInfo[] = "service_info";
|
||||||
constexpr char serviceProtocol[] = "service_protocol";
|
constexpr char serviceProtocol[] = "service_protocol";
|
||||||
|
|
||||||
|
constexpr char services[] = "services";
|
||||||
|
constexpr char serviceDescription[] = "service_description";
|
||||||
|
constexpr char subscriptionPlans[] = "subscription_plans";
|
||||||
|
constexpr char storeProductId[] = "store_product_id";
|
||||||
|
constexpr char priceLabel[] = "price_label";
|
||||||
|
constexpr char subtitle[] = "subtitle";
|
||||||
|
constexpr char isTrial[] = "is_trial";
|
||||||
|
constexpr char minPriceLabel[] = "min_price_label";
|
||||||
|
|
||||||
constexpr char apiPayload[] = "api_payload";
|
constexpr char apiPayload[] = "api_payload";
|
||||||
constexpr char keyPayload[] = "key_payload";
|
constexpr char keyPayload[] = "key_payload";
|
||||||
|
|
||||||
@@ -47,9 +61,6 @@ namespace
|
|||||||
|
|
||||||
constexpr char config[] = "config";
|
constexpr char config[] = "config";
|
||||||
|
|
||||||
constexpr char subscription[] = "subscription";
|
|
||||||
constexpr char endDate[] = "end_date";
|
|
||||||
|
|
||||||
constexpr char isConnectEvent[] = "is_connect_event";
|
constexpr char isConnectEvent[] = "is_connect_event";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,13 +252,190 @@ namespace
|
|||||||
|
|
||||||
return ErrorCode::NoError;
|
return ErrorCode::NoError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
||||||
|
struct StoreKitPlanQuote {
|
||||||
|
QString displayPrice;
|
||||||
|
double priceAmount = 0.0;
|
||||||
|
double subscriptionBillingMonths = 0.0;
|
||||||
|
QString displayPricePerMonth;
|
||||||
|
};
|
||||||
|
|
||||||
|
constexpr double kOneMonthThreshold = 1.0 + 1e-6;
|
||||||
|
constexpr double kMonthsFallbackThreshold = 1e-6;
|
||||||
|
constexpr double kMonthlyPriceEpsilon = 1e-9;
|
||||||
|
|
||||||
|
QStringList collectPremiumStoreProductIds(const QJsonArray &services)
|
||||||
|
{
|
||||||
|
QStringList productIds;
|
||||||
|
QSet<QString> seenProductIds;
|
||||||
|
for (const QJsonValue &serviceValue : services) {
|
||||||
|
const QJsonObject serviceObject = serviceValue.toObject();
|
||||||
|
if (serviceObject.value(configKey::serviceType).toString() != serviceType::amneziaPremium) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const QJsonArray subscriptionPlans =
|
||||||
|
serviceObject.value(configKey::serviceDescription).toObject().value(configKey::subscriptionPlans).toArray();
|
||||||
|
for (const QJsonValue &planValue : subscriptionPlans) {
|
||||||
|
if (!planValue.isObject()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const QString storeProductId = planValue.toObject().value(configKey::storeProductId).toString();
|
||||||
|
if (storeProductId.isEmpty() || seenProductIds.contains(storeProductId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenProductIds.insert(storeProductId);
|
||||||
|
productIds.append(storeProductId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return productIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
QHash<QString, StoreKitPlanQuote> buildStoreKitQuoteMap(const QList<QVariantMap> &fetchedProducts)
|
||||||
|
{
|
||||||
|
QHash<QString, StoreKitPlanQuote> quotesByProductId;
|
||||||
|
quotesByProductId.reserve(fetchedProducts.size());
|
||||||
|
|
||||||
|
for (const QVariantMap &productInfo : fetchedProducts) {
|
||||||
|
const QString productId = productInfo.value(QStringLiteral("productId")).toString();
|
||||||
|
if (productId.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString displayPrice = productInfo.value(QStringLiteral("displayPrice")).toString();
|
||||||
|
if (displayPrice.isEmpty()) {
|
||||||
|
const QString price = productInfo.value(QStringLiteral("price")).toString();
|
||||||
|
const QString currencyCode = productInfo.value(QStringLiteral("currencyCode")).toString();
|
||||||
|
displayPrice = currencyCode.isEmpty() ? price : (price + QLatin1Char(' ') + currencyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreKitPlanQuote quote;
|
||||||
|
quote.displayPrice = displayPrice;
|
||||||
|
quote.priceAmount = productInfo.value(QStringLiteral("priceAmount")).toDouble();
|
||||||
|
quote.subscriptionBillingMonths = productInfo.value(QStringLiteral("subscriptionBillingMonths")).toDouble();
|
||||||
|
quote.displayPricePerMonth = productInfo.value(QStringLiteral("displayPricePerMonth")).toString();
|
||||||
|
quotesByProductId.insert(productId, quote);
|
||||||
|
}
|
||||||
|
|
||||||
|
return quotesByProductId;
|
||||||
|
}
|
||||||
|
|
||||||
|
void mergeStoreKitPricesIntoPremiumPlans(QJsonObject &data)
|
||||||
|
{
|
||||||
|
QJsonArray services = data.value(configKey::services).toArray();
|
||||||
|
if (services.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QStringList productIds = collectPremiumStoreProductIds(services);
|
||||||
|
if (productIds.isEmpty()) {
|
||||||
|
qInfo().noquote() << "[IAP] No store_product_id in premium plans; skip StoreKit merge into services payload";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<QVariantMap> fetchedProducts;
|
||||||
|
QEventLoop loop;
|
||||||
|
IosController::Instance()->fetchProducts(productIds,
|
||||||
|
[&](const QList<QVariantMap> &products, const QStringList &invalidIds,
|
||||||
|
const QString &errorString) {
|
||||||
|
if (!errorString.isEmpty()) {
|
||||||
|
qWarning().noquote() << "[IAP] StoreKit merge fetch:" << errorString;
|
||||||
|
}
|
||||||
|
if (!invalidIds.isEmpty()) {
|
||||||
|
qWarning().noquote() << "[IAP] Unknown App Store product ids:" << invalidIds;
|
||||||
|
}
|
||||||
|
fetchedProducts = products;
|
||||||
|
loop.quit();
|
||||||
|
});
|
||||||
|
loop.exec();
|
||||||
|
|
||||||
|
const QHash<QString, StoreKitPlanQuote> quotesByProductId = buildStoreKitQuoteMap(fetchedProducts);
|
||||||
|
|
||||||
|
for (int serviceIndex = 0; serviceIndex < services.size(); ++serviceIndex) {
|
||||||
|
QJsonObject serviceObject = services.at(serviceIndex).toObject();
|
||||||
|
if (serviceObject.value(configKey::serviceType).toString() != serviceType::amneziaPremium) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject descriptionObject = serviceObject.value(configKey::serviceDescription).toObject();
|
||||||
|
const QJsonArray sourcePlans = descriptionObject.value(configKey::subscriptionPlans).toArray();
|
||||||
|
|
||||||
|
QJsonArray mergedPlans;
|
||||||
|
double minMonthlyAmount = std::numeric_limits<double>::infinity();
|
||||||
|
QString minMonthlyDisplay;
|
||||||
|
|
||||||
|
for (const QJsonValue &planValue : sourcePlans) {
|
||||||
|
if (!planValue.isObject()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject planObject = planValue.toObject();
|
||||||
|
const QString storeProductId = planObject.value(configKey::storeProductId).toString();
|
||||||
|
if (storeProductId.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto quoteIterator = quotesByProductId.constFind(storeProductId);
|
||||||
|
if (quoteIterator == quotesByProductId.cend()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool isTrialPlan = planObject.value(configKey::isTrial).toBool();
|
||||||
|
const StoreKitPlanQuote "e = *quoteIterator;
|
||||||
|
planObject.insert(configKey::priceLabel, quote.displayPrice);
|
||||||
|
|
||||||
|
const double months = quote.subscriptionBillingMonths;
|
||||||
|
if (!isTrialPlan && months > kOneMonthThreshold && !quote.displayPricePerMonth.isEmpty()) {
|
||||||
|
planObject.insert(
|
||||||
|
configKey::subtitle,
|
||||||
|
QCoreApplication::translate("ApiConfigsController", "%1/mo", "IAP: price per month in plan subtitle")
|
||||||
|
.arg(quote.displayPricePerMonth));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isTrialPlan && quote.priceAmount > 0.0) {
|
||||||
|
const double monthsForMin = months > kMonthsFallbackThreshold ? months : 1.0;
|
||||||
|
const double monthly = quote.priceAmount / monthsForMin;
|
||||||
|
if (monthly < minMonthlyAmount - kMonthlyPriceEpsilon) {
|
||||||
|
minMonthlyAmount = monthly;
|
||||||
|
minMonthlyDisplay = !quote.displayPricePerMonth.isEmpty() ? quote.displayPricePerMonth : quote.displayPrice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mergedPlans.append(planObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
descriptionObject.insert(configKey::subscriptionPlans, mergedPlans);
|
||||||
|
if (minMonthlyAmount < std::numeric_limits<double>::infinity() && !minMonthlyDisplay.isEmpty()) {
|
||||||
|
descriptionObject.insert(configKey::minPriceLabel,
|
||||||
|
QCoreApplication::translate("ApiConfigsController", "from %1 per month",
|
||||||
|
"IAP: card footer minimum monthly price from StoreKit")
|
||||||
|
.arg(minMonthlyDisplay));
|
||||||
|
}
|
||||||
|
serviceObject.insert(configKey::serviceDescription, descriptionObject);
|
||||||
|
services.replace(serviceIndex, serviceObject);
|
||||||
|
}
|
||||||
|
data.insert(configKey::services, services);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
ApiConfigsController::ApiConfigsController(const QSharedPointer<ServersModel> &serversModel,
|
ApiConfigsController::ApiConfigsController(const QSharedPointer<ServersModel> &serversModel,
|
||||||
const QSharedPointer<ApiServicesModel> &apiServicesModel,
|
const QSharedPointer<ApiServicesModel> &apiServicesModel,
|
||||||
|
const QSharedPointer<ApiSubscriptionPlansModel> &subscriptionPlansModel,
|
||||||
|
const QSharedPointer<ApiBenefitsModel> &benefitsModel,
|
||||||
const std::shared_ptr<Settings> &settings, QObject *parent)
|
const std::shared_ptr<Settings> &settings, QObject *parent)
|
||||||
: QObject(parent), m_serversModel(serversModel), m_apiServicesModel(apiServicesModel), m_settings(settings)
|
: QObject(parent)
|
||||||
|
, m_serversModel(serversModel)
|
||||||
|
, m_apiServicesModel(apiServicesModel)
|
||||||
|
, m_subscriptionPlansModel(subscriptionPlansModel)
|
||||||
|
, m_benefitsModel(benefitsModel)
|
||||||
|
, m_settings(settings)
|
||||||
{
|
{
|
||||||
|
connect(m_apiServicesModel.data(), &ApiServicesModel::serviceSelectionChanged, this, [this]() {
|
||||||
|
const ApiServicesModel::ApiServicesData serviceData = m_apiServicesModel->selectedServiceData();
|
||||||
|
m_subscriptionPlansModel->updateModel(serviceData.subscriptionPlansJson);
|
||||||
|
m_benefitsModel->updateModel(serviceData.benefits);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ApiConfigsController::exportVpnKey(const QString &fileName)
|
bool ApiConfigsController::exportVpnKey(const QString &fileName)
|
||||||
@@ -366,6 +554,8 @@ bool ApiConfigsController::fillAvailableServices()
|
|||||||
{
|
{
|
||||||
QJsonObject apiPayload;
|
QJsonObject apiPayload;
|
||||||
apiPayload[configKey::osVersion] = QSysInfo::productType();
|
apiPayload[configKey::osVersion] = QSysInfo::productType();
|
||||||
|
apiPayload[configKey::appVersion] = QString(APP_VERSION);
|
||||||
|
apiPayload[apiDefs::key::cliName] = QString(APPLICATION_NAME);
|
||||||
apiPayload[apiDefs::key::appLanguage] = m_settings->getAppLanguage().name().split("_").first();
|
apiPayload[apiDefs::key::appLanguage] = m_settings->getAppLanguage().name().split("_").first();
|
||||||
|
|
||||||
QByteArray responseBody;
|
QByteArray responseBody;
|
||||||
@@ -382,51 +572,11 @@ bool ApiConfigsController::fillAvailableServices()
|
|||||||
}
|
}
|
||||||
|
|
||||||
QJsonObject data = QJsonDocument::fromJson(responseBody).object();
|
QJsonObject data = QJsonDocument::fromJson(responseBody).object();
|
||||||
|
|
||||||
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
||||||
QEventLoop waitProducts;
|
mergeStoreKitPricesIntoPremiumPlans(data);
|
||||||
bool productsFetched = false;
|
|
||||||
QString productPrice;
|
|
||||||
QString productCurrency;
|
|
||||||
|
|
||||||
IosController::Instance()->fetchProducts(QStringList() << QStringLiteral("amnezia_premium_6_month"),
|
|
||||||
[&](const QList<QVariantMap> &products,
|
|
||||||
const QStringList &invalidIds,
|
|
||||||
const QString &errorString) {
|
|
||||||
if (!errorString.isEmpty() || products.isEmpty()) {
|
|
||||||
qWarning().noquote() << "[IAP] Failed to fetch product price:" << errorString;
|
|
||||||
} else {
|
|
||||||
const auto &product = products.first();
|
|
||||||
productPrice = product.value("price").toString();
|
|
||||||
productCurrency = product.value("currencyCode").toString();
|
|
||||||
productsFetched = true;
|
|
||||||
qInfo().noquote() << "[IAP] Fetched product price:" << productPrice << productCurrency;
|
|
||||||
}
|
|
||||||
waitProducts.quit();
|
|
||||||
});
|
|
||||||
waitProducts.exec();
|
|
||||||
|
|
||||||
if (productsFetched && !productPrice.isEmpty()) {
|
|
||||||
QJsonArray services = data.value("services").toArray();
|
|
||||||
for (int i = 0; i < services.size(); ++i) {
|
|
||||||
QJsonObject service = services[i].toObject();
|
|
||||||
if (service.value(configKey::serviceType).toString() == serviceType::amneziaPremium) {
|
|
||||||
QJsonObject serviceInfo = service.value(configKey::serviceInfo).toObject();
|
|
||||||
QString formattedPrice = productPrice;
|
|
||||||
if (!productCurrency.isEmpty()) {
|
|
||||||
formattedPrice += " " + productCurrency;
|
|
||||||
}
|
|
||||||
serviceInfo["price"] = formattedPrice;
|
|
||||||
service[configKey::serviceInfo] = serviceInfo;
|
|
||||||
services[i] = service;
|
|
||||||
data["services"] = services;
|
|
||||||
qInfo().noquote() << "[IAP] Updated premium service price in data:" << formattedPrice;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
m_apiServicesModel->updateModel(data);
|
m_apiServicesModel->updateModel(data);
|
||||||
if (m_apiServicesModel->rowCount() > 0) {
|
if (m_apiServicesModel->rowCount() > 0) {
|
||||||
m_apiServicesModel->setServiceIndex(0);
|
m_apiServicesModel->setServiceIndex(0);
|
||||||
@@ -437,39 +587,42 @@ bool ApiConfigsController::fillAvailableServices()
|
|||||||
bool ApiConfigsController::importService()
|
bool ApiConfigsController::importService()
|
||||||
{
|
{
|
||||||
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
||||||
bool isIosOrMacOsNe = true;
|
const bool isIosOrMacOsNe = true;
|
||||||
#else
|
#else
|
||||||
bool isIosOrMacOsNe = false;
|
const bool isIosOrMacOsNe = false;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
if (m_apiServicesModel->getSelectedServiceType() == serviceType::amneziaPremium) {
|
if (m_apiServicesModel->getSelectedServiceType() == serviceType::amneziaPremium) {
|
||||||
if (isIosOrMacOsNe) {
|
if (isIosOrMacOsNe) {
|
||||||
importSerivceFromAppStore();
|
return importPremiumFromAppStore(QString());
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else if (m_apiServicesModel->getSelectedServiceType() == serviceType::amneziaFree) {
|
||||||
importServiceFromGateway();
|
return importFreeFromGateway();
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ApiConfigsController::importSerivceFromAppStore()
|
bool ApiConfigsController::importPremiumFromAppStore(const QString &storeProductId)
|
||||||
{
|
{
|
||||||
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
||||||
|
QString productId = storeProductId.trimmed();
|
||||||
|
if (productId.isEmpty()) {
|
||||||
|
productId = QStringLiteral("amnezia_premium_6_month");
|
||||||
|
}
|
||||||
|
|
||||||
bool purchaseOk = false;
|
bool purchaseOk = false;
|
||||||
QString originalTransactionId;
|
QString originalTransactionId;
|
||||||
QString storeTransactionId;
|
QString storeTransactionId;
|
||||||
QString storeProductId;
|
QString purchasedStoreProductId;
|
||||||
QString purchaseError;
|
QString purchaseError;
|
||||||
QEventLoop waitPurchase;
|
QEventLoop waitPurchase;
|
||||||
IosController::Instance()->purchaseProduct(QStringLiteral("amnezia_premium_6_month"),
|
IosController::Instance()->purchaseProduct(productId,
|
||||||
[&](bool success, const QString &txId, const QString &purchasedProductId,
|
[&](bool success, const QString &transactionId, const QString &purchasedProductId,
|
||||||
const QString &originalTxId, const QString &errorString) {
|
const QString &originalTransactionIdResponse, const QString &errorString) {
|
||||||
purchaseOk = success;
|
purchaseOk = success;
|
||||||
originalTransactionId = originalTxId;
|
originalTransactionId = originalTransactionIdResponse;
|
||||||
storeTransactionId = txId;
|
storeTransactionId = transactionId;
|
||||||
storeProductId = purchasedProductId;
|
purchasedStoreProductId = purchasedProductId;
|
||||||
purchaseError = errorString;
|
purchaseError = errorString;
|
||||||
waitPurchase.quit();
|
waitPurchase.quit();
|
||||||
});
|
});
|
||||||
@@ -481,7 +634,7 @@ bool ApiConfigsController::importSerivceFromAppStore()
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
qInfo().noquote() << "[IAP] Purchase success. transactionId =" << storeTransactionId
|
qInfo().noquote() << "[IAP] Purchase success. transactionId =" << storeTransactionId
|
||||||
<< "originalTransactionId =" << originalTransactionId << "productId =" << storeProductId;
|
<< "originalTransactionId =" << originalTransactionId << "productId =" << purchasedStoreProductId;
|
||||||
|
|
||||||
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
|
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
|
||||||
QString(APP_VERSION),
|
QString(APP_VERSION),
|
||||||
@@ -505,18 +658,26 @@ bool ApiConfigsController::importSerivceFromAppStore()
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
errorCode = importServiceFromBilling(responseBody, isTestPurchase);
|
int duplicateServerIndex = -1;
|
||||||
|
errorCode = importServiceFromBilling(responseBody, isTestPurchase, duplicateServerIndex);
|
||||||
|
if (errorCode == ErrorCode::ApiConfigAlreadyAdded) {
|
||||||
|
emit installServerFromApiFinished(tr("This subscription has already been added"), duplicateServerIndex);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (errorCode != ErrorCode::NoError) {
|
if (errorCode != ErrorCode::NoError) {
|
||||||
emit errorOccurred(errorCode);
|
emit errorOccurred(errorCode);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
emit installServerFromApiFinished(
|
||||||
emit installServerFromApiFinished(tr("%1 installed successfully.").arg(m_apiServicesModel->getSelectedServiceName()));
|
tr("%1 has been added to the app").arg(m_apiServicesModel->getSelectedServiceName()));
|
||||||
#endif
|
|
||||||
return true;
|
return true;
|
||||||
|
#else
|
||||||
|
Q_UNUSED(storeProductId);
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ApiConfigsController::restoreSerivceFromAppStore()
|
bool ApiConfigsController::restoreServiceFromAppStore()
|
||||||
{
|
{
|
||||||
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
||||||
const QString premiumServiceType = QStringLiteral("amnezia-premium");
|
const QString premiumServiceType = QStringLiteral("amnezia-premium");
|
||||||
@@ -532,20 +693,12 @@ bool ApiConfigsController::restoreSerivceFromAppStore()
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure we have a valid premium selection for gateway requests
|
const int premiumServiceIndex = m_apiServicesModel->serviceIndexForType(premiumServiceType);
|
||||||
bool premiumSelected = false;
|
if (premiumServiceIndex < 0) {
|
||||||
for (int i = 0; i < m_apiServicesModel->rowCount(); ++i) {
|
|
||||||
m_apiServicesModel->setServiceIndex(i);
|
|
||||||
if (m_apiServicesModel->getSelectedServiceType() == premiumServiceType) {
|
|
||||||
premiumSelected = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!premiumSelected) {
|
|
||||||
emit errorOccurred(ErrorCode::ApiServicesMissingError);
|
emit errorOccurred(ErrorCode::ApiServicesMissingError);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
m_apiServicesModel->setServiceIndex(premiumServiceIndex);
|
||||||
|
|
||||||
bool restoreSuccess = false;
|
bool restoreSuccess = false;
|
||||||
QList<QVariantMap> restoredTransactions;
|
QList<QVariantMap> restoredTransactions;
|
||||||
@@ -567,15 +720,23 @@ bool ApiConfigsController::restoreSerivceFromAppStore()
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (restoredTransactions.isEmpty()) {
|
if (restoredTransactions.isEmpty()) {
|
||||||
qInfo().noquote() << "[IAP] Restore completed, but no transactions were returned";
|
qInfo().noquote() << "[IAP] Restore completed, but no active entitlements found";
|
||||||
emit errorOccurred(ErrorCode::ApiPurchaseError);
|
emit errorOccurred(ErrorCode::ApiNoPurchasedSubscriptionsError);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bool isTestPurchase = IosController::Instance()->isTestFlight();
|
||||||
|
const QString serviceType = m_apiServicesModel->getSelectedServiceType();
|
||||||
|
const QString serviceProtocol = m_apiServicesModel->getSelectedServiceProtocol();
|
||||||
|
const QString countryCode = m_apiServicesModel->getCountryCode();
|
||||||
|
const QString appLanguage = m_settings->getAppLanguage().name().split("_").first();
|
||||||
|
const QString installationUuid = m_settings->getInstallationUuid(true);
|
||||||
|
|
||||||
bool hasInstalledConfig = false;
|
bool hasInstalledConfig = false;
|
||||||
bool duplicateConfigAlreadyPresent = false;
|
bool duplicateConfigAlreadyPresent = false;
|
||||||
int duplicateCount = 0;
|
int duplicateServerIndex = -1;
|
||||||
QSet<QString> processedTransactions;
|
QSet<QString> processedOriginalTransactionIds;
|
||||||
|
|
||||||
for (const QVariantMap &transaction : restoredTransactions) {
|
for (const QVariantMap &transaction : restoredTransactions) {
|
||||||
const QString originalTransactionId = transaction.value(QStringLiteral("originalTransactionId")).toString();
|
const QString originalTransactionId = transaction.value(QStringLiteral("originalTransactionId")).toString();
|
||||||
const QString transactionId = transaction.value(QStringLiteral("transactionId")).toString();
|
const QString transactionId = transaction.value(QStringLiteral("transactionId")).toString();
|
||||||
@@ -586,28 +747,28 @@ bool ApiConfigsController::restoreSerivceFromAppStore()
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (processedTransactions.contains(originalTransactionId)) {
|
if (processedOriginalTransactionIds.contains(originalTransactionId)) {
|
||||||
duplicateCount++;
|
qInfo().noquote() << "[IAP] Skipping duplicate restored transaction" << originalTransactionId;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
processedTransactions.insert(originalTransactionId);
|
processedOriginalTransactionIds.insert(originalTransactionId);
|
||||||
|
|
||||||
qInfo().noquote() << "[IAP] Restoring subscription. transactionId =" << transactionId
|
qInfo().noquote() << "[IAP] Restoring subscription. transactionId =" << transactionId
|
||||||
<< "originalTransactionId =" << originalTransactionId << "productId =" << productId;
|
<< "originalTransactionId =" << originalTransactionId << "productId =" << productId;
|
||||||
|
|
||||||
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
|
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
|
||||||
QString(APP_VERSION),
|
QString(APP_VERSION),
|
||||||
m_settings->getAppLanguage().name().split("_").first(),
|
appLanguage,
|
||||||
m_settings->getInstallationUuid(true),
|
installationUuid,
|
||||||
m_apiServicesModel->getCountryCode(),
|
countryCode,
|
||||||
"",
|
"",
|
||||||
m_apiServicesModel->getSelectedServiceType(),
|
serviceType,
|
||||||
m_apiServicesModel->getSelectedServiceProtocol(),
|
serviceProtocol,
|
||||||
QJsonObject() };
|
QJsonObject() };
|
||||||
|
|
||||||
QJsonObject apiPayload = gatewayRequestData.toJsonObject();
|
QJsonObject apiPayload = gatewayRequestData.toJsonObject();
|
||||||
apiPayload[apiDefs::key::transactionId] = originalTransactionId;
|
apiPayload[apiDefs::key::transactionId] = originalTransactionId;
|
||||||
auto isTestPurchase = IosController::Instance()->isTestFlight();
|
|
||||||
QByteArray responseBody;
|
QByteArray responseBody;
|
||||||
ErrorCode errorCode = executeRequest(QString("%1v1/subscriptions"), apiPayload, responseBody, isTestPurchase);
|
ErrorCode errorCode = executeRequest(QString("%1v1/subscriptions"), apiPayload, responseBody, isTestPurchase);
|
||||||
if (errorCode != ErrorCode::NoError) {
|
if (errorCode != ErrorCode::NoError) {
|
||||||
@@ -616,34 +777,42 @@ bool ApiConfigsController::restoreSerivceFromAppStore()
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
ErrorCode installError = importServiceFromBilling(responseBody, isTestPurchase);
|
int currentDuplicateServerIndex = -1;
|
||||||
|
errorCode = importServiceFromBilling(responseBody, isTestPurchase, currentDuplicateServerIndex);
|
||||||
if (errorCode == ErrorCode::ApiConfigAlreadyAdded) {
|
if (errorCode == ErrorCode::ApiConfigAlreadyAdded) {
|
||||||
duplicateConfigAlreadyPresent = true;
|
duplicateConfigAlreadyPresent = true;
|
||||||
qInfo().noquote() << "[IAP] Skipping restored transaction" << originalTransactionId
|
if (duplicateServerIndex < 0) {
|
||||||
<< "because subscription config with the same vpn_key already exists";
|
duplicateServerIndex = currentDuplicateServerIndex;
|
||||||
} else if (errorCode != ErrorCode::NoError) {
|
}
|
||||||
qWarning().noquote() << "[IAP] Failed to process restored subscription response for transaction" << originalTransactionId;
|
qInfo().noquote() << "[IAP] Subscription config with the same vpn_key already exists" << originalTransactionId;
|
||||||
} else {
|
continue;
|
||||||
hasInstalledConfig = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (errorCode != ErrorCode::NoError) {
|
||||||
|
qWarning().noquote() << "[IAP] Failed to process restored subscription response for transaction" << originalTransactionId
|
||||||
|
<< "errorCode =" << static_cast<int>(errorCode);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasInstalledConfig = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasInstalledConfig) {
|
if (!hasInstalledConfig) {
|
||||||
const ErrorCode restoreError = duplicateConfigAlreadyPresent ? ErrorCode::ApiConfigAlreadyAdded : ErrorCode::ApiPurchaseError;
|
if (duplicateConfigAlreadyPresent) {
|
||||||
emit errorOccurred(restoreError);
|
emit installServerFromApiFinished(tr("This subscription has already been added"), duplicateServerIndex);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit errorOccurred(ErrorCode::ApiPurchaseError);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
emit installServerFromApiFinished(tr("Subscription restored successfully."));
|
emit installServerFromApiFinished(tr("Subscription restored successfully."));
|
||||||
if (duplicateCount > 0) {
|
|
||||||
qInfo().noquote() << "[IAP] Skipped" << duplicateCount
|
|
||||||
<< "duplicate restored transactions for original transaction IDs already processed";
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ApiConfigsController::importServiceFromGateway()
|
bool ApiConfigsController::importFreeFromGateway()
|
||||||
{
|
{
|
||||||
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
|
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
|
||||||
QString(APP_VERSION),
|
QString(APP_VERSION),
|
||||||
@@ -695,6 +864,72 @@ bool ApiConfigsController::importServiceFromGateway()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool ApiConfigsController::importTrialFromGateway(const QString &email)
|
||||||
|
{
|
||||||
|
emit trialEmailError(QString());
|
||||||
|
|
||||||
|
const QString trimmedEmail = email.trimmed();
|
||||||
|
if (trimmedEmail.isEmpty()) {
|
||||||
|
emit errorOccurred(ErrorCode::ApiConfigEmptyError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
|
||||||
|
QString(APP_VERSION),
|
||||||
|
m_settings->getAppLanguage().name().split("_").first(),
|
||||||
|
m_settings->getInstallationUuid(true),
|
||||||
|
m_apiServicesModel->getCountryCode(),
|
||||||
|
"",
|
||||||
|
m_apiServicesModel->getSelectedServiceType(),
|
||||||
|
m_apiServicesModel->getSelectedServiceProtocol(),
|
||||||
|
QJsonObject() };
|
||||||
|
|
||||||
|
ProtocolData protocolData = generateProtocolData(gatewayRequestData.serviceProtocol);
|
||||||
|
|
||||||
|
QJsonObject apiPayload = gatewayRequestData.toJsonObject();
|
||||||
|
appendProtocolDataToApiPayload(gatewayRequestData.serviceProtocol, protocolData, apiPayload);
|
||||||
|
apiPayload.insert(apiDefs::key::email, trimmedEmail);
|
||||||
|
|
||||||
|
QByteArray responseBody;
|
||||||
|
ErrorCode errorCode = executeRequest(QString("%1v1/trial"), apiPayload, responseBody);
|
||||||
|
if (errorCode != ErrorCode::NoError) {
|
||||||
|
if (errorCode == ErrorCode::ApiTrialAlreadyUsedError) {
|
||||||
|
emit trialEmailError(tr("This email address has already been used to activate a trial. If you like the service, you can upgrade to Premium"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
emit errorOccurred(errorCode);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject responseObject = QJsonDocument::fromJson(responseBody).object();
|
||||||
|
QString key = responseObject.value(apiDefs::key::config).toString();
|
||||||
|
if (key.isEmpty()) {
|
||||||
|
qWarning().noquote() << "[Trial] trial response does not contain config field";
|
||||||
|
emit errorOccurred(ErrorCode::ApiConfigEmptyError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
key.replace(QStringLiteral("vpn://"), QString());
|
||||||
|
QByteArray configBytes = QByteArray::fromBase64(key.toUtf8(), QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals);
|
||||||
|
QByteArray uncompressed = qUncompress(configBytes);
|
||||||
|
if (!uncompressed.isEmpty()) {
|
||||||
|
configBytes = uncompressed;
|
||||||
|
}
|
||||||
|
if (configBytes.isEmpty()) {
|
||||||
|
qWarning().noquote() << "[Trial] trial response config payload is empty";
|
||||||
|
emit errorOccurred(ErrorCode::ApiConfigEmptyError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject configObject = QJsonDocument::fromJson(configBytes).object();
|
||||||
|
quint16 crc = qChecksum(QJsonDocument(configObject).toJson());
|
||||||
|
configObject.insert(config_key::crc, crc);
|
||||||
|
m_serversModel->addServer(configObject);
|
||||||
|
|
||||||
|
emit installServerFromApiFinished(tr("%1 installed successfully.").arg(m_apiServicesModel->getSelectedServiceName()));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
bool ApiConfigsController::updateServiceFromGateway(const int serverIndex, const QString &newCountryCode, const QString &newCountryName,
|
bool ApiConfigsController::updateServiceFromGateway(const int serverIndex, const QString &newCountryCode, const QString &newCountryName,
|
||||||
bool reloadServiceConfig)
|
bool reloadServiceConfig)
|
||||||
{
|
{
|
||||||
@@ -721,6 +956,7 @@ bool ApiConfigsController::updateServiceFromGateway(const int serverIndex, const
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool isTestPurchase = apiConfig.value(apiDefs::key::isTestPurchase).toBool(false);
|
bool isTestPurchase = apiConfig.value(apiDefs::key::isTestPurchase).toBool(false);
|
||||||
|
bool wasSubscriptionExpired = m_serversModel->data(serverIndex, ServersModel::IsSubscriptionExpiredRole).toBool();
|
||||||
QByteArray responseBody;
|
QByteArray responseBody;
|
||||||
ErrorCode errorCode = executeRequest(QString("%1v1/config"), apiPayload, responseBody, isTestPurchase);
|
ErrorCode errorCode = executeRequest(QString("%1v1/config"), apiPayload, responseBody, isTestPurchase);
|
||||||
|
|
||||||
@@ -737,6 +973,12 @@ bool ApiConfigsController::updateServiceFromGateway(const int serverIndex, const
|
|||||||
newApiConfig.insert(configKey::serviceType, apiConfig.value(configKey::serviceType));
|
newApiConfig.insert(configKey::serviceType, apiConfig.value(configKey::serviceType));
|
||||||
newApiConfig.insert(configKey::serviceProtocol, apiConfig.value(configKey::serviceProtocol));
|
newApiConfig.insert(configKey::serviceProtocol, apiConfig.value(configKey::serviceProtocol));
|
||||||
newApiConfig.insert(apiDefs::key::vpnKey, apiConfig.value(apiDefs::key::vpnKey));
|
newApiConfig.insert(apiDefs::key::vpnKey, apiConfig.value(apiDefs::key::vpnKey));
|
||||||
|
if (apiConfig.contains(apiDefs::key::isInAppPurchase)) {
|
||||||
|
newApiConfig.insert(apiDefs::key::isInAppPurchase, apiConfig.value(apiDefs::key::isInAppPurchase));
|
||||||
|
}
|
||||||
|
if (apiConfig.contains(apiDefs::key::isTestPurchase)) {
|
||||||
|
newApiConfig.insert(apiDefs::key::isTestPurchase, apiConfig.value(apiDefs::key::isTestPurchase));
|
||||||
|
}
|
||||||
|
|
||||||
newServerConfig.insert(configKey::apiConfig, newApiConfig);
|
newServerConfig.insert(configKey::apiConfig, newApiConfig);
|
||||||
newServerConfig.insert(configKey::authData, gatewayRequestData.authData);
|
newServerConfig.insert(configKey::authData, gatewayRequestData.authData);
|
||||||
@@ -747,6 +989,11 @@ bool ApiConfigsController::updateServiceFromGateway(const int serverIndex, const
|
|||||||
newServerConfig.insert(config_key::nameOverriddenByUser, true);
|
newServerConfig.insert(config_key::nameOverriddenByUser, true);
|
||||||
}
|
}
|
||||||
m_serversModel->editServer(newServerConfig, serverIndex);
|
m_serversModel->editServer(newServerConfig, serverIndex);
|
||||||
|
|
||||||
|
if (wasSubscriptionExpired) {
|
||||||
|
emit subscriptionRefreshNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
if (reloadServiceConfig) {
|
if (reloadServiceConfig) {
|
||||||
emit reloadServerFromApiFinished(tr("API config reloaded"));
|
emit reloadServerFromApiFinished(tr("API config reloaded"));
|
||||||
} else if (newCountryName.isEmpty()) {
|
} else if (newCountryName.isEmpty()) {
|
||||||
@@ -756,7 +1003,18 @@ bool ApiConfigsController::updateServiceFromGateway(const int serverIndex, const
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
emit errorOccurred(errorCode);
|
if (errorCode == ErrorCode::ApiSubscriptionExpiredError) {
|
||||||
|
if (!apiConfig.value(apiDefs::key::isInAppPurchase).toBool(false)) {
|
||||||
|
apiConfig.insert(apiDefs::key::subscriptionExpiredByServer, true);
|
||||||
|
serverConfig.insert(configKey::apiConfig, apiConfig);
|
||||||
|
m_serversModel->editServer(serverConfig, serverIndex);
|
||||||
|
emit subscriptionExpiredOnServer();
|
||||||
|
} else {
|
||||||
|
emit errorOccurred(errorCode);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emit errorOccurred(errorCode);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -769,7 +1027,7 @@ bool ApiConfigsController::updateServiceFromTelegram(const int serverIndex)
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
GatewayController gatewayController(m_settings->getGatewayEndpoint(), m_settings->isDevGatewayEnv(), apiDefs::requestTimeoutMsecs,
|
GatewayController gatewayController(m_settings->getGatewayEndpoint(), m_settings->isDevGatewayEnv(), apiDefs::requestTimeoutMsecs,
|
||||||
m_settings->isStrictKillSwitchEnabled());
|
m_settings->isStrictKillSwitchEnabled(), m_settings);
|
||||||
|
|
||||||
auto serverConfig = m_serversModel->getServerConfig(serverIndex);
|
auto serverConfig = m_serversModel->getServerConfig(serverIndex);
|
||||||
auto installationUuid = m_settings->getInstallationUuid(true);
|
auto installationUuid = m_settings->getInstallationUuid(true);
|
||||||
@@ -942,43 +1200,63 @@ QString ApiConfigsController::getVpnKey()
|
|||||||
return m_vpnKey;
|
return m_vpnKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
ErrorCode ApiConfigsController::importServiceFromBilling(const QByteArray &responseBody, const bool isTestPurchase)
|
ErrorCode ApiConfigsController::importServiceFromBilling(const QByteArray &responseBody, const bool isTestPurchase,
|
||||||
|
int &duplicateServerIndex)
|
||||||
{
|
{
|
||||||
#ifdef Q_OS_IOS
|
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
||||||
|
duplicateServerIndex = -1;
|
||||||
QJsonObject responseObject = QJsonDocument::fromJson(responseBody).object();
|
QJsonObject responseObject = QJsonDocument::fromJson(responseBody).object();
|
||||||
QString key = responseObject.value(QStringLiteral("key")).toString();
|
const QString rawVpnKey = responseObject.value(QStringLiteral("key")).toString();
|
||||||
if (key.isEmpty()) {
|
if (rawVpnKey.isEmpty()) {
|
||||||
qWarning().noquote() << "[IAP] Subscription response does not contain a key field";
|
qWarning().noquote() << "[IAP] Subscription response does not contain a key field";
|
||||||
return ErrorCode::ApiPurchaseError;
|
return ErrorCode::ApiPurchaseError;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (m_serversModel->hasServerWithVpnKey(key)) {
|
QString normalizedVpnKey = rawVpnKey;
|
||||||
|
normalizedVpnKey.replace(QStringLiteral("vpn://"), QString());
|
||||||
|
|
||||||
|
duplicateServerIndex = m_serversModel->indexOfServerWithVpnKey(normalizedVpnKey);
|
||||||
|
if (duplicateServerIndex >= 0) {
|
||||||
qInfo().noquote() << "[IAP] Subscription config with the same vpn_key already exists";
|
qInfo().noquote() << "[IAP] Subscription config with the same vpn_key already exists";
|
||||||
return ErrorCode::ApiConfigAlreadyAdded;
|
return ErrorCode::ApiConfigAlreadyAdded;
|
||||||
}
|
}
|
||||||
|
|
||||||
QString normalizedKey = key;
|
QByteArray configPayload =
|
||||||
normalizedKey.replace(QStringLiteral("vpn://"), QString());
|
QByteArray::fromBase64(normalizedVpnKey.toUtf8(), QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals);
|
||||||
|
QByteArray configUncompressed = qUncompress(configPayload);
|
||||||
QByteArray configString = QByteArray::fromBase64(normalizedKey.toUtf8(), QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals);
|
const bool payloadWasCompressed = !configUncompressed.isEmpty();
|
||||||
QByteArray configUncompressed = qUncompress(configString);
|
if (payloadWasCompressed) {
|
||||||
if (!configUncompressed.isEmpty()) {
|
configPayload = configUncompressed;
|
||||||
configString = configUncompressed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (configString.isEmpty()) {
|
if (configPayload.isEmpty()) {
|
||||||
qWarning().noquote() << "[IAP] Subscription response config payload is empty";
|
qWarning().noquote() << "[IAP] Subscription response config payload is empty";
|
||||||
return ErrorCode::ApiPurchaseError;
|
return ErrorCode::ApiPurchaseError;
|
||||||
}
|
}
|
||||||
|
|
||||||
QJsonObject configObject = QJsonDocument::fromJson(configString).object();
|
QJsonObject configObject = QJsonDocument::fromJson(configPayload).object();
|
||||||
|
|
||||||
|
auto apiConfig = configObject.value(apiDefs::key::apiConfig).toObject();
|
||||||
|
apiConfig.insert(apiDefs::key::isTestPurchase, isTestPurchase);
|
||||||
|
apiConfig.insert(apiDefs::key::isInAppPurchase, true);
|
||||||
|
configObject.insert(apiDefs::key::apiConfig, apiConfig);
|
||||||
|
|
||||||
|
configPayload = QJsonDocument(configObject).toJson();
|
||||||
|
if (payloadWasCompressed) {
|
||||||
|
configPayload = qCompress(configPayload, 8);
|
||||||
|
}
|
||||||
|
normalizedVpnKey = QString(configPayload.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals));
|
||||||
|
|
||||||
|
duplicateServerIndex = m_serversModel->indexOfServerWithVpnKey(normalizedVpnKey);
|
||||||
|
if (duplicateServerIndex >= 0) {
|
||||||
|
qInfo().noquote() << "[IAP] Subscription config with the same vpn_key already exists";
|
||||||
|
return ErrorCode::ApiConfigAlreadyAdded;
|
||||||
|
}
|
||||||
|
|
||||||
|
apiConfig.insert(apiDefs::key::vpnKey, normalizedVpnKey);
|
||||||
|
configObject.insert(apiDefs::key::apiConfig, apiConfig);
|
||||||
|
|
||||||
quint16 crc = qChecksum(QJsonDocument(configObject).toJson());
|
quint16 crc = qChecksum(QJsonDocument(configObject).toJson());
|
||||||
auto apiConfig = configObject.value(apiDefs::key::apiConfig).toObject();
|
|
||||||
apiConfig[apiDefs::key::vpnKey] = normalizedKey;
|
|
||||||
apiConfig[apiDefs::key::isTestPurchase] = isTestPurchase;
|
|
||||||
|
|
||||||
configObject.insert(apiDefs::key::apiConfig, apiConfig);
|
|
||||||
configObject.insert(config_key::crc, crc);
|
configObject.insert(config_key::crc, crc);
|
||||||
m_serversModel->addServer(configObject);
|
m_serversModel->addServer(configObject);
|
||||||
|
|
||||||
@@ -986,6 +1264,7 @@ ErrorCode ApiConfigsController::importServiceFromBilling(const QByteArray &respo
|
|||||||
#else
|
#else
|
||||||
Q_UNUSED(responseBody)
|
Q_UNUSED(responseBody)
|
||||||
Q_UNUSED(isTestPurchase)
|
Q_UNUSED(isTestPurchase)
|
||||||
|
duplicateServerIndex = -1;
|
||||||
return ErrorCode::NoError;
|
return ErrorCode::NoError;
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -994,6 +1273,6 @@ ErrorCode ApiConfigsController::executeRequest(const QString &endpoint, const QJ
|
|||||||
bool isTestPurchase)
|
bool isTestPurchase)
|
||||||
{
|
{
|
||||||
GatewayController gatewayController(m_settings->getGatewayEndpoint(isTestPurchase), m_settings->isDevGatewayEnv(isTestPurchase),
|
GatewayController gatewayController(m_settings->getGatewayEndpoint(isTestPurchase), m_settings->isDevGatewayEnv(isTestPurchase),
|
||||||
apiDefs::requestTimeoutMsecs, m_settings->isStrictKillSwitchEnabled());
|
apiDefs::requestTimeoutMsecs, m_settings->isStrictKillSwitchEnabled(), m_settings);
|
||||||
return gatewayController.post(endpoint, apiPayload, responseBody);
|
return gatewayController.post(endpoint, apiPayload, responseBody);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
#ifndef APICONFIGSCONTROLLER_H
|
#ifndef APICONFIGSCONTROLLER_H
|
||||||
#define APICONFIGSCONTROLLER_H
|
#define APICONFIGSCONTROLLER_H
|
||||||
|
|
||||||
|
#include <QList>
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
|
|
||||||
#include "configurators/openvpn_configurator.h"
|
#include "configurators/openvpn_configurator.h"
|
||||||
|
#include "ui/models/api/apiBenefitsModel.h"
|
||||||
#include "ui/models/api/apiServicesModel.h"
|
#include "ui/models/api/apiServicesModel.h"
|
||||||
|
#include "ui/models/api/apiSubscriptionPlansModel.h"
|
||||||
#include "ui/models/servers_model.h"
|
#include "ui/models/servers_model.h"
|
||||||
|
|
||||||
class ApiConfigsController : public QObject
|
class ApiConfigsController : public QObject
|
||||||
@@ -12,7 +15,9 @@ class ApiConfigsController : public QObject
|
|||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
public:
|
public:
|
||||||
ApiConfigsController(const QSharedPointer<ServersModel> &serversModel, const QSharedPointer<ApiServicesModel> &apiServicesModel,
|
ApiConfigsController(const QSharedPointer<ServersModel> &serversModel, const QSharedPointer<ApiServicesModel> &apiServicesModel,
|
||||||
const std::shared_ptr<Settings> &settings, QObject *parent = nullptr);
|
const QSharedPointer<ApiSubscriptionPlansModel> &subscriptionPlansModel,
|
||||||
|
const QSharedPointer<ApiBenefitsModel> &benefitsModel, const std::shared_ptr<Settings> &settings,
|
||||||
|
QObject *parent = nullptr);
|
||||||
|
|
||||||
Q_PROPERTY(QList<QString> qrCodes READ getQrCodes NOTIFY vpnKeyExportReady)
|
Q_PROPERTY(QList<QString> qrCodes READ getQrCodes NOTIFY vpnKeyExportReady)
|
||||||
Q_PROPERTY(int qrCodesCount READ getQrCodesCount NOTIFY vpnKeyExportReady)
|
Q_PROPERTY(int qrCodesCount READ getQrCodesCount NOTIFY vpnKeyExportReady)
|
||||||
@@ -27,9 +32,10 @@ public slots:
|
|||||||
|
|
||||||
bool fillAvailableServices();
|
bool fillAvailableServices();
|
||||||
bool importService();
|
bool importService();
|
||||||
bool importSerivceFromAppStore();
|
bool importPremiumFromAppStore(const QString &storeProductId);
|
||||||
bool restoreSerivceFromAppStore();
|
bool restoreServiceFromAppStore();
|
||||||
bool importServiceFromGateway();
|
bool importFreeFromGateway();
|
||||||
|
bool importTrialFromGateway(const QString &email);
|
||||||
bool updateServiceFromGateway(const int serverIndex, const QString &newCountryCode, const QString &newCountryName,
|
bool updateServiceFromGateway(const int serverIndex, const QString &newCountryCode, const QString &newCountryName,
|
||||||
bool reloadServiceConfig = false);
|
bool reloadServiceConfig = false);
|
||||||
bool updateServiceFromTelegram(const int serverIndex);
|
bool updateServiceFromTelegram(const int serverIndex);
|
||||||
@@ -43,8 +49,11 @@ public slots:
|
|||||||
|
|
||||||
signals:
|
signals:
|
||||||
void errorOccurred(ErrorCode errorCode);
|
void errorOccurred(ErrorCode errorCode);
|
||||||
|
void trialEmailError(const QString &message);
|
||||||
|
void subscriptionExpiredOnServer();
|
||||||
|
void subscriptionRefreshNeeded();
|
||||||
|
|
||||||
void installServerFromApiFinished(const QString &message);
|
void installServerFromApiFinished(const QString &message, int preferredDefaultServerIndex = -1);
|
||||||
void changeApiCountryFinished(const QString &message);
|
void changeApiCountryFinished(const QString &message);
|
||||||
void reloadServerFromApiFinished(const QString &message);
|
void reloadServerFromApiFinished(const QString &message);
|
||||||
void updateServerFromApiFinished();
|
void updateServerFromApiFinished();
|
||||||
@@ -57,7 +66,7 @@ private:
|
|||||||
QString getVpnKey();
|
QString getVpnKey();
|
||||||
|
|
||||||
ErrorCode executeRequest(const QString &endpoint, const QJsonObject &apiPayload, QByteArray &responseBody, bool isTestPurchase = false);
|
ErrorCode executeRequest(const QString &endpoint, const QJsonObject &apiPayload, QByteArray &responseBody, bool isTestPurchase = false);
|
||||||
ErrorCode importServiceFromBilling(const QByteArray &responseBody, const bool isTestPurchase);
|
ErrorCode importServiceFromBilling(const QByteArray &responseBody, const bool isTestPurchase, int &duplicateServerIndex);
|
||||||
|
|
||||||
QList<QString> m_qrCodes;
|
QList<QString> m_qrCodes;
|
||||||
QString m_vpnKey;
|
QString m_vpnKey;
|
||||||
@@ -65,6 +74,9 @@ private:
|
|||||||
QSharedPointer<ServersModel> m_serversModel;
|
QSharedPointer<ServersModel> m_serversModel;
|
||||||
QSharedPointer<ApiServicesModel> m_apiServicesModel;
|
QSharedPointer<ApiServicesModel> m_apiServicesModel;
|
||||||
std::shared_ptr<Settings> m_settings;
|
std::shared_ptr<Settings> m_settings;
|
||||||
|
|
||||||
|
QSharedPointer<ApiSubscriptionPlansModel> m_subscriptionPlansModel;
|
||||||
|
QSharedPointer<ApiBenefitsModel> m_benefitsModel;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // APICONFIGSCONTROLLER_H
|
#endif
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ void ApiNewsController::fetchNews(bool showError)
|
|||||||
}
|
}
|
||||||
|
|
||||||
auto gatewayController = QSharedPointer<GatewayController>::create(m_settings->getGatewayEndpoint(), m_settings->isDevGatewayEnv(),
|
auto gatewayController = QSharedPointer<GatewayController>::create(m_settings->getGatewayEndpoint(), m_settings->isDevGatewayEnv(),
|
||||||
apiDefs::requestTimeoutMsecs, m_settings->isStrictKillSwitchEnabled());
|
apiDefs::requestTimeoutMsecs,
|
||||||
|
m_settings->isStrictKillSwitchEnabled(), m_settings);
|
||||||
QJsonObject payload;
|
QJsonObject payload;
|
||||||
payload.insert("locale", m_settings->getAppLanguage().name().split("_").first());
|
payload.insert("locale", m_settings->getAppLanguage().name().split("_").first());
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "apiSettingsController.h"
|
#include "apiSettingsController.h"
|
||||||
|
|
||||||
#include <QEventLoop>
|
#include <QEventLoop>
|
||||||
|
#include <QJsonDocument>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
|
|
||||||
#include "core/api/apiUtils.h"
|
#include "core/api/apiUtils.h"
|
||||||
@@ -22,6 +23,19 @@ namespace
|
|||||||
}
|
}
|
||||||
|
|
||||||
const int requestTimeoutMsecs = 12 * 1000; // 12 secs
|
const int requestTimeoutMsecs = 12 * 1000; // 12 secs
|
||||||
|
|
||||||
|
QString getSubscriptionStatusForRenewal(const QSharedPointer<ApiAccountInfoModel> &accountInfoModel)
|
||||||
|
{
|
||||||
|
if (!accountInfoModel.isNull() && accountInfoModel->data(QStringLiteral("isSubscriptionExpired")).toBool()) {
|
||||||
|
return QStringLiteral("expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accountInfoModel.isNull() && accountInfoModel->data(QStringLiteral("isSubscriptionExpiringSoon")).toBool()) {
|
||||||
|
return QStringLiteral("expire_soon");
|
||||||
|
}
|
||||||
|
|
||||||
|
return QStringLiteral("active");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ApiSettingsController::ApiSettingsController(const QSharedPointer<ServersModel> &serversModel,
|
ApiSettingsController::ApiSettingsController(const QSharedPointer<ServersModel> &serversModel,
|
||||||
@@ -57,7 +71,7 @@ bool ApiSettingsController::getAccountInfo(bool reload)
|
|||||||
|
|
||||||
bool isTestPurchase = apiConfig.value(apiDefs::key::isTestPurchase).toBool(false);
|
bool isTestPurchase = apiConfig.value(apiDefs::key::isTestPurchase).toBool(false);
|
||||||
GatewayController gatewayController(m_settings->getGatewayEndpoint(isTestPurchase), m_settings->isDevGatewayEnv(isTestPurchase),
|
GatewayController gatewayController(m_settings->getGatewayEndpoint(isTestPurchase), m_settings->isDevGatewayEnv(isTestPurchase),
|
||||||
requestTimeoutMsecs, m_settings->isStrictKillSwitchEnabled());
|
requestTimeoutMsecs, m_settings->isStrictKillSwitchEnabled(), m_settings);
|
||||||
|
|
||||||
QJsonObject apiPayload;
|
QJsonObject apiPayload;
|
||||||
apiPayload[configKey::userCountryCode] = apiConfig.value(configKey::userCountryCode).toString();
|
apiPayload[configKey::userCountryCode] = apiConfig.value(configKey::userCountryCode).toString();
|
||||||
@@ -85,6 +99,43 @@ bool ApiSettingsController::getAccountInfo(bool reload)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ApiSettingsController::getRenewalLink()
|
||||||
|
{
|
||||||
|
auto processedIndex = m_serversModel->getProcessedServerIndex();
|
||||||
|
auto serverConfig = m_serversModel->getServerConfig(processedIndex);
|
||||||
|
auto apiConfig = serverConfig.value(configKey::apiConfig).toObject();
|
||||||
|
auto authData = serverConfig.value(configKey::authData).toObject();
|
||||||
|
|
||||||
|
bool isTestPurchase = apiConfig.value(apiDefs::key::isTestPurchase).toBool(false);
|
||||||
|
auto gatewayController = QSharedPointer<GatewayController>::create(m_settings->getGatewayEndpoint(isTestPurchase),
|
||||||
|
m_settings->isDevGatewayEnv(isTestPurchase),
|
||||||
|
requestTimeoutMsecs,
|
||||||
|
m_settings->isStrictKillSwitchEnabled(), m_settings);
|
||||||
|
|
||||||
|
QJsonObject apiPayload;
|
||||||
|
apiPayload[configKey::userCountryCode] = apiConfig.value(configKey::userCountryCode).toString();
|
||||||
|
apiPayload[configKey::serviceType] = apiConfig.value(configKey::serviceType).toString();
|
||||||
|
apiPayload[configKey::authData] = authData;
|
||||||
|
apiPayload[apiDefs::key::cliVersion] = QString(APP_VERSION);
|
||||||
|
apiPayload[apiDefs::key::appLanguage] = m_settings->getAppLanguage().name().split("_").first();
|
||||||
|
apiPayload[apiDefs::key::subscriptionStatus] = getSubscriptionStatusForRenewal(m_apiAccountInfoModel);
|
||||||
|
|
||||||
|
auto future = gatewayController->postAsync(QString("%1v1/renewal_link"), apiPayload);
|
||||||
|
future.then(this, [this, gatewayController](QPair<ErrorCode, QByteArray> result) {
|
||||||
|
auto [errorCode, responseBody] = result;
|
||||||
|
if (errorCode != ErrorCode::NoError) {
|
||||||
|
emit errorOccurred(errorCode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject responseJson = QJsonDocument::fromJson(responseBody).object();
|
||||||
|
QString url = responseJson.value("renewal_url").toString();
|
||||||
|
if (!url.isEmpty()) {
|
||||||
|
emit renewalLinkReceived(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void ApiSettingsController::updateApiCountryModel()
|
void ApiSettingsController::updateApiCountryModel()
|
||||||
{
|
{
|
||||||
m_apiCountryModel->updateModel(m_apiAccountInfoModel->getAvailableCountries(), "");
|
m_apiCountryModel->updateModel(m_apiAccountInfoModel->getAvailableCountries(), "");
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ public slots:
|
|||||||
bool getAccountInfo(bool reload);
|
bool getAccountInfo(bool reload);
|
||||||
void updateApiCountryModel();
|
void updateApiCountryModel();
|
||||||
void updateApiDevicesModel();
|
void updateApiDevicesModel();
|
||||||
|
void getRenewalLink();
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void errorOccurred(ErrorCode errorCode);
|
void errorOccurred(ErrorCode errorCode);
|
||||||
|
void renewalLinkReceived(const QString &url);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QSharedPointer<ServersModel> m_serversModel;
|
QSharedPointer<ServersModel> m_serversModel;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#include "amnezia_application.h"
|
||||||
#include "utilities.h"
|
#include "utilities.h"
|
||||||
#include "core/controllers/vpnConfigurationController.h"
|
#include "core/controllers/vpnConfigurationController.h"
|
||||||
#include "version.h"
|
#include "version.h"
|
||||||
@@ -81,6 +82,8 @@ void ConnectionController::onConnectionStateChanged(Vpn::ConnectionState state)
|
|||||||
m_connectionStateText = tr("Connecting...");
|
m_connectionStateText = tr("Connecting...");
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case Vpn::ConnectionState::Connected: {
|
case Vpn::ConnectionState::Connected: {
|
||||||
|
amnApp->networkManager()->clearConnectionCache();
|
||||||
|
|
||||||
m_isConnectionInProgress = false;
|
m_isConnectionInProgress = false;
|
||||||
m_isConnected = true;
|
m_isConnected = true;
|
||||||
m_connectionStateText = tr("Connected");
|
m_connectionStateText = tr("Connected");
|
||||||
|
|||||||
@@ -217,6 +217,8 @@ bool ImportController::extractConfigFromData(QString data)
|
|||||||
|
|
||||||
bool ImportController::extractConfigFromQr(const QByteArray &data)
|
bool ImportController::extractConfigFromQr(const QByteArray &data)
|
||||||
{
|
{
|
||||||
|
m_configType = checkConfigFormat(QString::fromUtf8(data));
|
||||||
|
|
||||||
QJsonObject dataObj = QJsonDocument::fromJson(data).object();
|
QJsonObject dataObj = QJsonDocument::fromJson(data).object();
|
||||||
if (!dataObj.isEmpty()) {
|
if (!dataObj.isEmpty()) {
|
||||||
m_config = dataObj;
|
m_config = dataObj;
|
||||||
@@ -226,10 +228,13 @@ bool ImportController::extractConfigFromQr(const QByteArray &data)
|
|||||||
QByteArray ba_uncompressed = qUncompress(data);
|
QByteArray ba_uncompressed = qUncompress(data);
|
||||||
if (!ba_uncompressed.isEmpty()) {
|
if (!ba_uncompressed.isEmpty()) {
|
||||||
m_config = QJsonDocument::fromJson(ba_uncompressed).object();
|
m_config = QJsonDocument::fromJson(ba_uncompressed).object();
|
||||||
|
if (m_config.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
m_configType = checkConfigFormat(QString::fromUtf8(ba_uncompressed));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
m_configType = checkConfigFormat(data);
|
|
||||||
if (m_configType == ConfigTypes::Invalid) {
|
if (m_configType == ConfigTypes::Invalid) {
|
||||||
QByteArray ba = QByteArray::fromBase64(data, QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals);
|
QByteArray ba = QByteArray::fromBase64(data, QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals);
|
||||||
QByteArray baUncompressed = qUncompress(ba);
|
QByteArray baUncompressed = qUncompress(ba);
|
||||||
@@ -240,6 +245,10 @@ bool ImportController::extractConfigFromQr(const QByteArray &data)
|
|||||||
|
|
||||||
if (!ba.isEmpty()) {
|
if (!ba.isEmpty()) {
|
||||||
m_config = QJsonDocument::fromJson(ba).object();
|
m_config = QJsonDocument::fromJson(ba).object();
|
||||||
|
if (m_config.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
m_configType = checkConfigFormat(QString::fromUtf8(ba));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ namespace PageLoader
|
|||||||
PageSetupWizardViewConfig,
|
PageSetupWizardViewConfig,
|
||||||
PageSetupWizardQrReader,
|
PageSetupWizardQrReader,
|
||||||
PageSetupWizardApiServicesList,
|
PageSetupWizardApiServicesList,
|
||||||
PageSetupWizardApiServiceInfo,
|
PageSetupWizardApiFreeInfo,
|
||||||
|
|
||||||
PageProtocolOpenVpnSettings,
|
PageProtocolOpenVpnSettings,
|
||||||
PageProtocolShadowSocksSettings,
|
PageProtocolShadowSocksSettings,
|
||||||
@@ -76,6 +76,9 @@ namespace PageLoader
|
|||||||
PageShareFullAccess,
|
PageShareFullAccess,
|
||||||
PageShareConnection,
|
PageShareConnection,
|
||||||
|
|
||||||
|
PageSetupWizardApiPremiumInfo,
|
||||||
|
PageSetupWizardApiTrialEmail,
|
||||||
|
|
||||||
PageDevMenu
|
PageDevMenu
|
||||||
};
|
};
|
||||||
Q_ENUM_NS(PageEnum)
|
Q_ENUM_NS(PageEnum)
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ SettingsController::SettingsController(const QSharedPointer<ServersModel> &serve
|
|||||||
emit safeAreaBottomMarginChanged();
|
emit safeAreaBottomMarginChanged();
|
||||||
emit safeAreaTopMarginChanged();
|
emit safeAreaTopMarginChanged();
|
||||||
});
|
});
|
||||||
|
connect(AndroidController::instance(), &AndroidController::activityPaused, this, &SettingsController::activityPaused);
|
||||||
|
connect(AndroidController::instance(), &AndroidController::activityResumed, this, &SettingsController::activityResumed);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
m_isDevModeEnabled = m_settings->isDevGatewayEnv();
|
m_isDevModeEnabled = m_settings->isDevGatewayEnv();
|
||||||
|
|||||||
@@ -141,6 +141,9 @@ signals:
|
|||||||
void safeAreaTopMarginChanged();
|
void safeAreaTopMarginChanged();
|
||||||
void safeAreaBottomMarginChanged();
|
void safeAreaBottomMarginChanged();
|
||||||
|
|
||||||
|
void activityPaused();
|
||||||
|
void activityResumed();
|
||||||
|
|
||||||
void isHomeAdLabelVisibleChanged(bool visible);
|
void isHomeAdLabelVisibleChanged(bool visible);
|
||||||
void startMinimizedChanged();
|
void startMinimizedChanged();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "apiAccountInfoModel.h"
|
#include "apiAccountInfoModel.h"
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
|
|
||||||
#include "core/api/apiUtils.h"
|
#include "core/api/apiUtils.h"
|
||||||
@@ -31,8 +32,9 @@ QVariant ApiAccountInfoModel::data(const QModelIndex &index, int role) const
|
|||||||
return tr("Active");
|
return tr("Active");
|
||||||
}
|
}
|
||||||
|
|
||||||
return apiUtils::isSubscriptionExpired(m_accountInfoData.subscriptionEndDate) ? tr("<p><a style=\"color: #EB5757;\">Inactive</a>")
|
return apiUtils::isSubscriptionExpired(m_accountInfoData.subscriptionEndDate)
|
||||||
: tr("Active");
|
? QStringLiteral("<p><a style=\"color: #EB5757;\">%1</a>").arg(tr("Inactive"))
|
||||||
|
: QStringLiteral("<p><a style=\"color: #28c840;\">%1</a>").arg(tr("Active"));
|
||||||
}
|
}
|
||||||
case EndDateRole: {
|
case EndDateRole: {
|
||||||
if (m_accountInfoData.configType == apiDefs::ConfigType::AmneziaFreeV3) {
|
if (m_accountInfoData.configType == apiDefs::ConfigType::AmneziaFreeV3) {
|
||||||
@@ -52,7 +54,11 @@ QVariant ApiAccountInfoModel::data(const QModelIndex &index, int role) const
|
|||||||
}
|
}
|
||||||
case IsComponentVisibleRole: {
|
case IsComponentVisibleRole: {
|
||||||
return m_accountInfoData.configType == apiDefs::ConfigType::AmneziaPremiumV2
|
return m_accountInfoData.configType == apiDefs::ConfigType::AmneziaPremiumV2
|
||||||
|| m_accountInfoData.configType == apiDefs::ConfigType::ExternalPremium;
|
|| m_accountInfoData.configType == apiDefs::ConfigType::ExternalPremium
|
||||||
|
|| m_accountInfoData.configType == apiDefs::ConfigType::ExternalTrial;
|
||||||
|
}
|
||||||
|
case IsSubscriptionRenewalAvailableRole: {
|
||||||
|
return m_accountInfoData.isRenewalAvailable;
|
||||||
}
|
}
|
||||||
case HasExpiredWorkerRole: {
|
case HasExpiredWorkerRole: {
|
||||||
for (int i = 0; i < m_issuedConfigsInfo.size(); i++) {
|
for (int i = 0; i < m_issuedConfigsInfo.size(); i++) {
|
||||||
@@ -73,6 +79,33 @@ QVariant ApiAccountInfoModel::data(const QModelIndex &index, int role) const
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
case IsSubscriptionExpiredRole: {
|
||||||
|
if (m_accountInfoData.configType == apiDefs::ConfigType::AmneziaFreeV3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (m_accountInfoData.isInAppPurchase) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (m_accountInfoData.subscriptionEndDate.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return apiUtils::isSubscriptionExpired(m_accountInfoData.subscriptionEndDate);
|
||||||
|
}
|
||||||
|
case IsSubscriptionExpiringSoonRole: {
|
||||||
|
if (m_accountInfoData.configType == apiDefs::ConfigType::AmneziaFreeV3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (m_accountInfoData.isInAppPurchase) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (m_accountInfoData.subscriptionEndDate.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return apiUtils::isSubscriptionExpiringSoon(m_accountInfoData.subscriptionEndDate);
|
||||||
|
}
|
||||||
|
case IsInAppPurchaseRole: {
|
||||||
|
return m_accountInfoData.isInAppPurchase;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return QVariant();
|
return QVariant();
|
||||||
@@ -93,7 +126,11 @@ void ApiAccountInfoModel::updateModel(const QJsonObject &accountInfoObject, cons
|
|||||||
|
|
||||||
accountInfoData.configType = apiUtils::getConfigType(serverConfig);
|
accountInfoData.configType = apiUtils::getConfigType(serverConfig);
|
||||||
|
|
||||||
|
const QJsonObject apiConfig = serverConfig.value(apiDefs::key::apiConfig).toObject();
|
||||||
|
accountInfoData.isInAppPurchase = apiConfig.value(apiDefs::key::isInAppPurchase).toBool(false);
|
||||||
|
|
||||||
accountInfoData.subscriptionDescription = accountInfoObject.value(apiDefs::key::subscriptionDescription).toString();
|
accountInfoData.subscriptionDescription = accountInfoObject.value(apiDefs::key::subscriptionDescription).toString();
|
||||||
|
accountInfoData.isRenewalAvailable = accountInfoObject.value(apiDefs::key::isRenewalAvailable).toBool(false);
|
||||||
|
|
||||||
for (const auto &protocol : accountInfoObject.value(apiDefs::key::supportedProtocols).toArray()) {
|
for (const auto &protocol : accountInfoObject.value(apiDefs::key::supportedProtocols).toArray()) {
|
||||||
accountInfoData.supportedProtocols.push_back(protocol.toString());
|
accountInfoData.supportedProtocols.push_back(protocol.toString());
|
||||||
@@ -162,8 +199,12 @@ QHash<int, QByteArray> ApiAccountInfoModel::roleNames() const
|
|||||||
roles[ConnectedDevicesRole] = "connectedDevices";
|
roles[ConnectedDevicesRole] = "connectedDevices";
|
||||||
roles[ServiceDescriptionRole] = "serviceDescription";
|
roles[ServiceDescriptionRole] = "serviceDescription";
|
||||||
roles[IsComponentVisibleRole] = "isComponentVisible";
|
roles[IsComponentVisibleRole] = "isComponentVisible";
|
||||||
|
roles[IsSubscriptionRenewalAvailableRole] = "isSubscriptionRenewalAvailable";
|
||||||
roles[HasExpiredWorkerRole] = "hasExpiredWorker";
|
roles[HasExpiredWorkerRole] = "hasExpiredWorker";
|
||||||
roles[IsProtocolSelectionSupportedRole] = "isProtocolSelectionSupported";
|
roles[IsProtocolSelectionSupportedRole] = "isProtocolSelectionSupported";
|
||||||
|
roles[IsSubscriptionExpiredRole] = "isSubscriptionExpired";
|
||||||
|
roles[IsSubscriptionExpiringSoonRole] = "isSubscriptionExpiringSoon";
|
||||||
|
roles[IsInAppPurchaseRole] = "isInAppPurchase";
|
||||||
|
|
||||||
return roles;
|
return roles;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,12 @@ public:
|
|||||||
ServiceDescriptionRole,
|
ServiceDescriptionRole,
|
||||||
EndDateRole,
|
EndDateRole,
|
||||||
IsComponentVisibleRole,
|
IsComponentVisibleRole,
|
||||||
|
IsSubscriptionRenewalAvailableRole,
|
||||||
HasExpiredWorkerRole,
|
HasExpiredWorkerRole,
|
||||||
IsProtocolSelectionSupportedRole
|
IsProtocolSelectionSupportedRole,
|
||||||
|
IsSubscriptionExpiredRole,
|
||||||
|
IsSubscriptionExpiringSoonRole,
|
||||||
|
IsInAppPurchaseRole
|
||||||
};
|
};
|
||||||
|
|
||||||
explicit ApiAccountInfoModel(QObject *parent = nullptr);
|
explicit ApiAccountInfoModel(QObject *parent = nullptr);
|
||||||
@@ -31,7 +35,6 @@ public:
|
|||||||
public slots:
|
public slots:
|
||||||
void updateModel(const QJsonObject &accountInfoObject, const QJsonObject &serverConfig);
|
void updateModel(const QJsonObject &accountInfoObject, const QJsonObject &serverConfig);
|
||||||
QVariant data(const QString &roleString);
|
QVariant data(const QString &roleString);
|
||||||
|
|
||||||
QJsonArray getAvailableCountries();
|
QJsonArray getAvailableCountries();
|
||||||
QJsonArray getIssuedConfigsInfo();
|
QJsonArray getIssuedConfigsInfo();
|
||||||
|
|
||||||
@@ -56,6 +59,9 @@ private:
|
|||||||
QStringList supportedProtocols;
|
QStringList supportedProtocols;
|
||||||
|
|
||||||
QString subscriptionDescription;
|
QString subscriptionDescription;
|
||||||
|
|
||||||
|
bool isInAppPurchase = false;
|
||||||
|
bool isRenewalAvailable = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
AccountInfoData m_accountInfoData;
|
AccountInfoData m_accountInfoData;
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
#include "apiBenefitsModel.h"
|
||||||
|
|
||||||
|
#include <QHash>
|
||||||
|
#include <utility>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QJsonValue>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
namespace configKey
|
||||||
|
{
|
||||||
|
constexpr char title[] = "title";
|
||||||
|
constexpr char body[] = "body";
|
||||||
|
constexpr char icon[] = "icon";
|
||||||
|
constexpr char link[] = "link";
|
||||||
|
}
|
||||||
|
|
||||||
|
QString gatewayIconKeyToUrl(const QString &iconKey)
|
||||||
|
{
|
||||||
|
if (iconKey.startsWith(QLatin1String("qrc:"))) {
|
||||||
|
return iconKey;
|
||||||
|
}
|
||||||
|
static const QHash<QString, QString> map = {
|
||||||
|
{ QStringLiteral("globe-2"), QStringLiteral("qrc:/images/controls/globe-2.svg") },
|
||||||
|
{ QStringLiteral("smartphone"), QStringLiteral("qrc:/images/controls/smartphone.svg") },
|
||||||
|
{ QStringLiteral("gauge"), QStringLiteral("qrc:/images/controls/gauge.svg") },
|
||||||
|
{ QStringLiteral("infinity"), QStringLiteral("qrc:/images/controls/infinity.svg") },
|
||||||
|
{ QStringLiteral("tag"), QStringLiteral("qrc:/images/controls/tag.svg") },
|
||||||
|
{ QStringLiteral("history"), QStringLiteral("qrc:/images/controls/history.svg") },
|
||||||
|
{ QStringLiteral("info"), QStringLiteral("qrc:/images/controls/info.svg") },
|
||||||
|
{ QStringLiteral("app"), QStringLiteral("qrc:/images/controls/app.svg") },
|
||||||
|
{ QStringLiteral("download"), QStringLiteral("qrc:/images/controls/download.svg") },
|
||||||
|
{ QStringLiteral("help-circle"), QStringLiteral("qrc:/images/controls/help-circle.svg") },
|
||||||
|
};
|
||||||
|
return map.value(iconKey, QStringLiteral("qrc:/images/controls/info.svg"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiBenefitsModel::ApiBenefitsModel(QObject *parent)
|
||||||
|
: QAbstractListModel(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
int ApiBenefitsModel::rowCount(const QModelIndex &parent) const
|
||||||
|
{
|
||||||
|
if (parent.isValid()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return m_serviceBenefits.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariant ApiBenefitsModel::data(const QModelIndex &index, int role) const
|
||||||
|
{
|
||||||
|
if (!index.isValid() || index.row() < 0 || index.row() >= m_serviceBenefits.size()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const ServiceBenefitItem &item = m_serviceBenefits.at(index.row());
|
||||||
|
switch (role) {
|
||||||
|
case IconRole:
|
||||||
|
return item.icon;
|
||||||
|
case TitleRole:
|
||||||
|
return item.title;
|
||||||
|
case BodyRole:
|
||||||
|
return item.body;
|
||||||
|
case LinkRole:
|
||||||
|
return item.link;
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QHash<int, QByteArray> ApiBenefitsModel::roleNames() const
|
||||||
|
{
|
||||||
|
return {
|
||||||
|
{ IconRole, "icon" },
|
||||||
|
{ TitleRole, "title" },
|
||||||
|
{ BodyRole, "body" },
|
||||||
|
{ LinkRole, "link" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApiBenefitsModel::updateModel(const QJsonArray &benefits)
|
||||||
|
{
|
||||||
|
beginResetModel();
|
||||||
|
m_serviceBenefits.clear();
|
||||||
|
for (const QJsonValue &benefitValue : benefits) {
|
||||||
|
if (!benefitValue.isObject()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const QJsonObject benefitObject = benefitValue.toObject();
|
||||||
|
QString title = benefitObject.value(configKey::title).toString();
|
||||||
|
QString body = benefitObject.value(configKey::body).toString();
|
||||||
|
const bool isLink = benefitObject.value(configKey::link).toBool();
|
||||||
|
const QString iconKey = benefitObject.value(configKey::icon).toString();
|
||||||
|
if (isLink) {
|
||||||
|
body = body.trimmed();
|
||||||
|
}
|
||||||
|
if (title.isEmpty() && body.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ServiceBenefitItem item;
|
||||||
|
item.icon = gatewayIconKeyToUrl(iconKey);
|
||||||
|
item.title = std::move(title);
|
||||||
|
item.body = std::move(body);
|
||||||
|
item.link = isLink;
|
||||||
|
m_serviceBenefits.append(std::move(item));
|
||||||
|
}
|
||||||
|
endResetModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApiBenefitsModel::clear()
|
||||||
|
{
|
||||||
|
beginResetModel();
|
||||||
|
m_serviceBenefits.clear();
|
||||||
|
endResetModel();
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#ifndef APIBENEFITSMODEL_H
|
||||||
|
#define APIBENEFITSMODEL_H
|
||||||
|
|
||||||
|
#include <QAbstractListModel>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QString>
|
||||||
|
#include <QVector>
|
||||||
|
|
||||||
|
class ApiBenefitsModel : public QAbstractListModel
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
enum Roles {
|
||||||
|
IconRole = Qt::UserRole + 1,
|
||||||
|
TitleRole,
|
||||||
|
BodyRole,
|
||||||
|
LinkRole
|
||||||
|
};
|
||||||
|
Q_ENUM(Roles)
|
||||||
|
|
||||||
|
explicit ApiBenefitsModel(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
||||||
|
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||||
|
QHash<int, QByteArray> roleNames() const override;
|
||||||
|
|
||||||
|
void updateModel(const QJsonArray &benefits);
|
||||||
|
void clear();
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct ServiceBenefitItem
|
||||||
|
{
|
||||||
|
QString icon;
|
||||||
|
QString title;
|
||||||
|
QString body;
|
||||||
|
bool link = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
QVector<ServiceBenefitItem> m_serviceBenefits;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
#include "apiServicesModel.h"
|
#include "apiServicesModel.h"
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QJsonArray>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
|
|
||||||
|
#include "core/api/apiDefs.h"
|
||||||
#include "logger.h"
|
#include "logger.h"
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
@@ -17,15 +21,9 @@ namespace
|
|||||||
constexpr char serviceProtocol[] = "service_protocol";
|
constexpr char serviceProtocol[] = "service_protocol";
|
||||||
constexpr char serviceDescription[] = "service_description";
|
constexpr char serviceDescription[] = "service_description";
|
||||||
|
|
||||||
constexpr char name[] = "name";
|
|
||||||
constexpr char price[] = "price";
|
|
||||||
constexpr char speed[] = "speed";
|
|
||||||
constexpr char timelimit[] = "timelimit";
|
|
||||||
constexpr char region[] = "region";
|
|
||||||
|
|
||||||
constexpr char description[] = "description";
|
constexpr char description[] = "description";
|
||||||
constexpr char cardDescription[] = "card_description";
|
constexpr char cardDescription[] = "card_description";
|
||||||
constexpr char features[] = "features";
|
constexpr char serviceName[] = "service_name";
|
||||||
|
|
||||||
constexpr char availableCountries[] = "available_countries";
|
constexpr char availableCountries[] = "available_countries";
|
||||||
|
|
||||||
@@ -33,8 +31,9 @@ namespace
|
|||||||
|
|
||||||
constexpr char isAvailable[] = "is_available";
|
constexpr char isAvailable[] = "is_available";
|
||||||
|
|
||||||
constexpr char subscription[] = "subscription";
|
constexpr char subscriptionPlans[] = "subscription_plans";
|
||||||
constexpr char endDate[] = "end_date";
|
constexpr char minPriceLabel[] = "min_price_label";
|
||||||
|
constexpr char benefits[] = "benefits";
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace serviceType
|
namespace serviceType
|
||||||
@@ -44,7 +43,9 @@ namespace
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ApiServicesModel::ApiServicesModel(QObject *parent) : QAbstractListModel(parent)
|
ApiServicesModel::ApiServicesModel(QObject *parent)
|
||||||
|
: QAbstractListModel(parent)
|
||||||
|
, m_selectedServiceIndex(0)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,9 +69,8 @@ QVariant ApiServicesModel::data(const QModelIndex &index, int role) const
|
|||||||
return apiServiceData.serviceInfo.name;
|
return apiServiceData.serviceInfo.name;
|
||||||
}
|
}
|
||||||
case CardDescriptionRole: {
|
case CardDescriptionRole: {
|
||||||
auto speed = apiServiceData.serviceInfo.speed;
|
|
||||||
if (serviceType == serviceType::amneziaPremium) {
|
if (serviceType == serviceType::amneziaPremium) {
|
||||||
return apiServiceData.serviceInfo.cardDescription.arg(speed);
|
return apiServiceData.serviceInfo.cardDescription;
|
||||||
} else if (serviceType == serviceType::amneziaFree) {
|
} else if (serviceType == serviceType::amneziaFree) {
|
||||||
QString description = apiServiceData.serviceInfo.cardDescription;
|
QString description = apiServiceData.serviceInfo.cardDescription;
|
||||||
if (!isServiceAvailable) {
|
if (!isServiceAvailable) {
|
||||||
@@ -91,42 +91,35 @@ QVariant ApiServicesModel::data(const QModelIndex &index, int role) const
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case SpeedRole: {
|
case IsPremiumRole: {
|
||||||
return tr("%1 MBit/s").arg(apiServiceData.serviceInfo.speed);
|
return serviceType == serviceType::amneziaPremium;
|
||||||
}
|
}
|
||||||
case TimeLimitRole: {
|
case HasSubscriptionPlansRole: {
|
||||||
auto timeLimit = apiServiceData.serviceInfo.timeLimit;
|
return !apiServiceData.subscriptionPlansJson.isEmpty();
|
||||||
if (timeLimit == "0") {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return tr("%1 days").arg(timeLimit);
|
|
||||||
}
|
|
||||||
case RegionRole: {
|
|
||||||
return apiServiceData.serviceInfo.region;
|
|
||||||
}
|
|
||||||
case FeaturesRole: {
|
|
||||||
return apiServiceData.serviceInfo.features;
|
|
||||||
}
|
}
|
||||||
case PriceRole: {
|
case PriceRole: {
|
||||||
auto price = apiServiceData.serviceInfo.price;
|
return apiServiceData.minPriceLabel;
|
||||||
if (price == "free") {
|
|
||||||
return tr("Free");
|
|
||||||
}
|
|
||||||
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
|
||||||
return tr("%1 $").arg(price);
|
|
||||||
#else
|
|
||||||
return tr("%1 $/month").arg(price);
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
case EndDateRole: {
|
case EndDateRole: {
|
||||||
return QDateTime::fromString(apiServiceData.subscription.endDate, Qt::ISODate).toLocalTime().toString("d MMM yyyy");
|
return QDateTime::fromString(apiServiceData.subscription.endDate, Qt::ISODate).toLocalTime().toString("d MMM yyyy");
|
||||||
}
|
}
|
||||||
|
case TermsOfUseUrlRole: {
|
||||||
|
return apiServiceData.serviceInfo.termsOfUseUrl;
|
||||||
|
}
|
||||||
|
case PrivacyPolicyUrlRole: {
|
||||||
|
return apiServiceData.serviceInfo.privacyPolicyUrl;
|
||||||
|
}
|
||||||
|
case ShowRecommendedRole: {
|
||||||
|
return serviceType == serviceType::amneziaPremium;
|
||||||
|
}
|
||||||
case OrderRole: {
|
case OrderRole: {
|
||||||
if (serviceType == serviceType::amneziaPremium) {
|
if (serviceType == serviceType::amneziaPremium) {
|
||||||
return 0;
|
return 0;
|
||||||
} else if (serviceType == serviceType::amneziaFree) {
|
}
|
||||||
|
if (serviceType == serviceType::amneziaFree) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
return QVariant();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,12 +145,27 @@ void ApiServicesModel::updateModel(const QJsonObject &data)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!m_services.isEmpty() && m_selectedServiceIndex >= m_services.size()) {
|
||||||
|
m_selectedServiceIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
endResetModel();
|
endResetModel();
|
||||||
|
|
||||||
|
emit serviceSelectionChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ApiServicesModel::setServiceIndex(const int index)
|
void ApiServicesModel::setServiceIndex(const int index)
|
||||||
{
|
{
|
||||||
m_selectedServiceIndex = index;
|
m_selectedServiceIndex = index;
|
||||||
|
emit serviceSelectionChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiServicesModel::ApiServicesData ApiServicesModel::selectedServiceData() const
|
||||||
|
{
|
||||||
|
if (m_services.isEmpty() || m_selectedServiceIndex < 0 || m_selectedServiceIndex >= m_services.size()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return m_services.at(m_selectedServiceIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
QJsonObject ApiServicesModel::getSelectedServiceInfo()
|
QJsonObject ApiServicesModel::getSelectedServiceInfo()
|
||||||
@@ -214,6 +222,16 @@ QVariant ApiServicesModel::getSelectedServiceData(const QString roleString)
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int ApiServicesModel::serviceIndexForType(const QString &type) const
|
||||||
|
{
|
||||||
|
for (int serviceIndex = 0; serviceIndex < m_services.size(); ++serviceIndex) {
|
||||||
|
if (m_services.at(serviceIndex).type == type) {
|
||||||
|
return serviceIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
QHash<int, QByteArray> ApiServicesModel::roleNames() const
|
QHash<int, QByteArray> ApiServicesModel::roleNames() const
|
||||||
{
|
{
|
||||||
QHash<int, QByteArray> roles;
|
QHash<int, QByteArray> roles;
|
||||||
@@ -221,12 +239,13 @@ QHash<int, QByteArray> ApiServicesModel::roleNames() const
|
|||||||
roles[CardDescriptionRole] = "cardDescription";
|
roles[CardDescriptionRole] = "cardDescription";
|
||||||
roles[ServiceDescriptionRole] = "serviceDescription";
|
roles[ServiceDescriptionRole] = "serviceDescription";
|
||||||
roles[IsServiceAvailableRole] = "isServiceAvailable";
|
roles[IsServiceAvailableRole] = "isServiceAvailable";
|
||||||
roles[SpeedRole] = "speed";
|
roles[IsPremiumRole] = "isPremium";
|
||||||
roles[TimeLimitRole] = "timeLimit";
|
roles[HasSubscriptionPlansRole] = "hasSubscriptionPlans";
|
||||||
roles[RegionRole] = "region";
|
|
||||||
roles[FeaturesRole] = "features";
|
|
||||||
roles[PriceRole] = "price";
|
roles[PriceRole] = "price";
|
||||||
roles[EndDateRole] = "endDate";
|
roles[EndDateRole] = "endDate";
|
||||||
|
roles[TermsOfUseUrlRole] = "termsOfUseUrl";
|
||||||
|
roles[PrivacyPolicyUrlRole] = "privacyPolicyUrl";
|
||||||
|
roles[ShowRecommendedRole] = "showRecommended";
|
||||||
roles[OrderRole] = "order";
|
roles[OrderRole] = "order";
|
||||||
|
|
||||||
return roles;
|
return roles;
|
||||||
@@ -240,18 +259,22 @@ ApiServicesModel::ApiServicesData ApiServicesModel::getApiServicesData(const QJs
|
|||||||
auto availableCountries = data.value(configKey::availableCountries).toArray();
|
auto availableCountries = data.value(configKey::availableCountries).toArray();
|
||||||
auto serviceDescription = data.value(configKey::serviceDescription).toObject();
|
auto serviceDescription = data.value(configKey::serviceDescription).toObject();
|
||||||
|
|
||||||
auto subscriptionObject = data.value(configKey::subscription).toObject();
|
auto subscriptionObject = data.value(apiDefs::key::subscription).toObject();
|
||||||
|
|
||||||
ApiServicesData serviceData;
|
ApiServicesData serviceData;
|
||||||
serviceData.serviceInfo.name = serviceInfo.value(configKey::name).toString();
|
serviceData.serviceInfo.name = serviceDescription.value(configKey::serviceName).toString();
|
||||||
serviceData.serviceInfo.price = serviceInfo.value(configKey::price).toString();
|
|
||||||
serviceData.serviceInfo.region = serviceInfo.value(configKey::region).toString();
|
|
||||||
serviceData.serviceInfo.speed = serviceInfo.value(configKey::speed).toString();
|
|
||||||
serviceData.serviceInfo.timeLimit = serviceInfo.value(configKey::timelimit).toString();
|
|
||||||
|
|
||||||
serviceData.serviceInfo.cardDescription = serviceDescription.value(configKey::cardDescription).toString();
|
serviceData.serviceInfo.cardDescription = serviceDescription.value(configKey::cardDescription).toString();
|
||||||
serviceData.serviceInfo.description = serviceDescription.value(configKey::description).toString();
|
serviceData.serviceInfo.description = serviceDescription.value(configKey::description).toString();
|
||||||
serviceData.serviceInfo.features = serviceDescription.value(configKey::features).toString();
|
serviceData.serviceInfo.termsOfUseUrl = serviceDescription.value(apiDefs::key::termsOfUseUrl).toString();
|
||||||
|
serviceData.serviceInfo.privacyPolicyUrl = serviceDescription.value(apiDefs::key::privacyPolicyUrl).toString();
|
||||||
|
|
||||||
|
serviceData.subscriptionPlansJson = serviceDescription.value(configKey::subscriptionPlans).toArray();
|
||||||
|
serviceData.benefits = serviceDescription.value(configKey::benefits).toArray();
|
||||||
|
|
||||||
|
serviceData.minPriceLabel = serviceDescription.value(configKey::minPriceLabel).toString().trimmed();
|
||||||
|
|
||||||
|
serviceData.supportInfo = data.value(apiDefs::key::supportInfo).toObject();
|
||||||
|
|
||||||
serviceData.type = serviceType;
|
serviceData.type = serviceType;
|
||||||
serviceData.protocol = serviceProtocol;
|
serviceData.protocol = serviceProtocol;
|
||||||
@@ -267,7 +290,7 @@ ApiServicesModel::ApiServicesData ApiServicesModel::getApiServicesData(const QJs
|
|||||||
serviceData.serviceInfo.object = serviceInfo;
|
serviceData.serviceInfo.object = serviceInfo;
|
||||||
serviceData.availableCountries = availableCountries;
|
serviceData.availableCountries = availableCountries;
|
||||||
|
|
||||||
serviceData.subscription.endDate = subscriptionObject.value(configKey::endDate).toString();
|
serviceData.subscription.endDate = subscriptionObject.value(apiDefs::key::endDate).toString();
|
||||||
|
|
||||||
return serviceData;
|
return serviceData;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,65 +4,23 @@
|
|||||||
#include <QAbstractListModel>
|
#include <QAbstractListModel>
|
||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
|
#include <QVector>
|
||||||
|
|
||||||
class ApiServicesModel : public QAbstractListModel
|
class ApiServicesModel : public QAbstractListModel
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
enum Roles {
|
|
||||||
NameRole = Qt::UserRole + 1,
|
|
||||||
CardDescriptionRole,
|
|
||||||
ServiceDescriptionRole,
|
|
||||||
IsServiceAvailableRole,
|
|
||||||
SpeedRole,
|
|
||||||
TimeLimitRole,
|
|
||||||
RegionRole,
|
|
||||||
FeaturesRole,
|
|
||||||
PriceRole,
|
|
||||||
EndDateRole,
|
|
||||||
OrderRole
|
|
||||||
};
|
|
||||||
|
|
||||||
explicit ApiServicesModel(QObject *parent = nullptr);
|
|
||||||
|
|
||||||
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
|
||||||
|
|
||||||
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
|
||||||
|
|
||||||
public slots:
|
|
||||||
void updateModel(const QJsonObject &data);
|
|
||||||
|
|
||||||
void setServiceIndex(const int index);
|
|
||||||
|
|
||||||
QJsonObject getSelectedServiceInfo();
|
|
||||||
QString getSelectedServiceType();
|
|
||||||
QString getSelectedServiceProtocol();
|
|
||||||
QString getSelectedServiceName();
|
|
||||||
QJsonArray getSelectedServiceCountries();
|
|
||||||
|
|
||||||
QString getCountryCode();
|
|
||||||
|
|
||||||
QString getStoreEndpoint();
|
|
||||||
|
|
||||||
QVariant getSelectedServiceData(const QString roleString);
|
|
||||||
|
|
||||||
protected:
|
|
||||||
QHash<int, QByteArray> roleNames() const override;
|
|
||||||
|
|
||||||
private:
|
|
||||||
struct ServiceInfo
|
struct ServiceInfo
|
||||||
{
|
{
|
||||||
QString name;
|
QString name;
|
||||||
QString speed;
|
|
||||||
QString timeLimit;
|
|
||||||
QString region;
|
|
||||||
QString price;
|
|
||||||
|
|
||||||
QString description;
|
QString description;
|
||||||
QString features;
|
|
||||||
QString cardDescription;
|
QString cardDescription;
|
||||||
|
|
||||||
|
QString termsOfUseUrl;
|
||||||
|
QString privacyPolicyUrl;
|
||||||
|
|
||||||
QJsonObject object;
|
QJsonObject object;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,11 +38,66 @@ private:
|
|||||||
QString storeEndpoint;
|
QString storeEndpoint;
|
||||||
|
|
||||||
ServiceInfo serviceInfo;
|
ServiceInfo serviceInfo;
|
||||||
|
QJsonObject supportInfo;
|
||||||
Subscription subscription;
|
Subscription subscription;
|
||||||
|
|
||||||
QJsonArray availableCountries;
|
QJsonArray availableCountries;
|
||||||
|
|
||||||
|
QJsonArray subscriptionPlansJson;
|
||||||
|
QJsonArray benefits;
|
||||||
|
|
||||||
|
QString minPriceLabel;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
enum Roles {
|
||||||
|
NameRole = Qt::UserRole + 1,
|
||||||
|
CardDescriptionRole,
|
||||||
|
ServiceDescriptionRole,
|
||||||
|
IsServiceAvailableRole,
|
||||||
|
IsPremiumRole,
|
||||||
|
HasSubscriptionPlansRole,
|
||||||
|
PriceRole,
|
||||||
|
EndDateRole,
|
||||||
|
TermsOfUseUrlRole,
|
||||||
|
PrivacyPolicyUrlRole,
|
||||||
|
ShowRecommendedRole,
|
||||||
|
OrderRole
|
||||||
|
};
|
||||||
|
|
||||||
|
explicit ApiServicesModel(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
||||||
|
|
||||||
|
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||||
|
|
||||||
|
ApiServicesData selectedServiceData() const;
|
||||||
|
|
||||||
|
public slots:
|
||||||
|
void updateModel(const QJsonObject &data);
|
||||||
|
|
||||||
|
void setServiceIndex(const int index);
|
||||||
|
|
||||||
|
QJsonObject getSelectedServiceInfo();
|
||||||
|
QString getSelectedServiceType();
|
||||||
|
QString getSelectedServiceProtocol();
|
||||||
|
QString getSelectedServiceName();
|
||||||
|
QJsonArray getSelectedServiceCountries();
|
||||||
|
|
||||||
|
QString getCountryCode();
|
||||||
|
|
||||||
|
QString getStoreEndpoint();
|
||||||
|
|
||||||
|
QVariant getSelectedServiceData(const QString roleString);
|
||||||
|
|
||||||
|
Q_INVOKABLE int serviceIndexForType(const QString &type) const;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void serviceSelectionChanged();
|
||||||
|
|
||||||
|
protected:
|
||||||
|
QHash<int, QByteArray> roleNames() const override;
|
||||||
|
|
||||||
|
private:
|
||||||
ApiServicesData getApiServicesData(const QJsonObject &data);
|
ApiServicesData getApiServicesData(const QJsonObject &data);
|
||||||
|
|
||||||
QString m_countryCode;
|
QString m_countryCode;
|
||||||
@@ -93,4 +106,4 @@ private:
|
|||||||
int m_selectedServiceIndex;
|
int m_selectedServiceIndex;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // APISERVICESMODEL_H
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
#include "apiSubscriptionPlansModel.h"
|
||||||
|
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QJsonValue>
|
||||||
|
#include <QModelIndex>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
namespace configKey
|
||||||
|
{
|
||||||
|
constexpr char billingPeriod[] = "billing_period";
|
||||||
|
constexpr char priceLabel[] = "price_label";
|
||||||
|
constexpr char subtitle[] = "subtitle";
|
||||||
|
constexpr char recommended[] = "recommended";
|
||||||
|
constexpr char checkoutUrl[] = "checkout_url";
|
||||||
|
constexpr char isTrial[] = "is_trial";
|
||||||
|
constexpr char serviceProtocol[] = "service_protocol";
|
||||||
|
constexpr char storeProductId[] = "store_product_id";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiSubscriptionPlansModel::ApiSubscriptionPlansModel(QObject *parent)
|
||||||
|
: QAbstractListModel(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
int ApiSubscriptionPlansModel::rowCount(const QModelIndex &parent) const
|
||||||
|
{
|
||||||
|
if (parent.isValid()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return m_subscriptionPlans.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariant ApiSubscriptionPlansModel::data(const QModelIndex &index, int role) const
|
||||||
|
{
|
||||||
|
if (!index.isValid() || index.row() < 0 || index.row() >= m_subscriptionPlans.size()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const SubscriptionPlanItem &plan = m_subscriptionPlans.at(index.row());
|
||||||
|
switch (role) {
|
||||||
|
case BillingPeriodRole:
|
||||||
|
return plan.billingPeriod;
|
||||||
|
case PriceLabelRole:
|
||||||
|
return plan.priceLabel;
|
||||||
|
case SubtitleRole:
|
||||||
|
return plan.subtitle;
|
||||||
|
case RecommendedRole:
|
||||||
|
return plan.recommended;
|
||||||
|
case CheckoutUrlRole:
|
||||||
|
return plan.checkoutUrl;
|
||||||
|
case IsTrialRole:
|
||||||
|
return plan.isTrial;
|
||||||
|
case ServiceProtocolRole:
|
||||||
|
return plan.serviceProtocol;
|
||||||
|
case StoreProductIdRole:
|
||||||
|
return plan.storeProductId;
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QHash<int, QByteArray> ApiSubscriptionPlansModel::roleNames() const
|
||||||
|
{
|
||||||
|
return {
|
||||||
|
{ BillingPeriodRole, "billingPeriod" },
|
||||||
|
{ PriceLabelRole, "priceLabel" },
|
||||||
|
{ SubtitleRole, "subtitle" },
|
||||||
|
{ RecommendedRole, "recommended" },
|
||||||
|
{ CheckoutUrlRole, "checkoutUrl" },
|
||||||
|
{ IsTrialRole, "isTrial" },
|
||||||
|
{ ServiceProtocolRole, "serviceProtocol" },
|
||||||
|
{ StoreProductIdRole, "storeProductId" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApiSubscriptionPlansModel::updateModel(const QJsonArray &arr)
|
||||||
|
{
|
||||||
|
beginResetModel();
|
||||||
|
m_subscriptionPlans.clear();
|
||||||
|
m_subscriptionPlans.reserve(arr.size());
|
||||||
|
for (const QJsonValue &planValue : arr) {
|
||||||
|
if (!planValue.isObject()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const QJsonObject planObject = planValue.toObject();
|
||||||
|
SubscriptionPlanItem subscriptionPlan;
|
||||||
|
subscriptionPlan.billingPeriod = planObject.value(configKey::billingPeriod).toString();
|
||||||
|
subscriptionPlan.priceLabel = planObject.value(configKey::priceLabel).toString();
|
||||||
|
subscriptionPlan.subtitle = planObject.value(configKey::subtitle).toString();
|
||||||
|
subscriptionPlan.recommended = planObject.value(configKey::recommended).toBool();
|
||||||
|
subscriptionPlan.checkoutUrl = planObject.value(configKey::checkoutUrl).toString();
|
||||||
|
subscriptionPlan.isTrial = planObject.value(configKey::isTrial).toBool();
|
||||||
|
subscriptionPlan.serviceProtocol = planObject.value(configKey::serviceProtocol).toString();
|
||||||
|
subscriptionPlan.storeProductId = planObject.value(configKey::storeProductId).toString();
|
||||||
|
m_subscriptionPlans.append(std::move(subscriptionPlan));
|
||||||
|
}
|
||||||
|
endResetModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApiSubscriptionPlansModel::clear()
|
||||||
|
{
|
||||||
|
beginResetModel();
|
||||||
|
m_subscriptionPlans.clear();
|
||||||
|
endResetModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariantMap ApiSubscriptionPlansModel::planAt(int row) const
|
||||||
|
{
|
||||||
|
if (row < 0 || row >= m_subscriptionPlans.size()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const QModelIndex modelIndex = index(row, 0);
|
||||||
|
QVariantMap planMap;
|
||||||
|
const QHash<int, QByteArray> roles = roleNames();
|
||||||
|
for (auto roleIt = roles.cbegin(); roleIt != roles.cend(); ++roleIt) {
|
||||||
|
planMap.insert(QString::fromUtf8(roleIt.value()), data(modelIndex, roleIt.key()));
|
||||||
|
}
|
||||||
|
return planMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
int ApiSubscriptionPlansModel::recommendedRowIndex() const
|
||||||
|
{
|
||||||
|
for (int planIndex = 0; planIndex < m_subscriptionPlans.size(); ++planIndex) {
|
||||||
|
if (m_subscriptionPlans.at(planIndex).recommended) {
|
||||||
|
return planIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#ifndef APISUBSCRIPTIONPLANSMODEL_H
|
||||||
|
#define APISUBSCRIPTIONPLANSMODEL_H
|
||||||
|
|
||||||
|
#include <QAbstractListModel>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QVector>
|
||||||
|
|
||||||
|
class ApiSubscriptionPlansModel : public QAbstractListModel
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
enum Roles {
|
||||||
|
BillingPeriodRole = Qt::UserRole + 1,
|
||||||
|
PriceLabelRole,
|
||||||
|
SubtitleRole,
|
||||||
|
RecommendedRole,
|
||||||
|
CheckoutUrlRole,
|
||||||
|
IsTrialRole,
|
||||||
|
ServiceProtocolRole,
|
||||||
|
StoreProductIdRole
|
||||||
|
};
|
||||||
|
Q_ENUM(Roles)
|
||||||
|
|
||||||
|
explicit ApiSubscriptionPlansModel(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
||||||
|
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||||
|
QHash<int, QByteArray> roleNames() const override;
|
||||||
|
|
||||||
|
void updateModel(const QJsonArray &arr);
|
||||||
|
void clear();
|
||||||
|
|
||||||
|
Q_INVOKABLE QVariantMap planAt(int row) const;
|
||||||
|
Q_INVOKABLE int recommendedRowIndex() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct SubscriptionPlanItem
|
||||||
|
{
|
||||||
|
QString billingPeriod;
|
||||||
|
QString priceLabel;
|
||||||
|
QString subtitle;
|
||||||
|
bool recommended = false;
|
||||||
|
QString checkoutUrl;
|
||||||
|
bool isTrial = false;
|
||||||
|
QString serviceProtocol;
|
||||||
|
QString storeProductId;
|
||||||
|
};
|
||||||
|
|
||||||
|
QVector<SubscriptionPlanItem> m_subscriptionPlans;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -179,6 +179,40 @@ QVariant ServersModel::data(const QModelIndex &index, int role) const
|
|||||||
case AdEndpointRole: {
|
case AdEndpointRole: {
|
||||||
return apiConfig.value(apiDefs::key::serviceInfo).toObject().value(apiDefs::key::adEndpoint).toString();
|
return apiConfig.value(apiDefs::key::serviceInfo).toObject().value(apiDefs::key::adEndpoint).toString();
|
||||||
}
|
}
|
||||||
|
case IsRenewalAvailableRole: {
|
||||||
|
return apiConfig.value(apiDefs::key::serviceInfo).toObject().value(apiDefs::key::isRenewalAvailable).toBool(false);
|
||||||
|
}
|
||||||
|
case IsSubscriptionExpiredRole: {
|
||||||
|
if (configVersion != apiDefs::ConfigSource::AmneziaGateway) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (apiConfig.value(apiDefs::key::isInAppPurchase).toBool(false)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (apiConfig.value(apiDefs::key::subscriptionExpiredByServer).toBool(false)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const QString endDate =
|
||||||
|
apiConfig.value(apiDefs::key::subscription).toObject().value(apiDefs::key::endDate).toString();
|
||||||
|
if (endDate.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return apiUtils::isSubscriptionExpired(endDate);
|
||||||
|
}
|
||||||
|
case IsSubscriptionExpiringSoonRole: {
|
||||||
|
if (configVersion != apiDefs::ConfigSource::AmneziaGateway) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (apiConfig.value(apiDefs::key::isInAppPurchase).toBool(false)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const QString endDate =
|
||||||
|
apiConfig.value(apiDefs::key::subscription).toObject().value(apiDefs::key::endDate).toString();
|
||||||
|
if (endDate.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return apiUtils::isSubscriptionExpiringSoon(endDate);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return QVariant();
|
return QVariant();
|
||||||
@@ -442,6 +476,10 @@ QHash<int, QByteArray> ServersModel::roleNames() const
|
|||||||
roles[AdHeaderRole] = "adHeader";
|
roles[AdHeaderRole] = "adHeader";
|
||||||
roles[AdDescriptionRole] = "adDescription";
|
roles[AdDescriptionRole] = "adDescription";
|
||||||
roles[AdEndpointRole] = "adEndpoint";
|
roles[AdEndpointRole] = "adEndpoint";
|
||||||
|
roles[IsRenewalAvailableRole] = "isRenewalAvailable";
|
||||||
|
|
||||||
|
roles[IsSubscriptionExpiredRole] = "isSubscriptionExpired";
|
||||||
|
roles[IsSubscriptionExpiringSoonRole] = "isSubscriptionExpiringSoon";
|
||||||
|
|
||||||
return roles;
|
return roles;
|
||||||
}
|
}
|
||||||
@@ -727,21 +765,21 @@ bool ServersModel::isServerFromApiAlreadyExists(const QString &userCountryCode,
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ServersModel::hasServerWithVpnKey(const QString &vpnKey) const
|
int ServersModel::indexOfServerWithVpnKey(const QString &vpnKey) const
|
||||||
{
|
{
|
||||||
const QString normalizedInput = normalizeVpnKey(vpnKey);
|
const QString normalizedInput = normalizeVpnKey(vpnKey);
|
||||||
if (normalizedInput.isEmpty()) {
|
if (normalizedInput.isEmpty()) {
|
||||||
return false;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const auto &server : std::as_const(m_servers)) {
|
for (int i = 0; i < m_servers.size(); ++i) {
|
||||||
const auto apiConfig = server.toObject().value(configKey::apiConfig).toObject();
|
const auto apiConfig = m_servers.at(i).toObject().value(configKey::apiConfig).toObject();
|
||||||
const QString existingKey = normalizeVpnKey(apiConfig.value(apiDefs::key::vpnKey).toString());
|
const QString existingKey = normalizeVpnKey(apiConfig.value(apiDefs::key::vpnKey).toString());
|
||||||
if (!existingKey.isEmpty() && existingKey == normalizedInput) {
|
if (!existingKey.isEmpty() && existingKey == normalizedInput) {
|
||||||
return true;
|
return i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ServersModel::serverHasInstalledContainers(const int serverIndex) const
|
bool ServersModel::serverHasInstalledContainers(const int serverIndex) const
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ public:
|
|||||||
AdHeaderRole,
|
AdHeaderRole,
|
||||||
AdDescriptionRole,
|
AdDescriptionRole,
|
||||||
AdEndpointRole,
|
AdEndpointRole,
|
||||||
|
IsRenewalAvailableRole,
|
||||||
|
|
||||||
|
IsSubscriptionExpiredRole,
|
||||||
|
IsSubscriptionExpiringSoonRole,
|
||||||
|
|
||||||
HasAmneziaDns
|
HasAmneziaDns
|
||||||
};
|
};
|
||||||
@@ -140,7 +144,7 @@ public slots:
|
|||||||
|
|
||||||
bool isServerFromApiAlreadyExists(const quint16 crc);
|
bool isServerFromApiAlreadyExists(const quint16 crc);
|
||||||
bool isServerFromApiAlreadyExists(const QString &userCountryCode, const QString &serviceType, const QString &serviceProtocol);
|
bool isServerFromApiAlreadyExists(const QString &userCountryCode, const QString &serviceType, const QString &serviceProtocol);
|
||||||
bool hasServerWithVpnKey(const QString &vpnKey) const;
|
int indexOfServerWithVpnKey(const QString &vpnKey) const;
|
||||||
|
|
||||||
QVariant getDefaultServerData(const QString roleString);
|
QVariant getDefaultServerData(const QString roleString);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
|
||||||
|
import Style 1.0
|
||||||
|
|
||||||
|
import "../Controls2/TextTypes"
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string iconSource: ""
|
||||||
|
property string titleText: ""
|
||||||
|
property string bodyText: ""
|
||||||
|
property bool link: false
|
||||||
|
|
||||||
|
readonly property string bodyLineText: root.link && root.bodyText.length > 0 ? "@" + root.bodyText : root.bodyText
|
||||||
|
|
||||||
|
readonly property bool bodyClickable: root.link && root.bodyText.length > 0
|
||||||
|
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
Image {
|
||||||
|
Layout.alignment: Qt.AlignTop
|
||||||
|
Layout.preferredWidth: 22
|
||||||
|
Layout.preferredHeight: 22
|
||||||
|
source: root.iconSource
|
||||||
|
fillMode: Image.PreserveAspectFit
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
LabelTextType {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: root.titleText
|
||||||
|
color: AmneziaStyle.color.paleGray
|
||||||
|
font.pixelSize: 16
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: bodyLabel.implicitHeight
|
||||||
|
|
||||||
|
LabelTextType {
|
||||||
|
id: bodyLabel
|
||||||
|
width: parent.width
|
||||||
|
text: root.bodyLineText
|
||||||
|
color: root.link ? AmneziaStyle.color.goldenApricot : AmneziaStyle.color.mutedGray
|
||||||
|
font.pixelSize: 14
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: bodyLabel
|
||||||
|
visible: root.bodyClickable
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: Qt.openUrlExternally("https://t.me/" + root.bodyText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
|
||||||
|
import "."
|
||||||
|
|
||||||
|
import Style 1.0
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var benefitsModel: null
|
||||||
|
|
||||||
|
visible: benefitsModel && benefitsModel.rowCount() > 0
|
||||||
|
|
||||||
|
radius: 16
|
||||||
|
color: AmneziaStyle.color.benefitsPanelBackground
|
||||||
|
implicitHeight: inner.implicitHeight + 24
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: inner
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 20
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: benefitsModel
|
||||||
|
|
||||||
|
delegate: BenefitRow {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
iconSource: model.icon
|
||||||
|
titleText: model.title
|
||||||
|
bodyText: model.body
|
||||||
|
link: !!model.link
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,7 +67,12 @@ ListViewType {
|
|||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
|
|
||||||
text: name
|
text: name
|
||||||
descriptionText: serverDescription
|
descriptionText: isServerFromGatewayApi && (isSubscriptionExpired || isSubscriptionExpiringSoon)
|
||||||
|
? (isSubscriptionExpired ? qsTr("Subscription expired. Please renew") : qsTr("Subscription expiring soon"))
|
||||||
|
: serverDescription
|
||||||
|
descriptionColor: isServerFromGatewayApi && (isSubscriptionExpired || isSubscriptionExpiringSoon)
|
||||||
|
? (isSubscriptionExpired ? AmneziaStyle.color.vibrantRed : AmneziaStyle.color.goldenApricot)
|
||||||
|
: AmneziaStyle.color.mutedGray
|
||||||
|
|
||||||
checked: index === root.selectedIndex
|
checked: index === root.selectedIndex
|
||||||
checkable: !ConnectionController.isConnected
|
checkable: !ConnectionController.isConnected
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import PageEnum 1.0
|
||||||
|
import Style 1.0
|
||||||
|
|
||||||
|
import "../Controls2"
|
||||||
|
import "../Controls2/TextTypes"
|
||||||
|
|
||||||
|
DrawerType2 {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property bool isRenewalAvailable: false
|
||||||
|
|
||||||
|
onOpened: {
|
||||||
|
isRenewalAvailable = ServersModel.getProcessedServerData("isRenewalAvailable") && !ApiAccountInfoModel.data("isInAppPurchase")
|
||||||
|
}
|
||||||
|
|
||||||
|
expandedStateContent: ColumnLayout {
|
||||||
|
id: content
|
||||||
|
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
onImplicitHeightChanged: {
|
||||||
|
root.expandedHeight = content.implicitHeight + 32 + SettingsController.safeAreaBottomMargin
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.topMargin: 24
|
||||||
|
Layout.rightMargin: 16
|
||||||
|
Layout.leftMargin: 16
|
||||||
|
implicitHeight: titleText.implicitHeight
|
||||||
|
|
||||||
|
Header2TextType {
|
||||||
|
id: titleText
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
|
||||||
|
text: ServersModel.getProcessedServerData("name") + qsTr(" subscription has expired")
|
||||||
|
horizontalAlignment: Text.AlignLeft
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ParagraphTextType {
|
||||||
|
visible: root.isRenewalAvailable
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.topMargin: 8
|
||||||
|
Layout.rightMargin: 16
|
||||||
|
Layout.leftMargin: 16
|
||||||
|
|
||||||
|
text: qsTr("Renew to continue using VPN")
|
||||||
|
horizontalAlignment: Text.AlignLeft
|
||||||
|
}
|
||||||
|
|
||||||
|
BasicButtonType {
|
||||||
|
visible: root.isRenewalAvailable
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.topMargin: 16
|
||||||
|
Layout.rightMargin: 16
|
||||||
|
Layout.leftMargin: 16
|
||||||
|
|
||||||
|
text: qsTr("Renew")
|
||||||
|
|
||||||
|
defaultColor: AmneziaStyle.color.paleGray
|
||||||
|
hoveredColor: AmneziaStyle.color.lightGray
|
||||||
|
pressedColor: AmneziaStyle.color.mutedGray
|
||||||
|
textColor: AmneziaStyle.color.midnightBlack
|
||||||
|
|
||||||
|
clickedFunc: function() {
|
||||||
|
ApiSettingsController.getRenewalLink()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BasicButtonType {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
Layout.topMargin: 8
|
||||||
|
Layout.bottomMargin: 8
|
||||||
|
|
||||||
|
implicitHeight: 25
|
||||||
|
|
||||||
|
defaultColor: AmneziaStyle.color.transparent
|
||||||
|
hoveredColor: AmneziaStyle.color.translucentWhite
|
||||||
|
pressedColor: AmneziaStyle.color.sheerWhite
|
||||||
|
textColor: AmneziaStyle.color.goldenApricot
|
||||||
|
|
||||||
|
text: qsTr("Support")
|
||||||
|
|
||||||
|
clickedFunc: function() {
|
||||||
|
PageController.showBusyIndicator(true)
|
||||||
|
let result = ApiSettingsController.getAccountInfo(false)
|
||||||
|
PageController.showBusyIndicator(false)
|
||||||
|
if (result) {
|
||||||
|
root.closeTriggered()
|
||||||
|
PageController.goToPage(PageEnum.PageSettingsApiSupport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
|
||||||
|
import Style 1.0
|
||||||
|
|
||||||
|
import "../Controls2/TextTypes"
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property bool selected: false
|
||||||
|
property string billingPeriod: ""
|
||||||
|
property string priceLabel: ""
|
||||||
|
property string subtitle: ""
|
||||||
|
property bool showRecommendedBadge: false
|
||||||
|
property string recommendedText: "Recommended"
|
||||||
|
|
||||||
|
signal selectRequested
|
||||||
|
|
||||||
|
implicitHeight: cardLayout.implicitHeight + 28
|
||||||
|
radius: 16
|
||||||
|
color: AmneziaStyle.color.transparent
|
||||||
|
border.width: selected ? 2 : 1
|
||||||
|
border.color: selected ? AmneziaStyle.color.goldenApricot : AmneziaStyle.color.charcoalGray
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: cardLayout
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.leftMargin: 16
|
||||||
|
anchors.rightMargin: 16
|
||||||
|
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
LabelTextType {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: root.billingPeriod
|
||||||
|
color: root.selected ? AmneziaStyle.color.goldenApricot : AmneziaStyle.color.paleGray
|
||||||
|
font.pixelSize: 17
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
}
|
||||||
|
|
||||||
|
LabelTextType {
|
||||||
|
text: root.priceLabel
|
||||||
|
color: root.selected ? AmneziaStyle.color.goldenApricot : AmneziaStyle.color.paleGray
|
||||||
|
font.pixelSize: 17
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: root.subtitle.length > 0 || root.showRecommendedBadge
|
||||||
|
|
||||||
|
LabelTextType {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: root.subtitle
|
||||||
|
color: AmneziaStyle.color.mutedGray
|
||||||
|
font.pixelSize: 13
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
visible: root.showRecommendedBadge
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
radius: 10
|
||||||
|
color: AmneziaStyle.color.softViolet
|
||||||
|
implicitHeight: recLabel.implicitHeight + 8
|
||||||
|
implicitWidth: recLabel.implicitWidth + 16
|
||||||
|
|
||||||
|
LabelTextType {
|
||||||
|
id: recLabel
|
||||||
|
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: root.recommendedText
|
||||||
|
color: AmneziaStyle.color.midnightBlack
|
||||||
|
font.pixelSize: 11
|
||||||
|
font.weight: Font.Medium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
onClicked: root.selectRequested()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
|
||||||
|
import Style 1.0
|
||||||
|
|
||||||
|
import "../Controls2/TextTypes"
|
||||||
|
|
||||||
|
ParagraphTextType {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string termsUrl: ""
|
||||||
|
property string privacyUrl: ""
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
textFormat: Text.RichText
|
||||||
|
color: AmneziaStyle.color.mutedGray
|
||||||
|
font.pixelSize: 12
|
||||||
|
lineHeight: 1.35
|
||||||
|
lineHeightMode: Text.ProportionalHeight
|
||||||
|
|
||||||
|
text: qsTr("By continuing, you agree to the <a href=\"%1\" style=\"color: %3;\">Terms of Use</a> and <a href=\"%2\" style=\"color: %3;\">Privacy Policy</a>")
|
||||||
|
.arg(root.termsUrl)
|
||||||
|
.arg(root.privacyUrl)
|
||||||
|
.arg(AmneziaStyle.color.goldenApricotString)
|
||||||
|
|
||||||
|
onLinkActivated: function(link) {
|
||||||
|
Qt.openUrlExternally(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
acceptedButtons: Qt.NoButton
|
||||||
|
cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,11 @@ Button {
|
|||||||
property string bodyText
|
property string bodyText
|
||||||
property string footerText
|
property string footerText
|
||||||
|
|
||||||
|
property color headerTextColor: AmneziaStyle.color.paleGray
|
||||||
|
property color bodyTextColor: AmneziaStyle.color.mutedGray
|
||||||
|
property bool showRecommendedBadge: false
|
||||||
|
property string recommendedText: ""
|
||||||
|
|
||||||
property string hoveredColor: AmneziaStyle.color.slateGray
|
property string hoveredColor: AmneziaStyle.color.slateGray
|
||||||
property string defaultColor: AmneziaStyle.color.onyxBlack
|
property string defaultColor: AmneziaStyle.color.onyxBlack
|
||||||
|
|
||||||
@@ -23,11 +28,12 @@ Button {
|
|||||||
|
|
||||||
property string leftImageSource
|
property string leftImageSource
|
||||||
|
|
||||||
property real textOpacity: 1.0
|
|
||||||
|
|
||||||
property alias focusItem: rightImage
|
property alias focusItem: rightImage
|
||||||
|
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
|
clip: false
|
||||||
|
|
||||||
|
readonly property real cardTextOpacity: !enabled ? 1.0 : pressed ? 0.7 : hovered ? 0.8 : 1.0
|
||||||
|
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
id: backgroundRect
|
id: backgroundRect
|
||||||
@@ -35,7 +41,7 @@ Button {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
radius: 16
|
radius: 16
|
||||||
|
|
||||||
color: defaultColor
|
color: root.hovered && root.enabled ? root.hoveredColor : root.defaultColor
|
||||||
|
|
||||||
Behavior on color {
|
Behavior on color {
|
||||||
PropertyAnimation { duration: 200 }
|
PropertyAnimation { duration: 200 }
|
||||||
@@ -43,143 +49,157 @@ Button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
contentItem: Item {
|
contentItem: Item {
|
||||||
|
id: contentRoot
|
||||||
|
|
||||||
|
z: 1
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
|
|
||||||
implicitHeight: content.implicitHeight
|
readonly property bool badgeVisible: root.showRecommendedBadge && root.recommendedText !== ""
|
||||||
|
|
||||||
RowLayout {
|
implicitHeight: layoutCol.implicitHeight
|
||||||
id: content
|
|
||||||
|
|
||||||
anchors.fill: parent
|
ColumnLayout {
|
||||||
|
id: layoutCol
|
||||||
|
|
||||||
Image {
|
anchors.left: parent.left
|
||||||
id: leftImage
|
anchors.right: parent.right
|
||||||
source: leftImageSource
|
spacing: 0
|
||||||
|
|
||||||
visible: leftImageSource !== ""
|
Item {
|
||||||
|
id: badgeTopSpacer
|
||||||
|
|
||||||
Layout.alignment: Qt.AlignLeft | Qt.AlignTop
|
Layout.fillWidth: true
|
||||||
Layout.topMargin: 24
|
Layout.preferredHeight: contentRoot.badgeVisible ? (recBadge.height / 2 + 8) : 0
|
||||||
Layout.bottomMargin: 24
|
|
||||||
Layout.leftMargin: 24
|
|
||||||
}
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
|
|
||||||
ListItemTitleType {
|
|
||||||
text: root.headerText
|
|
||||||
visible: text !== ""
|
|
||||||
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.rightMargin: 16
|
|
||||||
Layout.leftMargin: 16
|
|
||||||
Layout.topMargin: 16
|
|
||||||
Layout.bottomMargin: root.bodyText !== "" ? 0 : 16
|
|
||||||
|
|
||||||
opacity: root.textOpacity
|
|
||||||
}
|
|
||||||
|
|
||||||
CaptionTextType {
|
|
||||||
text: root.bodyText
|
|
||||||
visible: text !== ""
|
|
||||||
|
|
||||||
color: AmneziaStyle.color.mutedGray
|
|
||||||
textFormat: Text.RichText
|
|
||||||
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.rightMargin: 16
|
|
||||||
Layout.leftMargin: 16
|
|
||||||
Layout.bottomMargin: root.footerText !== "" ? 0 : 16
|
|
||||||
|
|
||||||
opacity: root.textOpacity
|
|
||||||
}
|
|
||||||
|
|
||||||
ButtonTextType {
|
|
||||||
text: root.footerText
|
|
||||||
visible: text !== ""
|
|
||||||
|
|
||||||
color: AmneziaStyle.color.mutedGray
|
|
||||||
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.rightMargin: 16
|
|
||||||
Layout.leftMargin: 16
|
|
||||||
Layout.topMargin: 16
|
|
||||||
Layout.bottomMargin: 16
|
|
||||||
|
|
||||||
opacity: root.textOpacity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ImageButtonType {
|
|
||||||
id: rightImage
|
|
||||||
|
|
||||||
implicitWidth: 40
|
|
||||||
implicitHeight: 40
|
|
||||||
|
|
||||||
hoverEnabled: false
|
|
||||||
image: rightImageSource
|
|
||||||
imageColor: rightImageColor
|
|
||||||
visible: rightImageSource ? true : false
|
|
||||||
|
|
||||||
Layout.alignment: Qt.AlignRight | Qt.AlignTop
|
|
||||||
Layout.topMargin: 16
|
|
||||||
Layout.bottomMargin: 16
|
|
||||||
Layout.rightMargin: 16
|
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: rightImageBackground
|
id: recBadge
|
||||||
|
|
||||||
anchors.fill: parent
|
visible: contentRoot.badgeVisible
|
||||||
radius: 12
|
z: 2
|
||||||
color: "transparent"
|
|
||||||
|
|
||||||
Behavior on color {
|
anchors.left: parent.left
|
||||||
PropertyAnimation { duration: 200 }
|
anchors.leftMargin: 20
|
||||||
|
anchors.verticalCenter: parent.top
|
||||||
|
|
||||||
|
radius: 10
|
||||||
|
color: AmneziaStyle.color.softViolet
|
||||||
|
implicitHeight: recLabel.implicitHeight + 8
|
||||||
|
implicitWidth: recLabel.implicitWidth + 16
|
||||||
|
|
||||||
|
width: implicitWidth
|
||||||
|
height: implicitHeight
|
||||||
|
|
||||||
|
BadgeTextType {
|
||||||
|
id: recLabel
|
||||||
|
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: root.recommendedText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: content
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
Image {
|
||||||
|
id: leftImage
|
||||||
|
source: leftImageSource
|
||||||
|
|
||||||
|
visible: leftImageSource !== ""
|
||||||
|
|
||||||
|
Layout.alignment: Qt.AlignLeft | Qt.AlignTop
|
||||||
|
Layout.topMargin: 24
|
||||||
|
Layout.bottomMargin: 24
|
||||||
|
Layout.leftMargin: 24
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
|
||||||
|
ListItemTitleType {
|
||||||
|
text: root.headerText
|
||||||
|
visible: text !== ""
|
||||||
|
|
||||||
|
color: root.headerTextColor
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.rightMargin: 16
|
||||||
|
Layout.leftMargin: 16
|
||||||
|
Layout.topMargin: contentRoot.badgeVisible ? 0 : 16
|
||||||
|
Layout.bottomMargin: root.bodyText !== "" ? 0 : 16
|
||||||
|
|
||||||
|
opacity: root.cardTextOpacity
|
||||||
|
}
|
||||||
|
|
||||||
|
CaptionTextType {
|
||||||
|
text: root.bodyText
|
||||||
|
visible: text !== ""
|
||||||
|
|
||||||
|
color: root.bodyTextColor
|
||||||
|
textFormat: Text.RichText
|
||||||
|
onLinkActivated: function(link) {
|
||||||
|
Qt.openUrlExternally(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.rightMargin: 16
|
||||||
|
Layout.leftMargin: 16
|
||||||
|
Layout.bottomMargin: root.footerText !== "" ? 0 : 8
|
||||||
|
|
||||||
|
opacity: root.cardTextOpacity
|
||||||
|
}
|
||||||
|
|
||||||
|
ButtonTextType {
|
||||||
|
text: root.footerText
|
||||||
|
visible: text !== ""
|
||||||
|
|
||||||
|
color: AmneziaStyle.color.mutedGray
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.rightMargin: 16
|
||||||
|
Layout.leftMargin: 16
|
||||||
|
Layout.topMargin: 16
|
||||||
|
Layout.bottomMargin: 16
|
||||||
|
|
||||||
|
opacity: root.cardTextOpacity
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onClicked: {
|
ImageButtonType {
|
||||||
root.clicked()
|
id: rightImage
|
||||||
|
|
||||||
|
implicitWidth: 40
|
||||||
|
implicitHeight: 40
|
||||||
|
|
||||||
|
hoverEnabled: false
|
||||||
|
image: rightImageSource
|
||||||
|
imageColor: rightImageColor
|
||||||
|
visible: rightImageSource ? true : false
|
||||||
|
|
||||||
|
Layout.alignment: Qt.AlignRight | Qt.AlignTop
|
||||||
|
Layout.topMargin: 16
|
||||||
|
Layout.bottomMargin: 16
|
||||||
|
Layout.rightMargin: 16
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: rightImageBackground
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: 12
|
||||||
|
color: root.pressed ? rightImage.pressedColor : root.hovered ? rightImage.hoveredColor : rightImage.defaultColor
|
||||||
|
|
||||||
|
Behavior on color {
|
||||||
|
PropertyAnimation { duration: 200 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
root.clicked()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
anchors.fill: parent
|
|
||||||
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
hoverEnabled: true
|
|
||||||
enabled: root.enabled
|
|
||||||
|
|
||||||
onEntered: {
|
|
||||||
backgroundRect.color = root.hoveredColor
|
|
||||||
|
|
||||||
if (rightImageSource) {
|
|
||||||
rightImageBackground.color = rightImage.hoveredColor
|
|
||||||
}
|
|
||||||
root.textOpacity = 0.8
|
|
||||||
}
|
|
||||||
|
|
||||||
onExited: {
|
|
||||||
backgroundRect.color = root.defaultColor
|
|
||||||
|
|
||||||
if (rightImageSource) {
|
|
||||||
rightImageBackground.color = rightImage.defaultColor
|
|
||||||
}
|
|
||||||
root.textOpacity = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
onPressedChanged: {
|
|
||||||
if (rightImageSource) {
|
|
||||||
rightImageBackground.color = pressed ? rightImage.pressedColor : entered ? rightImage.hoveredColor : rightImage.defaultColor
|
|
||||||
}
|
|
||||||
root.textOpacity = 0.7
|
|
||||||
}
|
|
||||||
|
|
||||||
onClicked: {
|
|
||||||
root.clicked()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
|
import Qt5Compat.GraphicalEffects
|
||||||
|
|
||||||
import Style 1.0
|
import Style 1.0
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ Item {
|
|||||||
property int borderFocusedWidth: 1
|
property int borderFocusedWidth: 1
|
||||||
|
|
||||||
property string rightImageColor: AmneziaStyle.color.paleGray
|
property string rightImageColor: AmneziaStyle.color.paleGray
|
||||||
|
property string leftImageColor: ""
|
||||||
|
|
||||||
property bool descriptionOnTop: false
|
property bool descriptionOnTop: false
|
||||||
property bool hideDescription: true
|
property bool hideDescription: true
|
||||||
@@ -140,6 +142,14 @@ Item {
|
|||||||
|
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
source: leftImageSource
|
source: leftImageSource
|
||||||
|
visible: leftImageColor === ""
|
||||||
|
}
|
||||||
|
|
||||||
|
ColorOverlay {
|
||||||
|
anchors.fill: leftImage
|
||||||
|
source: leftImage
|
||||||
|
color: leftImageColor
|
||||||
|
visible: leftImageColor !== ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ Switch {
|
|||||||
Keys.onSpacePressed: event => handleSwitch(event)
|
Keys.onSpacePressed: event => handleSwitch(event)
|
||||||
|
|
||||||
function handleSwitch(event) {
|
function handleSwitch(event) {
|
||||||
if (!event.isAutoRepeat) {
|
if (root.enabled && !event.isAutoRepeat) {
|
||||||
root.checked = !root.checked
|
root.checked = !root.checked
|
||||||
root.toggled()
|
root.toggled()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import Style 1.0
|
||||||
|
|
||||||
|
Text {
|
||||||
|
lineHeight: 10 + LanguageModel.getLineHeightAppend()
|
||||||
|
lineHeightMode: Text.FixedHeight
|
||||||
|
|
||||||
|
color: AmneziaStyle.color.midnightBlack
|
||||||
|
font.pixelSize: 11
|
||||||
|
font.weight: Font.Medium
|
||||||
|
font.family: "PT Root UI VF"
|
||||||
|
|
||||||
|
wrapMode: Text.NoWrap
|
||||||
|
}
|
||||||
@@ -12,13 +12,17 @@ QtObject {
|
|||||||
readonly property color slateGray: '#2C2D30'
|
readonly property color slateGray: '#2C2D30'
|
||||||
readonly property color onyxBlack: '#1C1D21'
|
readonly property color onyxBlack: '#1C1D21'
|
||||||
readonly property color midnightBlack: '#0E0E11'
|
readonly property color midnightBlack: '#0E0E11'
|
||||||
readonly property color goldenApricot: '#FBB26A'
|
readonly property color goldenApricot: goldenApricotString
|
||||||
|
readonly property color benefitsPanelBackground: '#1C1C1E'
|
||||||
|
readonly property color softViolet: '#A87BE2'
|
||||||
readonly property color burntOrange: '#A85809'
|
readonly property color burntOrange: '#A85809'
|
||||||
readonly property color mutedBrown: '#84603D'
|
readonly property color mutedBrown: '#84603D'
|
||||||
readonly property color richBrown: '#633303'
|
readonly property color richBrown: '#633303'
|
||||||
readonly property color deepBrown: '#402102'
|
readonly property color deepBrown: '#402102'
|
||||||
readonly property color vibrantRed: '#EB5757'
|
readonly property color vibrantRed: '#EB5757'
|
||||||
readonly property color darkCharcoal: '#261E1A'
|
readonly property color darkCharcoal: '#261E1A'
|
||||||
|
readonly property color pearlGray: '#EAEAEC'
|
||||||
|
|
||||||
readonly property color sheerWhite: Qt.rgba(1, 1, 1, 0.12)
|
readonly property color sheerWhite: Qt.rgba(1, 1, 1, 0.12)
|
||||||
readonly property color translucentWhite: Qt.rgba(1, 1, 1, 0.08)
|
readonly property color translucentWhite: Qt.rgba(1, 1, 1, 0.08)
|
||||||
readonly property color barelyTranslucentWhite: Qt.rgba(1, 1, 1, 0.05)
|
readonly property color barelyTranslucentWhite: Qt.rgba(1, 1, 1, 0.05)
|
||||||
@@ -26,9 +30,10 @@ QtObject {
|
|||||||
readonly property color softGoldenApricot: Qt.rgba(251/255, 178/255, 106/255, 0.3)
|
readonly property color softGoldenApricot: Qt.rgba(251/255, 178/255, 106/255, 0.3)
|
||||||
readonly property color mistyGray: Qt.rgba(215/255, 216/255, 219/255, 0.8)
|
readonly property color mistyGray: Qt.rgba(215/255, 216/255, 219/255, 0.8)
|
||||||
readonly property color cloudyGray: Qt.rgba(215/255, 216/255, 219/255, 0.65)
|
readonly property color cloudyGray: Qt.rgba(215/255, 216/255, 219/255, 0.65)
|
||||||
readonly property color pearlGray: '#EAEAEC'
|
|
||||||
readonly property color translucentRichBrown: Qt.rgba(99/255, 51/255, 3/255, 0.26)
|
readonly property color translucentRichBrown: Qt.rgba(99/255, 51/255, 3/255, 0.26)
|
||||||
readonly property color translucentSlateGray: Qt.rgba(85/255, 86/255, 92/255, 0.13)
|
readonly property color translucentSlateGray: Qt.rgba(85/255, 86/255, 92/255, 0.13)
|
||||||
readonly property color translucentOnyxBlack: Qt.rgba(28/255, 29/255, 33/255, 0.13)
|
readonly property color translucentOnyxBlack: Qt.rgba(28/255, 29/255, 33/255, 0.13)
|
||||||
|
|
||||||
|
readonly property string goldenApricotString: '#FBB26A'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import QtQuick
|
|||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
|
|
||||||
import SortFilterProxyModel 0.2
|
|
||||||
|
|
||||||
import PageEnum 1.0
|
import PageEnum 1.0
|
||||||
import Style 1.0
|
import Style 1.0
|
||||||
|
|
||||||
@@ -52,6 +50,8 @@ PageType {
|
|||||||
width: listView.width
|
width: listView.width
|
||||||
|
|
||||||
TextFieldWithHeaderType {
|
TextFieldWithHeaderType {
|
||||||
|
id: gatewayEndpointField
|
||||||
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.topMargin: 16
|
Layout.topMargin: 16
|
||||||
Layout.rightMargin: 16
|
Layout.rightMargin: 16
|
||||||
@@ -64,13 +64,25 @@ PageType {
|
|||||||
|
|
||||||
clickedFunc: function() {
|
clickedFunc: function() {
|
||||||
SettingsController.resetGatewayEndpoint()
|
SettingsController.resetGatewayEndpoint()
|
||||||
|
gatewayEndpointField.textField.text = SettingsController.gatewayEndpoint
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
textField.onEditingFinished: {
|
BasicButtonType {
|
||||||
textField.text = textField.text.replace(/^\s+|\s+$/g, '')
|
id: saveButton
|
||||||
if (textField.text !== SettingsController.gatewayEndpoint) {
|
|
||||||
SettingsController.gatewayEndpoint = textField.text
|
Layout.fillWidth: true
|
||||||
|
Layout.margins: 16
|
||||||
|
|
||||||
|
text: qsTr("Save")
|
||||||
|
|
||||||
|
clickedFunc: function() {
|
||||||
|
var trimmed = gatewayEndpointField.textField.text.replace(/^\s+|\s+$/g, '')
|
||||||
|
gatewayEndpointField.textField.text = trimmed
|
||||||
|
if (trimmed !== SettingsController.gatewayEndpoint) {
|
||||||
|
SettingsController.gatewayEndpoint = trimmed
|
||||||
}
|
}
|
||||||
|
PageController.showNotificationMessage(qsTr("Settings saved"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -482,6 +482,7 @@ PageType {
|
|||||||
|
|
||||||
headerText: qsTr("I5 - Special junk 5")
|
headerText: qsTr("I5 - Special junk 5")
|
||||||
textField.text: serverSpecialJunk5
|
textField.text: serverSpecialJunk5
|
||||||
|
checkEmptyText: false
|
||||||
|
|
||||||
textField.onEditingFinished: {
|
textField.onEditingFinished: {
|
||||||
if (textField.text !== serverSpecialJunk5) {
|
if (textField.text !== serverSpecialJunk5) {
|
||||||
|
|||||||
@@ -18,12 +18,36 @@ PageType {
|
|||||||
id: root
|
id: root
|
||||||
|
|
||||||
property var processedServer
|
property var processedServer
|
||||||
|
property bool subscriptionExpired: false
|
||||||
|
property bool subscriptionExpiringSoon: false
|
||||||
|
property bool isSubscriptionRenewalAvailable: false
|
||||||
|
property bool isInAppPurchase: false
|
||||||
|
|
||||||
|
function updateSubscriptionState() {
|
||||||
|
root.subscriptionExpired = ServersModel.getProcessedServerData("isSubscriptionExpired")
|
||||||
|
root.subscriptionExpiringSoon = ServersModel.getProcessedServerData("isSubscriptionExpiringSoon")
|
||||||
|
root.isSubscriptionRenewalAvailable = ApiAccountInfoModel.data("isSubscriptionRenewalAvailable")
|
||||||
|
root.isInAppPurchase = ApiAccountInfoModel.data("isInAppPurchase")
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
root.updateSubscriptionState()
|
||||||
|
}
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
target: ServersModel
|
target: ServersModel
|
||||||
|
|
||||||
function onProcessedServerChanged() {
|
function onProcessedServerChanged() {
|
||||||
root.processedServer = proxyServersModel.get(0)
|
root.processedServer = proxyServersModel.get(0)
|
||||||
|
root.updateSubscriptionState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: ApiAccountInfoModel
|
||||||
|
|
||||||
|
function onModelReset() {
|
||||||
|
root.updateSubscriptionState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,12 +100,11 @@ PageType {
|
|||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.leftMargin: 16
|
Layout.leftMargin: 16
|
||||||
Layout.rightMargin: 16
|
Layout.rightMargin: 16
|
||||||
Layout.bottomMargin: 10
|
Layout.bottomMargin: root.subscriptionExpired || root.subscriptionExpiringSoon ? 0 : 4
|
||||||
|
|
||||||
actionButtonImage: "qrc:/images/controls/settings.svg"
|
actionButtonImage: "qrc:/images/controls/settings.svg"
|
||||||
|
|
||||||
headerText: root.processedServer.name
|
headerText: root.processedServer.name
|
||||||
descriptionText: qsTr("Location for connection")
|
|
||||||
|
|
||||||
actionButtonFunction: function() {
|
actionButtonFunction: function() {
|
||||||
PageController.showBusyIndicator(true)
|
PageController.showBusyIndicator(true)
|
||||||
@@ -94,6 +117,51 @@ PageType {
|
|||||||
PageController.goToPage(PageEnum.PageSettingsApiServerInfo)
|
PageController.goToPage(PageEnum.PageSettingsApiServerInfo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ParagraphTextType {
|
||||||
|
visible: root.subscriptionExpired || root.subscriptionExpiringSoon
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.leftMargin: 16
|
||||||
|
Layout.rightMargin: 16
|
||||||
|
Layout.topMargin: 12
|
||||||
|
|
||||||
|
text: root.subscriptionExpired ? qsTr("Subscription expired") : qsTr("Subscription expiring soon")
|
||||||
|
color: root.subscriptionExpired ? AmneziaStyle.color.vibrantRed : AmneziaStyle.color.goldenApricot
|
||||||
|
}
|
||||||
|
|
||||||
|
BasicButtonType {
|
||||||
|
visible: (root.subscriptionExpired || root.subscriptionExpiringSoon)
|
||||||
|
&& root.isSubscriptionRenewalAvailable && !root.isInAppPurchase
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.leftMargin: 16
|
||||||
|
Layout.rightMargin: 16
|
||||||
|
Layout.topMargin: 28
|
||||||
|
Layout.bottomMargin: 0
|
||||||
|
|
||||||
|
defaultColor: AmneziaStyle.color.paleGray
|
||||||
|
hoveredColor: AmneziaStyle.color.lightGray
|
||||||
|
pressedColor: AmneziaStyle.color.mutedGray
|
||||||
|
textColor: AmneziaStyle.color.midnightBlack
|
||||||
|
|
||||||
|
text: qsTr("Renew subscription")
|
||||||
|
|
||||||
|
clickedFunc: function() {
|
||||||
|
ApiSettingsController.getRenewalLink()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ParagraphTextType {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.leftMargin: 16
|
||||||
|
Layout.rightMargin: 16
|
||||||
|
Layout.topMargin: (root.subscriptionExpired || root.subscriptionExpiringSoon) ? 12 : 4
|
||||||
|
Layout.bottomMargin: 8
|
||||||
|
|
||||||
|
text: qsTr("Location for connection")
|
||||||
|
color: AmneziaStyle.color.mutedGray
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
delegate: ColumnLayout {
|
delegate: ColumnLayout {
|
||||||
|
|||||||
@@ -52,6 +52,30 @@ PageType {
|
|||||||
|
|
||||||
property var processedServer
|
property var processedServer
|
||||||
|
|
||||||
|
property bool isSubscriptionExpired: false
|
||||||
|
property bool isSubscriptionExpiringSoon: false
|
||||||
|
property bool isSubscriptionRenewalAvailable: false
|
||||||
|
property bool isInAppPurchase: false
|
||||||
|
|
||||||
|
function updateSubscriptionState() {
|
||||||
|
root.isSubscriptionExpired = ApiAccountInfoModel.data("isSubscriptionExpired")
|
||||||
|
root.isSubscriptionExpiringSoon = ApiAccountInfoModel.data("isSubscriptionExpiringSoon")
|
||||||
|
root.isSubscriptionRenewalAvailable = ApiAccountInfoModel.data("isSubscriptionRenewalAvailable")
|
||||||
|
root.isInAppPurchase = ApiAccountInfoModel.data("isInAppPurchase")
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
root.updateSubscriptionState()
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: ApiAccountInfoModel
|
||||||
|
|
||||||
|
function onModelReset() {
|
||||||
|
root.updateSubscriptionState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
target: ServersModel
|
target: ServersModel
|
||||||
|
|
||||||
@@ -103,17 +127,68 @@ PageType {
|
|||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.leftMargin: 16
|
Layout.leftMargin: 16
|
||||||
Layout.rightMargin: 16
|
Layout.rightMargin: 16
|
||||||
Layout.bottomMargin: 10
|
Layout.bottomMargin: root.isSubscriptionExpired || root.isSubscriptionExpiringSoon ? 0 : 10
|
||||||
|
|
||||||
actionButtonImage: "qrc:/images/controls/edit-3.svg"
|
actionButtonImage: "qrc:/images/controls/edit-3.svg"
|
||||||
|
|
||||||
headerText: root.processedServer.name
|
headerText: root.processedServer.name
|
||||||
descriptionText: ApiAccountInfoModel.data("serviceDescription")
|
|
||||||
|
|
||||||
actionButtonFunction: function() {
|
actionButtonFunction: function() {
|
||||||
serverNameEditDrawer.openTriggered()
|
serverNameEditDrawer.openTriggered()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ParagraphTextType {
|
||||||
|
visible: root.isSubscriptionExpired || root.isSubscriptionExpiringSoon
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.leftMargin: 16
|
||||||
|
Layout.rightMargin: 16
|
||||||
|
Layout.topMargin: 12
|
||||||
|
|
||||||
|
text: root.isSubscriptionExpired
|
||||||
|
? qsTr("Subscription expired")
|
||||||
|
: qsTr("Subscription expiring soon")
|
||||||
|
|
||||||
|
color: root.isSubscriptionExpired
|
||||||
|
? AmneziaStyle.color.vibrantRed
|
||||||
|
: AmneziaStyle.color.goldenApricot
|
||||||
|
}
|
||||||
|
|
||||||
|
ParagraphTextType {
|
||||||
|
visible: ApiAccountInfoModel.data("serviceDescription") !== ""
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.leftMargin: 16
|
||||||
|
Layout.rightMargin: 16
|
||||||
|
Layout.topMargin: 16
|
||||||
|
Layout.bottomMargin: root.isSubscriptionExpired || root.isSubscriptionExpiringSoon ? 0 : 10
|
||||||
|
|
||||||
|
text: ApiAccountInfoModel.data("serviceDescription")
|
||||||
|
color: AmneziaStyle.color.mutedGray
|
||||||
|
}
|
||||||
|
|
||||||
|
BasicButtonType {
|
||||||
|
visible: (root.isSubscriptionExpired || root.isSubscriptionExpiringSoon)
|
||||||
|
&& root.isSubscriptionRenewalAvailable && !root.isInAppPurchase
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.leftMargin: 16
|
||||||
|
Layout.rightMargin: 16
|
||||||
|
Layout.topMargin: 8
|
||||||
|
Layout.bottomMargin: 8
|
||||||
|
|
||||||
|
text: qsTr("Renew subscription")
|
||||||
|
|
||||||
|
defaultColor: AmneziaStyle.color.paleGray
|
||||||
|
hoveredColor: AmneziaStyle.color.lightGray
|
||||||
|
pressedColor: AmneziaStyle.color.mutedGray
|
||||||
|
textColor: AmneziaStyle.color.midnightBlack
|
||||||
|
|
||||||
|
clickedFunc: function() {
|
||||||
|
ApiSettingsController.getRenewalLink()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
delegate: ColumnLayout {
|
delegate: ColumnLayout {
|
||||||
@@ -151,10 +226,40 @@ PageType {
|
|||||||
|
|
||||||
readonly property bool isVisibleForAmneziaFree: ApiAccountInfoModel.data("isComponentVisible")
|
readonly property bool isVisibleForAmneziaFree: ApiAccountInfoModel.data("isComponentVisible")
|
||||||
|
|
||||||
|
BasicButtonType {
|
||||||
|
visible: !root.isSubscriptionExpired && !root.isSubscriptionExpiringSoon
|
||||||
|
&& root.isSubscriptionRenewalAvailable && !root.isInAppPurchase
|
||||||
|
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
Layout.topMargin: 16
|
||||||
|
Layout.bottomMargin: 16
|
||||||
|
|
||||||
|
implicitHeight: 25
|
||||||
|
|
||||||
|
defaultColor: AmneziaStyle.color.transparent
|
||||||
|
hoveredColor: AmneziaStyle.color.translucentWhite
|
||||||
|
pressedColor: AmneziaStyle.color.sheerWhite
|
||||||
|
textColor: AmneziaStyle.color.goldenApricot
|
||||||
|
leftImageSource: "qrc:/images/controls/refresh-cw.svg"
|
||||||
|
leftImageColor: AmneziaStyle.color.goldenApricot
|
||||||
|
|
||||||
|
text: qsTr("Renew subscription")
|
||||||
|
|
||||||
|
clickedFunc: function() {
|
||||||
|
ApiSettingsController.getRenewalLink()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DividerType {
|
||||||
|
visible: !root.isSubscriptionExpired && !root.isSubscriptionExpiringSoon
|
||||||
|
&& root.isSubscriptionRenewalAvailable && !root.isInAppPurchase
|
||||||
|
}
|
||||||
|
|
||||||
SwitcherType {
|
SwitcherType {
|
||||||
id: switcher
|
id: switcher
|
||||||
|
|
||||||
readonly property bool isVlessProtocol: ApiConfigsController.isVlessProtocol()
|
readonly property bool isVlessProtocol: ApiConfigsController.isVlessProtocol()
|
||||||
|
readonly property bool isProtocolSwitchBlocked: ServersModel.isDefaultServerCurrentlyProcessed() && ConnectionController.isConnected
|
||||||
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.topMargin: 24
|
Layout.topMargin: 24
|
||||||
@@ -162,6 +267,7 @@ PageType {
|
|||||||
Layout.leftMargin: 16
|
Layout.leftMargin: 16
|
||||||
|
|
||||||
visible: ApiAccountInfoModel.data("isProtocolSelectionSupported")
|
visible: ApiAccountInfoModel.data("isProtocolSelectionSupported")
|
||||||
|
enabled: !switcher.isProtocolSwitchBlocked
|
||||||
|
|
||||||
text: qsTr("Use VLESS protocol")
|
text: qsTr("Use VLESS protocol")
|
||||||
checked: switcher.isVlessProtocol
|
checked: switcher.isVlessProtocol
|
||||||
@@ -177,10 +283,14 @@ PageType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DividerType {
|
||||||
|
visible: footer.isVisibleForAmneziaFree
|
||||||
|
}
|
||||||
|
|
||||||
WarningType {
|
WarningType {
|
||||||
id: warning
|
id: warning
|
||||||
|
|
||||||
Layout.topMargin: 32
|
Layout.topMargin: 24
|
||||||
Layout.rightMargin: 16
|
Layout.rightMargin: 16
|
||||||
Layout.leftMargin: 16
|
Layout.leftMargin: 16
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
@@ -204,7 +314,7 @@ PageType {
|
|||||||
id: vpnKey
|
id: vpnKey
|
||||||
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.topMargin: warning.visible ? 16 : 32
|
Layout.topMargin: warning.visible ? 16 : 0
|
||||||
|
|
||||||
visible: footer.isVisibleForAmneziaFree
|
visible: footer.isVisibleForAmneziaFree
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
|
||||||
|
import Style 1.0
|
||||||
|
|
||||||
|
import "./"
|
||||||
|
import "../Controls2"
|
||||||
|
import "../Controls2/TextTypes"
|
||||||
|
import "../Config"
|
||||||
|
import "../Components"
|
||||||
|
|
||||||
|
PageType {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string freeHeaderName: ""
|
||||||
|
property string freeHeaderDescription: ""
|
||||||
|
|
||||||
|
function syncFromModel() {
|
||||||
|
root.freeHeaderName = String(ApiServicesModel.getSelectedServiceData("name"))
|
||||||
|
root.freeHeaderDescription = String(ApiServicesModel.getSelectedServiceData("serviceDescription"))
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: syncFromModel()
|
||||||
|
|
||||||
|
BackButtonType {
|
||||||
|
id: backButton
|
||||||
|
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.topMargin: 20 + SettingsController.safeAreaTopMargin
|
||||||
|
|
||||||
|
onFocusChanged: {
|
||||||
|
if (activeFocus) {
|
||||||
|
flick.contentY = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FlickableType {
|
||||||
|
id: flick
|
||||||
|
|
||||||
|
anchors.top: backButton.bottom
|
||||||
|
anchors.bottom: continueButton.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
|
||||||
|
contentHeight: scrollColumn.implicitHeight + 24
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: scrollColumn
|
||||||
|
|
||||||
|
width: flick.width
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
BaseHeaderType {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.topMargin: 8
|
||||||
|
Layout.leftMargin: 16
|
||||||
|
Layout.rightMargin: 16
|
||||||
|
Layout.bottomMargin: 24
|
||||||
|
|
||||||
|
headerText: root.freeHeaderName
|
||||||
|
descriptionText: root.freeHeaderDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
LabelTextType {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.leftMargin: 16
|
||||||
|
Layout.rightMargin: 16
|
||||||
|
Layout.bottomMargin: 12
|
||||||
|
|
||||||
|
text: qsTr("Free features")
|
||||||
|
color: AmneziaStyle.color.mutedGray
|
||||||
|
font.pixelSize: 13
|
||||||
|
}
|
||||||
|
|
||||||
|
BenefitsPanel {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.leftMargin: 16
|
||||||
|
Layout.rightMargin: 16
|
||||||
|
Layout.bottomMargin: 24
|
||||||
|
|
||||||
|
benefitsModel: ApiBenefitsModel
|
||||||
|
}
|
||||||
|
|
||||||
|
TermsAndPrivacyText {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.leftMargin: 16
|
||||||
|
Layout.rightMargin: 16
|
||||||
|
Layout.bottomMargin: 16
|
||||||
|
|
||||||
|
visible: !(Qt.platform.os === "ios" || IsMacOsNeBuild)
|
||||||
|
|
||||||
|
termsUrl: String(ApiServicesModel.getSelectedServiceData("termsOfUseUrl"))
|
||||||
|
privacyUrl: String(ApiServicesModel.getSelectedServiceData("privacyPolicyUrl"))
|
||||||
|
}
|
||||||
|
|
||||||
|
TermsAndPrivacyText {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.leftMargin: 16
|
||||||
|
Layout.rightMargin: 16
|
||||||
|
Layout.bottomMargin: 24
|
||||||
|
|
||||||
|
visible: (Qt.platform.os === "ios" || IsMacOsNeBuild)
|
||||||
|
|
||||||
|
termsUrl: "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/"
|
||||||
|
privacyUrl: LanguageModel.getCurrentSiteUrl("policy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BasicButtonType {
|
||||||
|
id: continueButton
|
||||||
|
|
||||||
|
z: 2
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.leftMargin: 16
|
||||||
|
anchors.rightMargin: 16
|
||||||
|
anchors.bottomMargin: 16 + SettingsController.safeAreaBottomMargin
|
||||||
|
|
||||||
|
text: qsTr("Continue")
|
||||||
|
|
||||||
|
clickedFunc: function() {
|
||||||
|
PageController.showBusyIndicator(true)
|
||||||
|
var result = ApiConfigsController.importService()
|
||||||
|
PageController.showBusyIndicator(false)
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
var endpoint = ApiServicesModel.getStoreEndpoint()
|
||||||
|
Qt.openUrlExternally(endpoint)
|
||||||
|
PageController.closePage()
|
||||||
|
PageController.closePage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
|
||||||
|
import Style 1.0
|
||||||
|
|
||||||
|
import "./"
|
||||||
|
import "../Controls2"
|
||||||
|
import "../Controls2/TextTypes"
|
||||||
|
import "../Config"
|
||||||
|
import "../Components"
|
||||||
|
import PageEnum 1.0
|
||||||
|
|
||||||
|
PageType {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property int selectedPlanIndex: 0
|
||||||
|
property string premiumHeaderName: ""
|
||||||
|
property string premiumHeaderDescription: ""
|
||||||
|
|
||||||
|
readonly property var currentPlan: ApiSubscriptionPlansModel.planAt(selectedPlanIndex)
|
||||||
|
|
||||||
|
function syncFromModel() {
|
||||||
|
root.selectedPlanIndex = ApiSubscriptionPlansModel.recommendedRowIndex()
|
||||||
|
|
||||||
|
root.premiumHeaderName = String(ApiServicesModel.getSelectedServiceData("name"))
|
||||||
|
root.premiumHeaderDescription = String(ApiServicesModel.getSelectedServiceData("serviceDescription"))
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: syncFromModel()
|
||||||
|
|
||||||
|
BackButtonType {
|
||||||
|
id: backButton
|
||||||
|
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.topMargin: 20 + SettingsController.safeAreaTopMargin
|
||||||
|
|
||||||
|
onFocusChanged: {
|
||||||
|
if (activeFocus) {
|
||||||
|
flick.contentY = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FlickableType {
|
||||||
|
id: flick
|
||||||
|
|
||||||
|
anchors.top: backButton.bottom
|
||||||
|
anchors.bottom: continueButton.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
|
||||||
|
contentHeight: scrollColumn.implicitHeight + 24
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: scrollColumn
|
||||||
|
|
||||||
|
width: flick.width
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
BaseHeaderType {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.topMargin: 8
|
||||||
|
Layout.leftMargin: 16
|
||||||
|
Layout.rightMargin: 16
|
||||||
|
Layout.bottomMargin: 24
|
||||||
|
|
||||||
|
headerText: root.premiumHeaderName
|
||||||
|
descriptionText: root.premiumHeaderDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: ApiSubscriptionPlansModel
|
||||||
|
|
||||||
|
delegate: SubscriptionPlanCard {
|
||||||
|
required property int index
|
||||||
|
required property var model
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.leftMargin: 16
|
||||||
|
Layout.rightMargin: 16
|
||||||
|
Layout.bottomMargin: index === ApiSubscriptionPlansModel.rowCount() - 1 ? 24 : 12
|
||||||
|
|
||||||
|
selected: root.selectedPlanIndex === index
|
||||||
|
billingPeriod: String(model.billingPeriod)
|
||||||
|
priceLabel: String(model.priceLabel)
|
||||||
|
subtitle: String(model.subtitle)
|
||||||
|
showRecommendedBadge: !!model.recommended
|
||||||
|
recommendedText: qsTr("Recommended")
|
||||||
|
|
||||||
|
onSelectRequested: root.selectedPlanIndex = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LabelTextType {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.leftMargin: 16
|
||||||
|
Layout.rightMargin: 16
|
||||||
|
Layout.bottomMargin: 12
|
||||||
|
|
||||||
|
text: qsTr("Premium features")
|
||||||
|
color: AmneziaStyle.color.mutedGray
|
||||||
|
font.pixelSize: 13
|
||||||
|
}
|
||||||
|
|
||||||
|
BenefitsPanel {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.leftMargin: 16
|
||||||
|
Layout.rightMargin: 16
|
||||||
|
Layout.bottomMargin: 24
|
||||||
|
|
||||||
|
benefitsModel: ApiBenefitsModel
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.leftMargin: 16
|
||||||
|
Layout.rightMargin: 16
|
||||||
|
Layout.bottomMargin: 24
|
||||||
|
visible: Qt.platform.os === "ios" || IsMacOsNeBuild
|
||||||
|
spacing: 16
|
||||||
|
|
||||||
|
ParagraphTextType {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
color: AmneziaStyle.color.mutedGray
|
||||||
|
font.pixelSize: 12
|
||||||
|
|
||||||
|
text: qsTr("Charged to your Apple ID at confirmation. Renews automatically unless auto-renew is turned off at least 24 hours before period end. Manage in Apple ID settings.")
|
||||||
|
}
|
||||||
|
|
||||||
|
TermsAndPrivacyText {
|
||||||
|
termsUrl: "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/"
|
||||||
|
privacyUrl: LanguageModel.getCurrentSiteUrl("policy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TermsAndPrivacyText {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.leftMargin: 16
|
||||||
|
Layout.rightMargin: 16
|
||||||
|
Layout.bottomMargin: 24
|
||||||
|
|
||||||
|
visible: !(Qt.platform.os === "ios" || IsMacOsNeBuild)
|
||||||
|
|
||||||
|
termsUrl: String(ApiServicesModel.getSelectedServiceData("termsOfUseUrl"))
|
||||||
|
privacyUrl: String(ApiServicesModel.getSelectedServiceData("privacyPolicyUrl"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BasicButtonType {
|
||||||
|
id: continueButton
|
||||||
|
|
||||||
|
z: 2
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.leftMargin: 16
|
||||||
|
anchors.rightMargin: 16
|
||||||
|
anchors.bottomMargin: 16 + SettingsController.safeAreaBottomMargin
|
||||||
|
|
||||||
|
text: {
|
||||||
|
var plan = root.currentPlan
|
||||||
|
if (!plan) {
|
||||||
|
return qsTr("Continue")
|
||||||
|
}
|
||||||
|
return qsTr("Subscribe — %1 for %2").arg(String(plan.billingPeriod)).arg(String(plan.priceLabel))
|
||||||
|
}
|
||||||
|
|
||||||
|
clickedFunc: function() {
|
||||||
|
var plan = root.currentPlan
|
||||||
|
if (!plan) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (plan.isTrial) {
|
||||||
|
PageController.goToPage(PageEnum.PageSetupWizardApiTrialEmail)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Qt.platform.os === "ios" || IsMacOsNeBuild) {
|
||||||
|
PageController.showBusyIndicator(true)
|
||||||
|
var storeId = plan.storeProductId !== undefined ? String(plan.storeProductId) : ""
|
||||||
|
ApiConfigsController.importPremiumFromAppStore(storeId)
|
||||||
|
PageController.showBusyIndicator(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (plan.checkoutUrl) {
|
||||||
|
Qt.openUrlExternally(plan.checkoutUrl)
|
||||||
|
PageController.closePage()
|
||||||
|
PageController.closePage()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
import QtQuick
|
|
||||||
import QtQuick.Controls
|
|
||||||
import QtQuick.Layouts
|
|
||||||
import QtQuick.Dialogs
|
|
||||||
|
|
||||||
import PageEnum 1.0
|
|
||||||
import Style 1.0
|
|
||||||
|
|
||||||
import "./"
|
|
||||||
import "../Controls2"
|
|
||||||
import "../Controls2/TextTypes"
|
|
||||||
import "../Config"
|
|
||||||
import "../Components"
|
|
||||||
|
|
||||||
PageType {
|
|
||||||
id: root
|
|
||||||
|
|
||||||
BackButtonType {
|
|
||||||
id: backButton
|
|
||||||
|
|
||||||
anchors.top: parent.top
|
|
||||||
anchors.left: parent.left
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.topMargin: 20 + SettingsController.safeAreaTopMargin
|
|
||||||
|
|
||||||
onFocusChanged: {
|
|
||||||
if (this.activeFocus) {
|
|
||||||
listView.positionViewAtBeginning()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ListViewType {
|
|
||||||
id: listView
|
|
||||||
|
|
||||||
anchors.top: backButton.bottom
|
|
||||||
anchors.bottom: parent.bottom
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.left: parent.left
|
|
||||||
|
|
||||||
header: ColumnLayout {
|
|
||||||
width: listView.width
|
|
||||||
|
|
||||||
BaseHeaderType {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.topMargin: 8
|
|
||||||
Layout.rightMargin: 16
|
|
||||||
Layout.leftMargin: 16
|
|
||||||
Layout.bottomMargin: 32
|
|
||||||
|
|
||||||
headerText: ApiServicesModel.getSelectedServiceData("name")
|
|
||||||
descriptionText: ApiServicesModel.getSelectedServiceData("serviceDescription")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
model: inputFields
|
|
||||||
spacing: 0
|
|
||||||
|
|
||||||
delegate: ColumnLayout {
|
|
||||||
width: listView.width
|
|
||||||
|
|
||||||
LabelWithImageType {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.margins: 16
|
|
||||||
|
|
||||||
imageSource: imagePath
|
|
||||||
leftText: lText
|
|
||||||
rightText: rText
|
|
||||||
|
|
||||||
visible: isVisible
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
footer: ColumnLayout {
|
|
||||||
width: listView.width
|
|
||||||
|
|
||||||
spacing: 0
|
|
||||||
|
|
||||||
ParagraphTextType {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.rightMargin: 16
|
|
||||||
Layout.leftMargin: 16
|
|
||||||
|
|
||||||
onLinkActivated: function(link) {
|
|
||||||
Qt.openUrlExternally(link)
|
|
||||||
}
|
|
||||||
textFormat: Text.RichText
|
|
||||||
text: {
|
|
||||||
var text = ApiServicesModel.getSelectedServiceData("features")
|
|
||||||
return text.replace("%1", LanguageModel.getCurrentSiteUrl("free")).replace("/free", "") // todo link should come from gateway
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
anchors.fill: parent
|
|
||||||
acceptedButtons: Qt.NoButton
|
|
||||||
cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ParagraphTextType {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.topMargin: 16
|
|
||||||
Layout.leftMargin: 16
|
|
||||||
Layout.rightMargin: 16
|
|
||||||
|
|
||||||
visible: (Qt.platform.os === "ios" || IsMacOsNeBuild) && ApiServicesModel.getSelectedServiceType() === "amnezia-premium"
|
|
||||||
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
textFormat: Text.PlainText
|
|
||||||
color: AmneziaStyle.color.mutedGray
|
|
||||||
font.pixelSize: 12
|
|
||||||
|
|
||||||
text: qsTr("Charged to your Apple ID at confirmation. Renews automatically unless auto-renew is turned off at least 24 hours before period end. Manage in Apple ID settings.")
|
|
||||||
}
|
|
||||||
|
|
||||||
BasicButtonType {
|
|
||||||
id: continueButton
|
|
||||||
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.topMargin: 32
|
|
||||||
Layout.bottomMargin: 16
|
|
||||||
Layout.leftMargin: 16
|
|
||||||
Layout.rightMargin: 16
|
|
||||||
|
|
||||||
text: ApiServicesModel.getSelectedServiceType() === "amnezia-premium" ? qsTr("Subscribe Now") : qsTr("Connect")
|
|
||||||
|
|
||||||
clickedFunc: function() {
|
|
||||||
PageController.showBusyIndicator(true)
|
|
||||||
var result = ApiConfigsController.importService()
|
|
||||||
PageController.showBusyIndicator(false)
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
var endpoint = ApiServicesModel.getStoreEndpoint()
|
|
||||||
Qt.openUrlExternally(endpoint)
|
|
||||||
PageController.closePage()
|
|
||||||
PageController.closePage()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ParagraphTextType {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.topMargin: 16
|
|
||||||
Layout.leftMargin: 16
|
|
||||||
Layout.rightMargin: 16
|
|
||||||
Layout.bottomMargin: 32
|
|
||||||
|
|
||||||
visible: (Qt.platform.os === "ios" || IsMacOsNeBuild) && ApiServicesModel.getSelectedServiceType() === "amnezia-premium"
|
|
||||||
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
textFormat: Text.RichText
|
|
||||||
color: AmneziaStyle.color.mutedGray
|
|
||||||
font.pixelSize: 12
|
|
||||||
|
|
||||||
text: {
|
|
||||||
var termsUrl = "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/"
|
|
||||||
var privacyUrl = LanguageModel.getCurrentSiteUrl("policy")
|
|
||||||
return qsTr("By continuing, you agree to the <a href=\"%1\" style=\"color: #FBB26A;\">Terms of Use</a> and <a href=\"%2\" style=\"color: #FBB26A;\">Privacy Policy</a>").arg(termsUrl).arg(privacyUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
onLinkActivated: function(link) {
|
|
||||||
Qt.openUrlExternally(link)
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
anchors.fill: parent
|
|
||||||
acceptedButtons: Qt.NoButton
|
|
||||||
cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
property list<QtObject> inputFields: [
|
|
||||||
region,
|
|
||||||
price,
|
|
||||||
timeLimit,
|
|
||||||
speed,
|
|
||||||
features
|
|
||||||
]
|
|
||||||
|
|
||||||
QtObject {
|
|
||||||
id: region
|
|
||||||
|
|
||||||
readonly property string imagePath: "qrc:/images/controls/map-pin.svg"
|
|
||||||
readonly property string lText: qsTr("For the region")
|
|
||||||
readonly property string rText: ApiServicesModel.getSelectedServiceData("region")
|
|
||||||
property bool isVisible: true
|
|
||||||
}
|
|
||||||
|
|
||||||
QtObject {
|
|
||||||
id: price
|
|
||||||
|
|
||||||
readonly property string imagePath: "qrc:/images/controls/tag.svg"
|
|
||||||
readonly property string lText: qsTr("Price")
|
|
||||||
readonly property string rText: ApiServicesModel.getSelectedServiceData("price")
|
|
||||||
property bool isVisible: true
|
|
||||||
}
|
|
||||||
|
|
||||||
QtObject {
|
|
||||||
id: timeLimit
|
|
||||||
|
|
||||||
readonly property string imagePath: "qrc:/images/controls/history.svg"
|
|
||||||
readonly property string lText: qsTr("Work period")
|
|
||||||
readonly property string rText: ApiServicesModel.getSelectedServiceData("timeLimit")
|
|
||||||
property bool isVisible: rText !== ""
|
|
||||||
}
|
|
||||||
|
|
||||||
QtObject {
|
|
||||||
id: speed
|
|
||||||
|
|
||||||
readonly property string imagePath: "qrc:/images/controls/gauge.svg"
|
|
||||||
readonly property string lText: qsTr("Speed")
|
|
||||||
readonly property string rText: ApiServicesModel.getSelectedServiceData("speed")
|
|
||||||
property bool isVisible: true
|
|
||||||
}
|
|
||||||
|
|
||||||
QtObject {
|
|
||||||
id: features
|
|
||||||
|
|
||||||
readonly property string imagePath: "qrc:/images/controls/info.svg"
|
|
||||||
readonly property string lText: qsTr("Features")
|
|
||||||
readonly property string rText: ""
|
|
||||||
property bool isVisible: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -67,8 +67,11 @@ PageType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
delegate: ColumnLayout {
|
delegate: ColumnLayout {
|
||||||
|
property bool hideCard: isPremium && !hasSubscriptionPlans
|
||||||
|
|
||||||
width: listView.width
|
width: listView.width
|
||||||
|
visible: !hideCard
|
||||||
|
height: hideCard ? 0 : implicitHeight
|
||||||
|
|
||||||
enabled: isServiceAvailable
|
enabled: isServiceAvailable
|
||||||
|
|
||||||
@@ -84,12 +87,19 @@ PageType {
|
|||||||
bodyText: cardDescription
|
bodyText: cardDescription
|
||||||
footerText: price
|
footerText: price
|
||||||
|
|
||||||
|
showRecommendedBadge: showRecommended && isServiceAvailable
|
||||||
|
recommendedText: qsTr("Recommended")
|
||||||
|
|
||||||
rightImageSource: "qrc:/images/controls/chevron-right.svg"
|
rightImageSource: "qrc:/images/controls/chevron-right.svg"
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
if (isServiceAvailable) {
|
if (isServiceAvailable) {
|
||||||
ApiServicesModel.setServiceIndex(proxyApiServicesModel.mapToSource(index))
|
ApiServicesModel.setServiceIndex(proxyApiServicesModel.mapToSource(index))
|
||||||
PageController.goToPage(PageEnum.PageSetupWizardApiServiceInfo)
|
if (ApiServicesModel.getSelectedServiceType() === "amnezia-premium") {
|
||||||
|
PageController.goToPage(PageEnum.PageSetupWizardApiPremiumInfo)
|
||||||
|
} else {
|
||||||
|
PageController.goToPage(PageEnum.PageSetupWizardApiFreeInfo)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
|
||||||
|
import PageEnum 1.0
|
||||||
|
import Style 1.0
|
||||||
|
|
||||||
|
import "./"
|
||||||
|
import "../Controls2"
|
||||||
|
import "../Controls2/TextTypes"
|
||||||
|
import "../Config"
|
||||||
|
import "../Components"
|
||||||
|
|
||||||
|
PageType {
|
||||||
|
id: root
|
||||||
|
property string trialEmailErrorMessage: ""
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: ApiConfigsController
|
||||||
|
|
||||||
|
function onTrialEmailError(message) {
|
||||||
|
root.trialEmailErrorMessage = message
|
||||||
|
emailField.errorText = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BackButtonType {
|
||||||
|
id: backButton
|
||||||
|
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.topMargin: 20 + SettingsController.safeAreaTopMargin
|
||||||
|
|
||||||
|
onFocusChanged: {
|
||||||
|
if (activeFocus) {
|
||||||
|
flick.contentY = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FlickableType {
|
||||||
|
id: flick
|
||||||
|
|
||||||
|
anchors.top: backButton.bottom
|
||||||
|
anchors.bottom: continueButton.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
|
||||||
|
contentHeight: scrollColumn.implicitHeight + 24
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: scrollColumn
|
||||||
|
|
||||||
|
width: flick.width
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
BaseHeaderType {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.topMargin: 8
|
||||||
|
Layout.leftMargin: 16
|
||||||
|
Layout.rightMargin: 16
|
||||||
|
Layout.bottomMargin: 24
|
||||||
|
|
||||||
|
headerText: qsTr("Create an account")
|
||||||
|
descriptionText: qsTr("To manage your subscription")
|
||||||
|
}
|
||||||
|
|
||||||
|
TextFieldWithHeaderType {
|
||||||
|
id: emailField
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.leftMargin: 16
|
||||||
|
Layout.rightMargin: 16
|
||||||
|
Layout.bottomMargin: 24
|
||||||
|
|
||||||
|
headerText: qsTr("Email")
|
||||||
|
textField.placeholderText: qsTr("Email")
|
||||||
|
textField.inputMethodHints: Qt.ImhEmailCharactersOnly
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: emailField.textField
|
||||||
|
|
||||||
|
function onTextChanged() {
|
||||||
|
if (root.trialEmailErrorMessage !== "") {
|
||||||
|
root.trialEmailErrorMessage = ""
|
||||||
|
emailField.errorText = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ParagraphTextType {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.leftMargin: 16
|
||||||
|
Layout.rightMargin: 16
|
||||||
|
Layout.bottomMargin: 24
|
||||||
|
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
color: AmneziaStyle.color.mutedGray
|
||||||
|
font.pixelSize: 12
|
||||||
|
text: qsTr("We will create an account for your trial subscription and send important subscription updates to this email address")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BasicButtonType {
|
||||||
|
id: continueButton
|
||||||
|
|
||||||
|
z: 2
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.leftMargin: 16
|
||||||
|
anchors.rightMargin: 16
|
||||||
|
anchors.bottomMargin: 16 + SettingsController.safeAreaBottomMargin
|
||||||
|
|
||||||
|
text: qsTr("Continue")
|
||||||
|
|
||||||
|
clickedFunc: function() {
|
||||||
|
root.trialEmailErrorMessage = ""
|
||||||
|
emailField.errorText = ""
|
||||||
|
|
||||||
|
var raw = emailField.textField.text.trim()
|
||||||
|
if (raw.length === 0 || raw.indexOf("@") < 0) {
|
||||||
|
PageController.showNotificationMessage(qsTr("Enter a valid email address"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
PageController.showBusyIndicator(true)
|
||||||
|
var ok = ApiConfigsController.importTrialFromGateway(raw)
|
||||||
|
PageController.showBusyIndicator(false)
|
||||||
|
if (ok) {
|
||||||
|
PageController.closePage()
|
||||||
|
PageController.closePage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -222,6 +222,9 @@ PageType {
|
|||||||
headerText: title
|
headerText: title
|
||||||
bodyText: description
|
bodyText: description
|
||||||
|
|
||||||
|
showRecommendedBadge: featuredAmneziaConnection
|
||||||
|
recommendedText: featuredAmneziaConnection ? qsTr("Recommended") : ""
|
||||||
|
|
||||||
rightImageSource: "qrc:/images/controls/chevron-right.svg"
|
rightImageSource: "qrc:/images/controls/chevron-right.svg"
|
||||||
leftImageSource: imageSource
|
leftImageSource: imageSource
|
||||||
|
|
||||||
@@ -275,8 +278,9 @@ PageType {
|
|||||||
id: amneziaVpn
|
id: amneziaVpn
|
||||||
|
|
||||||
property string title: qsTr("VPN by Amnezia")
|
property string title: qsTr("VPN by Amnezia")
|
||||||
property string description: qsTr("Connect to classic paid and free VPN services from Amnezia")
|
property string description: qsTr("The easiest way to connect to the VPN")
|
||||||
property string imageSource: "qrc:/images/controls/amnezia.svg"
|
property string imageSource: "qrc:/images/controls/amnezia.svg"
|
||||||
|
property bool featuredAmneziaConnection: true
|
||||||
property bool isVisible: true
|
property bool isVisible: true
|
||||||
property var handler: function() {
|
property var handler: function() {
|
||||||
PageController.showBusyIndicator(true)
|
PageController.showBusyIndicator(true)
|
||||||
@@ -291,6 +295,7 @@ PageType {
|
|||||||
QtObject {
|
QtObject {
|
||||||
id: selfHostVpn
|
id: selfHostVpn
|
||||||
|
|
||||||
|
property bool featuredAmneziaConnection: false
|
||||||
property string title: qsTr("Self-hosted VPN")
|
property string title: qsTr("Self-hosted VPN")
|
||||||
property string description: qsTr("Configure Amnezia VPN on your own server")
|
property string description: qsTr("Configure Amnezia VPN on your own server")
|
||||||
property string imageSource: "qrc:/images/controls/server.svg"
|
property string imageSource: "qrc:/images/controls/server.svg"
|
||||||
@@ -303,6 +308,7 @@ PageType {
|
|||||||
QtObject {
|
QtObject {
|
||||||
id: backupRestore
|
id: backupRestore
|
||||||
|
|
||||||
|
property bool featuredAmneziaConnection: false
|
||||||
property string title: qsTr("Restore from backup")
|
property string title: qsTr("Restore from backup")
|
||||||
property string description: qsTr("")
|
property string description: qsTr("")
|
||||||
property string imageSource: "qrc:/images/controls/archive-restore.svg"
|
property string imageSource: "qrc:/images/controls/archive-restore.svg"
|
||||||
@@ -321,6 +327,7 @@ PageType {
|
|||||||
QtObject {
|
QtObject {
|
||||||
id: fileOpen
|
id: fileOpen
|
||||||
|
|
||||||
|
property bool featuredAmneziaConnection: false
|
||||||
property string title: qsTr("File with connection settings")
|
property string title: qsTr("File with connection settings")
|
||||||
property string description: qsTr("")
|
property string description: qsTr("")
|
||||||
property string imageSource: "qrc:/images/controls/folder-search-2.svg"
|
property string imageSource: "qrc:/images/controls/folder-search-2.svg"
|
||||||
@@ -340,6 +347,7 @@ PageType {
|
|||||||
QtObject {
|
QtObject {
|
||||||
id: qrScan
|
id: qrScan
|
||||||
|
|
||||||
|
property bool featuredAmneziaConnection: false
|
||||||
property string title: qsTr("QR code")
|
property string title: qsTr("QR code")
|
||||||
property string description: qsTr("")
|
property string description: qsTr("")
|
||||||
property string imageSource: "qrc:/images/controls/scan-line.svg"
|
property string imageSource: "qrc:/images/controls/scan-line.svg"
|
||||||
@@ -355,13 +363,14 @@ PageType {
|
|||||||
QtObject {
|
QtObject {
|
||||||
id: restorePurchases
|
id: restorePurchases
|
||||||
|
|
||||||
|
property bool featuredAmneziaConnection: false
|
||||||
property string title: qsTr("Restore purchases")
|
property string title: qsTr("Restore purchases")
|
||||||
property string description: qsTr("")
|
property string description: qsTr("")
|
||||||
property string imageSource: "qrc:/images/controls/refresh-cw.svg"
|
property string imageSource: "qrc:/images/controls/refresh-cw.svg"
|
||||||
property bool isVisible: Qt.platform.os === "ios" || IsMacOsNeBuild
|
property bool isVisible: Qt.platform.os === "ios" || IsMacOsNeBuild
|
||||||
property var handler: function() {
|
property var handler: function() {
|
||||||
PageController.showBusyIndicator(true)
|
PageController.showBusyIndicator(true)
|
||||||
ApiConfigsController.restoreSerivceFromAppStore()
|
ApiConfigsController.restoreServiceFromAppStore()
|
||||||
PageController.showBusyIndicator(false)
|
PageController.showBusyIndicator(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -369,6 +378,7 @@ PageType {
|
|||||||
QtObject {
|
QtObject {
|
||||||
id: siteLink
|
id: siteLink
|
||||||
|
|
||||||
|
property bool featuredAmneziaConnection: false
|
||||||
property string title: qsTr("I have nothing")
|
property string title: qsTr("I have nothing")
|
||||||
property string description: qsTr("")
|
property string description: qsTr("")
|
||||||
property string imageSource: "qrc:/images/controls/help-circle.svg"
|
property string imageSource: "qrc:/images/controls/help-circle.svg"
|
||||||
|
|||||||
@@ -79,11 +79,23 @@ PageType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
textField.onTextChanged: {
|
textField.onTextChanged: {
|
||||||
if (headerText == qsTr("Password or SSH private key")) {
|
if (headerText === qsTr("Password or SSH private key")) {
|
||||||
buttonImageSource = textField.text !== "" ? imageSource : ""
|
buttonImageSource = textField.text !== "" ? imageSource : ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
WarningType {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.leftMargin: 16
|
||||||
|
Layout.rightMargin: 16
|
||||||
|
Layout.topMargin: 8
|
||||||
|
|
||||||
|
visible: title === qsTr("Password or SSH private key")
|
||||||
|
backGroundColor: AmneziaStyle.color.translucentWhite
|
||||||
|
iconPath: "qrc:/images/controls/alert-circle.svg"
|
||||||
|
textString: qsTr("SSH key requirements: supported key types are ED25519 and RSA in PEM format. Paste the private key, including the BEGIN/END lines. If your key doesn’t work, generate a compatible one")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
footer: ColumnLayout {
|
footer: ColumnLayout {
|
||||||
|
|||||||
@@ -225,9 +225,13 @@ PageType {
|
|||||||
Connections {
|
Connections {
|
||||||
target: ApiConfigsController
|
target: ApiConfigsController
|
||||||
|
|
||||||
function onInstallServerFromApiFinished(message) {
|
function onInstallServerFromApiFinished(message, preferredDefaultIndex) {
|
||||||
if (!ConnectionController.isConnected) {
|
if (!ConnectionController.isConnected) {
|
||||||
ServersModel.setDefaultServerIndex(ServersModel.getServersCount() - 1);
|
if (preferredDefaultIndex !== undefined && preferredDefaultIndex >= 0) {
|
||||||
|
ServersModel.setDefaultServerIndex(preferredDefaultIndex)
|
||||||
|
} else {
|
||||||
|
ServersModel.setDefaultServerIndex(ServersModel.getServersCount() - 1)
|
||||||
|
}
|
||||||
ServersModel.processedIndex = ServersModel.defaultIndex
|
ServersModel.processedIndex = ServersModel.defaultIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+42
-6
@@ -23,17 +23,25 @@ Window {
|
|||||||
if (Qt.application.state === Qt.ApplicationActive) {
|
if (Qt.application.state === Qt.ApplicationActive) {
|
||||||
root.visible = true
|
root.visible = true
|
||||||
refreshTimer.restart()
|
refreshTimer.restart()
|
||||||
} else if (Qt.application.state === Qt.ApplicationSuspended) {
|
|
||||||
// Hide window to stop the Qt render loop and prevent
|
|
||||||
// eglSwapBuffers from being called on a lost EGL context.
|
|
||||||
// NOTE: Do NOT hide on ApplicationInactive — that fires on any
|
|
||||||
// focus change (IME, notifications) and would blank the screen.
|
|
||||||
root.visible = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide the window immediately when Android Activity.onPause() fires so that
|
||||||
|
// Qt's render loop stops before the EGL surface is disconnected. This
|
||||||
|
// prevents "QRhiGles2: Failed to make context current" and the resulting
|
||||||
|
// black screen that appears after swiping home and returning.
|
||||||
|
Connections {
|
||||||
|
target: SettingsController
|
||||||
|
function onActivityPaused() {
|
||||||
|
if (Qt.platform.os === "android") root.visible = false
|
||||||
|
}
|
||||||
|
function onActivityResumed() {
|
||||||
|
if (Qt.platform.os === "android") root.visible = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
id: refreshTimer
|
id: refreshTimer
|
||||||
interval: 150
|
interval: 150
|
||||||
@@ -280,6 +288,34 @@ Window {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
objectName: "subscriptionExpiredDrawerItem"
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
|
||||||
|
SubscriptionExpiredDrawer {
|
||||||
|
id: subscriptionExpiredDrawer
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: ApiConfigsController
|
||||||
|
|
||||||
|
function onSubscriptionExpiredOnServer() {
|
||||||
|
subscriptionExpiredDrawer.openTriggered()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: ApiSettingsController
|
||||||
|
|
||||||
|
function onRenewalLinkReceived(url) {
|
||||||
|
Qt.openUrlExternally(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
objectName: "busyIndicatorItem"
|
objectName: "busyIndicatorItem"
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user