Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68ee051ab2 | ||
|
|
0e35a794c9 | ||
|
|
5ffb5a2dbb | ||
|
|
ae06a58013 | ||
|
|
9109eb8460 | ||
|
|
1ef8c983c7 | ||
|
|
072674d1a8 | ||
|
|
228f9f6004 | ||
|
|
6756d2fca5 | ||
|
|
5998c0fc46 | ||
|
|
f5b15a0394 | ||
|
|
38e5fd84bb | ||
|
|
e0113e3413 |
No files matched your search
@@ -1,6 +1,25 @@
|
||||
# Память проекта
|
||||
|
||||
## Android-приложение: сборка и выкладка (src/frontends/android)
|
||||
Основная работа в репозитории — Android-приложение SHX VPN (`src/frontends/android`), форк клиента strongSwan.
|
||||
Описание приложения, кода, локализации и релиза: `src/frontends/android/docs/README.md`.
|
||||
Дизайн: `src/frontends/android/docs/design/README.md` (макет «Финал 2026»).
|
||||
|
||||
## Android-приложение: дизайн и UI
|
||||
|
||||
- **Эталон — макет «Финал 2026»** (артборды `F_*`, тёмная тема `D_*`): живой холст https://claude.ai/artifact/UCC88C9hkU3hDU2YwMck1a,
|
||||
копия исходников в `docs/design/final-2026/`. Экраны делаются строго по макету; новый экран сначала появляется на холсте.
|
||||
- UI собирается только из компонентов макета: `ui/compose/design/ShxKit.kt`, `ShxIcons.kt`, цвета и шрифты `ui/compose/theme/ShxDesign.kt`
|
||||
(`LocalShxColors`, `ShxType`). Сторонние цвета, размеры и стандартные M3-диалоги не использовать.
|
||||
- Изменили экран в коде — обновите `F_*` и парный `D_*` на холсте и копию в `docs/design/final-2026/`.
|
||||
|
||||
## Android-приложение: строки и переводы
|
||||
|
||||
- Языки: en (база), ru, de, pl, uk, zh-rCN, zh-rTW. Новая строка добавляется сразу во все семь `values*/strings.xml`.
|
||||
- Плейсхолдеры и `\n` как в английской строке; кавычки и plurals по языку — см. раздел «Локализация» в `docs/README.md`.
|
||||
- После правок строк смотреть de и uk на эмуляторе (`adb shell cmd locale set-app-locales one.shx.strongswan.ext --locales de-DE`):
|
||||
длинные слова не должны переноситься в навигации и заголовках. Сброс — `--locales ""`.
|
||||
|
||||
## Android-приложение: сборка и выкладка
|
||||
|
||||
- **Бакет S3 (Selectel, `shx-app`) ПРИВАТНЫЙ.** Публичного чтения нет и включать его не нужно:
|
||||
`GET https://s3.ru-7.storage.selcloud.ru/shx-app/latest.json` всегда отдаёт `AccessDenied` — это норма, а не ошибка выкладки.
|
||||
@@ -8,9 +27,34 @@
|
||||
- Сборка и публикация — скриптом `build-local.sh` (Git Bash) или `build-local.ps1` в `src/frontends/android`:
|
||||
он читает `.env`, собирает `assembleRelease`, подписывает keystore'ом из `.env`, генерирует `latest.json`
|
||||
(sha256/size/versionCode) и заливает `shx-vpn.apk` + `latest.json` в корень бакета с `Cache-Control: no-cache`.
|
||||
- **Порядок релиза:** `--skip-upload` → smoke-тест APK на эмуляторе (AVD `shx36`, все вкладки, `adb logcat -b crash`) →
|
||||
выгрузка именно проверенного `build/publish/shx-vpn.apk` и `latest.json` через `aws s3 cp` → сверка `latest.json` в бакете.
|
||||
Выгрузка в S3 — только с подтверждения владельца.
|
||||
- **`.env` нельзя парсить через `source`/`eval`**: значения `NOTES_RU` и `AWS_CA_BUNDLE` содержат пробелы и
|
||||
обратные слэши. Разбирать только построчно `KEY=...` (как в `build-local.sh`).
|
||||
- aws cli установлен через pip: `%APPDATA%\Python\Python313\Scripts\aws` (скрипт сам добавляет путь в PATH).
|
||||
- `versionCode` растёт вручную в `app/build.gradle`; перед сборкой сверять с `versionCode` в `latest.json` бакета.
|
||||
- `NOTES_RU` в `.env` — заметки релиза (попадают в `latest.json` → диалог обновления в приложении); обновлять под релиз.
|
||||
`.env` не в git: в worktree лежит своя копия, после релиза перенести `NOTES_RU` в `.env` основного checkout.
|
||||
- **Сборка из worktree:** скопировать игнорируемые входы из основного checkout (`.env` с абсолютным `KEYSTORE_FILE`,
|
||||
`local.properties`, `Android.common.mk`, `app/src/main/jni/openssl/`, сгенерированные исходники libstrongswan — список в `docs/README.md`)
|
||||
и собирать с `ORG_GRADLE_PROJECT_shortNdkCommands=true` (иначе линковка упирается в лимит длины команды Windows).
|
||||
- Автообновление приложения: `logic/updates/UpdateManager.kt` качает манифест/APK, сверяет SHA-256 и подпись.
|
||||
|
||||
## Android-приложение: выкладка через панель SHX VPN
|
||||
|
||||
Когда владелец просит **«собери и выложи»** (или «собери и загрузи в панель»), выкладка идёт через панель
|
||||
`https://apk.shnt.top`, а не через `aws s3 cp`. Сама просьба и есть подтверждение владельца на загрузку.
|
||||
|
||||
- Команда (из `src/frontends/android`, с `ORG_GRADLE_PROJECT_shortNdkCommands=true` при сборке из worktree):
|
||||
`./gradlew uploadShx` — собирает `assembleRelease` и загружает APK **черновиком** в канал `beta`, тип `phone`.
|
||||
- Опубликовать сразу — только если владелец сказал «опубликуй»: `./gradlew uploadShx -PshxPublish -PshxRollout=<процент>`.
|
||||
Без явного процента спросить его. Канал релиза — `-PshxChannel=release`, другой тип — `-PshxType=<id>`.
|
||||
- Заметки «Что нового» берутся из `NOTES_RU` в `.env`; другие — `-PshxNotes="..."`. Перед выкладкой проверить, что
|
||||
`NOTES_RU` и `versionCode` в `app/build.gradle` относятся к этому релизу (панель отклонит уже загруженный код версии).
|
||||
- Нужны `SHX_URL` и `SHX_TOKEN` (заданы у пользователя Windows; скрипт `scripts/upload-apk.mjs` читает их и из реестра,
|
||||
если процесс запущен раньше). Не печатать значение токена.
|
||||
- Подпись: release APK подписывается keystore из `.env` (`KEYSTORE_FILE`); неподписанный APK задача не загружает.
|
||||
- После загрузки сообщить владельцу ссылку на версию из вывода скрипта и статус (черновик или текущая с процентом).
|
||||
- Сборка для старых приложений 2.19.x (`latest.json` в корне бакета) по-прежнему идёт через `build-local.*` и
|
||||
требует отдельного подтверждения.
|
||||
@@ -45,8 +45,8 @@ android {
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
|
||||
versionCode 165
|
||||
versionName "2.18.0"
|
||||
versionCode 172
|
||||
versionName "2.19.4"
|
||||
|
||||
/* Base URL of the update storage, trailing slash included */
|
||||
buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\""
|
||||
@@ -74,6 +74,17 @@ android {
|
||||
buildConfigField "String", "UPDATE_S3_KEY_XOR", "\"${java.util.Base64.getEncoder().encodeToString(updateXorKey)}\""
|
||||
buildConfigField "String", "UPDATE_S3_REGION", "\"${envOr('S3_REGION', 'us-east-1')}\""
|
||||
|
||||
/* SHX update API (web panel). When UPDATE_API_URL, UPDATE_API_PUBLIC_KEY and UPDATE_API_HMAC_KEY are all set,
|
||||
the app checks for updates there; the bucket path above stays as a fallback while the API is unreachable.
|
||||
UPDATE_API_PUBLIC_KEY is the raw Ed25519 public key (base64, 32 bytes) shown on the panel's "API updates" page;
|
||||
the HMAC key is only a guard against casual requests (an APK can always be unpacked), integrity rests on the
|
||||
manifest signature and on the SHA-256 plus APK signature checks. */
|
||||
buildConfigField "String", "UPDATE_API_URL", "\"${envOr('UPDATE_API_URL', '')}\""
|
||||
buildConfigField "String", "UPDATE_API_PUBLIC_KEY", "\"${envOr('UPDATE_API_PUBLIC_KEY', '')}\""
|
||||
buildConfigField "String", "UPDATE_API_HMAC_KEY", "\"${xorB64(envOr('UPDATE_API_HMAC_KEY', ''))}\""
|
||||
/* public download page, opened by «Download from the site» when an update fails */
|
||||
buildConfigField "String", "UPDATE_DOWNLOAD_PAGE_URL", "\"${envOr('UPDATE_DOWNLOAD_PAGE_URL', 'https://get.shnt.top/get/universal')}\""
|
||||
|
||||
externalNativeBuild {
|
||||
ndkBuild {
|
||||
arguments '-j' + Runtime.runtime.availableProcessors()
|
||||
@@ -175,6 +186,8 @@ dependencies {
|
||||
implementation 'androidx.activity:activity-compose:1.11.0'
|
||||
/* QR: scanning profiles (camera) and showing a profile as a QR code */
|
||||
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
||||
/* Ed25519 verification of the signed update manifest (java.security Ed25519 needs API 33, minSdk is 24) */
|
||||
implementation 'net.i2p.crypto:eddsa:0.3.0'
|
||||
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
@@ -182,6 +195,8 @@ dependencies {
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.assertj:assertj-core:3.27.6'
|
||||
testImplementation 'org.mockito:mockito-core:5.20.0'
|
||||
/* the android.jar org.json stubs throw on the JVM; the real implementation is needed for the update API tests */
|
||||
testImplementation 'org.json:json:20240303'
|
||||
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
|
||||
androidTestImplementation 'androidx.test:runner:1.7.0'
|
||||
@@ -189,3 +204,32 @@ dependencies {
|
||||
androidTestImplementation composeBom
|
||||
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
|
||||
}
|
||||
|
||||
/* Publish to the SHX VPN panel (https://apk.shnt.top): build the release APK and upload it in one step.
|
||||
./gradlew uploadShx -> draft in the beta channel, type phone
|
||||
./gradlew uploadShx -PshxPublish -PshxRollout=25
|
||||
./gradlew uploadShx -PshxChannel=release -PshxType=tv -PshxNotes="..."
|
||||
Needs Node 18+ and SHX_URL, SHX_TOKEN (a CI token from the panel's Settings -> Tokens).
|
||||
Release notes default to NOTES_RU from .env; the version comes from the build's output-metadata.json. */
|
||||
tasks.register('uploadShx', Exec) {
|
||||
group = 'publishing'
|
||||
description = 'Build the release APK and upload it to the SHX VPN panel'
|
||||
dependsOn 'assembleRelease'
|
||||
def apkFile = layout.buildDirectory.file('outputs/apk/release/app-release.apk')
|
||||
def script = rootProject.file('scripts/upload-apk.mjs')
|
||||
doFirst {
|
||||
def apk = apkFile.get().asFile
|
||||
if (!apk.exists()) {
|
||||
throw new GradleException("${apk} not found: the release APK is unsigned (set KEYSTORE_FILE in .env)")
|
||||
}
|
||||
def args = ['node', script.absolutePath, apk.absolutePath,
|
||||
'--type', (findProperty('shxType') ?: envOr('SHX_TYPE', 'phone')).toString(),
|
||||
'--channel', (findProperty('shxChannel') ?: 'beta').toString()]
|
||||
def notes = (findProperty('shxNotes') ?: envOr('NOTES_RU', '')).toString()
|
||||
if (notes) args += ['--notes', notes]
|
||||
if (project.hasProperty('shxPublish')) {
|
||||
args += ['--publish', '--rollout', (findProperty('shxRollout') ?: '100').toString()]
|
||||
}
|
||||
commandLine args
|
||||
}
|
||||
}
|
||||
@@ -64,3 +64,6 @@
|
||||
-keep class * extends androidx.room.RoomDatabase {
|
||||
<init>();
|
||||
}
|
||||
|
||||
# i2p eddsa (manifest signature check) references sun.security.x509 only for JDK interop, absent on Android
|
||||
-dontwarn sun.security.x509.**
|
||||
@@ -27,6 +27,7 @@
|
||||
<!-- SSID is redacted in the background on Android 10+ without this. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
@@ -339,6 +340,17 @@
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- after a reboot or a clock change: re-arm the pause and scenario alarms, evaluate the rules again -->
|
||||
<receiver
|
||||
android:name=".logic.BootReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.TIME_SET" />
|
||||
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
+13
-3
@@ -54,6 +54,16 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* A profile keeps the data source it was loaded from, which may be closed
|
||||
* by the time the profile is updated or deleted, so reopen on demand.
|
||||
*/
|
||||
private SQLiteDatabase database()
|
||||
{
|
||||
open();
|
||||
return mDatabase;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close()
|
||||
{
|
||||
@@ -67,7 +77,7 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
|
||||
public VpnProfile insertProfile(VpnProfile profile)
|
||||
{
|
||||
ContentValues values = ContentValuesFromVpnProfile(profile);
|
||||
long insertId = mDatabase.insert(DatabaseHelper.TABLE_VPN_PROFILE.Name, null, values);
|
||||
long insertId = database().insert(DatabaseHelper.TABLE_VPN_PROFILE.Name, null, values);
|
||||
if (insertId == -1)
|
||||
{
|
||||
return null;
|
||||
@@ -82,14 +92,14 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
|
||||
{
|
||||
final UUID uuid = profile.getUUID();
|
||||
ContentValues values = ContentValuesFromVpnProfile(profile);
|
||||
return mDatabase.update(DatabaseHelper.TABLE_VPN_PROFILE.Name, values, KEY_UUID + " = ?", new String[]{uuid.toString()}) > 0;
|
||||
return database().update(DatabaseHelper.TABLE_VPN_PROFILE.Name, values, KEY_UUID + " = ?", new String[]{uuid.toString()}) > 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteVpnProfile(VpnProfile profile)
|
||||
{
|
||||
final UUID uuid = profile.getUUID();
|
||||
return mDatabase.delete(DatabaseHelper.TABLE_VPN_PROFILE.Name, KEY_UUID + " = ?", new String[]{uuid.toString()}) > 0;
|
||||
return database().delete(DatabaseHelper.TABLE_VPN_PROFILE.Name, KEY_UUID + " = ?", new String[]{uuid.toString()}) > 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import org.strongswan.android.logic.autoconnect.AutoConnectManager
|
||||
import org.strongswan.android.logic.scenarios.ScenarioEngine
|
||||
|
||||
/**
|
||||
* Picks the automation up again after a reboot or a change of the clock.
|
||||
*
|
||||
* Starting the process runs the application's start-up (network rules, scenarios),
|
||||
* but alarms do not survive a reboot, and a time or time zone change moves the
|
||||
* edges of the scenario time windows. This receiver re-arms what the alarms
|
||||
* carried and lets the rules decide again. It never starts the tunnel by itself:
|
||||
* the rules and scenarios do, and a profile without a saved password stays off.
|
||||
*/
|
||||
class BootReceiver : BroadcastReceiver()
|
||||
{
|
||||
override fun onReceive(context: Context, intent: Intent?)
|
||||
{
|
||||
val action = intent?.action ?: return
|
||||
DiagLog.i(TAG, "system event: $action")
|
||||
if (action == Intent.ACTION_BOOT_COMPLETED)
|
||||
{
|
||||
PauseManager.rearmAfterRestart(context)
|
||||
}
|
||||
/* start() re-arms the time-window alarm for the current clock and zone */
|
||||
ScenarioEngine.start(context)
|
||||
AutoConnectManager.getInstance(context).reevaluate()
|
||||
val pending = goAsync()
|
||||
ScenarioEngine.evaluateWhenReady(context) { pending.finish() }
|
||||
}
|
||||
|
||||
private companion object
|
||||
{
|
||||
const val TAG = "BootReceiver"
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -269,7 +269,13 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
VpnProfile profile = loadProfile(uuid);
|
||||
if (profile != null)
|
||||
{
|
||||
profile.setPassword(bundle.getString(VpnProfileDataSource.KEY_PASSWORD));
|
||||
String password = bundle.getString(VpnProfileDataSource.KEY_PASSWORD);
|
||||
if (password != null)
|
||||
{ /* an unattended start (watchdog, pause end, FGS retry) carries no
|
||||
* password: keep the one saved with the profile instead of
|
||||
* wiping it, or the tunnel never comes back («password unavailable») */
|
||||
profile.setPassword(password);
|
||||
}
|
||||
/* a backup port of F_Profile, for this attempt only */
|
||||
int port = bundle.getInt(org.strongswan.android.logic.profiles.Resilience.KEY_PORT_OVERRIDE, 0);
|
||||
if (port > 0)
|
||||
|
||||
+18
-5
@@ -25,6 +25,7 @@ import android.net.Network;
|
||||
import android.net.NetworkRequest;
|
||||
import android.os.Build;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
|
||||
public class NetworkManager extends BroadcastReceiver implements Runnable
|
||||
@@ -33,7 +34,9 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
||||
private volatile boolean mRegistered;
|
||||
private ConnectivityManager.NetworkCallback mCallback;
|
||||
private Thread mEventNotifier;
|
||||
private int mConnectedNetworks = 0;
|
||||
/* a set, not a counter: a repeated onAvailable or a missed onLost cannot
|
||||
* make it drift away from the networks that really exist */
|
||||
private final HashSet<Network> mConnectedNetworks = new HashSet<>();
|
||||
private LinkedList<Boolean> mEvents = new LinkedList<>();
|
||||
|
||||
public NetworkManager(Context context)
|
||||
@@ -51,7 +54,7 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
||||
{
|
||||
/* we expect this to be called if connected to at least one network during
|
||||
* callback registration */
|
||||
mConnectedNetworks += 1;
|
||||
mConnectedNetworks.add(network);
|
||||
mEvents.addLast(true);
|
||||
NetworkManager.this.notifyAll();
|
||||
}
|
||||
@@ -63,8 +66,8 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
||||
synchronized (NetworkManager.this)
|
||||
{
|
||||
/* in particular mobile connections are disconnected overlapping with WiFi */
|
||||
mConnectedNetworks -= 1;
|
||||
mEvents.addLast(mConnectedNetworks > 0);
|
||||
mConnectedNetworks.remove(network);
|
||||
mEvents.addLast(!mConnectedNetworks.isEmpty());
|
||||
NetworkManager.this.notifyAll();
|
||||
}
|
||||
}
|
||||
@@ -74,7 +77,11 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
||||
|
||||
public void Register()
|
||||
{
|
||||
mEvents.clear();
|
||||
synchronized (this)
|
||||
{
|
||||
mEvents.clear();
|
||||
mConnectedNetworks.clear();
|
||||
}
|
||||
mRegistered = true;
|
||||
mEventNotifier = new Thread(this);
|
||||
mEventNotifier.start();
|
||||
@@ -174,6 +181,12 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
||||
break;
|
||||
}
|
||||
connected = mEvents.removeFirst();
|
||||
/* a burst of identical states (several networks coming up at once)
|
||||
* is one change for charon, not one reconnect attempt each */
|
||||
while (!mEvents.isEmpty() && mEvents.getFirst() == connected)
|
||||
{
|
||||
mEvents.removeFirst();
|
||||
}
|
||||
}
|
||||
/* call the native parts without holding the lock */
|
||||
networkChanged(!connected);
|
||||
|
||||
@@ -229,6 +229,32 @@ object PauseManager
|
||||
alarmManager(ctx).cancel(alarmIntent(ctx))
|
||||
}
|
||||
|
||||
/**
|
||||
* After a reboot: alarms do not survive it, so a pause that was running would
|
||||
* never end. Re-arms the alarm, or ends the pause right away if its time is up.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun rearmAfterRestart(context: Context)
|
||||
{
|
||||
val ctx = context.applicationContext
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||
val until = prefs.getLong(PREF_UNTIL, 0L)
|
||||
val profile = prefs.getString(PREF_PROFILE, null)
|
||||
if (profile == null || until == 0L)
|
||||
{
|
||||
return
|
||||
}
|
||||
if (until > System.currentTimeMillis())
|
||||
{
|
||||
DiagLog.i(TAG, "pause survives the restart, re-arming its alarm")
|
||||
store(ctx, until, profile)
|
||||
}
|
||||
else
|
||||
{
|
||||
onAlarm(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
private fun store(ctx: Context, until: Long, profileUuid: String)
|
||||
{
|
||||
PreferenceManager.getDefaultSharedPreferences(ctx).edit()
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic
|
||||
|
||||
import java.util.PriorityQueue
|
||||
|
||||
/**
|
||||
* The job queue behind [Scheduler]: charon's timers, ordered by due time, and the
|
||||
* bookkeeping of the single alarm that wakes the device for the earliest one.
|
||||
* Times are on any monotonic clock (the scheduler passes elapsedRealtime).
|
||||
*
|
||||
* Jobs due within [coalesceMs] of each other share one alarm: a job may run up to
|
||||
* [coalesceMs] early, never late. charon only schedules jobs longer than 3 s ahead,
|
||||
* so 2 s is harmless, and every alarm saved is one more slot in the Doze quota of
|
||||
* allow-while-idle alarms that the keepalive needs.
|
||||
*
|
||||
* Not thread-safe; the caller synchronises.
|
||||
*/
|
||||
class ScheduledJobQueue(private val coalesceMs: Long = COALESCE_MS)
|
||||
{
|
||||
private class Job(val id: String, val time: Long) : Comparable<Job>
|
||||
{
|
||||
override fun compareTo(other: Job): Int = time.compareTo(other.time)
|
||||
}
|
||||
|
||||
private val jobs = PriorityQueue<Job>()
|
||||
private var armed = NONE
|
||||
|
||||
/** Due time the alarm is currently set for, or [NONE]. */
|
||||
val armedAt: Long get() = armed
|
||||
|
||||
val size: Int get() = jobs.size
|
||||
|
||||
/**
|
||||
* Queues a job.
|
||||
*
|
||||
* @return true if the alarm has to be (re)armed for [nextAlarm]; false if the
|
||||
* armed alarm already covers the job
|
||||
*/
|
||||
fun add(id: String, time: Long): Boolean
|
||||
{
|
||||
jobs.add(Job(id, time))
|
||||
if (armed == NONE || time < armed - coalesceMs)
|
||||
{
|
||||
armed = time
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the jobs that are due at [now] (or within the coalescing window).
|
||||
* The alarm is considered fired: [nextAlarm] says whether to arm another one.
|
||||
*/
|
||||
fun popDue(now: Long): List<String>
|
||||
{
|
||||
val limit = now + coalesceMs
|
||||
val due = ArrayList<String>()
|
||||
while (true)
|
||||
{
|
||||
val head = jobs.peek() ?: break
|
||||
if (head.time > limit)
|
||||
{
|
||||
break
|
||||
}
|
||||
due.add(jobs.remove().id)
|
||||
}
|
||||
armed = jobs.peek()?.time ?: NONE
|
||||
return due
|
||||
}
|
||||
|
||||
/** Due time of the earliest queued job, or [NONE]; the time to arm after [popDue]. */
|
||||
fun nextAlarm(): Long = jobs.peek()?.time ?: NONE
|
||||
|
||||
fun clear()
|
||||
{
|
||||
jobs.clear()
|
||||
armed = NONE
|
||||
}
|
||||
|
||||
companion object
|
||||
{
|
||||
const val NONE = -1L
|
||||
const val COALESCE_MS = 2000L
|
||||
}
|
||||
}
|
||||
+28
-51
@@ -24,10 +24,10 @@ import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.os.Build;
|
||||
import android.os.SystemClock;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.PriorityQueue;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import androidx.annotation.RequiresApi;
|
||||
@@ -38,14 +38,18 @@ public class Scheduler extends BroadcastReceiver
|
||||
private final String TAG = Scheduler.class.getSimpleName();
|
||||
private final Context mContext;
|
||||
private final AlarmManager mManager;
|
||||
private final PriorityQueue<ScheduledJob> mJobs;
|
||||
/* due times are elapsedRealtime: unlike RTC they do not jump when the clock
|
||||
* is set (NTP, manual change, time zone), which would delay keepalives and
|
||||
* rekeyings by hours */
|
||||
private final ScheduledJobQueue mJobs;
|
||||
private boolean mExactLogged;
|
||||
|
||||
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||
public Scheduler(Context context)
|
||||
{
|
||||
mContext = context;
|
||||
mManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
|
||||
mJobs = new PriorityQueue<>();
|
||||
mJobs = new ScheduledJobQueue(ScheduledJobQueue.COALESCE_MS);
|
||||
|
||||
IntentFilter filter = new IntentFilter();
|
||||
filter.addAction(EXECUTE_JOB);
|
||||
@@ -108,7 +112,7 @@ public class Scheduler extends BroadcastReceiver
|
||||
* alarms instead of crashing with a SecurityException (connectivity then
|
||||
* only degrades slightly while timers may be delayed in doze mode).
|
||||
*
|
||||
* @param time wakeup time based on RTC
|
||||
* @param time wakeup time based on elapsedRealtime
|
||||
*/
|
||||
@RequiresApi(api = Build.VERSION_CODES.M)
|
||||
private void scheduleAlarm(long time)
|
||||
@@ -119,21 +123,26 @@ public class Scheduler extends BroadcastReceiver
|
||||
{
|
||||
exact = mManager.canScheduleExactAlarms();
|
||||
}
|
||||
if (!mExactLogged)
|
||||
{ /* once per run, for support reports */
|
||||
mExactLogged = true;
|
||||
DiagLog.i(TAG, "keepalive alarms are " + (exact ? "exact" : "inexact (exact alarms not allowed)"));
|
||||
}
|
||||
try
|
||||
{
|
||||
if (exact)
|
||||
{
|
||||
mManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending);
|
||||
mManager.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
|
||||
}
|
||||
else
|
||||
{
|
||||
mManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending);
|
||||
mManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
|
||||
}
|
||||
}
|
||||
catch (SecurityException e)
|
||||
{ /* permission might have been revoked, or OEM restrictions apply */
|
||||
Log.w(TAG, "Falling back to inexact alarm after SecurityException");
|
||||
mManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending);
|
||||
mManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,12 +158,10 @@ public class Scheduler extends BroadcastReceiver
|
||||
{
|
||||
synchronized (this)
|
||||
{
|
||||
ScheduledJob job = new ScheduledJob(id, System.currentTimeMillis() + ms);
|
||||
mJobs.add(job);
|
||||
|
||||
if (job == mJobs.peek())
|
||||
{ /* update the alarm if the job has to be executed before all others */
|
||||
scheduleAlarm(job.Time);
|
||||
long time = SystemClock.elapsedRealtime() + ms;
|
||||
if (mJobs.add(id, time))
|
||||
{ /* the job is due before the armed alarm: move the alarm */
|
||||
scheduleAlarm(time);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,30 +170,21 @@ public class Scheduler extends BroadcastReceiver
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent)
|
||||
{
|
||||
ArrayList<ScheduledJob> jobs = new ArrayList<>();
|
||||
long now = System.currentTimeMillis();
|
||||
List<String> jobs;
|
||||
|
||||
synchronized (this)
|
||||
{
|
||||
ScheduledJob job = mJobs.peek();
|
||||
while (job != null)
|
||||
jobs = mJobs.popDue(SystemClock.elapsedRealtime());
|
||||
long next = mJobs.nextAlarm();
|
||||
if (next != ScheduledJobQueue.NONE)
|
||||
{
|
||||
if (job.Time > now)
|
||||
{
|
||||
break;
|
||||
}
|
||||
jobs.add(mJobs.remove());
|
||||
job = mJobs.peek();
|
||||
}
|
||||
if (job != null)
|
||||
{
|
||||
scheduleAlarm(job.Time);
|
||||
scheduleAlarm(next);
|
||||
}
|
||||
}
|
||||
|
||||
for (ScheduledJob job : jobs)
|
||||
for (String id : jobs)
|
||||
{
|
||||
executeJob(job.Id);
|
||||
executeJob(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,25 +194,4 @@ public class Scheduler extends BroadcastReceiver
|
||||
* @param id job ID
|
||||
*/
|
||||
public native void executeJob(String id);
|
||||
|
||||
/**
|
||||
* Keep track of scheduled jobs.
|
||||
*/
|
||||
private static class ScheduledJob implements Comparable<ScheduledJob>
|
||||
{
|
||||
String Id;
|
||||
long Time;
|
||||
|
||||
ScheduledJob(String id, long time)
|
||||
{
|
||||
Id = id;
|
||||
Time = time;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(ScheduledJob o)
|
||||
{
|
||||
return Long.compare(Time, o.Time);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -232,6 +232,7 @@ public class WatchdogReceiver extends BroadcastReceiver
|
||||
return;
|
||||
case RESTART:
|
||||
{
|
||||
boolean autoRestart = uuid == null;
|
||||
if (uuid == null)
|
||||
{
|
||||
uuid = resolveAutoProfile(context, prefs);
|
||||
@@ -260,6 +261,10 @@ public class WatchdogReceiver extends BroadcastReceiver
|
||||
* once, which is harmless (retry flag set, same profile). */
|
||||
Log.i(TAG, "A connection was expected but the VPN process is gone, restarting it");
|
||||
DiagLog.i(TAG, "restarting the VPN process");
|
||||
if (autoRestart)
|
||||
{ /* this process does not remember the session was auto-started */
|
||||
AutoConnectManager.getInstance(context).markAutoSessionRestored();
|
||||
}
|
||||
Intent start = new Intent(context, CharonVpnService.class);
|
||||
start.putExtra(VpnProfileDataSource.KEY_UUID, uuid);
|
||||
start.putExtra(CharonVpnService.KEY_IS_RETRY, true);
|
||||
|
||||
+42
-6
@@ -91,6 +91,8 @@ class AutoConnectManager private constructor(
|
||||
private var currentNetwork: NetworkKind? = null
|
||||
/* marks the session this manager started itself */
|
||||
private val autoSession = AtomicBoolean(false)
|
||||
/* an auto session was restored by the watchdog and its tunnel is not up yet */
|
||||
@Volatile private var awaitingRestoredTunnel = false
|
||||
/* elapsedRealtime of the last park caused by a trusted Wi-Fi (flap guard) */
|
||||
private var lastParkAt = 0L
|
||||
|
||||
@@ -123,10 +125,28 @@ class AutoConnectManager private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Network scenarios need the network events even when the network rules are off. */
|
||||
private fun scenariosNeedNetwork(): Boolean =
|
||||
org.strongswan.android.logic.scenarios.ScenarioEngine.needsNetworkEvents(context)
|
||||
|
||||
/** Called when the scenarios changed: starts or stops the network listening to fit. */
|
||||
fun scenariosChanged()
|
||||
{
|
||||
config = loadConfig()
|
||||
if (config.enabled || scenariosNeedNetwork())
|
||||
{
|
||||
start()
|
||||
}
|
||||
else
|
||||
{
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
fun start()
|
||||
{
|
||||
config = loadConfig()
|
||||
if (!config.enabled)
|
||||
if (!config.enabled && !scenariosNeedNetwork())
|
||||
{
|
||||
return
|
||||
}
|
||||
@@ -186,8 +206,8 @@ class AutoConnectManager private constructor(
|
||||
}
|
||||
else
|
||||
{
|
||||
stop()
|
||||
autoSession.set(false)
|
||||
scenariosChanged()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,8 +234,8 @@ class AutoConnectManager private constructor(
|
||||
}
|
||||
else
|
||||
{
|
||||
stop()
|
||||
autoSession.set(false)
|
||||
scenariosChanged()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,12 +256,28 @@ class AutoConnectManager private constructor(
|
||||
override fun stateChanged()
|
||||
{
|
||||
val state = service?.state ?: return
|
||||
if (state == VpnStateService.State.DISABLED)
|
||||
if (state != VpnStateService.State.DISABLED)
|
||||
{
|
||||
awaitingRestoredTunnel = false
|
||||
}
|
||||
else if (!awaitingRestoredTunnel)
|
||||
{
|
||||
autoSession.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The watchdog brought an auto session back in a fresh process, whose memory
|
||||
* knows nothing of it. Mark it as auto-started again, so it parks on a trusted
|
||||
* Wi-Fi instead of running all night; the DISABLED state of the not yet
|
||||
* started service must not clear the mark.
|
||||
*/
|
||||
fun markAutoSessionRestored()
|
||||
{
|
||||
awaitingRestoredTunnel = true
|
||||
autoSession.set(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision function, unit-testable.
|
||||
*/
|
||||
@@ -268,8 +304,8 @@ class AutoConnectManager private constructor(
|
||||
|
||||
private fun evaluate()
|
||||
{
|
||||
if (!running.get())
|
||||
{
|
||||
if (!running.get() || !config.enabled)
|
||||
{ /* running only for the scenarios: they decide, not the network rules */
|
||||
return
|
||||
}
|
||||
val svc = service ?: return
|
||||
|
||||
+71
-1
@@ -30,6 +30,7 @@ import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -43,6 +44,7 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import org.strongswan.android.logic.CharonVpnService
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
import org.strongswan.android.logic.events.Events
|
||||
|
||||
@@ -130,6 +132,9 @@ class HealthCheckManager private constructor(
|
||||
private var tunnelAttempts = 0
|
||||
private var fastPollShifts = 0
|
||||
private var powerState = PowerState()
|
||||
/* tunnel inbound bytes at the previous cycle (-1: unknown) and when the last real probe ran */
|
||||
private var lastCycleInbound = -1L
|
||||
private var lastProbeAt = 0L
|
||||
|
||||
/* tracks screen/battery state so probing can back off while the device sleeps */
|
||||
private val powerReceiver = object : BroadcastReceiver()
|
||||
@@ -360,7 +365,31 @@ class HealthCheckManager private constructor(
|
||||
return
|
||||
}
|
||||
val gateway = svc.profile?.gateway
|
||||
val targets = config.targets
|
||||
val adapted = adaptToPowerState(config, powerState)
|
||||
val nowMs = SystemClock.elapsedRealtime()
|
||||
/* passive liveness: payload that came back through the tunnel since the
|
||||
* previous cycle proves it works without waking the radio for probes */
|
||||
val inbound = tunnelInboundBytes()
|
||||
val inboundDelta = if (lastCycleInbound >= 0 && inbound >= 0) inbound - lastCycleInbound else -1L
|
||||
lastCycleInbound = inbound
|
||||
if (trafficProvesAlive(
|
||||
adapted, serverAttempts + tunnelAttempts, fastPollShifts, pendingDecision != null,
|
||||
inboundDelta, nowMs - lastProbeAt,
|
||||
))
|
||||
{
|
||||
scheduleCycle(effectiveIntervalSec(adapted) * 1000L)
|
||||
return
|
||||
}
|
||||
lastProbeAt = nowMs
|
||||
/* screen off: one target is enough (the confirmation probe uses it too) */
|
||||
val targets = if (adapted.adaptiveMode && !powerState.screenOn && config.targets.size > 1)
|
||||
{
|
||||
setOf(config.targets.first())
|
||||
}
|
||||
else
|
||||
{
|
||||
config.targets
|
||||
}
|
||||
val mode = config.serverCheckMode
|
||||
val timeout = config.timeoutMs
|
||||
|
||||
@@ -609,6 +638,21 @@ class HealthCheckManager private constructor(
|
||||
tunnelAttempts = 0
|
||||
fastPollShifts = 0
|
||||
pendingDecision = null
|
||||
lastCycleInbound = -1L
|
||||
lastProbeAt = 0L
|
||||
}
|
||||
|
||||
/** Inbound byte counter of the tunnel's CHILD_SAs, -1 when unavailable. */
|
||||
private fun tunnelInboundBytes(): Long
|
||||
{
|
||||
return try
|
||||
{
|
||||
CharonVpnService.queryTraffic()?.getOrNull(0) ?: -1L
|
||||
}
|
||||
catch (_: Throwable)
|
||||
{
|
||||
-1L
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerVpnCallback()
|
||||
@@ -705,6 +749,32 @@ class HealthCheckManager private constructor(
|
||||
private const val FAST_POLL_MAX_SHIFTS = 3
|
||||
private const val AGGRESSIVE_INTERVAL_SEC = 5
|
||||
|
||||
/** Inbound tunnel payload since the last cycle that counts as proof of life. */
|
||||
internal const val PASSIVE_MIN_INBOUND_BYTES = 4096L
|
||||
|
||||
/** Even with traffic flowing a real probe runs at least this often. */
|
||||
internal const val PASSIVE_MAX_PROBE_AGE_MS = 5 * 60 * 1000L
|
||||
|
||||
/**
|
||||
* Pure function: whether the cycle may skip its probes because the
|
||||
* tunnel carried inbound data since the previous cycle. Only in
|
||||
* adaptive mode, never in aggressive mode, never while a failure is
|
||||
* being observed, and never for longer than [PASSIVE_MAX_PROBE_AGE_MS].
|
||||
* Outbound-only traffic proves nothing, so it does not count.
|
||||
*/
|
||||
fun trafficProvesAlive(
|
||||
adapted: HealthConfig,
|
||||
failedAttempts: Int,
|
||||
fastPollShifts: Int,
|
||||
decisionPending: Boolean,
|
||||
inboundDelta: Long,
|
||||
sinceLastProbeMs: Long,
|
||||
): Boolean =
|
||||
adapted.adaptiveMode && !adapted.aggressiveMode &&
|
||||
failedAttempts == 0 && fastPollShifts == 0 && !decisionPending &&
|
||||
inboundDelta >= PASSIVE_MIN_INBOUND_BYTES &&
|
||||
sinceLastProbeMs in 0 until PASSIVE_MAX_PROBE_AGE_MS
|
||||
|
||||
/** Minimum probe interval in adaptive mode while the screen is off. */
|
||||
internal const val SCREEN_OFF_MIN_INTERVAL_SEC = 60
|
||||
|
||||
|
||||
@@ -143,6 +143,16 @@ object Events
|
||||
)
|
||||
}
|
||||
|
||||
/** A report went to the panel: the code stays here so the user can find it later. */
|
||||
@JvmStatic
|
||||
fun reportSent(context: Context, code: String)
|
||||
{
|
||||
EventLog.getInstance(context).record(
|
||||
EventKind.OK, EventCategory.APP, context.getString(R.string.report_event_title, code),
|
||||
context.getString(R.string.report_event_text), key = "report:$code",
|
||||
)
|
||||
}
|
||||
|
||||
/** The watchdog did not bring back [name]: a password would be needed. */
|
||||
@JvmStatic
|
||||
fun restoreNeedsPassword(context: Context, uuid: String, name: String)
|
||||
|
||||
+15
@@ -542,10 +542,22 @@ class RoutingManager private constructor(private val context: Context)
|
||||
break
|
||||
}
|
||||
delay(SITE_REFRESH_MS)
|
||||
/* nobody looks at the screen: resolving only wakes the radio and
|
||||
* CPU, so wait for it to come on (re-checked every few minutes),
|
||||
* but never let the answers get older than the cap */
|
||||
var waited = SITE_REFRESH_MS
|
||||
while (waited < SITE_REFRESH_SCREEN_OFF_MAX_MS && !isScreenOn())
|
||||
{
|
||||
delay(SITE_SCREEN_RECHECK_MS)
|
||||
waited += SITE_SCREEN_RECHECK_MS
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isScreenOn(): Boolean =
|
||||
(context.getSystemService(Context.POWER_SERVICE) as? android.os.PowerManager)?.isInteractive ?: true
|
||||
|
||||
/**
|
||||
* Like [updateConfig], but force prefixes apply on the fly through the
|
||||
* atomic set swap of the userspace router - no reconnect needed.
|
||||
@@ -833,6 +845,9 @@ class RoutingManager private constructor(private val context: Context)
|
||||
private const val KEY_NOTICE_ONLY = "notice_only_profiles"
|
||||
private const val RECONNECT_DEBOUNCE_MS = 2000L
|
||||
const val SITE_REFRESH_MS = 30L * 60 * 1000
|
||||
/* with the screen off the refresh waits for it, up to this age */
|
||||
private const val SITE_REFRESH_SCREEN_OFF_MAX_MS = 3L * 60 * 60 * 1000
|
||||
private const val SITE_SCREEN_RECHECK_MS = 5L * 60 * 1000
|
||||
|
||||
/** Config schema: 1 = rule sets, 2 = direct apps + categories + patterns. */
|
||||
const val CONFIG_VERSION = 2
|
||||
|
||||
+129
-13
@@ -21,13 +21,16 @@ import android.app.usage.UsageStatsManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.os.SystemClock
|
||||
import android.telephony.TelephonyManager
|
||||
import android.util.Log
|
||||
import androidx.preference.PreferenceManager
|
||||
@@ -161,6 +164,12 @@ object ScenarioEngine
|
||||
private const val TAG = "Scenarios"
|
||||
private const val FILE_NAME = "scenarios.json"
|
||||
private const val POLL_MS = 4000L
|
||||
/* how long a broadcast waits for the VPN state service of a fresh process */
|
||||
private const val SERVICE_WAIT_MS = 8000L
|
||||
private const val SERVICE_POLL_MS = 200L
|
||||
/* the foreground app did not change for a minute: poll half as often */
|
||||
private const val SLOW_AFTER_POLLS = 15
|
||||
private const val SLOW_POLL_MS = 8000L
|
||||
private const val PREF_APPLIED = "pref_scenario_applied"
|
||||
private const val PREF_WAS_ON = "pref_scenario_was_on"
|
||||
private const val PREF_WAS_PROFILE = "pref_scenario_was_profile"
|
||||
@@ -174,6 +183,7 @@ object ScenarioEngine
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var appContext: Context? = null
|
||||
private var polling = false
|
||||
private var unchangedPolls = 0
|
||||
private var lastForeground: String? = null
|
||||
|
||||
fun flow(context: Context): StateFlow<List<Scenario>>
|
||||
@@ -188,9 +198,20 @@ object ScenarioEngine
|
||||
_list.value = list
|
||||
persist(context)
|
||||
start(context)
|
||||
org.strongswan.android.logic.autoconnect.AutoConnectManager.getInstance(context).scenariosChanged()
|
||||
evaluate(context)
|
||||
}
|
||||
|
||||
/** Whether an enabled scenario reacts to Wi-Fi, mobile network or roaming changes. */
|
||||
@JvmStatic
|
||||
fun needsNetworkEvents(context: Context): Boolean
|
||||
{
|
||||
load(context.applicationContext)
|
||||
return _list.value.any {
|
||||
it.enabled && (it.trigger == ScenarioTrigger.WIFI || it.trigger == ScenarioTrigger.MOBILE || it.trigger == ScenarioTrigger.ROAMING)
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts listening (from the application); idempotent. */
|
||||
@JvmStatic
|
||||
fun start(context: Context)
|
||||
@@ -203,36 +224,118 @@ object ScenarioEngine
|
||||
if (needPoll && !polling)
|
||||
{
|
||||
polling = true
|
||||
handler.post(poll)
|
||||
registerScreenReceiver(app)
|
||||
if (isScreenOn(app))
|
||||
{
|
||||
handler.post(poll)
|
||||
}
|
||||
}
|
||||
else if (!needPoll)
|
||||
{
|
||||
polling = false
|
||||
handler.removeCallbacks(poll)
|
||||
unregisterScreenReceiver(app)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isScreenOn(context: Context): Boolean =
|
||||
(context.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
|
||||
|
||||
/* the foreground app only changes while the screen is on: no polling (and no
|
||||
* wake-ups for it) while it is off, one immediate poll when it comes back */
|
||||
private val screenReceiver = object : BroadcastReceiver()
|
||||
{
|
||||
override fun onReceive(context: Context, intent: Intent)
|
||||
{
|
||||
handler.removeCallbacks(poll)
|
||||
if (polling && intent.action == Intent.ACTION_SCREEN_ON)
|
||||
{
|
||||
unchangedPolls = 0
|
||||
handler.post(poll)
|
||||
}
|
||||
}
|
||||
}
|
||||
private var screenReceiverRegistered = false
|
||||
|
||||
private fun registerScreenReceiver(app: Context)
|
||||
{
|
||||
if (screenReceiverRegistered)
|
||||
{
|
||||
return
|
||||
}
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
}
|
||||
runCatching { app.registerReceiver(screenReceiver, filter, null, handler) }
|
||||
.onSuccess { screenReceiverRegistered = true }
|
||||
}
|
||||
|
||||
private fun unregisterScreenReceiver(app: Context)
|
||||
{
|
||||
if (!screenReceiverRegistered)
|
||||
{
|
||||
return
|
||||
}
|
||||
runCatching { app.unregisterReceiver(screenReceiver) }
|
||||
screenReceiverRegistered = false
|
||||
}
|
||||
|
||||
private val poll = object : Runnable
|
||||
{
|
||||
override fun run()
|
||||
{
|
||||
val app = appContext ?: return
|
||||
if (!polling)
|
||||
{
|
||||
if (!polling || !isScreenOn(app))
|
||||
{ /* resumed by the screen-on broadcast */
|
||||
return
|
||||
}
|
||||
val screenOn = (app.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
|
||||
if (screenOn)
|
||||
val fg = foregroundApp(app)
|
||||
if (fg != null && fg != lastForeground)
|
||||
{
|
||||
val fg = foregroundApp(app)
|
||||
if (fg != null && fg != lastForeground)
|
||||
lastForeground = fg
|
||||
unchangedPolls = 0
|
||||
evaluate(app)
|
||||
}
|
||||
else
|
||||
{
|
||||
unchangedPolls++
|
||||
}
|
||||
handler.postDelayed(this, if (unchangedPolls >= SLOW_AFTER_POLLS) SLOW_POLL_MS else POLL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides again once the VPN state service exists. A broadcast that starts a
|
||||
* fresh process arrives before the managers bound the service; evaluating
|
||||
* then would silently drop the event. Gives up after [SERVICE_WAIT_MS]
|
||||
* (the next network or alarm event evaluates again) and always calls [done].
|
||||
*/
|
||||
@JvmStatic
|
||||
fun evaluateWhenReady(context: Context, done: Runnable?)
|
||||
{
|
||||
val app = context.applicationContext
|
||||
val deadline = SystemClock.elapsedRealtime() + SERVICE_WAIT_MS
|
||||
val check = object : Runnable
|
||||
{
|
||||
override fun run()
|
||||
{
|
||||
if (VpnStateService.getLiveInstance() == null && SystemClock.elapsedRealtime() < deadline)
|
||||
{
|
||||
handler.postDelayed(this, SERVICE_POLL_MS)
|
||||
return
|
||||
}
|
||||
try
|
||||
{
|
||||
lastForeground = fg
|
||||
evaluate(app)
|
||||
}
|
||||
finally
|
||||
{
|
||||
done?.run()
|
||||
}
|
||||
}
|
||||
handler.postDelayed(this, POLL_MS)
|
||||
}
|
||||
handler.post(check)
|
||||
}
|
||||
|
||||
/** Network, whitelist or alarm: decide again. */
|
||||
@@ -377,10 +480,12 @@ object ScenarioEngine
|
||||
{
|
||||
return
|
||||
}
|
||||
/* without a live service nothing can be put back: keep the record so the
|
||||
* next evaluation (the receiver waits for the service) still restores */
|
||||
val service = VpnStateService.getLiveInstance() ?: return
|
||||
val wasOn = prefs.getBoolean(PREF_WAS_ON, false)
|
||||
val wasProfile = prefs.getString(PREF_WAS_PROFILE, null)
|
||||
prefs.edit().remove(PREF_APPLIED).remove(PREF_WAS_ON).remove(PREF_WAS_PROFILE).apply()
|
||||
val service = VpnStateService.getLiveInstance() ?: return
|
||||
DiagLog.i(TAG, "scenario over, restoring (on=$wasOn)")
|
||||
if (PauseManager.isPaused(context))
|
||||
{
|
||||
@@ -508,12 +613,22 @@ object ScenarioEngine
|
||||
{
|
||||
c.add(Calendar.DAY_OF_YEAR, 1)
|
||||
}
|
||||
/* same choice as PauseManager: exact when the grant allows it */
|
||||
val exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
|
||||
try
|
||||
{
|
||||
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi)
|
||||
if (exact)
|
||||
{
|
||||
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi)
|
||||
}
|
||||
else
|
||||
{
|
||||
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi)
|
||||
}
|
||||
}
|
||||
catch (_: SecurityException)
|
||||
{
|
||||
{ /* revoked in between: an inexact alarm is still better than none */
|
||||
runCatching { am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -619,6 +734,7 @@ class ScenarioReceiver : BroadcastReceiver()
|
||||
override fun onReceive(context: Context, intent: Intent?)
|
||||
{
|
||||
ScenarioEngine.start(context)
|
||||
ScenarioEngine.evaluate(context)
|
||||
val pending = goAsync()
|
||||
ScenarioEngine.evaluateWhenReady(context) { pending.finish() }
|
||||
}
|
||||
}
|
||||
+54
-5
@@ -15,6 +15,8 @@
|
||||
package org.strongswan.android.logic.stats
|
||||
|
||||
import android.content.Context
|
||||
import android.os.PowerManager
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -37,7 +39,8 @@ data class DayTraffic(val day: String, val vpn: Long, val direct: Long)
|
||||
|
||||
/**
|
||||
* Daily traffic history (F_Stats). While the tunnel is up the daemon
|
||||
* counters are sampled every [SAMPLE_S] seconds and the growth is added to
|
||||
* counters are sampled every [SAMPLE_S] seconds (every [SAMPLE_OFF_S] with
|
||||
* the screen off: the counters are cumulative, so nothing is lost) and the growth is added to
|
||||
* the current day; the counters restart with every connection, so a drop
|
||||
* starts a new baseline. Kept for [KEEP_DAYS] days in traffic.json.
|
||||
*/
|
||||
@@ -46,12 +49,21 @@ object TrafficHistory
|
||||
private const val TAG = "TrafficHistory"
|
||||
private const val FILE_NAME = "traffic.json"
|
||||
private const val SAMPLE_S = 15L
|
||||
private const val SAMPLE_OFF_S = 60L
|
||||
/* the history lives in memory and is written out this often (not on every
|
||||
* sample: a full JSON rewrite every 15 s is flash wear for nothing); a
|
||||
* killed process loses at most this much of the statistics */
|
||||
internal const val SAVE_EVERY_MS = 5 * 60 * 1000L
|
||||
private const val KEEP_DAYS = 120
|
||||
|
||||
private val executor = Executors.newSingleThreadScheduledExecutor()
|
||||
private var task: ScheduledFuture<*>? = null
|
||||
private var lastVpn = -1L
|
||||
private var lastDirect = -1L
|
||||
private var lastSampleAt = 0L
|
||||
private var dirty = false
|
||||
private var lastSavedAt = 0L
|
||||
private var lastSavedDay: String? = null
|
||||
@Volatile private var loaded = false
|
||||
private val days = LinkedHashMap<String, LongArray>()
|
||||
private var firstDay: String? = null
|
||||
@@ -86,11 +98,38 @@ object TrafficHistory
|
||||
val running = task ?: return
|
||||
running.cancel(false)
|
||||
task = null
|
||||
executor.execute { sample(app) }
|
||||
executor.execute {
|
||||
sample(app, force = true)
|
||||
flush(app)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sample(context: Context)
|
||||
/** Writes the history out now if it changed since the last write. */
|
||||
@Synchronized
|
||||
fun flush(context: Context)
|
||||
{
|
||||
if (dirty)
|
||||
{
|
||||
save(context)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a changed history has to be written now: periodically and when the day turns. */
|
||||
internal fun saveDue(dirty: Boolean, now: Long, lastSavedAt: Long, dayChanged: Boolean): Boolean =
|
||||
dirty && (dayChanged || now - lastSavedAt >= SAVE_EVERY_MS)
|
||||
|
||||
private fun screenOn(context: Context): Boolean =
|
||||
(context.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive != false
|
||||
|
||||
private fun sample(context: Context, force: Boolean = false)
|
||||
{
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val screen = screenOn(context)
|
||||
if (!force && !screen && now - lastSampleAt < SAMPLE_OFF_S * 1000L - 1000L)
|
||||
{ /* screen off: a sample a minute is enough, the counters accumulate */
|
||||
return
|
||||
}
|
||||
lastSampleAt = now
|
||||
val traffic = runCatching { CharonVpnService.queryTraffic() }.getOrNull()
|
||||
val vpn = traffic?.let { it.getOrElse(0) { 0L } + it.getOrElse(1) { 0L } } ?: return
|
||||
/* direct bytes only exist with the userspace router (s[3]) */
|
||||
@@ -121,9 +160,16 @@ object TrafficHistory
|
||||
firstDay = key
|
||||
}
|
||||
prune()
|
||||
save(context)
|
||||
dirty = true
|
||||
if (saveDue(dirty, SystemClock.elapsedRealtime(), lastSavedAt, lastSavedDay != null && lastSavedDay != key))
|
||||
{
|
||||
save(context)
|
||||
}
|
||||
_changes.value = System.currentTimeMillis()
|
||||
org.strongswan.android.ui.widget.ShxWidgetState.refresh(context)
|
||||
if (screenOn(context))
|
||||
{ /* nobody sees the widget with the screen off */
|
||||
org.strongswan.android.ui.widget.ShxWidgetState.refresh(context)
|
||||
}
|
||||
}
|
||||
|
||||
/** The days from [from] to [to] (inclusive, wall clock), zeros where nothing was recorded. */
|
||||
@@ -205,6 +251,9 @@ object TrafficHistory
|
||||
file.writeText(json.toString())
|
||||
tmp.delete()
|
||||
}
|
||||
dirty = false
|
||||
lastSavedAt = SystemClock.elapsedRealtime()
|
||||
lastSavedDay = dayKey(System.currentTimeMillis())
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.updates
|
||||
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
|
||||
/**
|
||||
* What goes into a report sent from the log screen: today's part of charon.log and,
|
||||
* on request, the app's diag.log. No Android types here, so it runs in unit tests.
|
||||
*/
|
||||
object ReportContent
|
||||
{
|
||||
/* the panel unpacks a report with a 5 MB cap; 3 + 0.75 MB of text leaves room and stays far below 2 MB gzip */
|
||||
const val CHARON_MAX_BYTES = 3 * 1024 * 1024
|
||||
const val DIAG_MAX_BYTES = 768 * 1024
|
||||
const val CUT_MARK = "... (earlier lines cut)\n"
|
||||
|
||||
/** Why a report was not sent; each maps to a reason line of the "not sent" sheet */
|
||||
enum class Failure { NO_NETWORK, SERVER_DOWN, SIGNATURE, TOO_OFTEN, TOO_LARGE, REJECTED }
|
||||
|
||||
fun failure(httpCode: Int): Failure = when
|
||||
{
|
||||
httpCode == 0 -> Failure.NO_NETWORK
|
||||
httpCode == 401 -> Failure.SIGNATURE
|
||||
httpCode == 413 -> Failure.TOO_LARGE
|
||||
httpCode == 429 -> Failure.TOO_OFTEN
|
||||
httpCode >= 500 || httpCode == 404 -> Failure.SERVER_DOWN
|
||||
else -> Failure.REJECTED
|
||||
}
|
||||
|
||||
private val MONTHS = listOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||
/* charon: «Nov 8 14:02:11 06[IKE] ...» (strftime %b %e, C locale) */
|
||||
private val CHARON_DATE = Regex("""^(\w{3})\s+(\d{1,2})\s+\d{2}:\d{2}:\d{2}\s""")
|
||||
|
||||
/**
|
||||
* Lines of [month] (1-12) / [day] only; lines without a date stay with the dated line before them.
|
||||
* When nothing is from today the whole text is kept: an old log still helps support.
|
||||
*/
|
||||
fun todayOnly(text: String, month: Int, day: Int): String
|
||||
{
|
||||
var keep = true
|
||||
var today = false
|
||||
val out = StringBuilder()
|
||||
for (line in text.lineSequence())
|
||||
{
|
||||
CHARON_DATE.find(line)?.let { m ->
|
||||
keep = MONTHS.indexOf(m.groupValues[1]) + 1 == month && m.groupValues[2].toInt() == day
|
||||
today = today || keep
|
||||
}
|
||||
if (keep && line.isNotEmpty())
|
||||
{
|
||||
out.append(line).append('\n')
|
||||
}
|
||||
}
|
||||
/* undated lines alone (e.g. the cut mark of readTail) do not count as today's log */
|
||||
return if (today) out.toString() else text
|
||||
}
|
||||
|
||||
/** The last [maxBytes] of UTF-8 [text], cut at a line start; the cut is marked */
|
||||
fun tailBytes(text: String, maxBytes: Int): String
|
||||
{
|
||||
val bytes = text.toByteArray(Charsets.UTF_8)
|
||||
if (bytes.size <= maxBytes)
|
||||
{
|
||||
return text
|
||||
}
|
||||
var start = bytes.size - maxBytes
|
||||
/* '\n' is never part of a multi-byte UTF-8 sequence, so cutting after it keeps characters whole */
|
||||
while (start < bytes.size && bytes[start - 1] != '\n'.code.toByte())
|
||||
{
|
||||
start++
|
||||
}
|
||||
if (start == bytes.size)
|
||||
{ /* one huge line: cut anywhere, but not inside a character (skip 10xxxxxx continuation bytes) */
|
||||
start = bytes.size - maxBytes
|
||||
while (start < bytes.size && (bytes[start].toInt() and 0xC0) == 0x80)
|
||||
{
|
||||
start++
|
||||
}
|
||||
}
|
||||
return CUT_MARK + String(bytes, start, bytes.size - start, Charsets.UTF_8)
|
||||
}
|
||||
|
||||
/** Up to [maxBytes] from the end of [file] ("" when missing); a partial first line is dropped */
|
||||
fun readTail(file: File, maxBytes: Int): String
|
||||
{
|
||||
if (!file.exists())
|
||||
{
|
||||
return ""
|
||||
}
|
||||
return try
|
||||
{
|
||||
RandomAccessFile(file, "r").use { f ->
|
||||
val from = maxOf(0L, f.length() - maxBytes)
|
||||
val buf = ByteArray((f.length() - from).toInt())
|
||||
f.seek(from)
|
||||
f.readFully(buf)
|
||||
val text = String(buf, Charsets.UTF_8)
|
||||
if (from > 0) CUT_MARK + text.substringAfter('\n') else text
|
||||
}
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
/** One network check of the diagnostics screen: the panel shows them as a check list */
|
||||
data class Check(val title: String, val value: String, val ok: Boolean?)
|
||||
|
||||
/** JSON array of the checks (no org.json: this object stays testable without Android) */
|
||||
fun checksJson(checks: List<Check>): String = checks.joinToString(",", "[", "]") { c ->
|
||||
"{\"title\":${jsonString(c.title)},\"value\":${jsonString(c.value)},\"ok\":${c.ok?.toString() ?: "null"}}"
|
||||
}
|
||||
|
||||
private fun jsonString(s: String): String = buildString {
|
||||
append('"')
|
||||
for (ch in s)
|
||||
{
|
||||
when
|
||||
{
|
||||
ch == '"' -> append("\\\"")
|
||||
ch == '\\' -> append("\\\\")
|
||||
ch == '\n' -> append("\\n")
|
||||
ch == '\r' -> append("\\r")
|
||||
ch == '\t' -> append("\\t")
|
||||
ch < ' ' -> append("\\u%04x".format(ch.code))
|
||||
else -> append(ch)
|
||||
}
|
||||
}
|
||||
append('"')
|
||||
}
|
||||
|
||||
/** The report text: the connection log, the network checks and diag.log when asked for */
|
||||
fun build(charon: String, diag: String?, checks: List<Check>? = null): String = buildString {
|
||||
append("=== charon.log ===\n")
|
||||
append(tailBytes(charon, CHARON_MAX_BYTES).ifBlank { "(empty)\n" })
|
||||
if (!checks.isNullOrEmpty())
|
||||
{
|
||||
append("\n=== checks.json ===\n")
|
||||
append(checksJson(checks)).append('\n')
|
||||
}
|
||||
if (diag != null)
|
||||
{
|
||||
append("\n=== diag.log ===\n")
|
||||
append(tailBytes(diag, DIAG_MAX_BYTES).ifBlank { "(empty)\n" })
|
||||
}
|
||||
}
|
||||
|
||||
/** Meta of a report from the log screen; [network] is "Wi-Fi", "mobile" or "none" */
|
||||
fun meta(
|
||||
versionName: String, versionCode: Long, manufacturer: String, model: String, release: String, sdk: Int,
|
||||
network: String, comment: String, profile: String,
|
||||
) = ReportUploader.Meta(
|
||||
versionName = versionName,
|
||||
versionCode = versionCode,
|
||||
device = if (model.startsWith(manufacturer, ignoreCase = true)) model else "$manufacturer $model",
|
||||
os = "Android $release (API $sdk)",
|
||||
network = network,
|
||||
source = "log",
|
||||
comment = comment.trim(),
|
||||
profile = profile,
|
||||
)
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.updates
|
||||
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.zip.GZIPOutputStream
|
||||
|
||||
/**
|
||||
* Sends a diagnostic log to the web panel (POST /api/v1/reports) and returns the report
|
||||
* code the user can quote to support. The request is signed like the update check.
|
||||
* The "Send report" sheet of the log screen (LogActivity) calls it, see [ReportContent].
|
||||
*/
|
||||
class ReportUploader(
|
||||
private val baseUrl: String,
|
||||
private val hmacKey: String,
|
||||
private val installId: String,
|
||||
private val clock: () -> Long = { System.currentTimeMillis() },
|
||||
)
|
||||
{
|
||||
data class Meta(
|
||||
val versionName: String,
|
||||
val versionCode: Long,
|
||||
val device: String,
|
||||
val os: String,
|
||||
val network: String,
|
||||
val source: String,
|
||||
val comment: String,
|
||||
val profile: String,
|
||||
)
|
||||
|
||||
sealed class Result
|
||||
{
|
||||
data class Sent(val code: String) : Result()
|
||||
data class Failed(val httpCode: Int, val detail: String) : Result()
|
||||
}
|
||||
|
||||
companion object
|
||||
{
|
||||
/** Server limit: the whole request body must stay below 2 MB */
|
||||
const val MAX_BYTES = 2 * 1024 * 1024
|
||||
const val BOUNDARY = "----shxreport"
|
||||
private val CODE = Regex("^SHX-[0-9A-Z]{5}$")
|
||||
|
||||
/** The panel serves the app API under /api/v1 (the proxy routes only that prefix); the build may give just the host */
|
||||
fun apiBase(url: String): String
|
||||
{
|
||||
val base = url.trim().trimEnd('/')
|
||||
return if (base.endsWith("/api/v1")) base else "$base/api/v1"
|
||||
}
|
||||
|
||||
/** 201 with {"code":"SHX-XXXXX"} is the only success; a broken 201 is not "no network" */
|
||||
fun parseResponse(httpCode: Int, body: String): Result
|
||||
{
|
||||
if (httpCode != 201)
|
||||
{
|
||||
return Result.Failed(httpCode, body.take(200))
|
||||
}
|
||||
val code = try { JSONObject(body).optString("code", "") } catch (e: Exception) { "" }
|
||||
return if (CODE.matches(code)) Result.Sent(code) else Result.Failed(httpCode, "bad response: ${body.take(100)}")
|
||||
}
|
||||
|
||||
fun gzip(text: String): ByteArray
|
||||
{
|
||||
val out = ByteArrayOutputStream()
|
||||
GZIPOutputStream(out).use { it.write(text.toByteArray(Charsets.UTF_8)) }
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
fun metaJson(m: Meta): String = JSONObject()
|
||||
.put("versionName", m.versionName).put("versionCode", m.versionCode)
|
||||
.put("device", m.device.take(120)).put("os", m.os.take(60)).put("network", m.network.take(40))
|
||||
.put("source", m.source).put("comment", m.comment.take(1000)).put("profile", m.profile.take(200))
|
||||
.toString()
|
||||
|
||||
/** multipart/form-data with the gzip log and the meta JSON */
|
||||
fun multipartBody(logGz: ByteArray, meta: String): ByteArray
|
||||
{
|
||||
val out = ByteArrayOutputStream()
|
||||
fun w(s: String) = out.write(s.toByteArray(Charsets.UTF_8))
|
||||
w("--$BOUNDARY\r\nContent-Disposition: form-data; name=\"log\"; filename=\"log.gz\"\r\nContent-Type: application/gzip\r\n\r\n")
|
||||
out.write(logGz)
|
||||
/* a plain text field: @fastify/multipart turns an application/json field into an object and the server then reads "[object Object]" */
|
||||
w("\r\n--$BOUNDARY\r\nContent-Disposition: form-data; name=\"meta\"\r\n\r\n")
|
||||
w(meta)
|
||||
w("\r\n--$BOUNDARY--\r\n")
|
||||
return out.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
fun send(logText: String, meta: Meta): Result
|
||||
{
|
||||
val body = multipartBody(gzip(logText), metaJson(meta))
|
||||
if (body.size > MAX_BYTES)
|
||||
{
|
||||
return Result.Failed(413, "report is larger than 2 MB")
|
||||
}
|
||||
val url = apiBase(baseUrl) + "/reports"
|
||||
val ts = clock() / 1000
|
||||
val conn = URL(url).openConnection() as HttpURLConnection
|
||||
return try
|
||||
{
|
||||
conn.requestMethod = "POST"
|
||||
conn.doOutput = true
|
||||
conn.connectTimeout = 10_000
|
||||
conn.readTimeout = 20_000
|
||||
conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=$BOUNDARY")
|
||||
conn.setRequestProperty("X-Install-Id", installId)
|
||||
conn.setRequestProperty("X-Timestamp", ts.toString())
|
||||
conn.setRequestProperty("X-Signature", UpdateSigning.hmacBase64(hmacKey, UpdateSigning.requestInput("POST", URL(url).path, ts)))
|
||||
conn.outputStream.use { it.write(body) }
|
||||
val code = conn.responseCode
|
||||
val text = (if (code in 200..299) conn.inputStream else conn.errorStream)?.use { it.readBytes().decodeToString() } ?: ""
|
||||
parseResponse(code, text)
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Result.Failed(0, e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
finally
|
||||
{
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.updates
|
||||
|
||||
import net.i2p.crypto.eddsa.EdDSAEngine
|
||||
import net.i2p.crypto.eddsa.EdDSAPublicKey
|
||||
import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable
|
||||
import net.i2p.crypto.eddsa.spec.EdDSAPublicKeySpec
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
import java.security.MessageDigest
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
/**
|
||||
* Client of the SHX update API (/api/v1/updates/check).
|
||||
*
|
||||
* The wire contract is frozen in the web panel repository: docs/openapi.v1.json
|
||||
* and packages/contracts/vectors/signing.json. [UpdateSigning] builds exactly
|
||||
* the strings the server signs; UpdateApiTest checks it against those vectors.
|
||||
*/
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
object UpdateSigning
|
||||
{
|
||||
const val SIGNING_VERSION = "shx-manifest-v1"
|
||||
|
||||
/** The fields covered by the manifest signature (notes are display-only and not signed) */
|
||||
data class Fields(
|
||||
val update: Boolean,
|
||||
val mandatory: Boolean,
|
||||
val versionCode: Long,
|
||||
val versionName: String,
|
||||
val sha256: String,
|
||||
val size: Long,
|
||||
val apkUrl: String,
|
||||
val issuedAt: Long,
|
||||
val checkHours: Int,
|
||||
val wifiOnly: Boolean,
|
||||
)
|
||||
|
||||
/** Fields joined by '\n' in a strict order; absent values are 0 / empty (same as the server) */
|
||||
fun manifestInput(f: Fields): String
|
||||
{
|
||||
return listOf(
|
||||
SIGNING_VERSION,
|
||||
if (f.update) "1" else "0",
|
||||
if (f.mandatory) "1" else "0",
|
||||
f.versionCode.toString(),
|
||||
f.versionName,
|
||||
f.sha256,
|
||||
f.size.toString(),
|
||||
f.apkUrl,
|
||||
f.issuedAt.toString(),
|
||||
f.checkHours.toString(),
|
||||
if (f.wifiOnly) "1" else "0",
|
||||
).joinToString("\n")
|
||||
}
|
||||
|
||||
fun requestInput(method: String, path: String, timestamp: Long): String
|
||||
{
|
||||
return "${method.uppercase()}|$path|$timestamp"
|
||||
}
|
||||
|
||||
fun hmacBase64(key: String, input: String): String
|
||||
{
|
||||
val mac = Mac.getInstance("HmacSHA256")
|
||||
mac.init(SecretKeySpec(key.toByteArray(Charsets.UTF_8), "HmacSHA256"))
|
||||
return Base64.Default.encode(mac.doFinal(input.toByteArray(Charsets.UTF_8)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Ed25519 verification on API 24+ (java.security Ed25519 exists only from API 33),
|
||||
* therefore a small pure-Java implementation is used
|
||||
*/
|
||||
fun verifyEd25519(publicKeyRaw: ByteArray, input: String, signatureBase64: String): Boolean
|
||||
{
|
||||
return try
|
||||
{
|
||||
val spec = EdDSAPublicKeySpec(publicKeyRaw, EdDSANamedCurveTable.getByName(EdDSANamedCurveTable.ED_25519))
|
||||
val engine = EdDSAEngine(MessageDigest.getInstance("SHA-512"))
|
||||
engine.initVerify(EdDSAPublicKey(spec))
|
||||
engine.update(input.toByteArray(Charsets.UTF_8))
|
||||
engine.verify(Base64.Default.decode(signatureBase64))
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Where the HTTP is delegated to, so the client can be tested without a network */
|
||||
interface UpdateHttp
|
||||
{
|
||||
data class Response(val code: Int, val body: String)
|
||||
|
||||
fun get(url: String, headers: Map<String, String>): Response
|
||||
}
|
||||
|
||||
class HttpUrlConnectionHttp(private val timeoutMs: Int = 10_000) : UpdateHttp
|
||||
{
|
||||
override fun get(url: String, headers: Map<String, String>): UpdateHttp.Response
|
||||
{
|
||||
val conn = URL(url).openConnection(java.net.Proxy.NO_PROXY) as HttpURLConnection
|
||||
conn.connectTimeout = timeoutMs
|
||||
conn.readTimeout = timeoutMs
|
||||
conn.setRequestProperty("Connection", "close")
|
||||
conn.setRequestProperty("Cache-Control", "no-cache")
|
||||
for ((k, v) in headers)
|
||||
{
|
||||
conn.setRequestProperty(k, v)
|
||||
}
|
||||
return try
|
||||
{
|
||||
val code = conn.responseCode
|
||||
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
|
||||
UpdateHttp.Response(code, stream?.use { it.readBytes().decodeToString() } ?: "")
|
||||
}
|
||||
finally
|
||||
{
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Policy delivered inside the signed manifest; the user's own settings stay the default */
|
||||
data class UpdatePolicy(val checkHours: Int, val wifiOnly: Boolean)
|
||||
|
||||
sealed class UpdateApiResult
|
||||
{
|
||||
data class Update(val info: UpdateInfo, val mandatory: Boolean, val policy: UpdatePolicy, val issuedAt: Long) : UpdateApiResult()
|
||||
|
||||
data class NoUpdate(val policy: UpdatePolicy) : UpdateApiResult()
|
||||
|
||||
/** [kind]: NETWORK (try the legacy path), SIGNATURE (never fall back), OTHER (rate limit, bad request) */
|
||||
data class Failure(val kind: UpdateError, val detail: String) : UpdateApiResult()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
class UpdateApiClient(
|
||||
private val baseUrl: String,
|
||||
private val hmacKey: String,
|
||||
private val publicKeyBase64: String,
|
||||
private val installId: String,
|
||||
private val http: UpdateHttp = HttpUrlConnectionHttp(),
|
||||
private val clock: () -> Long = { System.currentTimeMillis() },
|
||||
)
|
||||
{
|
||||
companion object
|
||||
{
|
||||
/** The manifest is rejected when its issue time is far from the device clock (replay of a stale "no update") */
|
||||
const val MAX_SKEW_SECONDS = 6 * 3600L
|
||||
|
||||
fun isConfigured(baseUrl: String, hmacKey: String, publicKey: String): Boolean
|
||||
{
|
||||
return baseUrl.isNotBlank() && hmacKey.isNotBlank() && publicKey.isNotBlank()
|
||||
}
|
||||
}
|
||||
|
||||
fun check(packageId: String, versionCode: Long, channel: String, abi: String?, sdk: Int, betaToken: String?): UpdateApiResult
|
||||
{
|
||||
val path = URL(ReportUploader.apiBase(baseUrl) + "/updates/check").path
|
||||
val query = buildString {
|
||||
append("package=").append(enc(packageId))
|
||||
append("&versionCode=").append(versionCode)
|
||||
append("&channel=").append(enc(channel))
|
||||
if (!abi.isNullOrBlank()) append("&abi=").append(enc(abi))
|
||||
append("&sdk=").append(sdk)
|
||||
if (!betaToken.isNullOrBlank()) append("&betaToken=").append(enc(betaToken))
|
||||
}
|
||||
val ts = clock() / 1000
|
||||
val headers = mapOf(
|
||||
"X-Install-Id" to installId,
|
||||
"X-Timestamp" to ts.toString(),
|
||||
"X-Signature" to UpdateSigning.hmacBase64(hmacKey, UpdateSigning.requestInput("GET", path, ts)),
|
||||
)
|
||||
val response = try
|
||||
{
|
||||
http.get(ReportUploader.apiBase(baseUrl) + "/updates/check?" + query, headers)
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
return UpdateApiResult.Failure(UpdateError.NETWORK, e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
return when
|
||||
{
|
||||
response.code in 500..599 || response.code == 404 -> UpdateApiResult.Failure(UpdateError.NETWORK, "HTTP ${response.code}")
|
||||
response.code != 200 -> UpdateApiResult.Failure(UpdateError.OTHER, "HTTP ${response.code}")
|
||||
else -> parse(response.body)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parse(body: String): UpdateApiResult
|
||||
{
|
||||
val json = try { JSONObject(body) } catch (e: Exception) { return UpdateApiResult.Failure(UpdateError.OTHER, "bad json") }
|
||||
val update = json.optBoolean("update", false)
|
||||
val fields = UpdateSigning.Fields(
|
||||
update = update,
|
||||
mandatory = json.optBoolean("mandatory", false),
|
||||
versionCode = if (update) json.optLong("versionCode", 0L) else 0L,
|
||||
versionName = if (update) json.optString("versionName", "") else "",
|
||||
sha256 = if (update) json.optString("sha256", "").lowercase() else "",
|
||||
size = if (update) json.optLong("size", 0L) else 0L,
|
||||
apkUrl = if (update) json.optString("apkUrl", "") else "",
|
||||
issuedAt = json.optLong("issuedAt", 0L),
|
||||
checkHours = json.optInt("checkHours", 12),
|
||||
wifiOnly = json.optBoolean("wifiOnly", true),
|
||||
)
|
||||
val key = try { Base64.Default.decode(publicKeyBase64) } catch (e: Exception) { ByteArray(0) }
|
||||
if (key.size != 32 || !UpdateSigning.verifyEd25519(key, UpdateSigning.manifestInput(fields), json.optString("signature", "")))
|
||||
{
|
||||
return UpdateApiResult.Failure(UpdateError.SIGNATURE, "manifest signature mismatch")
|
||||
}
|
||||
val skew = Math.abs(clock() / 1000 - fields.issuedAt)
|
||||
if (skew > MAX_SKEW_SECONDS)
|
||||
{
|
||||
return UpdateApiResult.Failure(UpdateError.SIGNATURE, "manifest is too old or the device clock is off ($skew s)")
|
||||
}
|
||||
val policy = UpdatePolicy(fields.checkHours, fields.wifiOnly)
|
||||
if (!update)
|
||||
{
|
||||
return UpdateApiResult.NoUpdate(policy)
|
||||
}
|
||||
if (fields.apkUrl.isBlank() || fields.sha256.length != 64)
|
||||
{
|
||||
return UpdateApiResult.Failure(UpdateError.OTHER, "incomplete manifest")
|
||||
}
|
||||
val notes = HashMap<String, String>()
|
||||
json.optJSONObject("notes")?.let { n -> for (k in n.keys()) notes[k.lowercase()] = n.optString(k, "") }
|
||||
val info = UpdateInfo(
|
||||
versionCode = fields.versionCode, versionName = fields.versionName, apkUrl = fields.apkUrl,
|
||||
sha256 = fields.sha256, size = fields.size, notes = notes, fromApi = true, issuedAt = fields.issuedAt,
|
||||
)
|
||||
return UpdateApiResult.Update(info, fields.mandatory, policy, fields.issuedAt)
|
||||
}
|
||||
|
||||
private fun enc(s: String) = URLEncoder.encode(s, "UTF-8")
|
||||
}
|
||||
+203
-25
@@ -62,6 +62,7 @@ import java.net.Proxy
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/** Categories of update failures, mapped to localized messages by the UI */
|
||||
enum class UpdateError { NETWORK, HASH, SIGNATURE, INSTALL, OTHER }
|
||||
@@ -76,6 +77,10 @@ data class UpdateInfo(
|
||||
val sha256: String,
|
||||
val size: Long,
|
||||
val notes: Map<String, String>,
|
||||
/* set for manifests from the SHX update API: the file URL is presigned and short-lived */
|
||||
val fromApi: Boolean = false,
|
||||
val issuedAt: Long = 0L,
|
||||
val mandatory: Boolean = false,
|
||||
)
|
||||
|
||||
sealed class UpdateState
|
||||
@@ -117,6 +122,17 @@ class UpdateManager private constructor(private val context: Context)
|
||||
private const val UPDATES_CHANNEL = "org.strongswan.android.updates"
|
||||
private const val UPDATES_NOTIFICATION_ID = 1001
|
||||
private const val UPDATES_PROGRESS_NOTIFICATION_ID = 1002
|
||||
private const val PREF_INSTALL_ID = "pref_update_install_id"
|
||||
private const val PREF_UPDATE_CHANNEL = "pref_update_channel"
|
||||
private const val PREF_BETA_TOKEN = "pref_update_beta_token"
|
||||
private const val PREF_API_CHECK_HOURS = "pref_update_api_check_hours"
|
||||
private const val PREF_API_WIFI_ONLY = "pref_update_api_wifi_only"
|
||||
/* a mandatory update the last check found: keeps the blocking sheet up across restarts and failed checks */
|
||||
private const val PREF_MANDATORY_CODE = "pref_update_mandatory_code"
|
||||
private const val PREF_MANDATORY_NAME = "pref_update_mandatory_name"
|
||||
private const val PREF_MANDATORY_SIZE = "pref_update_mandatory_size"
|
||||
/* refresh the manifest when it is older than this before downloading (the URL lives 10 minutes) */
|
||||
private const val URL_MAX_AGE_SECONDS = 8 * 60L
|
||||
|
||||
@Volatile
|
||||
private var mInstance: UpdateManager? = null
|
||||
@@ -166,6 +182,11 @@ class UpdateManager private constructor(private val context: Context)
|
||||
* was known in this process: install as soon as the check finds it */
|
||||
@Volatile
|
||||
private var mInstallPending = false
|
||||
/* set by cancelDownload(); the copy loop and the install step check it */
|
||||
@Volatile
|
||||
private var mCancelRequested = false
|
||||
@Volatile
|
||||
private var mDownloadConn: HttpURLConnection? = null
|
||||
private var mVpnService: VpnStateService? = null
|
||||
|
||||
private val mConnection = object : ServiceConnection
|
||||
@@ -191,7 +212,8 @@ class UpdateManager private constructor(private val context: Context)
|
||||
{
|
||||
cleanup()
|
||||
context.bindService(Intent(context, VpnStateService::class.java), mConnection, Context.BIND_AUTO_CREATE)
|
||||
val request = PeriodicWorkRequestBuilder<UpdateCheckWorker>(12, TimeUnit.HOURS)
|
||||
/* the interval comes from the server policy (signed manifest), 12 h until it is known */
|
||||
val request = PeriodicWorkRequestBuilder<UpdateCheckWorker>(checkIntervalHours(), TimeUnit.HOURS)
|
||||
.setConstraints(Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
/* background checks only run while charging - zero battery impact */
|
||||
@@ -224,7 +246,7 @@ class UpdateManager private constructor(private val context: Context)
|
||||
return null
|
||||
}
|
||||
val last = mPrefs.getLong(Constants.PREF_LAST_UPDATE_CHECK, 0L)
|
||||
if (System.currentTimeMillis() - last >= Constants.UPDATE_CHECK_INTERVAL_MS)
|
||||
if (System.currentTimeMillis() - last >= TimeUnit.HOURS.toMillis(checkIntervalHours()))
|
||||
{
|
||||
return check()
|
||||
}
|
||||
@@ -244,6 +266,57 @@ class UpdateManager private constructor(private val context: Context)
|
||||
check()
|
||||
}
|
||||
|
||||
/** True while the update channel is "beta" */
|
||||
fun isBetaChannel(): Boolean = mPrefs.getString(PREF_UPDATE_CHANNEL, "release") == "beta"
|
||||
|
||||
/** Switches the channel (leaving beta forgets the invitation code) and checks right away */
|
||||
fun setChannel(beta: Boolean, token: String?)
|
||||
{
|
||||
val edit = mPrefs.edit().putString(PREF_UPDATE_CHANNEL, if (beta) "beta" else "release")
|
||||
if (beta) edit.putString(PREF_BETA_TOKEN, token?.trim()) else edit.remove(PREF_BETA_TOKEN)
|
||||
edit.apply()
|
||||
check()
|
||||
}
|
||||
|
||||
/** Version name and size of a mandatory update newer than the installed app, or null */
|
||||
fun pendingMandatory(): Pair<String, Long>?
|
||||
{
|
||||
if (mPrefs.getLong(PREF_MANDATORY_CODE, 0L) <= currentVersionCode())
|
||||
{
|
||||
return null
|
||||
}
|
||||
return (mPrefs.getString(PREF_MANDATORY_NAME, null) ?: "") to mPrefs.getLong(PREF_MANDATORY_SIZE, -1L)
|
||||
}
|
||||
|
||||
/** Interrupts a running download, deletes the partial file and goes back to Idle */
|
||||
@Synchronized
|
||||
fun cancelDownload()
|
||||
{
|
||||
if (mDownloadJob?.isActive != true)
|
||||
{
|
||||
return
|
||||
}
|
||||
mInstallPending = false
|
||||
mCancelRequested = true
|
||||
mDownloadJob?.cancel()
|
||||
/* unblocks a read waiting for the network */
|
||||
try
|
||||
{
|
||||
mDownloadConn?.disconnect()
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private fun onDownloadCancelled()
|
||||
{
|
||||
DiagLog.i(TAG, "update: download cancelled")
|
||||
cleanup()
|
||||
cancelDownloadProgress()
|
||||
mState.value = UpdateState.Idle
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun check(): Job?
|
||||
{
|
||||
@@ -257,7 +330,12 @@ class UpdateManager private constructor(private val context: Context)
|
||||
mInstallPending = false
|
||||
return@launch
|
||||
}
|
||||
mPrefs.edit().putLong(Constants.PREF_LAST_UPDATE_CHECK, System.currentTimeMillis()).apply()
|
||||
val mandatory = info.mandatory && info.versionCode > currentVersionCode()
|
||||
mPrefs.edit().putLong(Constants.PREF_LAST_UPDATE_CHECK, System.currentTimeMillis())
|
||||
.putLong(PREF_MANDATORY_CODE, if (mandatory) info.versionCode else 0L)
|
||||
.putString(PREF_MANDATORY_NAME, info.versionName)
|
||||
.putLong(PREF_MANDATORY_SIZE, info.size)
|
||||
.apply()
|
||||
if (info.versionCode > currentVersionCode())
|
||||
{
|
||||
DiagLog.i(TAG, "update: available ${info.versionName} (${info.versionCode}), " +
|
||||
@@ -300,7 +378,8 @@ class UpdateManager private constructor(private val context: Context)
|
||||
DiagLog.i(TAG, "update: silent download disabled, waiting for manual install")
|
||||
return
|
||||
}
|
||||
if (isNetworkMetered())
|
||||
/* server policy "Wi-Fi only" (default true); a policy of false allows the silent download on metered networks too */
|
||||
if (mPrefs.getBoolean(PREF_API_WIFI_ONLY, true) && isNetworkMetered())
|
||||
{
|
||||
DiagLog.i(TAG, "update: silent download deferred (network metered)")
|
||||
return
|
||||
@@ -309,12 +388,17 @@ class UpdateManager private constructor(private val context: Context)
|
||||
{
|
||||
return
|
||||
}
|
||||
mCancelRequested = false
|
||||
mDownloadJob = mScope.launch {
|
||||
try
|
||||
{
|
||||
DiagLog.i(TAG, "update: downloading ${info.versionName} (${info.size} B)")
|
||||
val apk = downloadApk(info)
|
||||
verifySignature(apk)
|
||||
if (mCancelRequested)
|
||||
{
|
||||
throw CancellationException("update download cancelled")
|
||||
}
|
||||
if (isVpnDown() && canInstallSilently())
|
||||
{
|
||||
Log.i(TAG, "Applying the downloaded update silently")
|
||||
@@ -340,6 +424,10 @@ class UpdateManager private constructor(private val context: Context)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e: CancellationException)
|
||||
{
|
||||
onDownloadCancelled()
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.e(TAG, "Silent update download failed", e)
|
||||
@@ -350,6 +438,9 @@ class UpdateManager private constructor(private val context: Context)
|
||||
}
|
||||
}
|
||||
|
||||
/** Check interval from the server policy, kept between 1 hour and a week */
|
||||
fun checkIntervalHours(): Long = mPrefs.getInt(PREF_API_CHECK_HOURS, 12).coerceIn(1, 168).toLong()
|
||||
|
||||
private fun isSilentUpdatesEnabled(): Boolean
|
||||
{
|
||||
return mPrefs.getBoolean(Constants.PREF_SILENT_UPDATES, false)
|
||||
@@ -408,7 +499,7 @@ class UpdateManager private constructor(private val context: Context)
|
||||
val pending = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA)
|
||||
{
|
||||
Notification.Builder(context, UPDATES_CHANNEL)
|
||||
val builder = Notification.Builder(context, UPDATES_CHANNEL)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText("$progress%")
|
||||
@@ -416,11 +507,16 @@ class UpdateManager private constructor(private val context: Context)
|
||||
.setStyle(Notification.ProgressStyle()
|
||||
.setProgressSegments(listOf(Notification.ProgressStyle.Segment(100)))
|
||||
.setProgress(progress))
|
||||
.setRequestPromotedOngoing(true)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(pending)
|
||||
.build()
|
||||
/* setRequestPromotedOngoing appeared in Android 16 QPR2 (API 36.1): on 36.0 it is a
|
||||
* NoSuchMethodError that killed the app as soon as an update download started */
|
||||
if (Build.VERSION.SDK_INT_FULL >= Build.VERSION_CODES_FULL.BAKLAVA_1)
|
||||
{
|
||||
builder.setRequestPromotedOngoing(true)
|
||||
}
|
||||
builder.build()
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -519,16 +615,25 @@ class UpdateManager private constructor(private val context: Context)
|
||||
check()
|
||||
return
|
||||
}
|
||||
mCancelRequested = false
|
||||
mDownloadJob = mScope.launch {
|
||||
try
|
||||
{
|
||||
DiagLog.i(TAG, "update: downloading ${info.versionName} (manual)")
|
||||
val apk = downloadApk(info)
|
||||
verifySignature(apk)
|
||||
if (mCancelRequested)
|
||||
{
|
||||
throw CancellationException("update download cancelled")
|
||||
}
|
||||
DiagLog.i(TAG, "update: installing ${info.versionName} (manual)")
|
||||
UpdateInstaller.install(context, apk)
|
||||
mState.value = UpdateState.Idle
|
||||
}
|
||||
catch (e: CancellationException)
|
||||
{
|
||||
onDownloadCancelled()
|
||||
}
|
||||
catch (e: UpdateException)
|
||||
{
|
||||
Log.e(TAG, "Update failed: ${e.kind}", e)
|
||||
@@ -560,6 +665,26 @@ class UpdateManager private constructor(private val context: Context)
|
||||
|
||||
private fun fetchManifest(): UpdateInfo?
|
||||
{
|
||||
if (apiConfigured())
|
||||
{
|
||||
when (val r = callApi())
|
||||
{
|
||||
is UpdateApiResult.Update -> return r.info.copy(mandatory = r.mandatory)
|
||||
is UpdateApiResult.NoUpdate -> return upToDateMarker()
|
||||
is UpdateApiResult.Failure ->
|
||||
{
|
||||
/* only a network or server failure falls back to the legacy bucket path; a bad
|
||||
* signature or a rate limit must not be bypassed by asking the bucket */
|
||||
if (r.kind != UpdateError.NETWORK || !legacyConfigured())
|
||||
{
|
||||
DiagLog.i(TAG, "update: API check failed: ${r.kind} ${r.detail}")
|
||||
mState.value = UpdateState.Failed(r.kind, r.detail)
|
||||
return null
|
||||
}
|
||||
DiagLog.i(TAG, "update: API unreachable (${r.detail}), using the legacy manifest")
|
||||
}
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
val conn = openConnection(getBaseUrl() + Constants.UPDATE_MANIFEST_FILE)
|
||||
@@ -592,6 +717,58 @@ class UpdateManager private constructor(private val context: Context)
|
||||
}
|
||||
}
|
||||
|
||||
/** also signs report uploads (ReportUploader) */
|
||||
internal fun apiHmacKey(): String = decodeUpdateSecret(BuildConfig.UPDATE_API_HMAC_KEY)
|
||||
|
||||
private fun apiConfigured(): Boolean =
|
||||
UpdateApiClient.isConfigured(BuildConfig.UPDATE_API_URL, apiHmacKey(), BuildConfig.UPDATE_API_PUBLIC_KEY)
|
||||
|
||||
/* the legacy path needs the bucket URL; without it there is nothing to fall back to */
|
||||
private fun legacyConfigured(): Boolean = BuildConfig.UPDATE_BASE_URL.isNotBlank()
|
||||
|
||||
/** Stable random installation id (no personal data); the server stores only its hash */
|
||||
internal fun installId(): String
|
||||
{
|
||||
mPrefs.getString(PREF_INSTALL_ID, null)?.let { return it }
|
||||
val bytes = ByteArray(18).also { java.security.SecureRandom().nextBytes(it) }
|
||||
val id = android.util.Base64.encodeToString(bytes, android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP or android.util.Base64.NO_PADDING)
|
||||
mPrefs.edit().putString(PREF_INSTALL_ID, id).apply()
|
||||
return id
|
||||
}
|
||||
|
||||
private fun callApi(): UpdateApiResult
|
||||
{
|
||||
val client = UpdateApiClient(BuildConfig.UPDATE_API_URL, apiHmacKey(), BuildConfig.UPDATE_API_PUBLIC_KEY, installId())
|
||||
val abi = Build.SUPPORTED_ABIS.firstOrNull()
|
||||
val result = client.check(context.packageName, currentVersionCode(), mPrefs.getString(PREF_UPDATE_CHANNEL, "release") ?: "release",
|
||||
abi, Build.VERSION.SDK_INT, mPrefs.getString(PREF_BETA_TOKEN, null))
|
||||
val policy = when (result)
|
||||
{
|
||||
is UpdateApiResult.Update -> result.policy
|
||||
is UpdateApiResult.NoUpdate -> result.policy
|
||||
else -> null
|
||||
}
|
||||
/* server hints are kept for the scheduler; the user's own settings stay in charge */
|
||||
policy?.let { mPrefs.edit().putInt(PREF_API_CHECK_HOURS, it.checkHours).putBoolean(PREF_API_WIFI_ONLY, it.wifiOnly).apply() }
|
||||
return result
|
||||
}
|
||||
|
||||
/** An UpdateInfo that sends check() into the "up to date" branch */
|
||||
private fun upToDateMarker() = UpdateInfo(currentVersionCode(), currentVersionName(), "", "", -1L, emptyMap(), fromApi = true)
|
||||
|
||||
/**
|
||||
* The presigned file URL lives ~10 minutes while a download may wait for Wi-Fi or charging:
|
||||
* ask the API again when the manifest is old
|
||||
*/
|
||||
private fun freshen(info: UpdateInfo, force: Boolean = false): UpdateInfo
|
||||
{
|
||||
if (!info.fromApi || info.apkUrl.isBlank()) return info
|
||||
val age = System.currentTimeMillis() / 1000 - info.issuedAt
|
||||
if (!force && age in 0..URL_MAX_AGE_SECONDS) return info
|
||||
val r = callApi()
|
||||
return if (r is UpdateApiResult.Update && r.info.versionCode == info.versionCode && r.info.sha256 == info.sha256) r.info.copy(mandatory = r.mandatory) else info
|
||||
}
|
||||
|
||||
private fun parseNotes(notes: JSONObject?): Map<String, String>
|
||||
{
|
||||
if (notes == null)
|
||||
@@ -606,8 +783,9 @@ class UpdateManager private constructor(private val context: Context)
|
||||
return result
|
||||
}
|
||||
|
||||
private fun downloadApk(info: UpdateInfo): File
|
||||
private fun downloadApk(original: UpdateInfo, retried: Boolean = false): File
|
||||
{
|
||||
val info = freshen(original, force = retried)
|
||||
val dir = File(context.filesDir, UPDATE_DIR)
|
||||
if (!dir.exists() && !dir.mkdirs())
|
||||
{
|
||||
@@ -620,36 +798,32 @@ class UpdateManager private constructor(private val context: Context)
|
||||
}
|
||||
apk.delete()
|
||||
|
||||
val conn = openConnection(info.apkUrl)
|
||||
/* a presigned URL already carries its own signature: adding S3 auth headers makes S3 reject it */
|
||||
val conn = openConnection(info.apkUrl, signS3 = !info.fromApi)
|
||||
conn.readTimeout = 30_000
|
||||
try
|
||||
{
|
||||
val code = conn.responseCode
|
||||
if (code == HttpURLConnection.HTTP_FORBIDDEN && info.fromApi && !retried)
|
||||
{ /* the link expired while waiting: fetch a fresh one once */
|
||||
conn.disconnect()
|
||||
return downloadApk(original, retried = true)
|
||||
}
|
||||
if (code != HttpURLConnection.HTTP_OK)
|
||||
{
|
||||
throw UpdateException(UpdateError.NETWORK, "HTTP $code")
|
||||
}
|
||||
val expected = if (info.size > 0) info.size else -1L
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
var downloaded = 0L
|
||||
var lastProgress = -1
|
||||
|
||||
conn.inputStream.use { input ->
|
||||
mDownloadConn = conn
|
||||
val downloaded = conn.inputStream.use { input ->
|
||||
FileOutputStream(apk).use { output ->
|
||||
while (true)
|
||||
{
|
||||
val len = input.read(buffer)
|
||||
if (len < 0)
|
||||
{
|
||||
break
|
||||
}
|
||||
output.write(buffer, 0, len)
|
||||
digest.update(buffer, 0, len)
|
||||
downloaded += len
|
||||
copyDownload(input, output, digest, { mCancelRequested }) { done ->
|
||||
if (expected > 0)
|
||||
{
|
||||
val progress = (downloaded * 100 / expected).toInt().coerceIn(0, 100)
|
||||
val progress = (done * 100 / expected).toInt().coerceIn(0, 100)
|
||||
if (progress != lastProgress)
|
||||
{
|
||||
lastProgress = progress
|
||||
@@ -674,6 +848,7 @@ class UpdateManager private constructor(private val context: Context)
|
||||
}
|
||||
finally
|
||||
{
|
||||
mDownloadConn = null
|
||||
conn.disconnect()
|
||||
cancelDownloadProgress()
|
||||
}
|
||||
@@ -771,14 +946,17 @@ class UpdateManager private constructor(private val context: Context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openConnection(url: String): HttpURLConnection
|
||||
private fun openConnection(url: String, signS3: Boolean = true): HttpURLConnection
|
||||
{
|
||||
val conn = URL(url).openConnection(Proxy.NO_PROXY) as HttpURLConnection
|
||||
conn.connectTimeout = CONNECT_TIMEOUT_MS
|
||||
conn.readTimeout = READ_TIMEOUT_MS
|
||||
conn.setRequestProperty("Connection", "close")
|
||||
conn.setRequestProperty("Cache-Control", "no-cache")
|
||||
applyS3Auth(conn, url)
|
||||
if (signS3)
|
||||
{
|
||||
applyS3Auth(conn, url)
|
||||
}
|
||||
return conn
|
||||
}
|
||||
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.updates
|
||||
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.security.MessageDigest
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Release notes of the app language: the full tag ("zh-tw"), then the language ("zh"),
|
||||
* then Russian, then English. Keys of [notes] are lowercase (see UpdateManager.parseNotes).
|
||||
*/
|
||||
fun pickNotes(notes: Map<String, String>, languageTag: String, language: String): String?
|
||||
{
|
||||
return listOf(languageTag.lowercase(), language.lowercase(), "ru", "en")
|
||||
.firstNotNullOfOrNull { key -> notes[key]?.takeIf { it.isNotBlank() } }
|
||||
}
|
||||
|
||||
/** Every non-empty line of the notes is one list item; a leading "-", "*" or "•" is dropped. */
|
||||
fun noteLines(text: String?): List<String>
|
||||
{
|
||||
return text.orEmpty().lines().map { it.trim().trimStart('-', '*', '•').trim() }.filter { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the download into [output] and the digest; [isCancelled] is checked between reads and
|
||||
* when a read fails (cancelling disconnects the connection, which breaks a blocked read).
|
||||
*/
|
||||
internal fun copyDownload(input: InputStream, output: OutputStream, digest: MessageDigest,
|
||||
isCancelled: () -> Boolean, onProgress: (Long) -> Unit): Long
|
||||
{
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
var total = 0L
|
||||
while (true)
|
||||
{
|
||||
if (isCancelled())
|
||||
{
|
||||
throw CancellationException("update download cancelled")
|
||||
}
|
||||
val len = try
|
||||
{
|
||||
input.read(buffer)
|
||||
}
|
||||
catch (e: IOException)
|
||||
{
|
||||
if (isCancelled()) throw CancellationException("update download cancelled") else throw e
|
||||
}
|
||||
if (len < 0)
|
||||
{
|
||||
return total
|
||||
}
|
||||
output.write(buffer, 0, len)
|
||||
digest.update(buffer, 0, len)
|
||||
total += len
|
||||
onProgress(total)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,11 @@
|
||||
|
||||
package org.strongswan.android.ui
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.IBinder
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
@@ -77,6 +81,7 @@ import org.strongswan.android.R
|
||||
import org.strongswan.android.data.LogContentProvider
|
||||
import org.strongswan.android.logic.CharonVpnService
|
||||
import org.strongswan.android.logic.DiagLog
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
import org.strongswan.android.ui.compose.SecondaryScaffold
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
import java.io.BufferedReader
|
||||
@@ -87,18 +92,49 @@ class LogActivity : AppCompatActivity()
|
||||
{
|
||||
private val messages = kotlinx.coroutines.flow.MutableSharedFlow<Int>(extraBufferCapacity = 1)
|
||||
|
||||
/* the active profile goes into the report meta */
|
||||
private var vpn: VpnStateService? = null
|
||||
private val connection = object : ServiceConnection
|
||||
{
|
||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?)
|
||||
{
|
||||
vpn = (binder as VpnStateService.LocalBinder).service
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?)
|
||||
{
|
||||
vpn = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?)
|
||||
{
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
bindService(Intent(this, VpnStateService::class.java), connection, Context.BIND_AUTO_CREATE)
|
||||
setContent {
|
||||
ShxTheme {
|
||||
LogScreen(onBack = { finish() }, onSend = { sendLog() }, messages = messages)
|
||||
val report: ReportViewModel = viewModel()
|
||||
/* without the panel API in this build the report goes by email, as before */
|
||||
LogScreen(onBack = { finish() }, onSend = { if (ReportViewModel.apiReady(this)) report.open() else sendLog("") }, messages = messages)
|
||||
ReportSheet(report, profile = ::activeProfile, onMail = { comment -> report.close(); sendLog(comment) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendLog()
|
||||
override fun onDestroy()
|
||||
{
|
||||
unbindService(connection)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun activeProfile(): String
|
||||
{
|
||||
val profile = vpn?.profile ?: return ""
|
||||
return listOfNotNull(profile.name, profile.gateway).filter { it.isNotBlank() }.distinct().joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun sendLog(comment: String)
|
||||
{
|
||||
/* the daemon log plus the lifecycle log; the latter survives process
|
||||
* deaths and documents watchdog/reconnect decisions */
|
||||
@@ -124,6 +160,10 @@ class LogActivity : AppCompatActivity()
|
||||
intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(MainActivity.CONTACT_EMAIL))
|
||||
intent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.log_mail_subject, version))
|
||||
intent.type = "text/plain"
|
||||
if (comment.isNotBlank())
|
||||
{
|
||||
intent.putExtra(Intent.EXTRA_TEXT, comment)
|
||||
}
|
||||
if (uris.size > 1)
|
||||
{
|
||||
intent.putExtra(Intent.EXTRA_STREAM, uris)
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui
|
||||
|
||||
import android.app.Application
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.strongswan.android.ui.compose.runDiagnostics
|
||||
import org.strongswan.android.BuildConfig
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.logic.CharonVpnService
|
||||
import org.strongswan.android.logic.DiagLog
|
||||
import org.strongswan.android.logic.connectivity.UnderlyingNetwork
|
||||
import org.strongswan.android.logic.events.Events
|
||||
import org.strongswan.android.logic.updates.ReportContent
|
||||
import org.strongswan.android.logic.updates.ReportUploader
|
||||
import org.strongswan.android.logic.updates.UpdateManager
|
||||
import org.strongswan.android.ui.compose.design.ShxButton
|
||||
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||
import org.strongswan.android.ui.compose.design.ShxFormField
|
||||
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||
import org.strongswan.android.ui.compose.design.ShxIntro
|
||||
import org.strongswan.android.ui.compose.design.ShxNote
|
||||
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||
import org.strongswan.android.ui.compose.design.ShxSheet
|
||||
import org.strongswan.android.ui.compose.design.ShxSheetTitle
|
||||
import org.strongswan.android.ui.compose.design.ShxToggleRow
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||
import org.strongswan.android.ui.compose.theme.ShxType
|
||||
import java.io.File
|
||||
import java.util.Calendar
|
||||
|
||||
/** State of the "Send report" sheet (mockups App8-App10); the upload survives a rotation */
|
||||
class ReportViewModel(app: Application) : AndroidViewModel(app)
|
||||
{
|
||||
sealed class Ui
|
||||
{
|
||||
data object Hidden : Ui()
|
||||
data class Form(val sending: Boolean = false) : Ui()
|
||||
data class Sent(val code: String) : Ui()
|
||||
data class Failed(val failure: ReportContent.Failure) : Ui()
|
||||
}
|
||||
|
||||
var ui by mutableStateOf<Ui>(Ui.Hidden)
|
||||
private set
|
||||
var comment by mutableStateOf("")
|
||||
var attachDiag by mutableStateOf(true)
|
||||
|
||||
/* a swipe may hide the sheet mid-upload: the result then opens it again */
|
||||
private var job: Job? = null
|
||||
|
||||
fun open()
|
||||
{
|
||||
ui = Ui.Form(sending = job?.isActive == true)
|
||||
}
|
||||
|
||||
fun close()
|
||||
{
|
||||
ui = Ui.Hidden
|
||||
}
|
||||
|
||||
fun send(profile: String)
|
||||
{
|
||||
if (job?.isActive == true)
|
||||
{
|
||||
return
|
||||
}
|
||||
ui = Ui.Form(sending = true)
|
||||
val app = getApplication<Application>()
|
||||
val note = comment
|
||||
val diag = attachDiag
|
||||
job = viewModelScope.launch {
|
||||
/* the same checks as the Diagnostics screen (~3 s); a hang must not block the report */
|
||||
val checks = if (!diag) null else withTimeoutOrNull(10_000) {
|
||||
runCatching { runDiagnostics(app) }.getOrNull()?.steps?.map { ReportContent.Check(it.title, it.value, it.ok) }
|
||||
}
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
val now = Calendar.getInstance()
|
||||
val charon = ReportContent.todayOnly(
|
||||
ReportContent.readTail(File(app.filesDir, CharonVpnService.LOG_FILE), ReportContent.CHARON_MAX_BYTES * 2),
|
||||
now.get(Calendar.MONTH) + 1, now.get(Calendar.DAY_OF_MONTH),
|
||||
)
|
||||
val text = ReportContent.build(charon, if (diag) ReportContent.readTail(DiagLog.file(app), ReportContent.DIAG_MAX_BYTES) else null, checks)
|
||||
val meta = ReportContent.meta(
|
||||
BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE.toLong(), Build.MANUFACTURER, Build.MODEL,
|
||||
Build.VERSION.RELEASE, Build.VERSION.SDK_INT, networkLabel(app), note, profile,
|
||||
)
|
||||
val updates = UpdateManager.getInstance(app)
|
||||
ReportUploader(BuildConfig.UPDATE_API_URL, updates.apiHmacKey(), updates.installId()).send(text, meta)
|
||||
}
|
||||
when (result)
|
||||
{
|
||||
is ReportUploader.Result.Sent ->
|
||||
{
|
||||
Events.reportSent(app, result.code)
|
||||
comment = ""
|
||||
ui = Ui.Sent(result.code)
|
||||
}
|
||||
is ReportUploader.Result.Failed ->
|
||||
{
|
||||
DiagLog.i("Report", "upload failed: HTTP ${result.httpCode} ${result.detail}")
|
||||
ui = Ui.Failed(ReportContent.failure(result.httpCode))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object
|
||||
{
|
||||
/** Only the address and the HMAC key are needed (the manifest key is for updates) */
|
||||
fun apiReady(context: Context): Boolean =
|
||||
BuildConfig.UPDATE_API_URL.isNotBlank() && UpdateManager.getInstance(context).apiHmacKey().isNotBlank()
|
||||
|
||||
/** The network under the tunnel: with the VPN up the active network is the VPN itself */
|
||||
private fun networkLabel(context: Context): String
|
||||
{
|
||||
val network = UnderlyingNetwork.resolve(context) ?: return "none"
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return "none"
|
||||
val caps = try { cm.getNetworkCapabilities(network) } catch (_: Exception) { null } ?: return "none"
|
||||
return when
|
||||
{
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "Wi-Fi"
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "mobile"
|
||||
else -> "other"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ReportSheet(model: ReportViewModel, profile: () -> String, onMail: (String) -> Unit)
|
||||
{
|
||||
val ui = model.ui
|
||||
if (ui == ReportViewModel.Ui.Hidden)
|
||||
{
|
||||
return
|
||||
}
|
||||
ShxSheet(onDismiss = model::close)
|
||||
{
|
||||
when (ui)
|
||||
{
|
||||
is ReportViewModel.Ui.Form -> ReportForm(model, ui.sending, onSend = { model.send(profile()) }, onMail = { onMail(model.comment) })
|
||||
is ReportViewModel.Ui.Sent -> ReportSent(ui.code, onDone = model::close)
|
||||
is ReportViewModel.Ui.Failed -> ReportFailed(ui.failure, onRetry = model::open, onMail = { onMail(model.comment) })
|
||||
ReportViewModel.Ui.Hidden -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* App8 */
|
||||
@Composable
|
||||
private fun ReportForm(model: ReportViewModel, sending: Boolean, onSend: () -> Unit, onMail: () -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp))
|
||||
{
|
||||
ShxSheetTitle(stringResource(R.string.log_send_report))
|
||||
ShxIntro(stringResource(R.string.report_intro))
|
||||
}
|
||||
ReasonCard(
|
||||
stringResource(R.string.report_contents), c.accent,
|
||||
listOf(R.string.report_item_log, R.string.report_item_device, R.string.report_item_network, R.string.report_item_ips).map { stringResource(it) to null },
|
||||
)
|
||||
ShxPanel()
|
||||
{
|
||||
ShxToggleRow(
|
||||
stringResource(R.string.report_diag_title), model.attachDiag, { model.attachDiag = it },
|
||||
subtitle = stringResource(R.string.report_diag_sub), enabled = !sending,
|
||||
)
|
||||
}
|
||||
ShxFormField(
|
||||
label = stringResource(R.string.report_comment_label),
|
||||
value = model.comment,
|
||||
onValueChange = { model.comment = it.take(1000) },
|
||||
singleLine = false,
|
||||
enabled = !sending,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
ShxButton(stringResource(R.string.report_cancel), model::close, style = ShxButtonStyle.OUTLINE, enabled = !sending, modifier = Modifier.weight(1f))
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.weight(2f))
|
||||
{
|
||||
ShxButton(if (sending) "" else stringResource(R.string.report_send), { if (!sending) onSend() }, modifier = Modifier.fillMaxWidth())
|
||||
if (sending)
|
||||
{
|
||||
CircularProgressIndicator(color = c.onAccent, strokeWidth = 2.5.dp, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!sending)
|
||||
{
|
||||
TextLink(stringResource(R.string.report_by_mail), onMail)
|
||||
}
|
||||
}
|
||||
|
||||
/* App9 */
|
||||
@Composable
|
||||
private fun ReportSent(code: String, onDone: () -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val context = LocalContext.current
|
||||
val copied = stringResource(R.string.report_code_copied)
|
||||
ResultHeader(ShxIcons.Check, c.successContainer, c.success, stringResource(R.string.report_sent_title), stringResource(R.string.report_sent_sub))
|
||||
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(16.dp))
|
||||
{
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp))
|
||||
{
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||
{
|
||||
Text(stringResource(R.string.report_code_label).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
|
||||
Text(code, style = ShxType.mono.copy(fontSize = 22.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, letterSpacing = 0.9.sp), color = c.text)
|
||||
}
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(44.dp).clip(CircleShape).background(c.tile).clickable(role = Role.Button) {
|
||||
(context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(ClipData.newPlainText("report", code))
|
||||
Toast.makeText(context, copied, Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
)
|
||||
{
|
||||
Icon(ShxIcons.Doc, contentDescription = stringResource(R.string.report_copy_code), tint = c.onAccentContainer, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
ShxNote(stringResource(R.string.report_saved_note))
|
||||
ShxButton(stringResource(R.string.report_done), onDone, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
/* App10 */
|
||||
@Composable
|
||||
private fun ReportFailed(failure: ReportContent.Failure, onRetry: () -> Unit, onMail: () -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val noAnswer = failure == ReportContent.Failure.NO_NETWORK || failure == ReportContent.Failure.SERVER_DOWN
|
||||
ResultHeader(
|
||||
ShxIcons.Alert, c.errorContainer, c.error, stringResource(R.string.report_failed_title),
|
||||
stringResource(if (noAnswer) R.string.report_failed_no_answer else R.string.report_failed_refused),
|
||||
)
|
||||
val reasons = when (failure)
|
||||
{
|
||||
ReportContent.Failure.NO_NETWORK -> listOf(R.string.report_reason_offline to R.string.report_reason_offline_d, R.string.report_reason_server to R.string.report_reason_server_d)
|
||||
ReportContent.Failure.SERVER_DOWN -> listOf(R.string.report_reason_server to R.string.report_reason_server_d)
|
||||
ReportContent.Failure.SIGNATURE -> listOf(R.string.report_reason_clock to R.string.report_reason_clock_d)
|
||||
ReportContent.Failure.TOO_OFTEN -> listOf(R.string.report_reason_often to R.string.report_reason_often_d)
|
||||
ReportContent.Failure.TOO_LARGE -> listOf(R.string.report_reason_large to R.string.report_reason_large_d)
|
||||
ReportContent.Failure.REJECTED -> listOf(R.string.report_reason_rejected to R.string.report_reason_rejected_d)
|
||||
}
|
||||
ReasonCard(stringResource(R.string.report_reasons), c.error, reasons.map { (t, d) -> stringResource(t) to stringResource(d) })
|
||||
ShxNote(stringResource(R.string.report_mail_note))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
ShxButton(stringResource(R.string.report_retry), onRetry, height = 52.dp, modifier = Modifier.weight(1f))
|
||||
ShxButton(stringResource(R.string.report_by_letter), onMail, style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
/** 52dp round icon, 22/800 title, 13 sub (as F_ErrImport) */
|
||||
@Composable
|
||||
private fun ResultHeader(icon: ImageVector, background: Color, tint: Color, title: String, sub: String)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 4.dp))
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(52.dp).clip(CircleShape).background(background))
|
||||
{
|
||||
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||
{
|
||||
Text(title, style = ShxType.pageTitle.copy(letterSpacing = (-0.02).sp), color = c.text)
|
||||
Text(sub, style = ShxType.sub, color = c.textSub)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** White card with a section label and dotted lines: «bold — muted» when [items] carry a second part */
|
||||
@Composable
|
||||
private fun ReasonCard(title: String, dot: Color, items: List<Pair<String, String?>>)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val shape = RoundedCornerShape(22.dp)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape).padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
)
|
||||
{
|
||||
Text(title.uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
|
||||
items.forEach { (head, tail) ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(dot))
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
if (tail == null)
|
||||
{
|
||||
append(head)
|
||||
}
|
||||
else
|
||||
{
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(head) }
|
||||
append(" — ")
|
||||
withStyle(SpanStyle(color = c.textSub)) { append(tail) }
|
||||
}
|
||||
},
|
||||
style = ShxType.note.copy(fontSize = 14.sp), color = c.text,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextLink(text: String, onClick: () -> Unit)
|
||||
{
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center)
|
||||
{
|
||||
Text(
|
||||
text, style = ShxType.chip, color = LocalShxColors.current.accentText,
|
||||
modifier = Modifier.clip(RoundedCornerShape(12.dp)).clickable(role = Role.Button, onClick = onClick).padding(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
+17
-11
@@ -71,6 +71,7 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
@@ -193,19 +194,24 @@ private fun ServerScreen(uuid: String, checkOnOpen: Boolean, onBack: () -> Unit)
|
||||
val extras by remember { ProfileExtras.flow(context) }.collectAsStateWithLifecycle()
|
||||
val extra = extras[uuid] ?: ProfileExtra(uuid)
|
||||
/* the live state of the tunnel and a direct ping of the server */
|
||||
val vpn by produceState<Pair<VpnStateService.State, String?>?>(null) {
|
||||
while (true)
|
||||
{
|
||||
val service = VpnStateService.getLiveInstance()
|
||||
value = service?.let { it.state to it.profile?.getUUID()?.toString() }
|
||||
delay(1500)
|
||||
val vpn by produceState<Pair<VpnStateService.State, String?>?>(null, owner) {
|
||||
/* only while the page is on screen */
|
||||
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
while (true)
|
||||
{
|
||||
val service = VpnStateService.getLiveInstance()
|
||||
value = service?.let { it.state to it.profile?.getUUID()?.toString() }
|
||||
delay(1500)
|
||||
}
|
||||
}
|
||||
}
|
||||
val rtt by produceState<Long?>(null, profile.gateway) {
|
||||
while (true)
|
||||
{
|
||||
value = withContext(Dispatchers.IO) { ConnectivityProbe.pingServerDirect(context, profile.gateway, 1500)?.first }
|
||||
delay(30_000)
|
||||
val rtt by produceState<Long?>(null, profile.gateway, owner) {
|
||||
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
while (true)
|
||||
{
|
||||
value = withContext(Dispatchers.IO) { ConnectivityProbe.pingServerDirect(context, profile.gateway, 1500)?.first }
|
||||
delay(30_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
val active = vpn?.second == uuid
|
||||
|
||||
@@ -55,6 +55,7 @@ class SettingsActivity : AppCompatActivity()
|
||||
Box(Modifier.padding(padding)) {
|
||||
SettingsScreen(profiles = profiles, showTitle = false)
|
||||
}
|
||||
org.strongswan.android.ui.compose.UpdateSheetHost()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -92,7 +92,7 @@ data class DiagReport(val steps: List<DiagStep>, val at: Long, val vpnUp: Boolea
|
||||
val allOk: Boolean? get() = if (steps.any { it.ok == null }) null else steps.all { it.ok == true }
|
||||
}
|
||||
|
||||
private suspend fun runDiagnostics(context: Context): DiagReport = coroutineScope {
|
||||
internal suspend fun runDiagnostics(context: Context): DiagReport = coroutineScope {
|
||||
val vpn = VpnStateService.getLiveInstance()
|
||||
val vpnUp = vpn?.state == VpnStateService.State.CONNECTED
|
||||
val gateway = (vpn?.profile)?.gateway
|
||||
|
||||
+12
-1
@@ -199,7 +199,14 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
{
|
||||
synchronized(underlyingNetworks)
|
||||
{
|
||||
underlyingNetworks[network] = capabilities
|
||||
val previous = underlyingNetworks.put(network, capabilities)
|
||||
/* fired on every signal-strength tick: the published state (network
|
||||
* kinds, SSID) only depends on the transports, and reading the SSID
|
||||
* is a system call, so unchanged transports end here */
|
||||
if (previous != null && sameTransports(previous, capabilities))
|
||||
{
|
||||
return
|
||||
}
|
||||
publishNetworks()
|
||||
}
|
||||
}
|
||||
@@ -214,6 +221,10 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
}
|
||||
}
|
||||
|
||||
private fun sameTransports(a: NetworkCapabilities, b: NetworkCapabilities): Boolean =
|
||||
a.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == b.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
|
||||
a.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == b.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
|
||||
private fun publishNetworks()
|
||||
{
|
||||
_networkAvailable.value = underlyingNetworks.isNotEmpty()
|
||||
|
||||
+17
-8
@@ -124,10 +124,23 @@ fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteChec
|
||||
var result by remember { mutableStateOf<CheckResult?>(null) }
|
||||
var recent by remember { mutableStateOf(prefs.getString(PREF_RECENT, "")!!.split('\n').filter { it.isNotBlank() }) }
|
||||
val rulesActive = remember { CharonVpnService.isUserspaceRoutingEnabled(context) }
|
||||
val apps by produceState<List<InstalledApp>>(emptyList(), mode) {
|
||||
if (mode == RouteCheckMode.APP && value.isEmpty())
|
||||
var apps by remember { mutableStateOf<List<InstalledApp>>(emptyList()) }
|
||||
|
||||
/** The installed apps; loads them on first use, so a check never runs against a list that is still empty. */
|
||||
suspend fun loadApps(): List<InstalledApp>
|
||||
{
|
||||
if (apps.isEmpty())
|
||||
{
|
||||
value = withContext(Dispatchers.IO) { DirectApps.loadInstalledApps(context.packageManager, context.packageName) }
|
||||
apps = withContext(Dispatchers.IO) { DirectApps.loadInstalledApps(context.packageManager, context.packageName) }
|
||||
}
|
||||
return apps
|
||||
}
|
||||
|
||||
LaunchedEffect(mode)
|
||||
{
|
||||
if (mode == RouteCheckMode.APP)
|
||||
{
|
||||
loadApps()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +163,7 @@ fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteChec
|
||||
{
|
||||
RouteCheckMode.SITE -> checkSite(context, manager, input, rulesActive)
|
||||
RouteCheckMode.IP -> checkIp(context, manager, input, rulesActive)
|
||||
RouteCheckMode.APP -> apps.firstOrNull { it.packageName == input || it.label.equals(input, ignoreCase = true) }
|
||||
RouteCheckMode.APP -> loadApps().firstOrNull { it.packageName == input || it.label.equals(input, ignoreCase = true) }
|
||||
?.let { CheckResult.App(it) } ?: CheckResult.BadInput
|
||||
}
|
||||
if (result !is CheckResult.BadInput)
|
||||
@@ -173,10 +186,6 @@ fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteChec
|
||||
{
|
||||
if (initialQuery.isNotBlank())
|
||||
{
|
||||
if (mode == RouteCheckMode.APP)
|
||||
{
|
||||
delay(300)
|
||||
}
|
||||
run()
|
||||
}
|
||||
}
|
||||
|
||||
+1
-94
@@ -52,7 +52,6 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.logic.updates.UpdateState
|
||||
import org.strongswan.android.ui.compose.design.ShxButton
|
||||
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||
@@ -221,91 +220,6 @@ fun KillSwitchPage(lockdown: Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
/** Update card (F_Update): app icon, version, what is new, progress, Later / Install. */
|
||||
@Composable
|
||||
fun UpdateCard(state: UpdateState, installed: String, onCheck: () -> Unit, onInstall: () -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val info = (state as? UpdateState.Available)?.info ?: (state as? UpdateState.Downloading)?.info
|
||||
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
|
||||
{
|
||||
val shape = RoundedCornerShape(24.dp)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape).padding(16.dp),
|
||||
)
|
||||
{
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp))
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(56.dp).clip(RoundedCornerShape(16.dp)).background(c.accent))
|
||||
{
|
||||
Icon(painterResource(R.drawable.ic_notification), contentDescription = null, tint = Color.White, modifier = Modifier.size(36.dp))
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||
{
|
||||
Text(
|
||||
if (info != null) "SHX VPN ${info.versionName}" else stringResource(R.string.update_card_uptodate),
|
||||
style = ShxType.cardHeading.copy(fontSize = 20.sp), color = c.text,
|
||||
)
|
||||
Text(
|
||||
when
|
||||
{
|
||||
info != null -> stringResource(R.string.update_card_have, installed, formatBytes(info.size))
|
||||
state is UpdateState.Checking -> stringResource(R.string.update_card_checking)
|
||||
state is UpdateState.Failed -> stringResource(R.string.update_card_failed)
|
||||
else -> stringResource(R.string.update_card_version, installed)
|
||||
},
|
||||
style = ShxType.sub, color = c.textSub,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (info != null)
|
||||
{
|
||||
val notes = info.notes["ru"] ?: info.notes["en"] ?: info.notes.values.firstOrNull()
|
||||
if (!notes.isNullOrBlank())
|
||||
{
|
||||
Text(stringResource(R.string.update_card_whats_new).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
|
||||
notes.split(Regex("(?<=[.;])\\s+")).map { it.trim().trimEnd('.', ';') }.filter { it.isNotEmpty() }.forEach { line ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(c.accent))
|
||||
Text(line, style = ShxType.note.copy(fontSize = 14.sp), color = c.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state is UpdateState.Downloading)
|
||||
{
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp))
|
||||
{
|
||||
Row(Modifier.fillMaxWidth())
|
||||
{
|
||||
Text(stringResource(R.string.update_card_downloading), style = ShxType.meta, color = c.textSub, modifier = Modifier.weight(1f))
|
||||
Text("${state.progress}%", style = ShxType.meta, color = c.textSub)
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)).background(c.tile))
|
||||
{
|
||||
Box(Modifier.fillMaxWidth(state.progress.coerceIn(0, 100) / 100f).height(6.dp).clip(RoundedCornerShape(3.dp)).background(c.accent))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
if (info != null)
|
||||
{
|
||||
ShxButton(stringResource(R.string.update_card_check), onCheck, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
|
||||
ShxButton(stringResource(R.string.update_card_install), onInstall, modifier = Modifier.weight(2f), enabled = state !is UpdateState.Downloading)
|
||||
}
|
||||
else
|
||||
{
|
||||
ShxButton(stringResource(R.string.update_card_check), onCheck, style = ShxButtonStyle.SOFT, modifier = Modifier.fillMaxWidth(), enabled = state !is UpdateState.Checking)
|
||||
}
|
||||
}
|
||||
Text(stringResource(R.string.update_card_footer), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* «Help» (F_Help): two big buttons (check the connection, contact support),
|
||||
* the frequent questions as an accordion, and «About the app».
|
||||
@@ -566,14 +480,7 @@ internal fun InstallPermissionSheet(onOpen: () -> Unit, onDismiss: () -> Unit)
|
||||
Text(stringResource(R.string.install_perm_hint), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.textSoft)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.successContainer).padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
)
|
||||
{
|
||||
Icon(ShxIcons.Shield, contentDescription = null, tint = c.onSuccess, modifier = Modifier.size(20.dp))
|
||||
Text(stringResource(R.string.install_perm_safe), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.onSuccess)
|
||||
}
|
||||
UpdateSafeNote(stringResource(R.string.install_perm_safe))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
ShxButton(stringResource(R.string.install_perm_later), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
|
||||
|
||||
+2
-172
@@ -105,8 +105,6 @@ import org.strongswan.android.logic.StrongSwanApplication
|
||||
import org.strongswan.android.logic.WatchdogReceiver
|
||||
import org.strongswan.android.logic.connectivity.WhiteListManager
|
||||
import org.strongswan.android.logic.shortcuts.VpnShortcutManager
|
||||
import org.strongswan.android.logic.updates.UpdateError
|
||||
import org.strongswan.android.logic.updates.UpdateInstaller
|
||||
import org.strongswan.android.logic.updates.UpdateManager
|
||||
import org.strongswan.android.logic.updates.UpdateState
|
||||
import org.strongswan.android.ui.LogActivity
|
||||
@@ -151,43 +149,9 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
|
||||
val updateManager = remember { UpdateManager.getInstance(context) }
|
||||
val updateState by updateManager.state.collectAsStateWithLifecycle()
|
||||
var showUpdateConfirm by remember { mutableStateOf(false) }
|
||||
var showInstallPermission by remember { mutableStateOf(false) }
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
val unknownSourcesLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) {
|
||||
if (UpdateInstaller.canInstall(context))
|
||||
{
|
||||
updateManager.downloadAndInstall()
|
||||
}
|
||||
else
|
||||
{
|
||||
scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.update_permission_denied)) }
|
||||
}
|
||||
}
|
||||
val startUpdate: () -> Unit = {
|
||||
if (UpdateInstaller.canInstall(context))
|
||||
{
|
||||
updateManager.downloadAndInstall()
|
||||
}
|
||||
else
|
||||
{ /* F_UpdateInstall explains the system page first */
|
||||
showInstallPermission = true
|
||||
}
|
||||
}
|
||||
if (showInstallPermission)
|
||||
{
|
||||
InstallPermissionSheet(
|
||||
onOpen = {
|
||||
showInstallPermission = false
|
||||
unknownSourcesLauncher.launch(UpdateInstaller.createInstallPermissionIntent(context))
|
||||
},
|
||||
onDismiss = { showInstallPermission = false },
|
||||
)
|
||||
}
|
||||
|
||||
var pageState by rememberSaveable { mutableStateOf(SettingsPage.ROOT) }
|
||||
val updatesRequested by ShxNavigation.updatesPage.collectAsStateWithLifecycle()
|
||||
@@ -419,18 +383,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
|
||||
if (page == SettingsPage.UPDATES)
|
||||
{
|
||||
UpdateCard(
|
||||
state = updateState,
|
||||
installed = versionName,
|
||||
onCheck = {
|
||||
when (updateState)
|
||||
{
|
||||
is UpdateState.Checking, is UpdateState.Downloading -> Unit
|
||||
else -> updateManager.checkNow()
|
||||
}
|
||||
},
|
||||
onInstall = { showUpdateConfirm = true },
|
||||
)
|
||||
UpdatesPage()
|
||||
}
|
||||
|
||||
if (page == SettingsPage.KILLSWITCH)
|
||||
@@ -445,75 +398,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
KillSwitchPage(remember(resumes) { context.isVpnLockdownEnabled() })
|
||||
}
|
||||
|
||||
if (page == SettingsPage.UPDATES)
|
||||
{
|
||||
ShxSection(title = stringResource(R.string.update_settings))
|
||||
{
|
||||
var autoUpdate by remember {
|
||||
mutableStateOf(prefs.getBoolean(Constants.PREF_AUTO_UPDATE_CHECK, true))
|
||||
}
|
||||
ShxSettingToggleRow(
|
||||
icon = ShxIcons.Refresh,
|
||||
title = stringResource(R.string.update_auto_check_title),
|
||||
subtitle = stringResource(R.string.update_auto_check_summary),
|
||||
checked = autoUpdate,
|
||||
onCheckedChange = {
|
||||
autoUpdate = it
|
||||
prefs.edit().putBoolean(Constants.PREF_AUTO_UPDATE_CHECK, it).apply()
|
||||
},
|
||||
)
|
||||
ShxSectionDivider()
|
||||
var silentUpdates by remember {
|
||||
mutableStateOf(prefs.getBoolean(Constants.PREF_SILENT_UPDATES, false))
|
||||
}
|
||||
ShxSettingToggleRow(
|
||||
icon = ShxIcons.Refresh,
|
||||
title = stringResource(R.string.update_silent_title),
|
||||
subtitle = stringResource(R.string.update_silent_summary),
|
||||
checked = silentUpdates,
|
||||
onCheckedChange = {
|
||||
silentUpdates = it
|
||||
prefs.edit().putBoolean(Constants.PREF_SILENT_UPDATES, it).apply()
|
||||
},
|
||||
)
|
||||
ShxSectionDivider()
|
||||
ShxSettingRow(
|
||||
icon = ShxIcons.Download,
|
||||
title = stringResource(R.string.update_check_title),
|
||||
subtitle = updateStatusText(updateState),
|
||||
onClick = {
|
||||
when (updateState)
|
||||
{
|
||||
is UpdateState.Checking, is UpdateState.Downloading -> Unit
|
||||
else -> updateManager.checkNow()
|
||||
}
|
||||
},
|
||||
)
|
||||
val updateInfo = (updateState as? UpdateState.Available)?.info
|
||||
?: (updateState as? UpdateState.Downloading)?.info
|
||||
if (updateInfo != null)
|
||||
{
|
||||
ShxSectionDivider()
|
||||
ShxSettingRow(
|
||||
icon = ShxIcons.Download,
|
||||
title = stringResource(R.string.update_download_install),
|
||||
subtitle = when (val state = updateState)
|
||||
{
|
||||
is UpdateState.Downloading -> stringResource(R.string.update_downloading_fmt, state.progress)
|
||||
else -> stringResource(R.string.update_available_fmt, updateInfo.versionName)
|
||||
},
|
||||
onClick = {
|
||||
if (updateState !is UpdateState.Downloading)
|
||||
{
|
||||
showUpdateConfirm = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (page == SettingsPage.ROOT || page == SettingsPage.UPDATES) Text(
|
||||
if (page == SettingsPage.ROOT) Text(
|
||||
text = stringResource(R.string.app_version_fmt, versionName),
|
||||
style = ShxType.meta,
|
||||
color = LocalShxColors.current.textSub,
|
||||
@@ -600,61 +485,6 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
)
|
||||
}
|
||||
|
||||
if (showUpdateConfirm && updateState is UpdateState.Available)
|
||||
{
|
||||
val info = (updateState as UpdateState.Available).info
|
||||
val notes = info.notes["ru"] ?: info.notes["en"] ?: info.notes.values.firstOrNull() ?: ""
|
||||
ShxAlertDialog(
|
||||
onDismissRequest = { showUpdateConfirm = false },
|
||||
title = { Text(stringResource(R.string.update_confirm_title, info.versionName)) },
|
||||
text = { if (notes.isNotEmpty()) { Text(notes) } },
|
||||
confirmButton = {
|
||||
ShxDialogButton(primary = true, onClick = {
|
||||
showUpdateConfirm = false
|
||||
startUpdate()
|
||||
}) { Text(stringResource(R.string.update_action)) }
|
||||
},
|
||||
dismissButton = {
|
||||
ShxDialogButton(onClick = { showUpdateConfirm = false }) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun updateStatusText(state: UpdateState): String
|
||||
{
|
||||
return when (state)
|
||||
{
|
||||
is UpdateState.Checking -> stringResource(R.string.update_checking)
|
||||
is UpdateState.Idle -> stringResource(R.string.update_status_idle)
|
||||
is UpdateState.UpToDate -> stringResource(R.string.update_status_uptodate)
|
||||
is UpdateState.Available ->
|
||||
{
|
||||
if (state.info.size > 0)
|
||||
{
|
||||
stringResource(R.string.update_available_fmt, state.info.versionName) + " • " +
|
||||
stringResource(R.string.update_size_mb_fmt, (state.info.size / (1024 * 1024)).toInt())
|
||||
}
|
||||
else
|
||||
{
|
||||
stringResource(R.string.update_available_fmt, state.info.versionName)
|
||||
}
|
||||
}
|
||||
is UpdateState.Downloading -> stringResource(R.string.update_downloading_fmt, state.progress)
|
||||
is UpdateState.Failed -> stringResource(
|
||||
when (state.error)
|
||||
{
|
||||
UpdateError.NETWORK -> R.string.update_error_network
|
||||
UpdateError.HASH -> R.string.update_error_hash
|
||||
UpdateError.SIGNATURE -> R.string.update_error_signature
|
||||
UpdateError.INSTALL -> R.string.update_error_install
|
||||
UpdateError.OTHER -> R.string.update_error_generic
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -260,6 +260,8 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
|
||||
{
|
||||
AddServerSheet(onDismiss = { addSheetOpen = false })
|
||||
}
|
||||
/* update sheets, the mandatory update included, over every tab */
|
||||
UpdateSheetHost()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+658
@@ -0,0 +1,658 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui.compose
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.text.format.DateUtils
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.preference.PreferenceManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.strongswan.android.BuildConfig
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.logic.updates.UpdateError
|
||||
import org.strongswan.android.logic.updates.UpdateInfo
|
||||
import org.strongswan.android.logic.updates.UpdateInstaller
|
||||
import org.strongswan.android.logic.updates.UpdateManager
|
||||
import org.strongswan.android.logic.updates.UpdateState
|
||||
import org.strongswan.android.logic.updates.noteLines
|
||||
import org.strongswan.android.logic.updates.pickNotes
|
||||
import org.strongswan.android.ui.compose.design.ShxButton
|
||||
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||
import org.strongswan.android.ui.compose.design.ShxChoiceCard
|
||||
import org.strongswan.android.ui.compose.design.ShxFormField
|
||||
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||
import org.strongswan.android.ui.compose.design.ShxIntro
|
||||
import org.strongswan.android.ui.compose.design.ShxNote
|
||||
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||
import org.strongswan.android.ui.compose.design.ShxSectionHeader
|
||||
import org.strongswan.android.ui.compose.design.ShxSheet
|
||||
import org.strongswan.android.ui.compose.design.ShxSheetTitle
|
||||
import org.strongswan.android.ui.compose.design.ShxTone
|
||||
import org.strongswan.android.ui.compose.design.ShxToggleRow
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
||||
import org.strongswan.android.ui.compose.theme.ShxType
|
||||
import org.strongswan.android.utils.Constants
|
||||
|
||||
/** Which update sheet the user opened: «What is new» (App2) or the install attempt (App3 / App5). */
|
||||
enum class UpdateSheet { REVIEW, ATTEMPT }
|
||||
|
||||
/** Shared between the Updates page and the one sheet host of the activity. */
|
||||
object UpdateSheets
|
||||
{
|
||||
val shown = MutableStateFlow<UpdateSheet?>(null)
|
||||
|
||||
fun show(sheet: UpdateSheet?)
|
||||
{
|
||||
shown.value = sheet
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* All update sheets of an activity (ShxApp and SettingsActivity call it once):
|
||||
* «What is new» (App2), the download (App3), the mandatory update (App4),
|
||||
* the failure (App5) and the install permission (App6).
|
||||
*/
|
||||
@Composable
|
||||
fun UpdateSheetHost()
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val manager = remember { UpdateManager.getInstance(context) }
|
||||
val state by manager.state.collectAsStateWithLifecycle()
|
||||
val sheet by UpdateSheets.shown.collectAsStateWithLifecycle()
|
||||
val mandatory = remember(state) { manager.pendingMandatory() }
|
||||
var askPermission by remember { mutableStateOf(false) }
|
||||
|
||||
val install = {
|
||||
UpdateSheets.show(UpdateSheet.ATTEMPT)
|
||||
manager.downloadAndInstall()
|
||||
}
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult())
|
||||
{
|
||||
if (UpdateInstaller.canInstall(context)) install()
|
||||
else Toast.makeText(context, R.string.update_permission_denied, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
val start: () -> Unit = {
|
||||
if (UpdateInstaller.canInstall(context)) install() else askPermission = true
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit)
|
||||
{ /* a mandatory update found earlier (e.g. before a restart): make sure it is still current */
|
||||
if (manager.pendingMandatory() != null && state !is UpdateState.Available && state !is UpdateState.Downloading)
|
||||
{
|
||||
manager.checkNow()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state)
|
||||
{ /* the installer took over, or the download was cancelled */
|
||||
if (sheet == UpdateSheet.ATTEMPT && (state is UpdateState.Idle || state is UpdateState.UpToDate))
|
||||
{
|
||||
UpdateSheets.show(null)
|
||||
}
|
||||
}
|
||||
|
||||
when
|
||||
{
|
||||
askPermission -> InstallPermissionSheet(
|
||||
onOpen = {
|
||||
askPermission = false
|
||||
launcher.launch(UpdateInstaller.createInstallPermissionIntent(context))
|
||||
},
|
||||
onDismiss = { askPermission = false },
|
||||
)
|
||||
sheet == UpdateSheet.ATTEMPT -> ShxSheet(onDismiss = { UpdateSheets.show(null) })
|
||||
{
|
||||
when (val s = state)
|
||||
{
|
||||
is UpdateState.Failed -> UpdateFailedContent(s, onRetry = start)
|
||||
else -> UpdateProgressContent(s, onCancel = { manager.cancelDownload() }, onInstall = start)
|
||||
}
|
||||
}
|
||||
mandatory != null -> MandatoryUpdateSheet(mandatory.first, mandatory.second, onUpdate = start)
|
||||
sheet == UpdateSheet.REVIEW ->
|
||||
{
|
||||
val info = (state as? UpdateState.Available)?.info
|
||||
if (info != null) UpdateReviewSheet(info, onInstall = start, onDismiss = { UpdateSheets.show(null) })
|
||||
else androidx.compose.runtime.SideEffect { UpdateSheets.show(null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** «Updates» page (App1): the available version, automatic mode, channel, safety note, manual check. */
|
||||
@Composable
|
||||
fun UpdatesPage()
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val c = LocalShxColors.current
|
||||
val manager = remember { UpdateManager.getInstance(context) }
|
||||
val prefs = remember { PreferenceManager.getDefaultSharedPreferences(context) }
|
||||
val state by manager.state.collectAsStateWithLifecycle()
|
||||
val info = (state as? UpdateState.Available)?.info ?: (state as? UpdateState.Downloading)?.info
|
||||
val busy = state is UpdateState.Checking || state is UpdateState.Downloading
|
||||
var beta by remember { mutableStateOf(manager.isBetaChannel()) }
|
||||
var askCode by remember { mutableStateOf(false) }
|
||||
|
||||
Column(Modifier.padding(horizontal = ShxSpacing.lg), verticalArrangement = Arrangement.spacedBy(ShxSpacing.md))
|
||||
{
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp)).background(c.accentContainer).padding(ShxSpacing.lg),
|
||||
)
|
||||
{
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp))
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(52.dp).clip(RoundedCornerShape(16.dp)).background(c.accent))
|
||||
{
|
||||
Icon(ShxIcons.Refresh, contentDescription = null, tint = c.onAccent, modifier = Modifier.size(26.dp))
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||
{
|
||||
Text(
|
||||
when
|
||||
{
|
||||
info != null -> stringResource(R.string.update_available_fmt, info.versionName)
|
||||
state is UpdateState.UpToDate -> stringResource(R.string.update_card_uptodate)
|
||||
else -> "SHX VPN ${BuildConfig.VERSION_NAME}"
|
||||
},
|
||||
style = ShxType.cardHeading, color = c.onAccentContainerStrong,
|
||||
)
|
||||
Text(
|
||||
when
|
||||
{
|
||||
info != null -> stringResource(R.string.updates_have_size, BuildConfig.VERSION_NAME, sizeText(info.size))
|
||||
state is UpdateState.Checking -> stringResource(R.string.update_card_checking)
|
||||
state is UpdateState.Failed -> stringResource(R.string.update_card_failed)
|
||||
state is UpdateState.UpToDate -> stringResource(R.string.update_card_version, BuildConfig.VERSION_NAME)
|
||||
else -> stringResource(R.string.update_status_idle)
|
||||
},
|
||||
style = ShxType.sub, color = c.onAccentContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (info != null)
|
||||
{
|
||||
val downloading = state as? UpdateState.Downloading
|
||||
ShxButton(
|
||||
if (downloading != null) stringResource(R.string.update_downloading_fmt, downloading.progress) else stringResource(R.string.updates_review),
|
||||
{ UpdateSheets.show(if (downloading != null) UpdateSheet.ATTEMPT else UpdateSheet.REVIEW) },
|
||||
height = 52.dp, modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ShxSectionHeader(stringResource(R.string.updates_section_auto))
|
||||
ShxPanel()
|
||||
{
|
||||
var autoCheck by remember { mutableStateOf(prefs.getBoolean(Constants.PREF_AUTO_UPDATE_CHECK, true)) }
|
||||
var silent by remember { mutableStateOf(prefs.getBoolean(Constants.PREF_SILENT_UPDATES, false)) }
|
||||
val hours = remember { manager.checkIntervalHours().toInt() }
|
||||
ShxToggleRow(
|
||||
stringResource(R.string.updates_auto_check), autoCheck,
|
||||
{ autoCheck = it; prefs.edit().putBoolean(Constants.PREF_AUTO_UPDATE_CHECK, it).apply() },
|
||||
subtitle = pluralStringResource(R.plurals.updates_auto_check_sub, hours, hours), divider = true,
|
||||
)
|
||||
ShxToggleRow(
|
||||
stringResource(R.string.updates_silent), silent,
|
||||
{ silent = it; prefs.edit().putBoolean(Constants.PREF_SILENT_UPDATES, it).apply() },
|
||||
subtitle = stringResource(R.string.updates_silent_sub),
|
||||
)
|
||||
}
|
||||
|
||||
ShxSectionHeader(stringResource(R.string.updates_section_channel))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
ShxChoiceCard(
|
||||
stringResource(R.string.updates_channel_stable), stringResource(R.string.updates_channel_stable_sub), !beta,
|
||||
{
|
||||
if (beta)
|
||||
{
|
||||
beta = false
|
||||
manager.setChannel(beta = false, token = null)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
ShxChoiceCard(
|
||||
stringResource(R.string.updates_channel_beta), stringResource(R.string.updates_channel_beta_sub), beta,
|
||||
{ if (!beta) askCode = true },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Text(stringResource(R.string.updates_channel_hint), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp))
|
||||
|
||||
UpdateSafeNote(stringResource(R.string.install_perm_safe))
|
||||
|
||||
ShxButton(
|
||||
stringResource(R.string.updates_check_now), { manager.checkNow() },
|
||||
style = ShxButtonStyle.SOFT, height = 52.dp, enabled = !busy, modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
val last = remember(state) { prefs.getLong(Constants.PREF_LAST_UPDATE_CHECK, 0L) }
|
||||
Text(
|
||||
when (val s = state)
|
||||
{
|
||||
is UpdateState.Checking -> stringResource(R.string.update_card_checking)
|
||||
is UpdateState.Failed -> stringResource(updateErrorText(s.error))
|
||||
else -> if (last > 0) stringResource(R.string.updates_last_check, lastCheckText(context, last))
|
||||
else stringResource(R.string.updates_never_checked)
|
||||
},
|
||||
style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub,
|
||||
textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
if (askCode)
|
||||
{
|
||||
BetaCodeSheet(
|
||||
onEnable = { code ->
|
||||
askCode = false
|
||||
beta = true
|
||||
manager.setChannel(beta = true, token = code)
|
||||
},
|
||||
onDismiss = { askCode = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Invitation code of the beta channel. */
|
||||
@Composable
|
||||
private fun BetaCodeSheet(onEnable: (String) -> Unit, onDismiss: () -> Unit)
|
||||
{
|
||||
var code by remember { mutableStateOf("") }
|
||||
ShxSheet(onDismiss = onDismiss)
|
||||
{
|
||||
ShxSheetTitle(stringResource(R.string.updates_beta_title))
|
||||
ShxIntro(stringResource(R.string.updates_beta_text))
|
||||
ShxFormField(label = stringResource(R.string.updates_beta_code), value = code, onValueChange = { code = it }, mono = true)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
ShxButton(stringResource(android.R.string.cancel), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
|
||||
ShxButton(stringResource(R.string.updates_beta_enable), { onEnable(code.trim()) }, enabled = code.isNotBlank(), modifier = Modifier.weight(2f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** «A new version is available» (App2): version, what is new from the release notes, Later / Download and install. */
|
||||
@Composable
|
||||
private fun UpdateReviewSheet(info: UpdateInfo, onInstall: () -> Unit, onDismiss: () -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val locale = LocalConfiguration.current.locales[0]
|
||||
val lines = noteLines(pickNotes(info.notes, "${locale.language}-${locale.country}", locale.language))
|
||||
ShxSheet(onDismiss = onDismiss)
|
||||
{
|
||||
UpdateSheetHeader(
|
||||
"SHX VPN ${info.versionName}",
|
||||
stringResource(R.string.updates_review_sub, BuildConfig.VERSION_NAME, sizeText(info.size)),
|
||||
)
|
||||
if (lines.isNotEmpty())
|
||||
{
|
||||
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(ShxSpacing.lg))
|
||||
{
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
Text(stringResource(R.string.update_card_whats_new).uppercase(), style = ShxType.section, color = c.accentText)
|
||||
lines.forEach { Bullet(it, c.accent) }
|
||||
}
|
||||
}
|
||||
}
|
||||
UpdateSafeNote(stringResource(R.string.updates_review_safe))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
ShxButton(stringResource(R.string.cert_later), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
|
||||
ShxButton(stringResource(R.string.update_download_install), onInstall, modifier = Modifier.weight(2f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Download (App3): progress and the four steps of the update. */
|
||||
@Composable
|
||||
private fun UpdateProgressContent(state: UpdateState, onCancel: () -> Unit, onInstall: () -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val info = (state as? UpdateState.Downloading)?.info ?: (state as? UpdateState.Available)?.info
|
||||
val progress = (state as? UpdateState.Downloading)?.progress ?: 0
|
||||
val downloading = state is UpdateState.Downloading
|
||||
UpdateSheetHeader(
|
||||
stringResource(R.string.updates_downloading_title, info?.versionName ?: ""),
|
||||
stringResource(R.string.updates_downloading_sub),
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm))
|
||||
{
|
||||
Row(Modifier.fillMaxWidth())
|
||||
{
|
||||
Text(stringResource(R.string.update_card_downloading), style = ShxType.chip, color = c.textSoft, modifier = Modifier.weight(1f))
|
||||
if (info != null && info.size > 0)
|
||||
{
|
||||
Text(
|
||||
stringResource(R.string.updates_progress, progress, sizeText(info.size * progress / 100), sizeText(info.size)),
|
||||
style = ShxType.chip, color = c.textSoft,
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(8.dp).clip(RoundedCornerShape(4.dp)).background(c.cardBorder))
|
||||
{
|
||||
Box(Modifier.fillMaxWidth(progress / 100f).height(8.dp).clip(RoundedCornerShape(4.dp)).background(c.accent))
|
||||
}
|
||||
}
|
||||
/* steps: the list is known once a version is shown; at 100% the file is being checked */
|
||||
val verifying = downloading && progress >= 100
|
||||
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(horizontal = ShxSpacing.lg, vertical = 6.dp))
|
||||
{
|
||||
UpdateStep(StepState.DONE, stringResource(R.string.updates_step_list), stringResource(R.string.updates_step_list_sub), divider = true)
|
||||
UpdateStep(
|
||||
if (verifying) StepState.DONE else StepState.RUNNING, stringResource(R.string.updates_step_download),
|
||||
if (info != null && info.size > 0) sizeText(info.size) else stringResource(R.string.update_card_downloading), divider = true,
|
||||
)
|
||||
UpdateStep(if (verifying) StepState.RUNNING else StepState.WAITING, stringResource(R.string.updates_step_verify), stringResource(R.string.updates_step_verify_sub), divider = true)
|
||||
UpdateStep(StepState.WAITING, stringResource(R.string.updates_step_install), stringResource(R.string.updates_step_install_sub), divider = false)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
ShxButton(stringResource(R.string.updates_cancel), onCancel, style = ShxButtonStyle.OUTLINE, enabled = downloading, modifier = Modifier.weight(1f))
|
||||
/* while downloading the app installs on its own; Available here means a background download
|
||||
* finished or failed without an install request, so the button starts it */
|
||||
ShxButton(stringResource(R.string.update_card_install), onInstall, enabled = state is UpdateState.Available, modifier = Modifier.weight(2f))
|
||||
}
|
||||
}
|
||||
|
||||
private enum class StepState { DONE, RUNNING, WAITING }
|
||||
|
||||
@Composable
|
||||
private fun UpdateStep(step: StepState, title: String, sub: String, divider: Boolean)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
Column()
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = ShxSpacing.md),
|
||||
)
|
||||
{
|
||||
val shape = CircleShape
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(24.dp).clip(shape).then(
|
||||
when (step)
|
||||
{
|
||||
StepState.DONE -> Modifier.background(c.success)
|
||||
StepState.RUNNING -> Modifier.background(c.accentContainer).border(2.dp, c.accent, shape)
|
||||
StepState.WAITING -> Modifier.background(c.card).border(1.dp, c.outline, shape)
|
||||
}
|
||||
),
|
||||
)
|
||||
{
|
||||
when (step)
|
||||
{
|
||||
StepState.DONE -> Icon(ShxIcons.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(14.dp))
|
||||
StepState.RUNNING -> Text("…", style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold), color = c.onAccentContainer)
|
||||
StepState.WAITING -> Unit
|
||||
}
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(1.dp))
|
||||
{
|
||||
Text(title, style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = if (step == StepState.WAITING) c.iconMuted else c.text)
|
||||
Text(sub, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
|
||||
}
|
||||
}
|
||||
if (divider)
|
||||
{
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Mandatory update (App4): cannot be closed; update now or leave the app. */
|
||||
@Composable
|
||||
private fun MandatoryUpdateSheet(versionName: String, size: Long, onUpdate: () -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val activity = LocalActivity.current
|
||||
ShxSheet(onDismiss = {}, dismissible = false)
|
||||
{
|
||||
UpdateSheetHeader(
|
||||
stringResource(R.string.updates_mandatory_title),
|
||||
stringResource(R.string.updates_mandatory_sub, BuildConfig.VERSION_NAME),
|
||||
icon = ShxIcons.Alert, iconBackground = c.warningContainer, iconTint = c.onWarningContainer, round = true,
|
||||
)
|
||||
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(horizontal = ShxSpacing.lg, vertical = 14.dp))
|
||||
{
|
||||
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm))
|
||||
{
|
||||
Text(stringResource(R.string.updates_mandatory_what).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
|
||||
Bullet(stringResource(R.string.updates_mandatory_reason), c.warning)
|
||||
Bullet(stringResource(R.string.updates_mandatory_install, versionName, sizeText(size)), c.accent)
|
||||
}
|
||||
}
|
||||
ShxNote(stringResource(R.string.updates_mandatory_locked), tone = ShxTone.WARNING)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
ShxButton(stringResource(R.string.cert_update_now), onUpdate, modifier = Modifier.fillMaxWidth())
|
||||
ShxButton(stringResource(R.string.updates_exit), { activity?.finishAndRemoveTask() }, style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Update not installed (App5): reasons by error kind, retry, the download page, details for support. */
|
||||
@Composable
|
||||
private fun UpdateFailedContent(state: UpdateState.Failed, onRetry: () -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val context = LocalContext.current
|
||||
val checkFailed = state.error == UpdateError.HASH || state.error == UpdateError.SIGNATURE
|
||||
UpdateSheetHeader(
|
||||
stringResource(R.string.updates_failed_title),
|
||||
stringResource(
|
||||
when (state.error)
|
||||
{
|
||||
UpdateError.HASH, UpdateError.SIGNATURE -> R.string.updates_failed_sub_check
|
||||
UpdateError.NETWORK -> R.string.updates_failed_sub_network
|
||||
UpdateError.INSTALL -> R.string.updates_failed_sub_install
|
||||
UpdateError.OTHER -> R.string.updates_failed_sub_other
|
||||
}
|
||||
),
|
||||
icon = ShxIcons.Shield, iconBackground = c.errorContainer, iconTint = c.error, round = true,
|
||||
)
|
||||
val (title, detail) = when (state.error)
|
||||
{
|
||||
UpdateError.HASH -> R.string.updates_reason_hash to R.string.updates_reason_hash_d
|
||||
UpdateError.SIGNATURE -> R.string.updates_reason_signature to R.string.updates_reason_signature_d
|
||||
UpdateError.NETWORK -> R.string.updates_reason_network to R.string.updates_reason_network_d
|
||||
UpdateError.INSTALL -> R.string.updates_reason_install to R.string.updates_reason_install_d
|
||||
UpdateError.OTHER -> R.string.updates_reason_other to R.string.updates_reason_other_d
|
||||
}
|
||||
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(horizontal = ShxSpacing.lg, vertical = 14.dp))
|
||||
{
|
||||
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm))
|
||||
{
|
||||
Text(stringResource(R.string.updates_failed_what).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
|
||||
Reason(stringResource(title), stringResource(detail), if (checkFailed) c.error else c.accent)
|
||||
Reason(stringResource(R.string.updates_reason_same), stringResource(R.string.updates_reason_same_d, BuildConfig.VERSION_NAME), c.accent)
|
||||
Reason(stringResource(R.string.updates_reason_later), stringResource(R.string.updates_reason_later_d), c.accent)
|
||||
}
|
||||
}
|
||||
ShxNote(stringResource(R.string.updates_manual_note))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
ShxButton(stringResource(R.string.updates_retry), onRetry, height = 52.dp, modifier = Modifier.weight(1f))
|
||||
ShxButton(
|
||||
stringResource(R.string.updates_from_site),
|
||||
{ runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(BuildConfig.UPDATE_DOWNLOAD_PAGE_URL))) } },
|
||||
style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center)
|
||||
{
|
||||
Text(
|
||||
stringResource(R.string.updates_support),
|
||||
style = ShxType.chip, color = c.accentText,
|
||||
modifier = Modifier.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(role = Role.Button) { copySupportDetails(context, state) }.padding(horizontal = ShxSpacing.sm, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Error kind, message and versions for support; the beta code stays out. */
|
||||
private fun copySupportDetails(context: Context, state: UpdateState.Failed)
|
||||
{
|
||||
val manager = UpdateManager.getInstance(context)
|
||||
val text = buildString {
|
||||
appendLine("SHX VPN update error: ${state.error}")
|
||||
appendLine("Detail: ${state.detail ?: "-"}")
|
||||
appendLine("Installed: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
|
||||
appendLine("Channel: ${if (manager.isBetaChannel()) "beta" else "release"}")
|
||||
appendLine("Android: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT}), ${Build.MANUFACTURER} ${Build.MODEL}, ${Build.SUPPORTED_ABIS.firstOrNull() ?: "?"}")
|
||||
}
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("SHX VPN", text))
|
||||
/* Android 13+ confirms the copy itself */
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
|
||||
{
|
||||
Toast.makeText(context, R.string.updates_copied, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
/** Icon well, sheet title and subtitle of the update sheets. */
|
||||
@Composable
|
||||
private fun UpdateSheetHeader(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
icon: ImageVector? = null,
|
||||
iconBackground: Color = LocalShxColors.current.accent,
|
||||
iconTint: Color = Color.White,
|
||||
round: Boolean = false,
|
||||
)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 4.dp),
|
||||
)
|
||||
{
|
||||
val size = if (round) 52.dp else 56.dp
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(size).clip(if (round) CircleShape else RoundedCornerShape(16.dp)).background(iconBackground))
|
||||
{
|
||||
if (icon != null) Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(24.dp))
|
||||
else Icon(painterResource(R.drawable.ic_notification), contentDescription = null, tint = iconTint, modifier = Modifier.size(36.dp))
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||
{
|
||||
Text(title, style = ShxType.pageTitle, color = c.text)
|
||||
Text(subtitle, style = ShxType.sub, color = c.textSub)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Green «safe» box with a shield (App1, App2, App6). */
|
||||
@Composable
|
||||
internal fun UpdateSafeNote(text: String)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.successContainer).padding(horizontal = 14.dp, vertical = ShxSpacing.md),
|
||||
)
|
||||
{
|
||||
Icon(ShxIcons.Shield, contentDescription = null, tint = c.onSuccess, modifier = Modifier.size(20.dp))
|
||||
Text(text, style = ShxType.note, color = c.onSuccess)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Bullet(text: String, dot: Color)
|
||||
{
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(dot))
|
||||
Text(text, style = ShxType.note.copy(fontSize = 14.sp), color = LocalShxColors.current.text)
|
||||
}
|
||||
}
|
||||
|
||||
/** «Title — detail» line of App5. */
|
||||
@Composable
|
||||
private fun Reason(title: String, detail: String, dot: Color)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(dot))
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = c.text)) { append(title) }
|
||||
append(" — ")
|
||||
withStyle(SpanStyle(color = c.textSoft)) { append(detail) }
|
||||
},
|
||||
style = ShxType.note.copy(fontSize = 14.sp), color = c.text,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sizeText(bytes: Long): String = if (bytes > 0) formatBytes(bytes) else "—"
|
||||
|
||||
private fun lastCheckText(context: Context, time: Long): String =
|
||||
DateUtils.getRelativeDateTimeString(context, time, DateUtils.DAY_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0).toString()
|
||||
|
||||
internal fun updateErrorText(error: UpdateError): Int = when (error)
|
||||
{
|
||||
UpdateError.NETWORK -> R.string.update_error_network
|
||||
UpdateError.HASH -> R.string.update_error_hash
|
||||
UpdateError.SIGNATURE -> R.string.update_error_signature
|
||||
UpdateError.INSTALL -> R.string.update_error_install
|
||||
UpdateError.OTHER -> R.string.update_error_generic
|
||||
}
|
||||
+22
-6
@@ -331,7 +331,7 @@ fun ShxButton(
|
||||
.background(if (enabled) bg else bg.copy(alpha = 0.38f))
|
||||
.then(if (style == ShxButtonStyle.OUTLINE) Modifier.border(1.dp, c.outline, shape) else Modifier)
|
||||
.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
|
||||
.padding(horizontal = if (height >= 44.dp) 16.dp else 14.dp),
|
||||
.padding(horizontal = if (height >= 44.dp) 10.dp else 14.dp),
|
||||
)
|
||||
{
|
||||
if (icon != null)
|
||||
@@ -339,7 +339,17 @@ fun ShxButton(
|
||||
Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Text(text, style = textStyle, color = if (enabled) fg else fg.copy(alpha = 0.6f), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
/* two lines on large buttons: on 320dp screens «Скачать и установить» beside «Позже» was cut to «Скачать и установ…» */
|
||||
Text(
|
||||
text,
|
||||
style = textStyle,
|
||||
color = if (enabled) fg else fg.copy(alpha = 0.6f),
|
||||
/* a single word must not wrap mid-word («Отмени / ть»): it shrinks instead */
|
||||
maxLines = if (height >= 52.dp && text.trim().contains(' ')) 2 else 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
autoSize = androidx.compose.foundation.text.TextAutoSize.StepBased(minFontSize = 11.sp, maxFontSize = textStyle.fontSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,14 +565,18 @@ fun ShxTag(text: String, background: Color, color: Color)
|
||||
)
|
||||
}
|
||||
|
||||
/** Bottom sheet of the mockup: radius 32, 36x4 handle, page background. */
|
||||
/**
|
||||
* Bottom sheet of the mockup: radius 32, 36x4 handle, page background.
|
||||
* [dismissible] = false: swipe, scrim tap and «Back» do not close it (a mandatory update).
|
||||
*/
|
||||
@Composable
|
||||
fun ShxSheet(onDismiss: () -> Unit, content: @Composable ColumnScope.() -> Unit)
|
||||
fun ShxSheet(onDismiss: () -> Unit, dismissible: Boolean = true, content: @Composable ColumnScope.() -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
onDismissRequest = { if (dismissible) onDismiss() },
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true, confirmValueChange = { dismissible || it != androidx.compose.material3.SheetValue.Hidden }),
|
||||
properties = androidx.compose.material3.ModalBottomSheetProperties(shouldDismissOnBackPress = dismissible, shouldDismissOnClickOutside = dismissible),
|
||||
containerColor = c.background,
|
||||
scrimColor = c.scrim,
|
||||
shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp),
|
||||
@@ -571,6 +585,8 @@ fun ShxSheet(onDismiss: () -> Unit, content: @Composable ColumnScope.() -> Unit)
|
||||
},
|
||||
)
|
||||
{
|
||||
/* belt and braces for a mandatory sheet: «Back» is swallowed even if the dialog forwards it */
|
||||
androidx.activity.compose.BackHandler(enabled = !dismissible) {}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth().verticalScroll(androidx.compose.foundation.rememberScrollState()).padding(start = 16.dp, end = 16.dp, bottom = 28.dp),
|
||||
|
||||
+12
-5
@@ -40,6 +40,9 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.delay
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.logic.PauseManager
|
||||
@@ -103,12 +106,16 @@ internal fun HomeProblem.titleRes(): Int = when (this)
|
||||
internal fun rememberNow(): Long
|
||||
{
|
||||
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
LaunchedEffect(Unit)
|
||||
{
|
||||
while (true)
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
LaunchedEffect(lifecycle)
|
||||
{ /* no ticking while the app is in the background */
|
||||
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED)
|
||||
{
|
||||
now = System.currentTimeMillis()
|
||||
delay(1000L - now % 1000L)
|
||||
while (true)
|
||||
{
|
||||
now = System.currentTimeMillis()
|
||||
delay(1000L - now % 1000L)
|
||||
}
|
||||
}
|
||||
}
|
||||
return now
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
*/
|
||||
|
||||
#include <errno.h>
|
||||
#include <poll.h>
|
||||
#include <stdint.h>
|
||||
#include <sys/eventfd.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "android_jni.h"
|
||||
@@ -70,6 +73,12 @@ struct private_android_service_t {
|
||||
*/
|
||||
int tunfd;
|
||||
|
||||
/**
|
||||
* eventfd that wakes the TUN reader whenever tunfd is replaced or closed
|
||||
* (-1 if it could not be created, then the reader falls back to a 1 s poll)
|
||||
*/
|
||||
int wakefd;
|
||||
|
||||
/**
|
||||
* MTU of TUN device
|
||||
*/
|
||||
@@ -136,6 +145,21 @@ static void engine_write_packet(void *ctx, ip_packet_t *packet)
|
||||
deliver_plain(ctx, packet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wake the TUN reader so it picks up a replaced or closed tunfd. Call it
|
||||
* after this->tunfd was changed (the eventfd counter stays readable until the
|
||||
* reader drains it, so a wake-up before the reader polls is not lost).
|
||||
*/
|
||||
static void wake_reader(private_android_service_t *this)
|
||||
{
|
||||
uint64_t one = 1;
|
||||
|
||||
if (this->wakefd >= 0 && write(this->wakefd, &one, sizeof(one)) < 0)
|
||||
{
|
||||
DBG1(DBG_DMN, "waking the TUN reader failed: %s", strerror(errno));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Job handling outbound plaintext packets
|
||||
*/
|
||||
@@ -143,16 +167,10 @@ static job_requeue_t handle_plain(private_android_service_t *this)
|
||||
{
|
||||
ip_packet_t *packet;
|
||||
chunk_t raw;
|
||||
fd_set set;
|
||||
struct pollfd fds[2];
|
||||
ssize_t len;
|
||||
int tunfd;
|
||||
int tunfd, wakefd, nfds = 1, timeout;
|
||||
bool old, dns_proxy;
|
||||
timeval_t tv = {
|
||||
/* check every second if tunfd is still valid */
|
||||
.tv_sec = 1,
|
||||
};
|
||||
|
||||
FD_ZERO(&set);
|
||||
|
||||
this->lock->read_lock(this->lock);
|
||||
if (this->tunfd < 0)
|
||||
@@ -161,28 +179,60 @@ static job_requeue_t handle_plain(private_android_service_t *this)
|
||||
return JOB_REQUEUE_NONE;
|
||||
}
|
||||
tunfd = this->tunfd;
|
||||
FD_SET(tunfd, &set);
|
||||
wakefd = this->wakefd;
|
||||
/* cache this while we have the lock */
|
||||
dns_proxy = this->use_dns_proxy;
|
||||
this->lock->unlock(this->lock);
|
||||
|
||||
fds[0].fd = tunfd;
|
||||
fds[0].events = POLLIN;
|
||||
fds[0].revents = 0;
|
||||
if (wakefd >= 0)
|
||||
{ /* a replaced or closed tunfd wakes us, so the timeout is only a
|
||||
* safety net; without the eventfd re-check every second as before */
|
||||
fds[1].fd = wakefd;
|
||||
fds[1].events = POLLIN;
|
||||
fds[1].revents = 0;
|
||||
nfds = 2;
|
||||
timeout = 60000;
|
||||
}
|
||||
else
|
||||
{
|
||||
timeout = 1000;
|
||||
}
|
||||
|
||||
old = thread_cancelability(TRUE);
|
||||
len = select(tunfd + 1, &set, NULL, NULL, &tv);
|
||||
len = poll(fds, nfds, timeout);
|
||||
thread_cancelability(old);
|
||||
|
||||
if (len < 0)
|
||||
{
|
||||
if (errno == EBADF)
|
||||
{ /* the TUN device got closed just before calling select(), retry */
|
||||
{ /* the TUN device got closed just before calling poll(), retry */
|
||||
return JOB_REQUEUE_FAIR;
|
||||
}
|
||||
DBG1(DBG_DMN, "select on TUN device failed: %s", strerror(errno));
|
||||
DBG1(DBG_DMN, "poll on TUN device failed: %s", strerror(errno));
|
||||
return JOB_REQUEUE_NONE;
|
||||
}
|
||||
else if (len == 0)
|
||||
{ /* timeout, check again right away */
|
||||
return JOB_REQUEUE_DIRECT;
|
||||
}
|
||||
if (nfds == 2 && (fds[1].revents & POLLIN))
|
||||
{ /* tunfd was replaced or closed: drain the counter and start over with
|
||||
* the current fd (pending TUN data stays, poll is level-triggered) */
|
||||
uint64_t cnt;
|
||||
|
||||
if (read(wakefd, &cnt, sizeof(cnt)) < 0 && errno != EAGAIN)
|
||||
{
|
||||
DBG1(DBG_DMN, "reading the wake-up fd failed: %s", strerror(errno));
|
||||
}
|
||||
return JOB_REQUEUE_DIRECT;
|
||||
}
|
||||
if (fds[0].revents & POLLNVAL)
|
||||
{ /* the fd was closed while polling, retry with the current one */
|
||||
return JOB_REQUEUE_FAIR;
|
||||
}
|
||||
|
||||
raw = chunk_alloc(this->mtu);
|
||||
len = read(tunfd, raw.ptr, raw.len);
|
||||
@@ -375,6 +425,8 @@ static bool setup_tun_device(private_android_service_t *this,
|
||||
}
|
||||
this->tunfd = tunfd;
|
||||
this->lock->unlock(this->lock);
|
||||
/* a reader polling the previous fd must switch to the new one */
|
||||
wake_reader(this);
|
||||
|
||||
DBG1(DBG_DMN, "successfully created TUN device");
|
||||
|
||||
@@ -422,6 +474,7 @@ static bool setup_tun_device_without_dns(private_android_service_t *this)
|
||||
}
|
||||
this->tunfd = tunfd;
|
||||
this->lock->unlock(this->lock);
|
||||
wake_reader(this);
|
||||
|
||||
/* the fd changed, re-bind the userspace router to the new TUN */
|
||||
direct_engine_set_tun(this, engine_write_packet, this->mtu);
|
||||
@@ -446,6 +499,8 @@ static void close_tun_device(private_android_service_t *this)
|
||||
tunfd = this->tunfd;
|
||||
this->tunfd = -1;
|
||||
this->lock->unlock(this->lock);
|
||||
/* let the reader notice tunfd < 0 and quit instead of polling a dead fd */
|
||||
wake_reader(this);
|
||||
|
||||
/* drop the remembered binding too, so a lazily created engine does not
|
||||
* attach to the dead TUN, and flush the engine if it exists */
|
||||
@@ -977,6 +1032,10 @@ METHOD(android_service_t, destroy, void,
|
||||
close_tun_device(this);
|
||||
this->dns_proxy->destroy(this->dns_proxy);
|
||||
this->lock->destroy(this->lock);
|
||||
if (this->wakefd >= 0)
|
||||
{
|
||||
close(this->wakefd);
|
||||
}
|
||||
this->settings->destroy(this->settings);
|
||||
free(this);
|
||||
}
|
||||
@@ -1005,6 +1064,7 @@ android_service_t *android_service_create(android_creds_t *creds,
|
||||
.settings = settings,
|
||||
.creds = creds,
|
||||
.tunfd = -1,
|
||||
.wakefd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC),
|
||||
.mtu = settings->get_int(settings, "global.mtu", ANDROID_DEFAULT_MTU),
|
||||
);
|
||||
/* only allow queries for the VPN gateway */
|
||||
|
||||
@@ -797,10 +797,9 @@ static void *engine_thread(private_direct_engine_t *this)
|
||||
{
|
||||
active = TRUE;
|
||||
this->udp->reprotect(this->udp);
|
||||
if (this->tcp_active)
|
||||
{
|
||||
this->tcp->reprotect(this->tcp);
|
||||
}
|
||||
/* also while lwIP is dormant: this clears the negative cache of
|
||||
* failed direct connects, which must not survive a roam */
|
||||
this->tcp->reprotect(this->tcp);
|
||||
/* the network changed: whatever was dead for direct connects
|
||||
* may work again - drop the tunnel fallbacks with the rest of
|
||||
* the failure state (tcp->reprotect clears the neg table) */
|
||||
@@ -812,8 +811,22 @@ static void *engine_thread(private_direct_engine_t *this)
|
||||
this->bind_lock->unlock(this->bind_lock);
|
||||
|
||||
if (!bound && this->unbound_flushed)
|
||||
{ /* TUN still gone and already flushed */
|
||||
{ /* TUN still gone and already flushed: nothing to do until the
|
||||
* re-bind wakes us, so back off like an idle round instead of
|
||||
* polling at the active cadence all through a re-authentication */
|
||||
drain_queue_nobind(this);
|
||||
if (active)
|
||||
{
|
||||
poll_ms = ENGINE_POLL_MS;
|
||||
}
|
||||
else
|
||||
{
|
||||
poll_ms *= 2;
|
||||
if (poll_ms > ENGINE_IDLE_POLL_MS)
|
||||
{
|
||||
poll_ms = ENGINE_IDLE_POLL_MS;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -862,6 +875,12 @@ static void *engine_thread(private_direct_engine_t *this)
|
||||
{
|
||||
this->tcp->process_poll(this->tcp, fds + 1 + udp_n, tcp_n);
|
||||
this->tcp->check_timers(this->tcp);
|
||||
if (!active && this->tcp->flow_count(this->tcp) == 0)
|
||||
{ /* a full idle round with no TCP flow left: let lwIP go dormant
|
||||
* again (the next TCP packet wakes it, its timers catch up),
|
||||
* so the idle cadence stretches back to ENGINE_IDLE_POLL_MS */
|
||||
this->tcp_active = FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
{ /* adapt the cadence after the work of this round is known */
|
||||
|
||||
@@ -966,6 +966,12 @@ METHOD(direct_tcp_t, check_timers, void,
|
||||
enumerator->destroy(enumerator);
|
||||
}
|
||||
|
||||
METHOD(direct_tcp_t, flow_count, size_t,
|
||||
private_direct_tcp_t *this)
|
||||
{
|
||||
return this->relays->get_count(this->relays);
|
||||
}
|
||||
|
||||
METHOD(direct_tcp_t, set_mtu, void,
|
||||
private_direct_tcp_t *this, uint16_t mtu)
|
||||
{
|
||||
@@ -1290,6 +1296,7 @@ direct_tcp_t *direct_tcp_create(uint16_t mtu, direct_engine_t *engine,
|
||||
.public = {
|
||||
.handle_packet = _handle_packet,
|
||||
.check_timers = _check_timers,
|
||||
.flow_count = _flow_count,
|
||||
.set_mtu = _set_mtu,
|
||||
.reprotect = _reprotect,
|
||||
.fill_poll = _fill_poll,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
|
||||
<resources>
|
||||
<string name="report_intro">Der Bericht geht über einen geschützten Kanal an das SHX-Team. Mit dem Berichtscode findet der Support Ihr Protokoll.</string>
|
||||
<string name="report_contents">Inhalt des Berichts</string>
|
||||
<string name="report_item_log">Verbindungsprotokoll von heute</string>
|
||||
<string name="report_item_device">App-Version und Telefonmodell</string>
|
||||
<string name="report_item_network">Netzwerktyp und aktiver Server</string>
|
||||
<string name="report_item_ips">IP-Adressen der Server aus dem Protokoll</string>
|
||||
<string name="report_diag_title">Diagnose anhängen</string>
|
||||
<string name="report_diag_sub">Ergebnisse der Netzwerkprüfung und erweitertes App-Protokoll</string>
|
||||
<string name="report_comment_label">Was ist passiert? (optional)</string>
|
||||
<string name="report_cancel">Abbrechen</string>
|
||||
<string name="report_send">Senden</string>
|
||||
<string name="report_by_mail">Per E-Mail senden</string>
|
||||
<string name="report_sent_title">Bericht gesendet</string>
|
||||
<string name="report_sent_sub">Nennen Sie diesen Code dem Support</string>
|
||||
<string name="report_code_label">Berichtscode</string>
|
||||
<string name="report_copy_code">Code kopieren</string>
|
||||
<string name="report_code_copied">Code kopiert</string>
|
||||
<string name="report_saved_note">Der Code ist unter „Ereignisse“ gespeichert und lässt sich später finden.</string>
|
||||
<string name="report_done">Fertig</string>
|
||||
<string name="report_failed_title">Bericht nicht gesendet</string>
|
||||
<string name="report_failed_no_answer">Der Server hat nicht geantwortet</string>
|
||||
<string name="report_failed_refused">Der Server hat den Bericht abgelehnt</string>
|
||||
<string name="report_reasons">Mögliche Ursachen</string>
|
||||
<string name="report_reason_offline">Kein Internet</string>
|
||||
<string name="report_reason_offline_d">Netzwerk prüfen und wiederholen</string>
|
||||
<string name="report_reason_server">Server nicht erreichbar</string>
|
||||
<string name="report_reason_server_d">in ein paar Minuten wiederholen</string>
|
||||
<string name="report_reason_clock">Telefonuhr geht falsch</string>
|
||||
<string name="report_reason_clock_d">automatische Zeit einschalten und wiederholen</string>
|
||||
<string name="report_reason_often">Zu häufig</string>
|
||||
<string name="report_reason_often_d">in einer Stunde erneut versuchen</string>
|
||||
<string name="report_reason_large">Protokoll zu groß</string>
|
||||
<string name="report_reason_large_d">per E-Mail senden</string>
|
||||
<string name="report_reason_rejected">Anfrage abgelehnt</string>
|
||||
<string name="report_reason_rejected_d">Protokoll per E-Mail senden</string>
|
||||
<string name="report_mail_note">Das Protokoll lässt sich wie bisher auch per E-Mail senden.</string>
|
||||
<string name="report_retry">Wiederholen</string>
|
||||
<string name="report_by_letter">Per E-Mail senden</string>
|
||||
<string name="report_event_title">Bericht gesendet, Code %1$s</string>
|
||||
<string name="report_event_text">Nennen Sie diesen Code dem Support, damit er Ihr Protokoll findet.</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- App self-update: the Updates page and the update sheets -->
|
||||
<resources>
|
||||
<string name="updates_have_size">Installiert: %1$s · %2$s</string>
|
||||
<string name="updates_review">Ansehen und installieren</string>
|
||||
<string name="updates_section_auto">Automatisch</string>
|
||||
<string name="updates_auto_check">Selbst prüfen</string>
|
||||
<string name="updates_silent">Still installieren</string>
|
||||
<string name="updates_silent_sub">Über WLAN laden und installieren, wenn das VPN aus ist</string>
|
||||
<string name="updates_section_channel">Kanal</string>
|
||||
<string name="updates_channel_stable">Stabil</string>
|
||||
<string name="updates_channel_stable_sub">Geprüfte Versionen</string>
|
||||
<string name="updates_channel_beta">Beta</string>
|
||||
<string name="updates_channel_beta_sub">Früher, aber mit Fehlern</string>
|
||||
<string name="updates_channel_hint">Die Beta wird mit einem Einladungscode des Administrators freigeschaltet.</string>
|
||||
<string name="updates_beta_title">Einladungscode für die Beta</string>
|
||||
<string name="updates_beta_text">Gib den Code ein, den du vom Administrator erhalten hast. Ohne gültigen Code bleibt die App bei stabilen Versionen.</string>
|
||||
<string name="updates_beta_code">Einladungscode</string>
|
||||
<string name="updates_beta_enable">Beta einschalten</string>
|
||||
<string name="updates_check_now">Jetzt prüfen</string>
|
||||
<string name="updates_last_check">Letzte Prüfung: %1$s</string>
|
||||
<string name="updates_never_checked">Noch nicht geprüft</string>
|
||||
<string name="updates_review_sub">Installiert: %1$s · %2$s · die Datei wird vor der Installation geprüft</string>
|
||||
<string name="updates_review_safe">Die App prüft die Prüfsumme und die SHX-Signatur. Eine gefälschte Datei wird nicht installiert.</string>
|
||||
<string name="updates_downloading_title">%1$s wird geladen</string>
|
||||
<string name="updates_downloading_sub">Du kannst dieses Fenster schließen: Der Download läuft weiter</string>
|
||||
<string name="updates_progress">%1$d%% · %2$s von %3$s</string>
|
||||
<string name="updates_step_list">Versionsliste erhalten</string>
|
||||
<string name="updates_step_list_sub">Über einen geschützten Kanal</string>
|
||||
<string name="updates_step_download">Datei wird geladen</string>
|
||||
<string name="updates_step_verify">Datei wird geprüft</string>
|
||||
<string name="updates_step_verify_sub">Prüfsumme und SHX-Signatur</string>
|
||||
<string name="updates_step_install">Installation</string>
|
||||
<string name="updates_step_install_sub">Android fragt nach einer Bestätigung</string>
|
||||
<string name="updates_cancel">Abbrechen</string>
|
||||
<string name="updates_mandatory_title">SHX VPN muss aktualisiert werden</string>
|
||||
<string name="updates_mandatory_sub">Version %1$s wird nicht mehr unterstützt</string>
|
||||
<string name="updates_mandatory_what">Was das bedeutet</string>
|
||||
<string name="updates_mandatory_reason">Der Server nimmt keine Verbindungen der alten Version mehr an</string>
|
||||
<string name="updates_mandatory_install">Installiere %1$s (%2$s), deine Server und Einstellungen bleiben erhalten</string>
|
||||
<string name="updates_mandatory_locked">Dieses Fenster lässt sich erst nach dem Update schließen.</string>
|
||||
<string name="updates_exit">App beenden</string>
|
||||
<string name="updates_failed_title">Update nicht installiert</string>
|
||||
<string name="updates_failed_sub_check">Die Datei hat die Prüfung nicht bestanden</string>
|
||||
<string name="updates_failed_sub_network">Die Datei wurde nicht geladen</string>
|
||||
<string name="updates_failed_sub_install">Android hat die Datei nicht installiert</string>
|
||||
<string name="updates_failed_sub_other">Etwas ist schiefgelaufen</string>
|
||||
<string name="updates_failed_what">Was passiert ist</string>
|
||||
<string name="updates_reason_hash">Prüfsumme stimmt nicht</string>
|
||||
<string name="updates_reason_hash_d">die Datei wurde unterwegs beschädigt, die Kopie ist gelöscht</string>
|
||||
<string name="updates_reason_signature">Signatur stimmt nicht</string>
|
||||
<string name="updates_reason_signature_d">die Datei ist nicht von SHX signiert, die Kopie ist gelöscht</string>
|
||||
<string name="updates_reason_network">Keine Verbindung zum Update-Server</string>
|
||||
<string name="updates_reason_network_d">der Download wurde unterbrochen</string>
|
||||
<string name="updates_reason_install">Android hat die Installation abgelehnt</string>
|
||||
<string name="updates_reason_install_d">das Installationsprogramm meldete einen Fehler</string>
|
||||
<string name="updates_reason_other">Unerwarteter Fehler</string>
|
||||
<string name="updates_reason_other_d">Details für den Support findest du unten</string>
|
||||
<string name="updates_reason_same">Version unverändert</string>
|
||||
<string name="updates_reason_same_d">du hast weiterhin %1$s</string>
|
||||
<string name="updates_reason_later">Versuche es später erneut</string>
|
||||
<string name="updates_reason_later_d">oder wechsle das Netz: WLAN statt Mobilfunk</string>
|
||||
<string name="updates_manual_note">Du kannst die App manuell über die Download-Seite installieren. Deine Server und Einstellungen bleiben erhalten.</string>
|
||||
<string name="updates_retry">Wiederholen</string>
|
||||
<string name="updates_from_site">Von der Website laden</string>
|
||||
<string name="updates_support">Details für den Support</string>
|
||||
<string name="updates_copied">Kopiert</string>
|
||||
<plurals name="updates_auto_check_sub">
|
||||
<item quantity="one">Alle %d Stunde, während das Telefon lädt</item>
|
||||
<item quantity="other">Alle %d Stunden, während das Telefon lädt</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
|
||||
<resources>
|
||||
<string name="report_intro">Raport trafi do zespołu SHX bezpiecznym kanałem. Po kodzie raportu wsparcie znajdzie Twój dziennik.</string>
|
||||
<string name="report_contents">Co trafi do raportu</string>
|
||||
<string name="report_item_log">Dziennik połączenia z dzisiaj</string>
|
||||
<string name="report_item_device">Wersja aplikacji i model telefonu</string>
|
||||
<string name="report_item_network">Typ sieci i aktywny serwer</string>
|
||||
<string name="report_item_ips">Adresy IP serwerów z dziennika</string>
|
||||
<string name="report_diag_title">Dołącz diagnostykę</string>
|
||||
<string name="report_diag_sub">Wyniki sprawdzenia sieci i rozszerzony dziennik aplikacji</string>
|
||||
<string name="report_comment_label">Co się stało (opcjonalnie)</string>
|
||||
<string name="report_cancel">Anuluj</string>
|
||||
<string name="report_send">Wyślij</string>
|
||||
<string name="report_by_mail">Wyślij e-mailem</string>
|
||||
<string name="report_sent_title">Raport wysłany</string>
|
||||
<string name="report_sent_sub">Podaj ten kod wsparciu</string>
|
||||
<string name="report_code_label">Kod raportu</string>
|
||||
<string name="report_copy_code">Kopiuj kod</string>
|
||||
<string name="report_code_copied">Kod skopiowany</string>
|
||||
<string name="report_saved_note">Kod zapisano w „Zdarzeniach”, można go znaleźć później.</string>
|
||||
<string name="report_done">Gotowe</string>
|
||||
<string name="report_failed_title">Raport nie został wysłany</string>
|
||||
<string name="report_failed_no_answer">Serwer nie odpowiedział</string>
|
||||
<string name="report_failed_refused">Serwer odrzucił raport</string>
|
||||
<string name="report_reasons">Możliwe przyczyny</string>
|
||||
<string name="report_reason_offline">Brak internetu</string>
|
||||
<string name="report_reason_offline_d">sprawdź sieć i ponów</string>
|
||||
<string name="report_reason_server">Serwer niedostępny</string>
|
||||
<string name="report_reason_server_d">ponów za kilka minut</string>
|
||||
<string name="report_reason_clock">Zły czas w telefonie</string>
|
||||
<string name="report_reason_clock_d">włącz automatyczny czas i ponów</string>
|
||||
<string name="report_reason_often">Zbyt często</string>
|
||||
<string name="report_reason_often_d">spróbuj za godzinę</string>
|
||||
<string name="report_reason_large">Dziennik jest za duży</string>
|
||||
<string name="report_reason_large_d">wyślij go e-mailem</string>
|
||||
<string name="report_reason_rejected">Żądanie odrzucone</string>
|
||||
<string name="report_reason_rejected_d">wyślij dziennik e-mailem</string>
|
||||
<string name="report_mail_note">Dziennik można też wysłać e-mailem, jak dotąd.</string>
|
||||
<string name="report_retry">Ponów</string>
|
||||
<string name="report_by_letter">Wyślij e-mailem</string>
|
||||
<string name="report_event_title">Raport wysłany, kod %1$s</string>
|
||||
<string name="report_event_text">Podaj ten kod wsparciu, aby znalazło Twój dziennik.</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- App self-update: the Updates page and the update sheets -->
|
||||
<resources>
|
||||
<string name="updates_have_size">Masz %1$s · %2$s</string>
|
||||
<string name="updates_review">Zobacz i zainstaluj</string>
|
||||
<string name="updates_section_auto">Automatycznie</string>
|
||||
<string name="updates_auto_check">Sprawdzaj samodzielnie</string>
|
||||
<string name="updates_silent">Instaluj po cichu</string>
|
||||
<string name="updates_silent_sub">Pobierz przez Wi-Fi i zainstaluj, gdy VPN jest wyłączony</string>
|
||||
<string name="updates_section_channel">Kanał</string>
|
||||
<string name="updates_channel_stable">Stabilny</string>
|
||||
<string name="updates_channel_stable_sub">Sprawdzone wersje</string>
|
||||
<string name="updates_channel_beta">Beta</string>
|
||||
<string name="updates_channel_beta_sub">Wcześniej, ale z błędami</string>
|
||||
<string name="updates_channel_hint">Beta jest dostępna z kodem zaproszenia od administratora.</string>
|
||||
<string name="updates_beta_title">Kod zaproszenia do bety</string>
|
||||
<string name="updates_beta_text">Wpisz kod od administratora. Bez prawidłowego kodu aplikacja zostanie na stabilnych wersjach.</string>
|
||||
<string name="updates_beta_code">Kod zaproszenia</string>
|
||||
<string name="updates_beta_enable">Włącz betę</string>
|
||||
<string name="updates_check_now">Sprawdź teraz</string>
|
||||
<string name="updates_last_check">Ostatnie sprawdzenie: %1$s</string>
|
||||
<string name="updates_never_checked">Jeszcze nie sprawdzano</string>
|
||||
<string name="updates_review_sub">Masz %1$s · %2$s · plik sprawdzimy przed instalacją</string>
|
||||
<string name="updates_review_safe">Aplikacja sprawdzi sumę kontrolną i podpis SHX. Podrobiony plik się nie zainstaluje.</string>
|
||||
<string name="updates_downloading_title">Pobieramy %1$s</string>
|
||||
<string name="updates_downloading_sub">Możesz zamknąć to okno: pobieranie będzie trwać</string>
|
||||
<string name="updates_progress">%1$d%% · %2$s z %3$s</string>
|
||||
<string name="updates_step_list">Lista wersji pobrana</string>
|
||||
<string name="updates_step_list_sub">Przez chroniony kanał</string>
|
||||
<string name="updates_step_download">Pobieranie pliku</string>
|
||||
<string name="updates_step_verify">Sprawdzanie pliku</string>
|
||||
<string name="updates_step_verify_sub">Suma kontrolna i podpis SHX</string>
|
||||
<string name="updates_step_install">Instalacja</string>
|
||||
<string name="updates_step_install_sub">Android poprosi o potwierdzenie</string>
|
||||
<string name="updates_cancel">Anuluj</string>
|
||||
<string name="updates_mandatory_title">SHX VPN wymaga aktualizacji</string>
|
||||
<string name="updates_mandatory_sub">Wersja %1$s nie jest już obsługiwana</string>
|
||||
<string name="updates_mandatory_what">Co to oznacza</string>
|
||||
<string name="updates_mandatory_reason">Serwer nie przyjmuje już połączeń ze starej wersji</string>
|
||||
<string name="updates_mandatory_install">Zainstaluj %1$s (%2$s), Twoje serwery i ustawienia zostaną zachowane</string>
|
||||
<string name="updates_mandatory_locked">Tego okna nie można zamknąć, dopóki aplikacja nie zostanie zaktualizowana.</string>
|
||||
<string name="updates_exit">Zamknij aplikację</string>
|
||||
<string name="updates_failed_title">Aktualizacja nie została zainstalowana</string>
|
||||
<string name="updates_failed_sub_check">Plik nie przeszedł weryfikacji</string>
|
||||
<string name="updates_failed_sub_network">Nie udało się pobrać pliku</string>
|
||||
<string name="updates_failed_sub_install">Android nie zainstalował pliku</string>
|
||||
<string name="updates_failed_sub_other">Coś poszło nie tak</string>
|
||||
<string name="updates_failed_what">Co się stało</string>
|
||||
<string name="updates_reason_hash">Suma kontrolna się nie zgadza</string>
|
||||
<string name="updates_reason_hash_d">plik uszkodził się po drodze, kopia została usunięta</string>
|
||||
<string name="updates_reason_signature">Podpis się nie zgadza</string>
|
||||
<string name="updates_reason_signature_d">plik nie jest podpisany przez SHX, kopia została usunięta</string>
|
||||
<string name="updates_reason_network">Brak połączenia z serwerem aktualizacji</string>
|
||||
<string name="updates_reason_network_d">pobieranie zostało przerwane</string>
|
||||
<string name="updates_reason_install">Android odrzucił instalację</string>
|
||||
<string name="updates_reason_install_d">instalator systemowy zwrócił błąd</string>
|
||||
<string name="updates_reason_other">Nieoczekiwany błąd</string>
|
||||
<string name="updates_reason_other_d">szczegóły dla wsparcia znajdziesz poniżej</string>
|
||||
<string name="updates_reason_same">Wersja się nie zmieniła</string>
|
||||
<string name="updates_reason_same_d">nadal masz %1$s</string>
|
||||
<string name="updates_reason_later">Spróbuj później</string>
|
||||
<string name="updates_reason_later_d">lub zmień sieć: Wi-Fi zamiast komórkowej</string>
|
||||
<string name="updates_manual_note">Możesz zainstalować ręcznie ze strony pobierania. Twoje serwery i ustawienia pozostaną bez zmian.</string>
|
||||
<string name="updates_retry">Ponów</string>
|
||||
<string name="updates_from_site">Pobierz ze strony</string>
|
||||
<string name="updates_support">Szczegóły dla wsparcia</string>
|
||||
<string name="updates_copied">Skopiowano</string>
|
||||
<plurals name="updates_auto_check_sub">
|
||||
<item quantity="one">Co %d godzinę, gdy telefon się ładuje</item>
|
||||
<item quantity="few">Co %d godziny, gdy telefon się ładuje</item>
|
||||
<item quantity="many">Co %d godzin, gdy telefon się ładuje</item>
|
||||
<item quantity="other">Co %d godziny, gdy telefon się ładuje</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
|
||||
<resources>
|
||||
<string name="report_intro">Отчёт уйдёт команде SHX по защищённому каналу. По коду отчёта поддержка найдёт ваш журнал.</string>
|
||||
<string name="report_contents">Что войдёт в отчёт</string>
|
||||
<string name="report_item_log">Журнал подключения за сегодня</string>
|
||||
<string name="report_item_device">Версия приложения и модель телефона</string>
|
||||
<string name="report_item_network">Тип сети и активный сервер</string>
|
||||
<string name="report_item_ips">IP-адреса серверов из журнала</string>
|
||||
<string name="report_diag_title">Приложить диагностику</string>
|
||||
<string name="report_diag_sub">Результаты проверки сети и расширенный журнал приложения</string>
|
||||
<string name="report_comment_label">Что случилось (необязательно)</string>
|
||||
<string name="report_cancel">Отмена</string>
|
||||
<string name="report_send">Отправить</string>
|
||||
<string name="report_by_mail">Отправить по почте</string>
|
||||
<string name="report_sent_title">Отчёт отправлен</string>
|
||||
<string name="report_sent_sub">Назовите этот код поддержке</string>
|
||||
<string name="report_code_label">Код отчёта</string>
|
||||
<string name="report_copy_code">Скопировать код</string>
|
||||
<string name="report_code_copied">Код скопирован</string>
|
||||
<string name="report_saved_note">Код сохранён в «Событиях», его можно найти позже.</string>
|
||||
<string name="report_done">Готово</string>
|
||||
<string name="report_failed_title">Отчёт не отправлен</string>
|
||||
<string name="report_failed_no_answer">Сервер не ответил</string>
|
||||
<string name="report_failed_refused">Сервер отклонил отчёт</string>
|
||||
<string name="report_reasons">Возможные причины</string>
|
||||
<string name="report_reason_offline">Нет интернета</string>
|
||||
<string name="report_reason_offline_d">проверьте сеть и повторите</string>
|
||||
<string name="report_reason_server">Сервер недоступен</string>
|
||||
<string name="report_reason_server_d">повторите через несколько минут</string>
|
||||
<string name="report_reason_clock">Часы телефона сбиты</string>
|
||||
<string name="report_reason_clock_d">включите автоматическое время и повторите</string>
|
||||
<string name="report_reason_often">Слишком часто</string>
|
||||
<string name="report_reason_often_d">попробуйте через час</string>
|
||||
<string name="report_reason_large">Журнал слишком большой</string>
|
||||
<string name="report_reason_large_d">отправьте его письмом</string>
|
||||
<string name="report_reason_rejected">Запрос отклонён</string>
|
||||
<string name="report_reason_rejected_d">отправьте журнал письмом</string>
|
||||
<string name="report_mail_note">Журнал можно отправить и письмом, как раньше.</string>
|
||||
<string name="report_retry">Повторить</string>
|
||||
<string name="report_by_letter">Отправить письмом</string>
|
||||
<string name="report_event_title">Отчёт отправлен, код %1$s</string>
|
||||
<string name="report_event_text">Назовите этот код поддержке, чтобы она нашла ваш журнал.</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- App self-update: the Updates page and the update sheets -->
|
||||
<resources>
|
||||
<string name="updates_have_size">У вас %1$s · %2$s</string>
|
||||
<string name="updates_review">Посмотреть и установить</string>
|
||||
<string name="updates_section_auto">Автоматически</string>
|
||||
<string name="updates_auto_check">Проверять сами</string>
|
||||
<string name="updates_silent">Ставить тихо</string>
|
||||
<string name="updates_silent_sub">Скачать по Wi-Fi и установить, когда VPN выключен</string>
|
||||
<string name="updates_section_channel">Канал</string>
|
||||
<string name="updates_channel_stable">Стабильный</string>
|
||||
<string name="updates_channel_stable_sub">Проверенные версии</string>
|
||||
<string name="updates_channel_beta">Бета</string>
|
||||
<string name="updates_channel_beta_sub">Раньше, но с ошибками</string>
|
||||
<string name="updates_channel_hint">Бета открывается по коду приглашения от администратора.</string>
|
||||
<string name="updates_beta_title">Код приглашения в бету</string>
|
||||
<string name="updates_beta_text">Введите код, который дал администратор. Без верного кода приложение останется на стабильных версиях.</string>
|
||||
<string name="updates_beta_code">Код приглашения</string>
|
||||
<string name="updates_beta_enable">Включить бету</string>
|
||||
<string name="updates_check_now">Проверить сейчас</string>
|
||||
<string name="updates_last_check">Последняя проверка: %1$s</string>
|
||||
<string name="updates_never_checked">Ещё не проверяли</string>
|
||||
<string name="updates_review_sub">У вас %1$s · %2$s · файл проверим перед установкой</string>
|
||||
<string name="updates_review_safe">Приложение сверит контрольную сумму и подпись SHX. Подделанный файл не установится.</string>
|
||||
<string name="updates_downloading_title">Скачиваем %1$s</string>
|
||||
<string name="updates_downloading_sub">Можно закрыть шторку: загрузка продолжится</string>
|
||||
<string name="updates_progress">%1$d%% · %2$s из %3$s</string>
|
||||
<string name="updates_step_list">Список версий получен</string>
|
||||
<string name="updates_step_list_sub">По защищённому каналу</string>
|
||||
<string name="updates_step_download">Скачивание файла</string>
|
||||
<string name="updates_step_verify">Проверка файла</string>
|
||||
<string name="updates_step_verify_sub">Контрольная сумма и подпись SHX</string>
|
||||
<string name="updates_step_install">Установка</string>
|
||||
<string name="updates_step_install_sub">Android спросит подтверждение</string>
|
||||
<string name="updates_cancel">Отменить</string>
|
||||
<string name="updates_mandatory_title">Нужно обновить SHX VPN</string>
|
||||
<string name="updates_mandatory_sub">Версия %1$s больше не поддерживается</string>
|
||||
<string name="updates_mandatory_what">Что это значит</string>
|
||||
<string name="updates_mandatory_reason">Сервер больше не принимает подключения со старой версии</string>
|
||||
<string name="updates_mandatory_install">Установите %1$s (%2$s), ваши серверы и настройки сохранятся</string>
|
||||
<string name="updates_mandatory_locked">Закрыть эту шторку нельзя, пока приложение не обновлено.</string>
|
||||
<string name="updates_exit">Выйти из приложения</string>
|
||||
<string name="updates_failed_title">Обновление не установлено</string>
|
||||
<string name="updates_failed_sub_check">Файл не прошёл проверку</string>
|
||||
<string name="updates_failed_sub_network">Файл не скачался</string>
|
||||
<string name="updates_failed_sub_install">Android не установил файл</string>
|
||||
<string name="updates_failed_sub_other">Что-то пошло не так</string>
|
||||
<string name="updates_failed_what">Что произошло</string>
|
||||
<string name="updates_reason_hash">Контрольная сумма не совпала</string>
|
||||
<string name="updates_reason_hash_d">файл повреждён по дороге, копия удалена</string>
|
||||
<string name="updates_reason_signature">Подпись не совпала</string>
|
||||
<string name="updates_reason_signature_d">файл подписан не SHX, копия удалена</string>
|
||||
<string name="updates_reason_network">Нет связи с сервером обновлений</string>
|
||||
<string name="updates_reason_network_d">загрузка прервалась</string>
|
||||
<string name="updates_reason_install">Android отклонил установку</string>
|
||||
<string name="updates_reason_install_d">системный установщик вернул ошибку</string>
|
||||
<string name="updates_reason_other">Непредвиденная ошибка</string>
|
||||
<string name="updates_reason_other_d">подробности для поддержки — ниже</string>
|
||||
<string name="updates_reason_same">Версия не изменилась</string>
|
||||
<string name="updates_reason_same_d">у вас по-прежнему %1$s</string>
|
||||
<string name="updates_reason_later">Попробуйте позже</string>
|
||||
<string name="updates_reason_later_d">или смените сеть: Wi-Fi вместо мобильной</string>
|
||||
<string name="updates_manual_note">Установить вручную можно со страницы скачивания. Ваши серверы и настройки останутся на месте.</string>
|
||||
<string name="updates_retry">Повторить</string>
|
||||
<string name="updates_from_site">Скачать с сайта</string>
|
||||
<string name="updates_support">Подробности для поддержки</string>
|
||||
<string name="updates_copied">Скопировано</string>
|
||||
<plurals name="updates_auto_check_sub">
|
||||
<item quantity="one">Раз в %d час, когда телефон на зарядке</item>
|
||||
<item quantity="few">Раз в %d часа, когда телефон на зарядке</item>
|
||||
<item quantity="many">Раз в %d часов, когда телефон на зарядке</item>
|
||||
<item quantity="other">Раз в %d часа, когда телефон на зарядке</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
|
||||
<resources>
|
||||
<string name="report_intro">Звіт піде команді SHX захищеним каналом. За кодом звіту підтримка знайде ваш журнал.</string>
|
||||
<string name="report_contents">Що увійде у звіт</string>
|
||||
<string name="report_item_log">Журнал підключення за сьогодні</string>
|
||||
<string name="report_item_device">Версія застосунку й модель телефона</string>
|
||||
<string name="report_item_network">Тип мережі й активний сервер</string>
|
||||
<string name="report_item_ips">IP-адреси серверів із журналу</string>
|
||||
<string name="report_diag_title">Додати діагностику</string>
|
||||
<string name="report_diag_sub">Результати перевірки мережі та розширений журнал застосунку</string>
|
||||
<string name="report_comment_label">Що сталося (необов’язково)</string>
|
||||
<string name="report_cancel">Скасувати</string>
|
||||
<string name="report_send">Надіслати</string>
|
||||
<string name="report_by_mail">Надіслати поштою</string>
|
||||
<string name="report_sent_title">Звіт надіслано</string>
|
||||
<string name="report_sent_sub">Назвіть цей код підтримці</string>
|
||||
<string name="report_code_label">Код звіту</string>
|
||||
<string name="report_copy_code">Скопіювати код</string>
|
||||
<string name="report_code_copied">Код скопійовано</string>
|
||||
<string name="report_saved_note">Код збережено в «Подіях», його можна знайти пізніше.</string>
|
||||
<string name="report_done">Готово</string>
|
||||
<string name="report_failed_title">Звіт не надіслано</string>
|
||||
<string name="report_failed_no_answer">Сервер не відповів</string>
|
||||
<string name="report_failed_refused">Сервер відхилив звіт</string>
|
||||
<string name="report_reasons">Можливі причини</string>
|
||||
<string name="report_reason_offline">Немає інтернету</string>
|
||||
<string name="report_reason_offline_d">перевірте мережу й повторіть</string>
|
||||
<string name="report_reason_server">Сервер недоступний</string>
|
||||
<string name="report_reason_server_d">повторіть за кілька хвилин</string>
|
||||
<string name="report_reason_clock">Годинник телефона збитий</string>
|
||||
<string name="report_reason_clock_d">увімкніть автоматичний час і повторіть</string>
|
||||
<string name="report_reason_often">Надто часто</string>
|
||||
<string name="report_reason_often_d">спробуйте за годину</string>
|
||||
<string name="report_reason_large">Журнал завеликий</string>
|
||||
<string name="report_reason_large_d">надішліть його листом</string>
|
||||
<string name="report_reason_rejected">Запит відхилено</string>
|
||||
<string name="report_reason_rejected_d">надішліть журнал листом</string>
|
||||
<string name="report_mail_note">Журнал можна надіслати й листом, як раніше.</string>
|
||||
<string name="report_retry">Повторити</string>
|
||||
<string name="report_by_letter">Надіслати листом</string>
|
||||
<string name="report_event_title">Звіт надіслано, код %1$s</string>
|
||||
<string name="report_event_text">Назвіть цей код підтримці, щоб вона знайшла ваш журнал.</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- App self-update: the Updates page and the update sheets -->
|
||||
<resources>
|
||||
<string name="updates_have_size">У вас %1$s · %2$s</string>
|
||||
<string name="updates_review">Переглянути й установити</string>
|
||||
<string name="updates_section_auto">Автоматично</string>
|
||||
<string name="updates_auto_check">Перевіряти самостійно</string>
|
||||
<string name="updates_silent">Установлювати тихо</string>
|
||||
<string name="updates_silent_sub">Завантажити через Wi-Fi й установити, коли VPN вимкнено</string>
|
||||
<string name="updates_section_channel">Канал</string>
|
||||
<string name="updates_channel_stable">Стабільний</string>
|
||||
<string name="updates_channel_stable_sub">Перевірені версії</string>
|
||||
<string name="updates_channel_beta">Бета</string>
|
||||
<string name="updates_channel_beta_sub">Раніше, але з помилками</string>
|
||||
<string name="updates_channel_hint">Бета відкривається за кодом запрошення від адміністратора.</string>
|
||||
<string name="updates_beta_title">Код запрошення до бети</string>
|
||||
<string name="updates_beta_text">Введіть код, який дав адміністратор. Без правильного коду застосунок залишиться на стабільних версіях.</string>
|
||||
<string name="updates_beta_code">Код запрошення</string>
|
||||
<string name="updates_beta_enable">Увімкнути бету</string>
|
||||
<string name="updates_check_now">Перевірити зараз</string>
|
||||
<string name="updates_last_check">Остання перевірка: %1$s</string>
|
||||
<string name="updates_never_checked">Ще не перевіряли</string>
|
||||
<string name="updates_review_sub">У вас %1$s · %2$s · файл перевіримо перед установленням</string>
|
||||
<string name="updates_review_safe">Застосунок звірить контрольну суму й підпис SHX. Підроблений файл не встановиться.</string>
|
||||
<string name="updates_downloading_title">Завантажуємо %1$s</string>
|
||||
<string name="updates_downloading_sub">Можна закрити шторку: завантаження триватиме</string>
|
||||
<string name="updates_progress">%1$d%% · %2$s з %3$s</string>
|
||||
<string name="updates_step_list">Список версій отримано</string>
|
||||
<string name="updates_step_list_sub">Через захищений канал</string>
|
||||
<string name="updates_step_download">Завантаження файлу</string>
|
||||
<string name="updates_step_verify">Перевірка файлу</string>
|
||||
<string name="updates_step_verify_sub">Контрольна сума й підпис SHX</string>
|
||||
<string name="updates_step_install">Установлення</string>
|
||||
<string name="updates_step_install_sub">Android попросить підтвердження</string>
|
||||
<string name="updates_cancel">Скасувати</string>
|
||||
<string name="updates_mandatory_title">Потрібно оновити SHX VPN</string>
|
||||
<string name="updates_mandatory_sub">Версія %1$s більше не підтримується</string>
|
||||
<string name="updates_mandatory_what">Що це означає</string>
|
||||
<string name="updates_mandatory_reason">Сервер більше не приймає підключення зі старої версії</string>
|
||||
<string name="updates_mandatory_install">Установіть %1$s (%2$s), ваші сервери й налаштування збережуться</string>
|
||||
<string name="updates_mandatory_locked">Цю шторку не можна закрити, доки застосунок не оновлено.</string>
|
||||
<string name="updates_exit">Вийти із застосунку</string>
|
||||
<string name="updates_failed_title">Оновлення не встановлено</string>
|
||||
<string name="updates_failed_sub_check">Файл не пройшов перевірку</string>
|
||||
<string name="updates_failed_sub_network">Файл не завантажився</string>
|
||||
<string name="updates_failed_sub_install">Android не встановив файл</string>
|
||||
<string name="updates_failed_sub_other">Щось пішло не так</string>
|
||||
<string name="updates_failed_what">Що сталося</string>
|
||||
<string name="updates_reason_hash">Контрольна сума не збіглася</string>
|
||||
<string name="updates_reason_hash_d">файл пошкоджено дорогою, копію видалено</string>
|
||||
<string name="updates_reason_signature">Підпис не збігся</string>
|
||||
<string name="updates_reason_signature_d">файл підписано не SHX, копію видалено</string>
|
||||
<string name="updates_reason_network">Немає зв’язку із сервером оновлень</string>
|
||||
<string name="updates_reason_network_d">завантаження перервалося</string>
|
||||
<string name="updates_reason_install">Android відхилив установлення</string>
|
||||
<string name="updates_reason_install_d">системний установник повернув помилку</string>
|
||||
<string name="updates_reason_other">Непередбачена помилка</string>
|
||||
<string name="updates_reason_other_d">подробиці для підтримки — нижче</string>
|
||||
<string name="updates_reason_same">Версія не змінилася</string>
|
||||
<string name="updates_reason_same_d">у вас і далі %1$s</string>
|
||||
<string name="updates_reason_later">Спробуйте пізніше</string>
|
||||
<string name="updates_reason_later_d">або змініть мережу: Wi-Fi замість мобільної</string>
|
||||
<string name="updates_manual_note">Установити вручну можна зі сторінки завантаження. Ваші сервери й налаштування залишаться на місці.</string>
|
||||
<string name="updates_retry">Повторити</string>
|
||||
<string name="updates_from_site">Завантажити із сайту</string>
|
||||
<string name="updates_support">Подробиці для підтримки</string>
|
||||
<string name="updates_copied">Скопійовано</string>
|
||||
<plurals name="updates_auto_check_sub">
|
||||
<item quantity="one">Раз на %d годину, коли телефон заряджається</item>
|
||||
<item quantity="few">Раз на %d години, коли телефон заряджається</item>
|
||||
<item quantity="many">Раз на %d годин, коли телефон заряджається</item>
|
||||
<item quantity="other">Раз на %d години, коли телефон заряджається</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
|
||||
<resources>
|
||||
<string name="report_intro">报告将通过加密通道发送给 SHX 团队。客服凭报告代码即可找到你的日志。</string>
|
||||
<string name="report_contents">报告包含的内容</string>
|
||||
<string name="report_item_log">今天的连接日志</string>
|
||||
<string name="report_item_device">应用版本和手机型号</string>
|
||||
<string name="report_item_network">网络类型和当前服务器</string>
|
||||
<string name="report_item_ips">日志中的服务器 IP 地址</string>
|
||||
<string name="report_diag_title">附加诊断信息</string>
|
||||
<string name="report_diag_sub">网络检查结果和应用的详细日志</string>
|
||||
<string name="report_comment_label">发生了什么(可选)</string>
|
||||
<string name="report_cancel">取消</string>
|
||||
<string name="report_send">发送</string>
|
||||
<string name="report_by_mail">通过邮件发送</string>
|
||||
<string name="report_sent_title">报告已发送</string>
|
||||
<string name="report_sent_sub">请把此代码告诉客服</string>
|
||||
<string name="report_code_label">报告代码</string>
|
||||
<string name="report_copy_code">复制代码</string>
|
||||
<string name="report_code_copied">代码已复制</string>
|
||||
<string name="report_saved_note">代码已保存在“事件”中,之后可以在那里找到。</string>
|
||||
<string name="report_done">完成</string>
|
||||
<string name="report_failed_title">报告未发送</string>
|
||||
<string name="report_failed_no_answer">服务器没有响应</string>
|
||||
<string name="report_failed_refused">服务器拒绝了报告</string>
|
||||
<string name="report_reasons">可能的原因</string>
|
||||
<string name="report_reason_offline">没有网络</string>
|
||||
<string name="report_reason_offline_d">检查网络后重试</string>
|
||||
<string name="report_reason_server">服务器不可用</string>
|
||||
<string name="report_reason_server_d">几分钟后重试</string>
|
||||
<string name="report_reason_clock">手机时间不准</string>
|
||||
<string name="report_reason_clock_d">开启自动时间后重试</string>
|
||||
<string name="report_reason_often">过于频繁</string>
|
||||
<string name="report_reason_often_d">一小时后再试</string>
|
||||
<string name="report_reason_large">日志过大</string>
|
||||
<string name="report_reason_large_d">请通过邮件发送</string>
|
||||
<string name="report_reason_rejected">请求被拒绝</string>
|
||||
<string name="report_reason_rejected_d">请通过邮件发送日志</string>
|
||||
<string name="report_mail_note">也可以像以前一样通过邮件发送日志。</string>
|
||||
<string name="report_retry">重试</string>
|
||||
<string name="report_by_letter">通过邮件发送</string>
|
||||
<string name="report_event_title">报告已发送,代码 %1$s</string>
|
||||
<string name="report_event_text">把此代码告诉客服,以便找到你的日志。</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- App self-update: the Updates page and the update sheets -->
|
||||
<resources>
|
||||
<string name="updates_have_size">当前 %1$s · %2$s</string>
|
||||
<string name="updates_review">查看并安装</string>
|
||||
<string name="updates_section_auto">自动</string>
|
||||
<string name="updates_auto_check">自动检查</string>
|
||||
<string name="updates_silent">静默安装</string>
|
||||
<string name="updates_silent_sub">通过 Wi-Fi 下载,并在 VPN 关闭时安装</string>
|
||||
<string name="updates_section_channel">渠道</string>
|
||||
<string name="updates_channel_stable">稳定版</string>
|
||||
<string name="updates_channel_stable_sub">经过测试的版本</string>
|
||||
<string name="updates_channel_beta">测试版</string>
|
||||
<string name="updates_channel_beta_sub">更早获得,但可能有错误</string>
|
||||
<string name="updates_channel_hint">测试版需要管理员提供的邀请码。</string>
|
||||
<string name="updates_beta_title">测试版邀请码</string>
|
||||
<string name="updates_beta_text">输入管理员提供的邀请码。没有有效的邀请码时,应用仍使用稳定版。</string>
|
||||
<string name="updates_beta_code">邀请码</string>
|
||||
<string name="updates_beta_enable">启用测试版</string>
|
||||
<string name="updates_check_now">立即检查</string>
|
||||
<string name="updates_last_check">上次检查:%1$s</string>
|
||||
<string name="updates_never_checked">尚未检查</string>
|
||||
<string name="updates_review_sub">当前 %1$s · %2$s · 安装前会校验文件</string>
|
||||
<string name="updates_review_safe">应用会校验校验和与 SHX 签名,伪造的文件无法安装。</string>
|
||||
<string name="updates_downloading_title">正在下载 %1$s</string>
|
||||
<string name="updates_downloading_sub">可以关闭此面板,下载会继续</string>
|
||||
<string name="updates_progress">%1$d%% · %2$s / %3$s</string>
|
||||
<string name="updates_step_list">已获取版本列表</string>
|
||||
<string name="updates_step_list_sub">通过受保护的通道</string>
|
||||
<string name="updates_step_download">下载文件</string>
|
||||
<string name="updates_step_verify">校验文件</string>
|
||||
<string name="updates_step_verify_sub">校验和与 SHX 签名</string>
|
||||
<string name="updates_step_install">安装</string>
|
||||
<string name="updates_step_install_sub">Android 会请求确认</string>
|
||||
<string name="updates_cancel">取消</string>
|
||||
<string name="updates_mandatory_title">需要更新 SHX VPN</string>
|
||||
<string name="updates_mandatory_sub">版本 %1$s 已不再受支持</string>
|
||||
<string name="updates_mandatory_what">这意味着什么</string>
|
||||
<string name="updates_mandatory_reason">服务器不再接受旧版本的连接</string>
|
||||
<string name="updates_mandatory_install">安装 %1$s(%2$s),你的服务器和设置会保留</string>
|
||||
<string name="updates_mandatory_locked">应用更新前无法关闭此面板。</string>
|
||||
<string name="updates_exit">退出应用</string>
|
||||
<string name="updates_failed_title">更新未安装</string>
|
||||
<string name="updates_failed_sub_check">文件未通过校验</string>
|
||||
<string name="updates_failed_sub_network">文件未能下载</string>
|
||||
<string name="updates_failed_sub_install">Android 未安装该文件</string>
|
||||
<string name="updates_failed_sub_other">出现问题</string>
|
||||
<string name="updates_failed_what">发生了什么</string>
|
||||
<string name="updates_reason_hash">校验和不匹配</string>
|
||||
<string name="updates_reason_hash_d">文件在传输中损坏,副本已删除</string>
|
||||
<string name="updates_reason_signature">签名不匹配</string>
|
||||
<string name="updates_reason_signature_d">文件不是由 SHX 签名的,副本已删除</string>
|
||||
<string name="updates_reason_network">无法连接更新服务器</string>
|
||||
<string name="updates_reason_network_d">下载已中断</string>
|
||||
<string name="updates_reason_install">Android 拒绝了安装</string>
|
||||
<string name="updates_reason_install_d">系统安装程序返回了错误</string>
|
||||
<string name="updates_reason_other">意外错误</string>
|
||||
<string name="updates_reason_other_d">可在下方复制给支持人员的详细信息</string>
|
||||
<string name="updates_reason_same">版本未变化</string>
|
||||
<string name="updates_reason_same_d">当前仍为 %1$s</string>
|
||||
<string name="updates_reason_later">请稍后再试</string>
|
||||
<string name="updates_reason_later_d">或切换网络:用 Wi-Fi 代替移动数据</string>
|
||||
<string name="updates_manual_note">也可以从下载页面手动安装,你的服务器和设置会保留。</string>
|
||||
<string name="updates_retry">重试</string>
|
||||
<string name="updates_from_site">从网站下载</string>
|
||||
<string name="updates_support">复制给支持人员的详细信息</string>
|
||||
<string name="updates_copied">已复制</string>
|
||||
<plurals name="updates_auto_check_sub">
|
||||
<item quantity="other">手机充电时每 %d 小时检查一次</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
|
||||
<resources>
|
||||
<string name="report_intro">報告將透過加密通道傳送給 SHX 團隊。客服憑報告代碼即可找到你的日誌。</string>
|
||||
<string name="report_contents">報告包含的內容</string>
|
||||
<string name="report_item_log">今天的連線日誌</string>
|
||||
<string name="report_item_device">應用程式版本和手機型號</string>
|
||||
<string name="report_item_network">網路類型和目前伺服器</string>
|
||||
<string name="report_item_ips">日誌中的伺服器 IP 位址</string>
|
||||
<string name="report_diag_title">附加診斷資訊</string>
|
||||
<string name="report_diag_sub">網路檢查結果和應用程式的詳細日誌</string>
|
||||
<string name="report_comment_label">發生了什麼(選填)</string>
|
||||
<string name="report_cancel">取消</string>
|
||||
<string name="report_send">傳送</string>
|
||||
<string name="report_by_mail">透過電子郵件傳送</string>
|
||||
<string name="report_sent_title">報告已傳送</string>
|
||||
<string name="report_sent_sub">請把此代碼告訴客服</string>
|
||||
<string name="report_code_label">報告代碼</string>
|
||||
<string name="report_copy_code">複製代碼</string>
|
||||
<string name="report_code_copied">代碼已複製</string>
|
||||
<string name="report_saved_note">代碼已儲存在「事件」中,之後可以在那裡找到。</string>
|
||||
<string name="report_done">完成</string>
|
||||
<string name="report_failed_title">報告未傳送</string>
|
||||
<string name="report_failed_no_answer">伺服器沒有回應</string>
|
||||
<string name="report_failed_refused">伺服器拒絕了報告</string>
|
||||
<string name="report_reasons">可能的原因</string>
|
||||
<string name="report_reason_offline">沒有網路</string>
|
||||
<string name="report_reason_offline_d">檢查網路後重試</string>
|
||||
<string name="report_reason_server">伺服器無法使用</string>
|
||||
<string name="report_reason_server_d">幾分鐘後重試</string>
|
||||
<string name="report_reason_clock">手機時間不準</string>
|
||||
<string name="report_reason_clock_d">開啟自動時間後重試</string>
|
||||
<string name="report_reason_often">過於頻繁</string>
|
||||
<string name="report_reason_often_d">一小時後再試</string>
|
||||
<string name="report_reason_large">日誌過大</string>
|
||||
<string name="report_reason_large_d">請透過電子郵件傳送</string>
|
||||
<string name="report_reason_rejected">要求被拒絕</string>
|
||||
<string name="report_reason_rejected_d">請透過電子郵件傳送日誌</string>
|
||||
<string name="report_mail_note">也可以像以前一樣透過電子郵件傳送日誌。</string>
|
||||
<string name="report_retry">重試</string>
|
||||
<string name="report_by_letter">透過電子郵件傳送</string>
|
||||
<string name="report_event_title">報告已傳送,代碼 %1$s</string>
|
||||
<string name="report_event_text">把此代碼告訴客服,以便找到你的日誌。</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- App self-update: the Updates page and the update sheets -->
|
||||
<resources>
|
||||
<string name="updates_have_size">目前 %1$s · %2$s</string>
|
||||
<string name="updates_review">檢視並安裝</string>
|
||||
<string name="updates_section_auto">自動</string>
|
||||
<string name="updates_auto_check">自動檢查</string>
|
||||
<string name="updates_silent">靜默安裝</string>
|
||||
<string name="updates_silent_sub">透過 Wi-Fi 下載,並在 VPN 關閉時安裝</string>
|
||||
<string name="updates_section_channel">頻道</string>
|
||||
<string name="updates_channel_stable">穩定版</string>
|
||||
<string name="updates_channel_stable_sub">經過測試的版本</string>
|
||||
<string name="updates_channel_beta">測試版</string>
|
||||
<string name="updates_channel_beta_sub">更早取得,但可能有錯誤</string>
|
||||
<string name="updates_channel_hint">測試版需要管理員提供的邀請碼。</string>
|
||||
<string name="updates_beta_title">測試版邀請碼</string>
|
||||
<string name="updates_beta_text">輸入管理員提供的邀請碼。沒有有效的邀請碼時,應用程式仍使用穩定版。</string>
|
||||
<string name="updates_beta_code">邀請碼</string>
|
||||
<string name="updates_beta_enable">啟用測試版</string>
|
||||
<string name="updates_check_now">立即檢查</string>
|
||||
<string name="updates_last_check">上次檢查:%1$s</string>
|
||||
<string name="updates_never_checked">尚未檢查</string>
|
||||
<string name="updates_review_sub">目前 %1$s · %2$s · 安裝前會驗證檔案</string>
|
||||
<string name="updates_review_safe">應用程式會驗證檢查碼與 SHX 簽章,偽造的檔案無法安裝。</string>
|
||||
<string name="updates_downloading_title">正在下載 %1$s</string>
|
||||
<string name="updates_downloading_sub">可以關閉此面板,下載會繼續</string>
|
||||
<string name="updates_progress">%1$d%% · %2$s / %3$s</string>
|
||||
<string name="updates_step_list">已取得版本清單</string>
|
||||
<string name="updates_step_list_sub">透過受保護的通道</string>
|
||||
<string name="updates_step_download">下載檔案</string>
|
||||
<string name="updates_step_verify">驗證檔案</string>
|
||||
<string name="updates_step_verify_sub">檢查碼與 SHX 簽章</string>
|
||||
<string name="updates_step_install">安裝</string>
|
||||
<string name="updates_step_install_sub">Android 會要求確認</string>
|
||||
<string name="updates_cancel">取消</string>
|
||||
<string name="updates_mandatory_title">需要更新 SHX VPN</string>
|
||||
<string name="updates_mandatory_sub">版本 %1$s 已不再支援</string>
|
||||
<string name="updates_mandatory_what">這代表什麼</string>
|
||||
<string name="updates_mandatory_reason">伺服器不再接受舊版本的連線</string>
|
||||
<string name="updates_mandatory_install">安裝 %1$s(%2$s),你的伺服器和設定會保留</string>
|
||||
<string name="updates_mandatory_locked">應用程式更新前無法關閉此面板。</string>
|
||||
<string name="updates_exit">結束應用程式</string>
|
||||
<string name="updates_failed_title">更新未安裝</string>
|
||||
<string name="updates_failed_sub_check">檔案未通過驗證</string>
|
||||
<string name="updates_failed_sub_network">檔案未能下載</string>
|
||||
<string name="updates_failed_sub_install">Android 未安裝該檔案</string>
|
||||
<string name="updates_failed_sub_other">發生問題</string>
|
||||
<string name="updates_failed_what">發生了什麼</string>
|
||||
<string name="updates_reason_hash">檢查碼不相符</string>
|
||||
<string name="updates_reason_hash_d">檔案在傳輸中損壞,副本已刪除</string>
|
||||
<string name="updates_reason_signature">簽章不相符</string>
|
||||
<string name="updates_reason_signature_d">檔案不是由 SHX 簽署,副本已刪除</string>
|
||||
<string name="updates_reason_network">無法連線到更新伺服器</string>
|
||||
<string name="updates_reason_network_d">下載已中斷</string>
|
||||
<string name="updates_reason_install">Android 拒絕了安裝</string>
|
||||
<string name="updates_reason_install_d">系統安裝程式傳回了錯誤</string>
|
||||
<string name="updates_reason_other">非預期的錯誤</string>
|
||||
<string name="updates_reason_other_d">可在下方複製給支援人員的詳細資訊</string>
|
||||
<string name="updates_reason_same">版本未變更</string>
|
||||
<string name="updates_reason_same_d">目前仍為 %1$s</string>
|
||||
<string name="updates_reason_later">請稍後再試</string>
|
||||
<string name="updates_reason_later_d">或切換網路:用 Wi-Fi 取代行動數據</string>
|
||||
<string name="updates_manual_note">也可以從下載頁面手動安裝,你的伺服器和設定會保留。</string>
|
||||
<string name="updates_retry">重試</string>
|
||||
<string name="updates_from_site">從網站下載</string>
|
||||
<string name="updates_support">複製給支援人員的詳細資訊</string>
|
||||
<string name="updates_copied">已複製</string>
|
||||
<plurals name="updates_auto_check_sub">
|
||||
<item quantity="other">手機充電時每 %d 小時檢查一次</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
|
||||
<resources>
|
||||
<string name="report_intro">The report goes to the SHX team over a secure channel. With the report code, support will find your log.</string>
|
||||
<string name="report_contents">What the report includes</string>
|
||||
<string name="report_item_log">Today’s connection log</string>
|
||||
<string name="report_item_device">App version and phone model</string>
|
||||
<string name="report_item_network">Network type and active server</string>
|
||||
<string name="report_item_ips">Server IP addresses from the log</string>
|
||||
<string name="report_diag_title">Attach diagnostics</string>
|
||||
<string name="report_diag_sub">Network check results and the app’s extended log</string>
|
||||
<string name="report_comment_label">What happened (optional)</string>
|
||||
<string name="report_cancel">Cancel</string>
|
||||
<string name="report_send">Send</string>
|
||||
<string name="report_by_mail">Send by email</string>
|
||||
<string name="report_sent_title">Report sent</string>
|
||||
<string name="report_sent_sub">Tell this code to support</string>
|
||||
<string name="report_code_label">Report code</string>
|
||||
<string name="report_copy_code">Copy code</string>
|
||||
<string name="report_code_copied">Code copied</string>
|
||||
<string name="report_saved_note">The code is saved in “Events”, you can find it there later.</string>
|
||||
<string name="report_done">Done</string>
|
||||
<string name="report_failed_title">Report not sent</string>
|
||||
<string name="report_failed_no_answer">The server did not answer</string>
|
||||
<string name="report_failed_refused">The server refused the report</string>
|
||||
<string name="report_reasons">Possible reasons</string>
|
||||
<string name="report_reason_offline">No internet</string>
|
||||
<string name="report_reason_offline_d">check the network and retry</string>
|
||||
<string name="report_reason_server">Server unavailable</string>
|
||||
<string name="report_reason_server_d">retry in a few minutes</string>
|
||||
<string name="report_reason_clock">Phone clock is off</string>
|
||||
<string name="report_reason_clock_d">turn on automatic time and retry</string>
|
||||
<string name="report_reason_often">Too often</string>
|
||||
<string name="report_reason_often_d">try again in an hour</string>
|
||||
<string name="report_reason_large">Log is too large</string>
|
||||
<string name="report_reason_large_d">send it by email</string>
|
||||
<string name="report_reason_rejected">Request refused</string>
|
||||
<string name="report_reason_rejected_d">send the log by email</string>
|
||||
<string name="report_mail_note">The log can also be sent by email, as before.</string>
|
||||
<string name="report_retry">Retry</string>
|
||||
<string name="report_by_letter">Send by email</string>
|
||||
<string name="report_event_title">Report sent, code %1$s</string>
|
||||
<string name="report_event_text">Tell this code to support so they can find your log.</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- App self-update: the Updates page and the update sheets -->
|
||||
<resources>
|
||||
<string name="updates_have_size">You have %1$s · %2$s</string>
|
||||
<string name="updates_review">View and install</string>
|
||||
<string name="updates_section_auto">Automatically</string>
|
||||
<string name="updates_auto_check">Check by itself</string>
|
||||
<string name="updates_silent">Install quietly</string>
|
||||
<string name="updates_silent_sub">Download over Wi-Fi and install while the VPN is off</string>
|
||||
<string name="updates_section_channel">Channel</string>
|
||||
<string name="updates_channel_stable">Stable</string>
|
||||
<string name="updates_channel_stable_sub">Tested versions</string>
|
||||
<string name="updates_channel_beta">Beta</string>
|
||||
<string name="updates_channel_beta_sub">Earlier, but with bugs</string>
|
||||
<string name="updates_channel_hint">Beta opens with an invitation code from the administrator.</string>
|
||||
<string name="updates_beta_title">Beta invitation code</string>
|
||||
<string name="updates_beta_text">Enter the code the administrator gave you. Without a valid code the app stays on stable versions.</string>
|
||||
<string name="updates_beta_code">Invitation code</string>
|
||||
<string name="updates_beta_enable">Turn on beta</string>
|
||||
<string name="updates_check_now">Check now</string>
|
||||
<string name="updates_last_check">Last check: %1$s</string>
|
||||
<string name="updates_never_checked">Not checked yet</string>
|
||||
<string name="updates_review_sub">You have %1$s · %2$s · the file is checked before installing</string>
|
||||
<string name="updates_review_safe">The app checks the checksum and the SHX signature. A forged file will not install.</string>
|
||||
<string name="updates_downloading_title">Downloading %1$s</string>
|
||||
<string name="updates_downloading_sub">You can close this sheet: the download goes on</string>
|
||||
<string name="updates_progress">%1$d%% · %2$s of %3$s</string>
|
||||
<string name="updates_step_list">Version list received</string>
|
||||
<string name="updates_step_list_sub">Over a protected channel</string>
|
||||
<string name="updates_step_download">Downloading the file</string>
|
||||
<string name="updates_step_verify">Checking the file</string>
|
||||
<string name="updates_step_verify_sub">Checksum and SHX signature</string>
|
||||
<string name="updates_step_install">Installation</string>
|
||||
<string name="updates_step_install_sub">Android will ask for confirmation</string>
|
||||
<string name="updates_cancel">Cancel</string>
|
||||
<string name="updates_mandatory_title">SHX VPN needs an update</string>
|
||||
<string name="updates_mandatory_sub">Version %1$s is no longer supported</string>
|
||||
<string name="updates_mandatory_what">What this means</string>
|
||||
<string name="updates_mandatory_reason">The server no longer accepts connections from the old version</string>
|
||||
<string name="updates_mandatory_install">Install %1$s (%2$s): your servers and settings stay</string>
|
||||
<string name="updates_mandatory_locked">This sheet can’t be closed until the app is updated.</string>
|
||||
<string name="updates_exit">Exit the app</string>
|
||||
<string name="updates_failed_title">Update not installed</string>
|
||||
<string name="updates_failed_sub_check">The file did not pass the check</string>
|
||||
<string name="updates_failed_sub_network">The file did not download</string>
|
||||
<string name="updates_failed_sub_install">Android did not install the file</string>
|
||||
<string name="updates_failed_sub_other">Something went wrong</string>
|
||||
<string name="updates_failed_what">What happened</string>
|
||||
<string name="updates_reason_hash">Checksum mismatch</string>
|
||||
<string name="updates_reason_hash_d">the file was damaged on the way, the copy is deleted</string>
|
||||
<string name="updates_reason_signature">Signature mismatch</string>
|
||||
<string name="updates_reason_signature_d">the file is not signed by SHX, the copy is deleted</string>
|
||||
<string name="updates_reason_network">No connection to the update server</string>
|
||||
<string name="updates_reason_network_d">the download was interrupted</string>
|
||||
<string name="updates_reason_install">Android rejected the installation</string>
|
||||
<string name="updates_reason_install_d">the system installer returned an error</string>
|
||||
<string name="updates_reason_other">Unexpected error</string>
|
||||
<string name="updates_reason_other_d">the details for support are below</string>
|
||||
<string name="updates_reason_same">Version unchanged</string>
|
||||
<string name="updates_reason_same_d">you still have %1$s</string>
|
||||
<string name="updates_reason_later">Try again later</string>
|
||||
<string name="updates_reason_later_d">or switch networks: Wi-Fi instead of mobile data</string>
|
||||
<string name="updates_manual_note">You can install it manually from the download page. Your servers and settings stay in place.</string>
|
||||
<string name="updates_retry">Retry</string>
|
||||
<string name="updates_from_site">Download from the site</string>
|
||||
<string name="updates_support">Details for support</string>
|
||||
<string name="updates_copied">Copied</string>
|
||||
<plurals name="updates_auto_check_sub">
|
||||
<item quantity="one">Every %d hour while the phone is charging</item>
|
||||
<item quantity="other">Every %d hours while the phone is charging</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic
|
||||
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.Test
|
||||
|
||||
class ScheduledJobQueueTest
|
||||
{
|
||||
@Test
|
||||
fun firstJobArmsTheAlarm()
|
||||
{
|
||||
val q = ScheduledJobQueue(2000)
|
||||
assertThat(q.add("a", 10_000)).isTrue()
|
||||
assertThat(q.armedAt).isEqualTo(10_000)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun laterJobDoesNotRearm()
|
||||
{
|
||||
val q = ScheduledJobQueue(2000)
|
||||
q.add("a", 10_000)
|
||||
assertThat(q.add("b", 50_000)).isFalse()
|
||||
assertThat(q.armedAt).isEqualTo(10_000)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun jobJustBeforeTheArmedAlarmReusesIt()
|
||||
{
|
||||
val q = ScheduledJobQueue(2000)
|
||||
q.add("a", 10_000)
|
||||
assertThat(q.add("b", 8_500)).isFalse()
|
||||
assertThat(q.armedAt).isEqualTo(10_000)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearlyEarlierJobRearms()
|
||||
{
|
||||
val q = ScheduledJobQueue(2000)
|
||||
q.add("a", 10_000)
|
||||
assertThat(q.add("b", 5_000)).isTrue()
|
||||
assertThat(q.armedAt).isEqualTo(5_000)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun popDueRunsJobsInOrderIncludingTheCoalescingWindow()
|
||||
{
|
||||
val q = ScheduledJobQueue(2000)
|
||||
q.add("c", 11_900)
|
||||
q.add("a", 10_000)
|
||||
q.add("b", 10_500)
|
||||
q.add("late", 30_000)
|
||||
assertThat(q.popDue(10_000)).containsExactly("a", "b", "c")
|
||||
assertThat(q.nextAlarm()).isEqualTo(30_000)
|
||||
assertThat(q.armedAt).isEqualTo(30_000)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun earlyOrSpuriousWakeupKeepsTheQueue()
|
||||
{
|
||||
val q = ScheduledJobQueue(2000)
|
||||
q.add("a", 10_000)
|
||||
assertThat(q.popDue(5_000)).isEmpty()
|
||||
assertThat(q.nextAlarm()).isEqualTo(10_000)
|
||||
assertThat(q.armedAt).isEqualTo(10_000)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyQueueLeavesNothingArmed()
|
||||
{
|
||||
val q = ScheduledJobQueue(2000)
|
||||
q.add("a", 10_000)
|
||||
assertThat(q.popDue(10_000)).containsExactly("a")
|
||||
assertThat(q.nextAlarm()).isEqualTo(ScheduledJobQueue.NONE)
|
||||
assertThat(q.armedAt).isEqualTo(ScheduledJobQueue.NONE)
|
||||
assertThat(q.add("b", 20_000)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearForgetsTheArmedAlarm()
|
||||
{
|
||||
val q = ScheduledJobQueue(2000)
|
||||
q.add("a", 10_000)
|
||||
q.clear()
|
||||
assertThat(q.size).isEqualTo(0)
|
||||
assertThat(q.add("b", 40_000)).isTrue()
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic
|
||||
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.Test
|
||||
import org.strongswan.android.logic.stats.TrafficHistory
|
||||
|
||||
class TrafficHistoryTest
|
||||
{
|
||||
private val every = TrafficHistory.SAVE_EVERY_MS
|
||||
|
||||
@Test
|
||||
fun cleanHistoryIsNeverWritten()
|
||||
{
|
||||
assertThat(TrafficHistory.saveDue(false, 10 * every, 0, true)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun changedHistoryWaitsForTheInterval()
|
||||
{
|
||||
assertThat(TrafficHistory.saveDue(true, every - 1, 0, false)).isFalse()
|
||||
assertThat(TrafficHistory.saveDue(true, every, 0, false)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dayChangeWritesAtOnce()
|
||||
{
|
||||
assertThat(TrafficHistory.saveDue(true, 1000, 900, true)).isTrue()
|
||||
}
|
||||
}
|
||||
+26
@@ -242,4 +242,30 @@ class HealthCheckManagerTest
|
||||
assertThat(HealthCheckManager.shouldProbeServer(ServerCheckMode.WITH_TUNNEL, 0, 3)).isTrue()
|
||||
assertThat(HealthCheckManager.shouldProbeServer(ServerCheckMode.WITH_TUNNEL, 0, 0)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun inboundTrafficSkipsProbesInAdaptiveMode()
|
||||
{
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 0, false, 10_000, 60_000)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noPassiveSkipWithoutInboundOrAfterCap()
|
||||
{
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 0, false, 100, 60_000)).isFalse()
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 0, false, -1, 60_000)).isFalse()
|
||||
assertThat(
|
||||
HealthCheckManager.trafficProvesAlive(config, 0, 0, false, 10_000, HealthCheckManager.PASSIVE_MAX_PROBE_AGE_MS)
|
||||
).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noPassiveSkipWhileFailureIsObservedOrNotAdaptive()
|
||||
{
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config, 1, 0, false, 10_000, 1000)).isFalse()
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 2, false, 10_000, 1000)).isFalse()
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 0, true, 10_000, 1000)).isFalse()
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config.copy(adaptiveMode = false), 0, 0, false, 10_000, 1000)).isFalse()
|
||||
assertThat(HealthCheckManager.trafficProvesAlive(config.copy(aggressiveMode = true), 0, 0, false, 10_000, 1000)).isFalse()
|
||||
}
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.updates
|
||||
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.json.JSONObject
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
class ReportContentTest
|
||||
{
|
||||
@Test
|
||||
fun checksGoBetweenTheLogAndDiagAsAJsonArray()
|
||||
{
|
||||
val checks = listOf(
|
||||
ReportContent.Check("Интернет", "Wi-Fi", true),
|
||||
ReportContent.Check("Сервер \"nl1\"", "нет ответа\n15 с", false),
|
||||
ReportContent.Check("DNS", "проверяется", null),
|
||||
)
|
||||
val text = ReportContent.build("a\n", "d\n", checks)
|
||||
assertThat(text).isEqualTo(
|
||||
"=== charon.log ===\na\n\n=== checks.json ===\n" +
|
||||
"[{\"title\":\"Интернет\",\"value\":\"Wi-Fi\",\"ok\":true}," +
|
||||
"{\"title\":\"Сервер \\\"nl1\\\"\",\"value\":\"нет ответа\\n15 с\",\"ok\":false}," +
|
||||
"{\"title\":\"DNS\",\"value\":\"проверяется\",\"ok\":null}]\n" +
|
||||
"\n=== diag.log ===\nd\n"
|
||||
)
|
||||
assertThat(ReportContent.build("a\n", null, emptyList())).doesNotContain("checks.json")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun apiBaseAddsTheApiPrefixOnlyWhenMissing()
|
||||
{
|
||||
/* the proxy routes only /api/v1/ and the HMAC covers the full path */
|
||||
assertThat(ReportUploader.apiBase("https://get.shnt.top")).isEqualTo("https://get.shnt.top/api/v1")
|
||||
assertThat(ReportUploader.apiBase("https://get.shnt.top/")).isEqualTo("https://get.shnt.top/api/v1")
|
||||
assertThat(ReportUploader.apiBase("https://get.shnt.top/api/v1/")).isEqualTo("https://get.shnt.top/api/v1")
|
||||
assertThat(java.net.URL(ReportUploader.apiBase("http://127.0.0.1:4000") + "/reports").path).isEqualTo("/api/v1/reports")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseResponseAcceptsOnlyA201WithAValidCode()
|
||||
{
|
||||
assertThat(ReportUploader.parseResponse(201, """{"code":"SHX-7F3K2"}""")).isEqualTo(ReportUploader.Result.Sent("SHX-7F3K2"))
|
||||
val broken = ReportUploader.parseResponse(201, "<html>proxy</html>")
|
||||
assertThat(broken).isInstanceOf(ReportUploader.Result.Failed::class.java)
|
||||
assertThat((broken as ReportUploader.Result.Failed).httpCode).isEqualTo(201)
|
||||
assertThat(ReportUploader.parseResponse(201, """{"code":"nope"}""")).isInstanceOf(ReportUploader.Result.Failed::class.java)
|
||||
val limited = ReportUploader.parseResponse(429, """{"error":{"code":"rate_limited"}}""") as ReportUploader.Result.Failed
|
||||
assertThat(limited.httpCode).isEqualTo(429)
|
||||
assertThat(limited.detail).contains("rate_limited")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failureMapsServerAnswersToReasons()
|
||||
{
|
||||
assertThat(ReportContent.failure(0)).isEqualTo(ReportContent.Failure.NO_NETWORK)
|
||||
assertThat(ReportContent.failure(401)).isEqualTo(ReportContent.Failure.SIGNATURE)
|
||||
assertThat(ReportContent.failure(413)).isEqualTo(ReportContent.Failure.TOO_LARGE)
|
||||
assertThat(ReportContent.failure(429)).isEqualTo(ReportContent.Failure.TOO_OFTEN)
|
||||
assertThat(ReportContent.failure(502)).isEqualTo(ReportContent.Failure.SERVER_DOWN)
|
||||
assertThat(ReportContent.failure(404)).isEqualTo(ReportContent.Failure.SERVER_DOWN)
|
||||
assertThat(ReportContent.failure(400)).isEqualTo(ReportContent.Failure.REJECTED)
|
||||
assertThat(ReportContent.failure(201)).isEqualTo(ReportContent.Failure.REJECTED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun todayOnlyKeepsTodaysLinesWithTheirContinuations()
|
||||
{
|
||||
val log = """
|
||||
Nov 7 23:59:58 06[IKE] old line
|
||||
old continuation
|
||||
Nov 8 00:00:01 06[IKE] initiating IKE_SA
|
||||
payload continuation
|
||||
Nov 8 14:02:30 07[IKE] CHILD_SA established
|
||||
""".trimIndent()
|
||||
assertThat(ReportContent.todayOnly(log, 11, 8)).isEqualTo(
|
||||
"Nov 8 00:00:01 06[IKE] initiating IKE_SA\n payload continuation\nNov 8 14:02:30 07[IKE] CHILD_SA established\n"
|
||||
)
|
||||
assertThat(ReportContent.todayOnly("Dec 18 10:00:00 01[IKE] two-digit day\n", 12, 18)).contains("two-digit day")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun todayOnlyFallsBackToTheWholeLogWhenNothingIsFromToday()
|
||||
{
|
||||
val log = "Nov 7 10:00:00 06[IKE] yesterday\n"
|
||||
assertThat(ReportContent.todayOnly(log, 11, 8)).isEqualTo(log)
|
||||
/* a cut log starts with an undated mark: it alone must not count as "today" */
|
||||
val cut = ReportContent.CUT_MARK + log
|
||||
assertThat(ReportContent.todayOnly(cut, 11, 8)).isEqualTo(cut)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tailBytesCutsTheStartAtALineBoundaryAndKeepsCyrillicWhole()
|
||||
{
|
||||
val text = (1..200).joinToString("") { "строка $it: нет ответа\n" }
|
||||
val cut = ReportContent.tailBytes(text, 1000)
|
||||
assertThat(cut).startsWith(ReportContent.CUT_MARK)
|
||||
val body = cut.removePrefix(ReportContent.CUT_MARK)
|
||||
assertThat(body.toByteArray(Charsets.UTF_8).size).isLessThanOrEqualTo(1000)
|
||||
assertThat(body).startsWith("строка ").endsWith("строка 200: нет ответа\n")
|
||||
assertThat(body).doesNotContain("�")
|
||||
assertThat(ReportContent.tailBytes("short\n", 1000)).isEqualTo("short\n")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tailBytesOfOneHugeLineDoesNotSplitACharacter()
|
||||
{
|
||||
val cut = ReportContent.tailBytes("ж".repeat(1000), 101).removePrefix(ReportContent.CUT_MARK)
|
||||
assertThat(cut).doesNotContain("�")
|
||||
assertThat(cut).isEqualTo("ж".repeat(50))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readTailDropsThePartialFirstLine()
|
||||
{
|
||||
val file = File.createTempFile("charon", ".log")
|
||||
try
|
||||
{
|
||||
file.writeText("first line\nsecond line\nthird line\n")
|
||||
assertThat(ReportContent.readTail(file, 1000)).isEqualTo("first line\nsecond line\nthird line\n")
|
||||
assertThat(ReportContent.readTail(file, 15)).isEqualTo(ReportContent.CUT_MARK + "third line\n")
|
||||
assertThat(ReportContent.readTail(File(file.path + ".missing"), 100)).isEmpty()
|
||||
}
|
||||
finally
|
||||
{
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildAddsDiagOnlyWhenAskedAndStaysWithinTheLimits()
|
||||
{
|
||||
val plain = ReportContent.build("Nov 8 14:02:31 08[IKE] tunnel up\n", null)
|
||||
assertThat(plain).isEqualTo("=== charon.log ===\nNov 8 14:02:31 08[IKE] tunnel up\n")
|
||||
val withDiag = ReportContent.build("", "11-08 14:02:00.000 Watchdog: ok\n")
|
||||
assertThat(withDiag).contains("=== charon.log ===\n(empty)").contains("=== diag.log ===\n11-08 14:02:00.000 Watchdog: ok")
|
||||
val huge = "x".repeat(99) + "\n"
|
||||
val big = ReportContent.build(huge.repeat(40_000), huge.repeat(10_000))
|
||||
assertThat(big.toByteArray(Charsets.UTF_8).size).isLessThanOrEqualTo(ReportContent.CHARON_MAX_BYTES + ReportContent.DIAG_MAX_BYTES + 200)
|
||||
/* what the panel unpacks for the report card (5 MB cap) */
|
||||
assertThat(big.length).isLessThan(5 * 1024 * 1024)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun metaDescribesThePhoneAndPassesTheServerSchema()
|
||||
{
|
||||
val meta = ReportContent.meta("2.19.2", 170, "Google", "Pixel 7", "14", 34, "Wi-Fi", " Не подключается в метро ", "nl1 · nl1.shx.one")
|
||||
assertThat(meta.device).isEqualTo("Google Pixel 7")
|
||||
assertThat(ReportContent.meta("2.19.2", 170, "samsung", "SAMSUNG SM-S911B", "14", 34, "mobile", "", "").device).isEqualTo("SAMSUNG SM-S911B")
|
||||
assertThat(meta.os).isEqualTo("Android 14 (API 34)")
|
||||
assertThat(meta.comment).isEqualTo("Не подключается в метро")
|
||||
val json = JSONObject(ReportUploader.metaJson(meta))
|
||||
/* ReportMetaSchema: source is "log" | "diagnostics", versionCode an integer */
|
||||
assertThat(json.getString("source")).isEqualTo("log")
|
||||
assertThat(json.getInt("versionCode")).isEqualTo(170)
|
||||
assertThat(json.getString("network")).isEqualTo("Wi-Fi")
|
||||
assertThat(json.getString("profile")).isEqualTo("nl1 · nl1.shx.one")
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.updates
|
||||
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.json.JSONObject
|
||||
import org.junit.Test
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.util.zip.GZIPInputStream
|
||||
|
||||
class ReportUploaderTest
|
||||
{
|
||||
private val meta = ReportUploader.Meta("2.19.1", 169, "Pixel 7", "Android 14", "LTE", "log", "Не подключается", "nl1")
|
||||
|
||||
@Test
|
||||
fun gzipRoundTripsTheLogAndStartsWithTheGzipMagic()
|
||||
{
|
||||
val gz = ReportUploader.gzip("09:38:11 IKE_SA_INIT → nl1 :4500\n")
|
||||
assertThat(gz[0]).isEqualTo(0x1f.toByte())
|
||||
assertThat(gz[1]).isEqualTo(0x8b.toByte())
|
||||
assertThat(GZIPInputStream(ByteArrayInputStream(gz)).readBytes().decodeToString()).isEqualTo("09:38:11 IKE_SA_INIT → nl1 :4500\n")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun metaJsonCarriesTheFieldsTheServerValidatesAndTruncatesLongOnes()
|
||||
{
|
||||
val json = JSONObject(ReportUploader.metaJson(meta.copy(comment = "x".repeat(2000), device = "d".repeat(500))))
|
||||
assertThat(json.getString("versionName")).isEqualTo("2.19.1")
|
||||
assertThat(json.getInt("versionCode")).isEqualTo(169)
|
||||
assertThat(json.getString("comment")).hasSize(1000)
|
||||
assertThat(json.getString("device")).hasSize(120)
|
||||
assertThat(json.getString("source")).isEqualTo("log")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipartBodyHasTheLogAndMetaPartsInTheExpectedShape()
|
||||
{
|
||||
val body = ReportUploader.multipartBody(byteArrayOf(0x1f, 0x8b.toByte(), 1, 2), "{\"a\":1}").decodeToString(throwOnInvalidSequence = false)
|
||||
assertThat(body).contains("name=\"log\"; filename=\"log.gz\"")
|
||||
assertThat(body).contains("name=\"meta\"")
|
||||
assertThat(body).contains("{\"a\":1}")
|
||||
/* the meta part must stay a plain field (see multipartBody) */
|
||||
assertThat(body.substringAfter("name=\"meta\"").substringBefore("{")).doesNotContain("Content-Type")
|
||||
assertThat(body).endsWith("--${ReportUploader.BOUNDARY}--\r\n")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOversizedReportIsRefusedBeforeAnyRequest()
|
||||
{
|
||||
/* 62 symbols are ~6 bits each: 4 MB of text stays above 2 MB after gzip */
|
||||
val alphabet = ('a'..'z') + ('A'..'Z') + ('0'..'9')
|
||||
val huge = java.util.Random(1).let { r -> buildString { repeat(4 * 1024 * 1024) { append(alphabet[r.nextInt(alphabet.size)]) } } }
|
||||
val result = ReportUploader("https://api.example/api/v1", "k", "install-0001").send(huge, meta)
|
||||
assertThat(result).isInstanceOf(ReportUploader.Result.Failed::class.java)
|
||||
assertThat((result as ReportUploader.Result.Failed).httpCode).isEqualTo(413)
|
||||
}
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.updates
|
||||
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.json.JSONObject
|
||||
import org.junit.Test
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
/**
|
||||
* Checks the client against the shared vectors of the web panel
|
||||
* (packages/contracts/vectors/signing.json). If the server contract changes,
|
||||
* the vectors are regenerated there and copied to src/test/resources.
|
||||
*/
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
class UpdateApiTest
|
||||
{
|
||||
private val vectors = JSONObject(
|
||||
javaClass.classLoader!!.getResourceAsStream("shx-signing-vectors.json")!!.use { it.readBytes().decodeToString() })
|
||||
private val publicKeyRaw = vectors.getJSONObject("ed25519").getString("publicKeyRawBase64")
|
||||
private val manifest = vectors.getJSONObject("manifest")
|
||||
private val hmacKey = vectors.getJSONObject("request").getString("hmacKey")
|
||||
|
||||
private fun fields(o: JSONObject) = UpdateSigning.Fields(
|
||||
update = o.getBoolean("update"),
|
||||
mandatory = o.getBoolean("mandatory"),
|
||||
versionCode = o.optLong("versionCode", 0L),
|
||||
versionName = o.optString("versionName", ""),
|
||||
sha256 = o.optString("sha256", ""),
|
||||
size = o.optLong("size", 0L),
|
||||
apkUrl = o.optString("apkUrl", ""),
|
||||
issuedAt = o.getLong("issuedAt"),
|
||||
checkHours = o.getInt("checkHours"),
|
||||
wifiOnly = o.getBoolean("wifiOnly"),
|
||||
)
|
||||
|
||||
private fun responseJson(v: JSONObject, patch: (JSONObject) -> Unit = {}): String
|
||||
{
|
||||
val f = v.getJSONObject("fields")
|
||||
val body = JSONObject(f.toString())
|
||||
body.put("signature", v.getString("signatureBase64"))
|
||||
if (f.getBoolean("update"))
|
||||
{
|
||||
body.put("notes", JSONObject().put("ru", "Исправления").put("en", "Fixes"))
|
||||
}
|
||||
patch(body)
|
||||
return body.toString()
|
||||
}
|
||||
|
||||
private class FakeHttp(private val response: UpdateHttp.Response) : UpdateHttp
|
||||
{
|
||||
var lastUrl: String? = null
|
||||
var lastHeaders: Map<String, String> = emptyMap()
|
||||
override fun get(url: String, headers: Map<String, String>): UpdateHttp.Response
|
||||
{
|
||||
lastUrl = url
|
||||
lastHeaders = headers
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
private fun client(http: UpdateHttp, nowSeconds: Long = manifest.getJSONObject("fields").getLong("issuedAt")) =
|
||||
UpdateApiClient("https://api.example/api/v1", hmacKey, publicKeyRaw, "install-0001", http) { nowSeconds * 1000 }
|
||||
|
||||
@Test
|
||||
fun manifestSigningInputMatchesTheServerVector()
|
||||
{
|
||||
assertThat(UpdateSigning.manifestInput(fields(manifest.getJSONObject("fields")))).isEqualTo(manifest.getString("signingInput"))
|
||||
val none = vectors.getJSONObject("noUpdate")
|
||||
assertThat(UpdateSigning.manifestInput(fields(none.getJSONObject("fields")))).isEqualTo(none.getString("signingInput"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ed25519SignatureFromTheServerVerifiesAndTamperingIsRejected()
|
||||
{
|
||||
val key = Base64.Default.decode(publicKeyRaw)
|
||||
val input = manifest.getString("signingInput")
|
||||
val signature = manifest.getString("signatureBase64")
|
||||
assertThat(UpdateSigning.verifyEd25519(key, input, signature)).isTrue()
|
||||
assertThat(UpdateSigning.verifyEd25519(key, input + "x", signature)).isFalse()
|
||||
assertThat(UpdateSigning.verifyEd25519(key, input.replace("250003", "250004"), signature)).isFalse()
|
||||
assertThat(UpdateSigning.verifyEd25519(ByteArray(32), input, signature)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requestSignatureMatchesTheServerVector()
|
||||
{
|
||||
val r = vectors.getJSONObject("request")
|
||||
val input = UpdateSigning.requestInput(r.getString("method"), r.getString("path"), r.getString("timestamp").toLong())
|
||||
assertThat(input).isEqualTo(r.getString("signingInput"))
|
||||
assertThat(UpdateSigning.hmacBase64(hmacKey, input)).isEqualTo(r.getString("signatureBase64"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBareHostGetsTheApiPrefix()
|
||||
{
|
||||
/* UPDATE_API_URL=https://get.shnt.top (no /api/v1) used to hit /updates/check -> 404 -> silent legacy fallback */
|
||||
val http = FakeHttp(UpdateHttp.Response(200, responseJson(manifest)))
|
||||
val bare = UpdateApiClient("https://api.example/", hmacKey, publicKeyRaw, "install-0001", http) {
|
||||
manifest.getJSONObject("fields").getLong("issuedAt") * 1000
|
||||
}
|
||||
assertThat(bare.check("one.shx.strongswan.ext", 168, "release", "arm64-v8a", 34, null)).isInstanceOf(UpdateApiResult.Update::class.java)
|
||||
assertThat(http.lastUrl).startsWith("https://api.example/api/v1/updates/check?")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clientReturnsTheOfferedUpdateAndSendsASignedRequest()
|
||||
{
|
||||
val http = FakeHttp(UpdateHttp.Response(200, responseJson(manifest)))
|
||||
val result = client(http).check("one.shx.strongswan.ext", 168, "release", "arm64-v8a", 34, null)
|
||||
assertThat(result).isInstanceOf(UpdateApiResult.Update::class.java)
|
||||
val update = result as UpdateApiResult.Update
|
||||
assertThat(update.info.versionName).isEqualTo("2.5.0-beta.3")
|
||||
assertThat(update.info.versionCode).isEqualTo(250003L)
|
||||
assertThat(update.info.fromApi).isTrue()
|
||||
assertThat(update.info.notes["ru"]).isEqualTo("Исправления")
|
||||
assertThat(update.policy).isEqualTo(UpdatePolicy(12, true))
|
||||
assertThat(http.lastUrl).startsWith("https://api.example/api/v1/updates/check?package=one.shx.strongswan.ext&versionCode=168")
|
||||
assertThat(http.lastHeaders["X-Install-Id"]).isEqualTo("install-0001")
|
||||
val ts = http.lastHeaders["X-Timestamp"]!!.toLong()
|
||||
assertThat(http.lastHeaders["X-Signature"]).isEqualTo(
|
||||
UpdateSigning.hmacBase64(hmacKey, UpdateSigning.requestInput("GET", "/api/v1/updates/check", ts)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clientAcceptsASignedNoUpdateAnswer()
|
||||
{
|
||||
val http = FakeHttp(UpdateHttp.Response(200, responseJson(vectors.getJSONObject("noUpdate"))))
|
||||
val result = client(http).check("one.shx.strongswan.ext", 169, "release", null, 34, null)
|
||||
assertThat(result).isInstanceOf(UpdateApiResult.NoUpdate::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tamperedFieldsAreRejectedAsASignatureFailureAndNeverFallBack()
|
||||
{
|
||||
for (field in listOf("sha256", "apkUrl", "versionCode", "versionName", "size", "mandatory", "checkHours", "wifiOnly"))
|
||||
{
|
||||
val body = responseJson(manifest) { o ->
|
||||
when (field)
|
||||
{
|
||||
"mandatory", "wifiOnly" -> o.put(field, !o.getBoolean(field))
|
||||
"versionCode", "size", "checkHours" -> o.put(field, o.getLong(field) + 1)
|
||||
"sha256" -> o.put(field, "cd".repeat(32))
|
||||
else -> o.put(field, o.getString(field) + "x")
|
||||
}
|
||||
}
|
||||
val result = client(FakeHttp(UpdateHttp.Response(200, body))).check("one.shx.strongswan.ext", 1, "release", null, 34, null)
|
||||
assertThat(result).describedAs(field).isInstanceOf(UpdateApiResult.Failure::class.java)
|
||||
assertThat((result as UpdateApiResult.Failure).kind).describedAs(field).isEqualTo(UpdateError.SIGNATURE)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aStaleManifestIsRejectedEvenWithAValidSignature()
|
||||
{
|
||||
val issued = manifest.getJSONObject("fields").getLong("issuedAt")
|
||||
val result = client(FakeHttp(UpdateHttp.Response(200, responseJson(manifest))), nowSeconds = issued + UpdateApiClient.MAX_SKEW_SECONDS + 60)
|
||||
.check("one.shx.strongswan.ext", 1, "release", null, 34, null)
|
||||
assertThat((result as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.SIGNATURE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aWrongPublicKeyFailsAndServerErrorsAreClassified()
|
||||
{
|
||||
val http = FakeHttp(UpdateHttp.Response(200, responseJson(manifest)))
|
||||
val wrongKey = UpdateApiClient("https://api.example/api/v1", hmacKey, Base64.Default.encode(ByteArray(32) { 7 }), "install-0001", http) { 1760438401_000L }
|
||||
assertThat((wrongKey.check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.SIGNATURE)
|
||||
// 404/5xx: сеть или сервер, допускается запасной путь; 401/429: нет
|
||||
assertThat((client(FakeHttp(UpdateHttp.Response(503, ""))).check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.NETWORK)
|
||||
assertThat((client(FakeHttp(UpdateHttp.Response(404, ""))).check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.NETWORK)
|
||||
assertThat((client(FakeHttp(UpdateHttp.Response(401, ""))).check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.OTHER)
|
||||
assertThat((client(FakeHttp(UpdateHttp.Response(429, ""))).check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.OTHER)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theClientIsConfiguredOnlyWithAllThreeValues()
|
||||
{
|
||||
assertThat(UpdateApiClient.isConfigured("https://api", "key", "pub")).isTrue()
|
||||
assertThat(UpdateApiClient.isConfigured("", "key", "pub")).isFalse()
|
||||
assertThat(UpdateApiClient.isConfigured("https://api", "", "pub")).isFalse()
|
||||
assertThat(UpdateApiClient.isConfigured("https://api", "key", "")).isFalse()
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package org.strongswan.android.logic.updates
|
||||
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.assertj.core.api.Assertions.assertThatThrownBy
|
||||
import org.junit.Test
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.security.MessageDigest
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
class UpdateNotesTest
|
||||
{
|
||||
private val notes = mapOf("ru" to "Русский", "en" to "English", "zh-tw" to "繁體", "zh" to "中文")
|
||||
|
||||
@Test
|
||||
fun picksAppLanguageThenRussianThenEnglish()
|
||||
{
|
||||
assertThat(pickNotes(notes, "en-US", "en")).isEqualTo("English")
|
||||
assertThat(pickNotes(notes, "zh-TW", "zh")).isEqualTo("繁體")
|
||||
assertThat(pickNotes(notes, "zh-CN", "zh")).isEqualTo("中文")
|
||||
assertThat(pickNotes(notes, "de-DE", "de")).isEqualTo("Русский")
|
||||
assertThat(pickNotes(mapOf("en" to "English", "ru" to " "), "de-DE", "de")).isEqualTo("English")
|
||||
assertThat(pickNotes(emptyMap(), "ru-RU", "ru")).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyNonEmptyLineIsAnItem()
|
||||
{
|
||||
assertThat(noteLines("Серверы\n\n - История трафика \r\n• Исправлен сбой\n")).containsExactly("Серверы", "История трафика", "Исправлен сбой")
|
||||
assertThat(noteLines("Одна строка. Без переносов.")).containsExactly("Одна строка. Без переносов.")
|
||||
assertThat(noteLines(null)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun copiesWithDigestAndProgress()
|
||||
{
|
||||
val data = ByteArray(200_000) { it.toByte() }
|
||||
val out = ByteArrayOutputStream()
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
var last = 0L
|
||||
val total = copyDownload(ByteArrayInputStream(data), out, digest, { false }) { last = it }
|
||||
assertThat(total).isEqualTo(data.size.toLong())
|
||||
assertThat(last).isEqualTo(data.size.toLong())
|
||||
assertThat(out.toByteArray()).isEqualTo(data)
|
||||
assertThat(digest.digest()).isEqualTo(MessageDigest.getInstance("SHA-256").digest(data))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancelStopsBetweenReads()
|
||||
{
|
||||
var cancelled = false
|
||||
val out = ByteArrayOutputStream()
|
||||
assertThatThrownBy {
|
||||
copyDownload(ByteArrayInputStream(ByteArray(300_000)), out, MessageDigest.getInstance("SHA-256"), { cancelled }) { cancelled = true }
|
||||
}.isInstanceOf(CancellationException::class.java)
|
||||
/* one 64 KiB chunk, then the loop stopped */
|
||||
assertThat(out.size()).isEqualTo(64 * 1024)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failedReadAfterCancelIsACancellation()
|
||||
{
|
||||
var cancelled = false
|
||||
/* what disconnect() does to a blocked read */
|
||||
val broken = object : InputStream()
|
||||
{
|
||||
override fun read(): Int = throw IOException("socket closed")
|
||||
override fun read(b: ByteArray, off: Int, len: Int): Int
|
||||
{
|
||||
cancelled = true
|
||||
throw IOException("socket closed")
|
||||
}
|
||||
}
|
||||
assertThatThrownBy {
|
||||
copyDownload(broken, ByteArrayOutputStream(), MessageDigest.getInstance("SHA-256"), { cancelled }) {}
|
||||
}.isInstanceOf(CancellationException::class.java)
|
||||
/* without a cancel the network error stays a network error */
|
||||
assertThatThrownBy {
|
||||
copyDownload(object : InputStream() { override fun read(): Int = throw IOException("reset") }, ByteArrayOutputStream(), MessageDigest.getInstance("SHA-256"), { false }) {}
|
||||
}.isInstanceOf(IOException::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"note": "ТЕСТОВЫЙ ключ, только для проверки формата подписи.",
|
||||
"ed25519": {
|
||||
"privateKeyPem": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIA9Mac/nzlc6O6s6/5p7z3BfaggTulVsGYSLJyiG9/H8\n-----END PRIVATE KEY-----\n",
|
||||
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEASE5f2zN0uBApPHejtQ7Ud0+AcNN+UjvbBgHYIo/lmnY=\n-----END PUBLIC KEY-----\n",
|
||||
"publicKeyRawBase64": "SE5f2zN0uBApPHejtQ7Ud0+AcNN+UjvbBgHYIo/lmnY="
|
||||
},
|
||||
"manifest": {
|
||||
"fields": {
|
||||
"update": true,
|
||||
"mandatory": false,
|
||||
"versionCode": 250003,
|
||||
"versionName": "2.5.0-beta.3",
|
||||
"checkHours": 12,
|
||||
"wifiOnly": true,
|
||||
"sha256": "abababababababababababababababababababababababababababababababab",
|
||||
"size": 38900000,
|
||||
"apkUrl": "https://s3.example/app.apk?sig=1",
|
||||
"issuedAt": 1760438401
|
||||
},
|
||||
"signingInput": "shx-manifest-v1\n1\n0\n250003\n2.5.0-beta.3\nabababababababababababababababababababababababababababababababab\n38900000\nhttps://s3.example/app.apk?sig=1\n1760438401\n12\n1",
|
||||
"signatureBase64": "KkPqAQX4DwqhgNWdJecOePHsnPzOGCYAGNG25mQganJGQiD2MTl9YSrcHHeO5hoGeLoOa4rEU40rV++YtODGCw=="
|
||||
},
|
||||
"noUpdate": {
|
||||
"fields": {
|
||||
"update": false,
|
||||
"mandatory": false,
|
||||
"issuedAt": 1760438401,
|
||||
"checkHours": 12,
|
||||
"wifiOnly": true
|
||||
},
|
||||
"signingInput": "shx-manifest-v1\n0\n0\n0\n\n\n0\n\n1760438401\n12\n1",
|
||||
"signatureBase64": "8Q5+fb7t6ot5iXYBPbv9CCspNzJOEVmQqHLh/6k2lNNtMxeyuTfbldKaKFzQV4orknVKijbf5ChgYDlC5pNvDQ=="
|
||||
},
|
||||
"request": {
|
||||
"hmacKey": "test-hmac-key",
|
||||
"method": "GET",
|
||||
"path": "/api/v1/updates/check",
|
||||
"timestamp": "1760438400",
|
||||
"signingInput": "GET|/api/v1/updates/check|1760438400",
|
||||
"signatureBase64": "m6eQoNLWfDApDH2E1/q3aW/qG06t01/2zvxL1RD+Ckw="
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
# SHX VPN для Android
|
||||
|
||||
Форк Android-клиента strongSwan (IKEv2/IPsec), пакет `one.shx.strongswan.ext`. Текущая версия — **2.19.1 (versionCode 169)**.
|
||||
Распространяется вне Google Play: APK и манифест обновлений лежат в приватном бакете S3, приложение обновляется само.
|
||||
|
||||
- Дизайн: [design/README.md](design/README.md) — макет «Финал 2026» и соответствие артбордов коду.
|
||||
- История UI-плана (до 2.15, устарела): [ui-ux-plan.md](ui-ux-plan.md).
|
||||
|
||||
## Что умеет приложение
|
||||
|
||||
| Раздел | Возможности |
|
||||
|---|---|
|
||||
| Главная | Кнопка подключения, шаги подключения из журнала демона, пауза (минуты, до утра, до смены сети), трафик за сегодня, карточки проблем (нет сети, сервер не отвечает, вход отклонён, белые списки, истекает сертификат) с действием в один тап, подсказки сценариев |
|
||||
| Серверы | Один сервер = один профиль `.sswan`. Добавление по QR, ссылке, файлу или вручную; проверка профиля перед добавлением; «Поделиться» QR-кодом без пароля; страница сервера: источник профиля, ежедневная проверка ссылки и лист изменений, запасные порты, резервный сервер, мост при белых списках, DNS, MTU, фрагментация IKE |
|
||||
| Маршруты | Раздельный туннель, сайты РФ напрямую (база адресов, режимы «Стабильно»/«Гибко»), приложения напрямую, правила (сайт, адрес/сеть, шаблон пакета), недавние приложения, недоступные хосты, «Как ходит трафик» |
|
||||
| Сценарии | Правила для сетей (доверенная Wi-Fi / мобильная или чужая сеть) и свои сценарии: Wi-Fi, мобильная сеть, приложение открыто, время, роуминг, белые списки → включить, выключить, пауза, сменить сервер. Общий порядок, верхний важнее; ручное действие перекрывает сценарий до его окончания |
|
||||
| Защита | DNS через туннель: из профиля, Cloudflare, AdGuard (реклама и трекеры), Cloudflare 1.1.1.2 (фишинг), свой адрес; «Без VPN — без сети» (инструкция к системному Always-on) |
|
||||
| Статистика | Трафик по дням/неделям/месяцам: через VPN и напрямую, по приложениям (нужен доступ к статистике использования), итоги недели |
|
||||
| Диагностика | Интернет, сайты РФ и зарубежные, сервер, DNS, белые списки за ~10 секунд; проверка сайта; отчёт в поддержку |
|
||||
| События | Центр событий в приложении и push по каналам (статус, проблемы, новые приложения, обновления, итоги недели) |
|
||||
| Система | Виджеты трёх размеров (Glance), плитка быстрых настроек, ярлыки профилей, автообновление с проверкой SHA-256 и подписи |
|
||||
|
||||
Профиль без сохранённого пароля никогда не запускается сам (watchdog, правила, сценарии, резерв, мост): вместо этого приходит событие со ссылкой на сервер.
|
||||
|
||||
## Устройство кода
|
||||
|
||||
`app/src/main/java/org/strongswan/android/`:
|
||||
|
||||
| Пакет | Что внутри |
|
||||
|---|---|
|
||||
| `logic/` | `CharonVpnService` (туннель и уведомление), `VpnStateService`, `WatchdogReceiver`, `PauseManager` |
|
||||
| `logic/autoconnect` | Правила для сетей: `AutoConnectManager`, `NetworkMonitor`, `SsidReader` |
|
||||
| `logic/scenarios` | `ScenarioEngine`: условия, порядок, ручной приоритет |
|
||||
| `logic/profiles` | `ProfileExtras` (настройки поверх `.sswan`), `ProfileUpdater` (ссылка и diff), `Resilience` (порты, резерв, мост) |
|
||||
| `logic/routing` | `RoutingManager`, база адресов РФ (`RuIpListManager`), правила и шаблоны пакетов, `RouteCheck`, `AppTraffic` |
|
||||
| `logic/connectivity` | Проверки сети и сервера, `HealthCheckManager`, детект белых списков |
|
||||
| `logic/protection` | Выбор DNS и фильтрации |
|
||||
| `logic/stats` | `TrafficHistory`, `WeeklySummary` |
|
||||
| `logic/events` | `EventLog` (центр событий), `Events` (push) |
|
||||
| `logic/updates` | `UpdateManager`, загрузка и установка APK, проверка подписи |
|
||||
| `ui/compose` | Экраны на Compose; `design/` — компоненты макета (`ShxKit`, `ShxIcons`), `theme/` — цвета и шрифты макета, `home/`, `routing/` |
|
||||
| `ui/` | Activity вторичных экранов, `VpnTileService`, `widget/ShxWidget` |
|
||||
|
||||
Нативная часть (libcharon, libstrongswan, OpenSSL) собирается NDK из `app/src/main/jni`.
|
||||
|
||||
## Тулчейн
|
||||
|
||||
AGP 9.1.1, Gradle 9.3.1, Kotlin 2.2.21, Java 11, compileSdk 37, targetSdk 36, **minSdk 24**, NDK 27.3,
|
||||
Compose BOM 2026.09.00, material3 1.5.0-beta01 (закреплена явно до стабильной 1.5.0), Glance 1.2.
|
||||
|
||||
## Сборка и тесты
|
||||
|
||||
Из `src/frontends/android`:
|
||||
|
||||
```bash
|
||||
ORG_GRADLE_PROJECT_shortNdkCommands=true ./gradlew :app:assembleDebug :app:testDebugUnitTest -PshortNdkCommands
|
||||
```
|
||||
|
||||
`shortNdkCommands` нужен на Windows в глубоких путях (worktree): иначе команда линковки libstrongswan длиннее лимита CreateProcess.
|
||||
В worktree нет git-игнорируемых входов — их копируют из основного checkout: `.env`, `local.properties`, `Android.common.mk`,
|
||||
`app/src/main/jni/openssl/`, сгенерированные `oid.{c,h}`, `proposal_keywords_static.{c,h}`, `settings_{lexer.c,parser.c,parser.h}`.
|
||||
|
||||
`lintDebug` пока падает на старых ошибках (NewApi в `FgsStartRetry`, MissingTranslation у неиспользуемых строк) — это не регресс.
|
||||
|
||||
## Локализация
|
||||
|
||||
Языки: en (база, `values/`), ru, de, pl, uk, zh-CN, zh-TW. Каждая новая строка добавляется сразу во все семь.
|
||||
Правила перевода:
|
||||
|
||||
- плейсхолдеры (`%1$s`, `%1$d%%`) и `\n` сохраняются как в английской строке;
|
||||
- кавычки по языку: ru/uk «…», de „…“, pl „…”, zh “…” / 「…」; апостроф — ’;
|
||||
- plurals: de — one/other, pl/uk/ru — one/few/many/other, zh — other;
|
||||
- системные термины как в Android: «Постоянный VPN», «Durchgehend aktives VPN», «Stały VPN», «Постійно ввімкнена VPN», «始终开启的 VPN», «永久連線的 VPN»;
|
||||
- после правок посмотреть de и uk на эмуляторе: длинные слова не должны переноситься в нижней навигации и заголовках.
|
||||
|
||||
## Релиз
|
||||
|
||||
1. Сверить `versionCode` с бакетом: `aws s3 cp s3://shx-app/latest.json -` (endpoint и ключи из `.env`). Публичный GET отдаёт `AccessDenied` — это норма, бакет приватный.
|
||||
2. Поднять `versionCode`/`versionName` в `app/build.gradle`.
|
||||
3. Обновить `NOTES_RU` в `.env` (одна строка; текст попадёт в диалог обновления). `.env` читается построчно, без `source`/`eval`.
|
||||
4. Собрать без выгрузки: `ORG_GRADLE_PROJECT_shortNdkCommands=true ./build-local.sh --skip-upload` (или `build-local.ps1 -SkipUpload`). Успех — строки `BUILD SUCCESSFUL` и `==> Built`.
|
||||
5. Smoke-тест: установить `build/publish/shx-vpn.apk` на эмулятор (AVD `shx36`), пройти онбординг и все вкладки, проверить `adb logcat -b crash`.
|
||||
6. Выложить именно проверенный APK: `aws s3 cp` сначала `shx-vpn.apk`, потом `latest.json` (оба с `--cache-control no-cache`, json ещё с `--content-type application/json`), затем прочитать `latest.json` из бакета и сверить sha256.
|
||||
|
||||
Известное ограничение Android 13+: приложению, установленному не из магазина, система блокирует «Статистику использования» («Контролируется настройками с ограниченным доступом»). Обычно снимается в «Информации о приложении» → ⋮ → «Разрешить ограниченные настройки» (пункт появляется после попытки включить разрешение); на Samsung может мешать «Автоблокировщик», крайний вариант — `adb shell appops set one.shx.strongswan.ext GET_USAGE_STATS allow`.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Макет «Финал 2026»
|
||||
|
||||
Утверждённый дизайн SHX VPN. Все экраны приложения сделаны строго по нему.
|
||||
|
||||
- **Живой холст (смотреть и править):** https://claude.ai/artifact/UCC88C9hkU3hDU2YwMck1a. Это артефакт типа «Design» в Claude, доступ только у владельца.
|
||||
- **Копия в репозитории:** `final-2026/` — исходники артбордов и раскладка холста (`canvas.json`) на 2026-10-13, версия 2.18.0.
|
||||
|
||||
## Как устроены файлы
|
||||
|
||||
- Каждый артборд — отдельный `*.dc.html`: разметка с инлайн-стилями плюс `class Component extends DCLogic`, у которого `renderVals()` отдаёт данные для `{{…}}`, `<sc-for>` и `<sc-if>`.
|
||||
- Скрипт `./support.js`, который подключают файлы, — среда выполнения редактора Design. В репозиторий она не входит, поэтому вне холста файлы не отрисуются. Это исходник для чтения и сравнения, а не самостоятельные страницы.
|
||||
- `canvas.json` хранит страницы холста, координаты и подписи артбордов.
|
||||
- Шрифт — Manrope (Google Fonts), в приложении — `res/font/manrope_*.ttf`.
|
||||
|
||||
## Страницы холста
|
||||
|
||||
| Префикс | Страница | Что это |
|
||||
|---|---|---|
|
||||
| `O_*` | Обзор | Анализ, фичи по приоритетам, решения по дизайну, карта экранов |
|
||||
| `F_*` | Финал 2026 | 57 артбордов светлой темы — **эталон** |
|
||||
| `D_*` | Финал 2026 · тёмная | 54 артборда тёмной темы, те же экраны |
|
||||
| остальные | Исследование | Ранние варианты (Главная 2.0, простой режим, «Для опытных»). Не реализуются, оставлены для истории |
|
||||
|
||||
`D_*` не рисуются вручную: они получаются из `F_*` заменой цветов светлой палитры на тёмную (`LightShxColors` → `DarkShxColors` в `ShxDesign.kt`). Исключение — `D_QsTile`, он правился руками. Когда меняется `F_*`, обновите и парный `D_*`.
|
||||
|
||||
## Артборд → код
|
||||
|
||||
Пути относительно `app/src/main/java/org/strongswan/android/`.
|
||||
|
||||
| Артборд | Экран | Код |
|
||||
|---|---|---|
|
||||
| `F_Home`, `F_HomeConnecting`, `F_HomePaused`, `F_HomeEmpty` | Главная | `ui/compose/home/HomeContent.kt`, `HomeParts.kt`, `ConnectSteps.kt` |
|
||||
| `F_HomeAlert`, `F_HomeOffline`, `F_HomeUnreachable`, `F_ErrAuth` | Главная с проблемой | `ui/compose/home/HomeStatus.kt` |
|
||||
| `F_HomeScenario`, `F_ScenarioSuggest` | Подсказки сценариев | `ui/compose/home/ScenarioHints.kt` |
|
||||
| `F_CertExpiry` | Сертификат истекает | `ui/compose/home/CertExpiry.kt` |
|
||||
| `F_Pause` | Пауза | `ui/compose/home/HomeStatus.kt`, `logic/PauseManager.kt` |
|
||||
| `F_Servers`, `F_ServerDelete` | Серверы | `ui/compose/ProfilesScreen.kt` |
|
||||
| `F_Profile`, `F_ProfileFile`, `F_ProfileUpdate` | Страница сервера | `ui/ServerActivity.kt`, `logic/profiles/*` |
|
||||
| `F_Import`, `F_ImportQR` | Добавить сервер | `ui/compose/AddServerSheet.kt` |
|
||||
| `F_ImportPreview`, `F_ErrImport` | Проверка профиля | `ui/compose/ImportScreen.kt` |
|
||||
| `F_ManualAdd` | Сервер вручную | `ui/compose/ProfileFormScreen.kt` |
|
||||
| `F_Share` | Поделиться по QR | `ui/compose/ShareProfileScreen.kt` |
|
||||
| `F_Stats`, `F_StatsEmpty` | Статистика | `ui/StatsActivity.kt`, `logic/stats/*` |
|
||||
| `F_Diag` | Диагностика | `ui/compose/DiagnosticsScreen.kt` |
|
||||
| `F_Check` | Как ходит трафик | `ui/compose/RouteCheckScreen.kt` |
|
||||
| `F_Routing` | Маршруты | `ui/compose/routing/RoutingHub.kt` |
|
||||
| `F_Apps`, `F_AppPicker` | Приложения напрямую | `ui/compose/routing/DirectAppsScreen.kt` |
|
||||
| `F_Rules`, `F_RulesEmpty`, `F_RuleAdd` | Правила | `ui/compose/routing/RulesScreen.kt` |
|
||||
| `F_Recent`, `F_RecentEmpty` | Недавние приложения | `ui/compose/routing/RecentAppsScreen.kt` |
|
||||
| `F_Automation`, `F_ScenarioAdd` | Сценарии | `ui/ScenariosActivity.kt`, `logic/scenarios/Scenarios.kt` |
|
||||
| `F_Settings` | Настройки | `ui/compose/SettingsScreen.kt` |
|
||||
| `F_Protection` | DNS и реклама | `ui/compose/SettingsScreen.kt`, `SettingsPages.kt`, `logic/protection/Protection.kt` |
|
||||
| `F_KillSwitch`, `F_NotifSettings`, `F_Appearance`, `F_Update`, `F_UpdateInstall`, `F_Help` | Страницы настроек | `ui/compose/SettingsPages.kt` |
|
||||
| `F_Log` | Журнал | `ui/LogActivity.kt` |
|
||||
| `F_Certs` | Сертификаты | `ui/TrustedCertificatesActivity.kt` |
|
||||
| `F_Battery` | Экономия батареи | `ui/BatteryActivity.kt` |
|
||||
| `F_Onboarding` | Онбординг | `ui/OnboardingActivity.kt` |
|
||||
| `F_Permissions` | Разрешения | `ui/compose/PermissionsScreen.kt` |
|
||||
| `F_VpnConsent` | Подсказка к системному запросу VPN | `ui/compose/VpnDialogs.kt` |
|
||||
| `F_Inbox` | События в приложении | `ui/compose/InboxScreen.kt`, `logic/events/*` |
|
||||
| `F_Shade`, `F_SysNotifs` | Уведомления Android | `logic/CharonVpnService.java`, `logic/events/Events.kt` |
|
||||
| `F_Widgets` | Виджеты | `ui/widget/ShxWidget.kt` |
|
||||
| `F_QsTile` | Плитка быстрых настроек | `ui/VpnTileService.java` |
|
||||
| `Logo` | Логотип «Щит-S» | `res/drawable/ic_launcher_*.xml`, `res/mipmap-anydpi*` |
|
||||
|
||||
## Дизайн-система в коде
|
||||
|
||||
- `ui/compose/theme/ShxDesign.kt` — цвета макета (`LocalShxColors`, `LightShxColors`/`DarkShxColors`) и типографика `ShxType`.
|
||||
- `ui/compose/design/ShxKit.kt` — компоненты макета: `ShxScreenTitle`, `ShxPageHeader`, `ShxPanel`, `ShxRow`, `ShxToggleRow`, `ShxSwitch`, `ShxButton`, `ShxTabs`, `ShxChip`, `ShxField`/`ShxFormField`, `ShxSheet`, `ShxDialog`/`ShxAlertDialog`, `ShxDialogButton`, `ShxNote`.
|
||||
- `ui/compose/design/ShxIcons.kt` — линейные иконки макета.
|
||||
|
||||
Новый экран сначала рисуется на холсте как `F_*` (и `D_*`), потом собирается из этих компонентов, без сторонних цветов и размеров.
|
||||
|
||||
## Как обновить копию в репозитории
|
||||
|
||||
1. Правки делаются на холсте (или публикацией файлов в артефакт).
|
||||
2. Скачать `project/*` текущей версии артефакта (в Claude Code: Artifact `read` с `paths`) и заменить `final-2026/`.
|
||||
3. Если появились новые артборды — дописать их в таблицу выше.
|
||||
@@ -0,0 +1,114 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Приложения без VPN</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#6D4AFF}a:hover{color:#4B2FD1}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 1200px; box-sizing: border-box; background: #F3F0F7; font-family: 'Manrope', system-ui, sans-serif; color: #1C1B20; overflow: hidden; font-variant-numeric: tabular-nums">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; padding: 14px 16px 0">
|
||||
<a href="AdvRouting.dc.html" aria-label="Назад" style="width: 44px; height: 44px; border-radius: 22px; background: rgba(255,255,255,0.8); box-shadow: 0 1px 2px rgba(28,27,32,0.08); display: flex; align-items: center; justify-content: center; color: #1C1B20">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
|
||||
</a>
|
||||
<button style="flex: none; white-space: nowrap; height: 44px; padding: 0 16px; border: 0; border-radius: 22px; background: rgba(255,255,255,0.8); box-shadow: 0 1px 2px rgba(28,27,32,0.08); color: #6D4AFF; font: 700 14px 'Manrope', sans-serif">Системные</button>
|
||||
</div>
|
||||
<header style="padding: 44px 24px 18px">
|
||||
<h1 style="margin: 0; font-size: 34px; font-weight: 800; letter-spacing: -0.03em">Приложения</h1>
|
||||
<p style="margin: 6px 0 0; font-size: 14px; color: #6E6A78">24 из 111 работают без VPN</p>
|
||||
</header>
|
||||
|
||||
<div style="padding: 0 16px; display: flex; flex-direction: column; gap: 18px">
|
||||
<label style="display: flex; align-items: center; gap: 8px; height: 44px; padding: 0 14px; border-radius: 14px; background: #E8E4EE; color: #6E6A78">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>
|
||||
<input type="text" placeholder="Поиск" aria-label="Поиск приложений" style="flex: 1; min-width: 0; width: 100%; border: 0; background: transparent; font: 500 16px 'Manrope', sans-serif; color: #1C1B20; outline: none">
|
||||
</label>
|
||||
|
||||
<div role="tablist" aria-label="Фильтр" style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); padding: 3px; border-radius: 14px; background: #E8E4EE">
|
||||
<button role="tab" aria-selected="true" style="height: 36px; border: 0; border-radius: 11px; background: #FFFFFF; color: #1C1B20; font: 800 13px 'Manrope', sans-serif; box-shadow: 0 1px 3px rgba(28,27,32,0.14)">Напрямую</button>
|
||||
<button role="tab" style="height: 36px; border: 0; border-radius: 11px; background: transparent; color: #49454F; font: 600 13px 'Manrope', sans-serif">Через VPN</button>
|
||||
<button role="tab" style="height: 36px; border: 0; border-radius: 11px; background: transparent; color: #49454F; font: 600 13px 'Manrope', sans-serif">Все</button>
|
||||
</div>
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 8px">
|
||||
<h2 style="margin: 0 12px; font-size: 13px; font-weight: 700; color: #6E6A78">Наборы</h2>
|
||||
<div style="display: flex; gap: 8px; overflow: hidden">
|
||||
<sc-for list="{{sets}}" as="s" hint-placeholder-count="4">
|
||||
<button style="flex: none; display: flex; flex-direction: column; align-items: flex-start; gap: 6px; width: 104px; padding: 12px; border-radius: 20px; border: 0; background: {{s.bg}}; color: {{s.fg}}; font-family: 'Manrope', sans-serif; text-align: left">
|
||||
<span style="font-size: 13px; font-weight: 800">{{s.name}}</span>
|
||||
<span style="font-size: 12px; font-weight: 600; opacity: 0.85">{{s.state}}</span>
|
||||
</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<sc-for list="{{groups}}" as="g" hint-placeholder-count="3">
|
||||
<section style="display: flex; flex-direction: column; gap: 8px">
|
||||
<h2 style="margin: 0 12px; font-size: 13px; font-weight: 700; color: #6E6A78">{{g.title}}</h2>
|
||||
<div style="border-radius: 24px; background: #FFFFFF; overflow: hidden">
|
||||
<sc-for list="{{g.apps}}" as="a" hint-placeholder-count="3">
|
||||
<div style="display: flex; align-items: center; gap: 14px; padding-left: 16px; height: 64px">
|
||||
<span style="flex: none; width: 36px; height: 36px; border-radius: 10px; background: {{a.color}}; color: #FFFFFF; display: flex; align-items: center; justify-content: center; font-size: 15px; font-weight: 800">{{a.letter}}</span>
|
||||
<span style="flex: 1; height: 64px; display: flex; align-items: center; gap: 10px; padding-right: 14px; border-bottom: {{a.divider}}; min-width: 0">
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0">
|
||||
<span style="font-size: 16px; font-weight: 600">{{a.name}}</span>
|
||||
<span style="font-size: 12px; color: {{a.reasonColor}}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">{{a.reason}}</span>
|
||||
</span>
|
||||
<button aria-pressed="{{a.on}}" aria-label="{{a.name}}: напрямую" style="flex: none; width: 50px; height: 30px; border: 0; border-radius: 15px; background: {{a.track}}; padding: 3px; display: flex; justify-content: {{a.align}}"><span style="width: 24px; height: 24px; border-radius: 12px; background: #FFFFFF; box-shadow: 0 1px 3px rgba(0,0,0,0.2)"></span></button>
|
||||
</span>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</section>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":1200}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var set = function (name, state, done) {
|
||||
return { name: name, state: state, bg: done ? '#6D4AFF' : '#FFFFFF', fg: done ? '#FFFFFF' : '#1C1B20' };
|
||||
};
|
||||
var reasons = { me: ['добавлено вами', '#6E6A78'], tpl: ['по правилу ', '#6E6A78'], over: ['исключение из ', '#B45309'] };
|
||||
var grp = function (title, apps) {
|
||||
return { title: title, apps: apps.map(function (a, i) {
|
||||
var on = a[3] !== 'over';
|
||||
return { name: a[0], letter: a[1], color: a[2],
|
||||
reason: reasons[a[3]][0] + (a[4] ? '«' + a[4] + '»' : ''), reasonColor: reasons[a[3]][1],
|
||||
on: on ? 'true' : 'false', track: on ? '#6D4AFF' : '#D6D2DD', align: on ? 'flex-end' : 'flex-start',
|
||||
divider: i < apps.length - 1 ? '1px solid #EEEAF2' : '0' };
|
||||
}) };
|
||||
};
|
||||
return {
|
||||
sets: [set('Банки', '8 из 8', true), set('Госуслуги', '3 из 3', true), set('Магазины', '2 из 5', false), set('Связь', '0 из 4', false)],
|
||||
groups: [
|
||||
grp('Банки', [
|
||||
['СберБанк Онлайн', 'С', '#21A038', 'tpl', 'ru.*'],
|
||||
['Т-Банк', 'Т', '#3A3A3A', 'me'],
|
||||
['Альфа-Банк', 'А', '#C62828', 'tpl', 'ru.*']
|
||||
]),
|
||||
grp('Госуслуги и сервисы', [
|
||||
['Госуслуги', 'Г', '#1F5FBF', 'tpl', 'ru.*'],
|
||||
['Яндекс Карты', 'Я', '#D84315', 'tpl', '*yandex*'],
|
||||
['Яндекс Браузер', 'Я', '#5E35B1', 'over', '*yandex*']
|
||||
]),
|
||||
grp('Магазины', [
|
||||
['Wildberries', 'W', '#7B2CBF', 'me'],
|
||||
['Ozon', 'O', '#1565C0', 'me']
|
||||
])
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,99 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Диагностика</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#6D4AFF}a:hover{color:#4B2FD1}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 1100px; box-sizing: border-box; background: #F3F0F7; font-family: 'Manrope', system-ui, sans-serif; color: #1C1B20; overflow: hidden; font-variant-numeric: tabular-nums">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; padding: 14px 16px 0">
|
||||
<a href="AdvHub.dc.html" aria-label="Назад" style="width: 44px; height: 44px; border-radius: 22px; background: rgba(255,255,255,0.8); box-shadow: 0 1px 2px rgba(28,27,32,0.08); display: flex; align-items: center; justify-content: center; color: #1C1B20">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
|
||||
</a>
|
||||
<button style="flex: none; white-space: nowrap; height: 44px; padding: 0 16px; border: 0; border-radius: 22px; background: rgba(255,255,255,0.8); box-shadow: 0 1px 2px rgba(28,27,32,0.08); color: #6D4AFF; font: 700 14px 'Manrope', sans-serif">Журнал</button>
|
||||
</div>
|
||||
<header style="padding: 44px 24px 18px">
|
||||
<h1 style="margin: 0; font-size: 34px; font-weight: 800; letter-spacing: -0.03em">Диагностика</h1>
|
||||
</header>
|
||||
|
||||
<div style="padding: 0 16px; display: flex; flex-direction: column; gap: 22px">
|
||||
<section style="border-radius: 24px; background: #FFFFFF; padding: 14px; display: flex; flex-direction: column; gap: 12px">
|
||||
<div style="display: flex; gap: 8px">
|
||||
<label style="flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; height: 44px; padding: 0 12px; border-radius: 14px; background: #F3F0F7; color: #6E6A78">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>
|
||||
<input type="text" value="kinopoisk.ru" aria-label="Домен" style="flex: 1; min-width: 0; width: 100%; border: 0; background: transparent; font: 600 16px 'Manrope', sans-serif; color: #1C1B20; outline: none">
|
||||
</label>
|
||||
<button style="flex: none; white-space: nowrap; height: 44px; padding: 0 16px; border: 0; border-radius: 14px; background: #6D4AFF; color: #FFFFFF; font: 700 14px 'Manrope', sans-serif">Проверить</button>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 12px; padding: 4px 4px 0">
|
||||
<span style="width: 36px; height: 36px; border-radius: 18px; background: #E2F5EA; color: #148A45; display: flex; align-items: center; justify-content: center">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12l5 5 9-10"/></svg>
|
||||
</span>
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 1px">
|
||||
<span style="font-size: 16px; font-weight: 800">Напрямую, мимо VPN</span>
|
||||
<span style="font-size: 13px; color: #6E6A78">2 адреса · RU-диапазон</span>
|
||||
</span>
|
||||
<a href="AdvRules.dc.html" style="font-size: 14px; font-weight: 700; text-decoration: none; color: #6D4AFF">В VPN</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 8px">
|
||||
<h2 style="margin: 0 12px; font-size: 13px; font-weight: 700; color: #6E6A78">Последнее подключение</h2>
|
||||
<div style="border-radius: 24px; background: #FFFFFF; padding: 6px 16px">
|
||||
<sc-for list="{{steps}}" as="s" hint-placeholder-count="4">
|
||||
<div style="display: flex; align-items: center; gap: 12px; height: 48px">
|
||||
<span style="width: 10px; height: 10px; border-radius: 5px; background: {{s.dot}}"></span>
|
||||
<span style="flex: 1; font-size: 15px; font-weight: 600">{{s.title}}</span>
|
||||
<span style="font-size: 14px; color: #6E6A78">{{s.value}}</span>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 8px">
|
||||
<h2 style="margin: 0 12px; display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 700; color: #6E6A78">Недоступные хосты<span style="padding: 1px 7px; border-radius: 6px; background: #FFE2B8; color: #7A3A06; font-size: 11px; font-weight: 800">3</span></h2>
|
||||
<div style="border-radius: 24px; background: #FFFFFF; overflow: hidden">
|
||||
<sc-for list="{{hosts}}" as="h" hint-placeholder-count="3">
|
||||
<div style="display: flex; align-items: center; gap: 10px; margin-left: 16px; padding-right: 12px; height: 60px; border-bottom: {{h.divider}}">
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px">
|
||||
<span style="font-size: 14px; font-weight: 700; font-family: ui-monospace, 'Cascadia Mono', Menlo, monospace">{{h.host}}</span>
|
||||
<span style="font-size: 12px; color: #6E6A78">{{h.meta}}</span>
|
||||
</span>
|
||||
<button style="height: 34px; padding: 0 14px; border: 0; border-radius: 17px; background: #EFEAFF; color: #6D4AFF; font: 700 13px 'Manrope', sans-serif">В VPN</button>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button style="height: 52px; border: 0; border-radius: 18px; background: #FFFFFF; color: #6D4AFF; font: 700 16px 'Manrope', sans-serif">Отправить отчёт</button>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":1100}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var ok = '#148A45';
|
||||
var hs = [['95.163.•••.12:443', 'отказов 14 · пауза 4 мин'], ['178.248.•••.70:443', 'отказов 9 · пауза 2 мин'], ['212.193.•••.5:5222', 'отказов 6 · пауза 40 с']];
|
||||
return {
|
||||
steps: [
|
||||
{ dot: ok, title: 'Сеть', value: 'Wi-Fi' },
|
||||
{ dot: ok, title: 'Сервер ответил', value: '38 мс · порт 4500' },
|
||||
{ dot: ok, title: 'Вход', value: 'сертификат принят' },
|
||||
{ dot: ok, title: 'Туннель', value: 'MTU 1400' }
|
||||
],
|
||||
hosts: hs.map(function (h, i) { return { host: h[0], meta: h[1], divider: i < hs.length - 1 ? '1px solid #EEEAF2' : '0' }; })
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,94 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Для опытных</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#6D4AFF}a:hover{color:#4B2FD1}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 1120px; box-sizing: border-box; background: #F3F0F7; font-family: 'Manrope', system-ui, sans-serif; color: #1C1B20; overflow: hidden; font-variant-numeric: tabular-nums">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; padding: 14px 16px 0">
|
||||
<a href="SimpleSettings.dc.html" aria-label="Назад" style="width: 44px; height: 44px; border-radius: 22px; background: rgba(255,255,255,0.8); box-shadow: 0 1px 2px rgba(28,27,32,0.08), inset 0 0 0 1px rgba(255,255,255,0.9); display: flex; align-items: center; justify-content: center; color: #1C1B20">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
<header style="padding: 44px 24px 20px">
|
||||
<h1 style="margin: 0; font-size: 34px; font-weight: 800; letter-spacing: -0.03em">Для опытных</h1>
|
||||
<p style="margin: 6px 0 0; font-size: 14px; color: #6E6A78">Всё, что спрятано из простого режима</p>
|
||||
</header>
|
||||
|
||||
<div style="padding: 0 16px; display: flex; flex-direction: column; gap: 22px">
|
||||
<label style="display: flex; align-items: center; gap: 8px; height: 44px; padding: 0 14px; border-radius: 14px; background: #E8E4EE; color: #6E6A78">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>
|
||||
<input type="text" placeholder="Поиск по настройкам" aria-label="Поиск по настройкам" style="flex: 1; min-width: 0; width: 100%; border: 0; background: transparent; font: 500 16px 'Manrope', sans-serif; color: #1C1B20; outline: none">
|
||||
</label>
|
||||
|
||||
<sc-for list="{{groups}}" as="g" hint-placeholder-count="5">
|
||||
<section style="display: flex; flex-direction: column; gap: 8px">
|
||||
<h2 style="margin: 0 12px; font-size: 13px; font-weight: 700; color: #6E6A78">{{g.title}}</h2>
|
||||
<div style="border-radius: 24px; background: #FFFFFF; overflow: hidden">
|
||||
<sc-for list="{{g.rows}}" as="r" hint-placeholder-count="2">
|
||||
<a href="{{r.href}}" style="display: flex; align-items: center; gap: 14px; padding-left: 16px; height: 56px; text-decoration: none; color: #1C1B20">
|
||||
<span style="flex: none; width: 30px; height: 30px; border-radius: 9px; background: {{r.tile}}; color: #FFFFFF; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 800">{{r.tag}}</span>
|
||||
<span style="flex: 1; height: 56px; display: flex; align-items: center; gap: 8px; padding-right: 14px; border-bottom: {{r.divider}}">
|
||||
<span style="flex: 1; font-size: 16px; font-weight: 600">{{r.title}}</span>
|
||||
<span style="font-size: 14px; color: {{r.valueColor}}; font-weight: {{r.valueWeight}}">{{r.value}}</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#B3AFBC" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
|
||||
</span>
|
||||
</a>
|
||||
</sc-for>
|
||||
</div>
|
||||
</section>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":1120}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var P = '#6D4AFF', G = '#148A45', A = '#C2620E', S = '#5E5A7D', B = '#2F6FDB';
|
||||
var grp = function (title, rows) {
|
||||
return { title: title, rows: rows.map(function (r, i) {
|
||||
return { tag: r[0], tile: r[1], title: r[2], value: r[3], href: r[4],
|
||||
valueColor: r[5] ? '#B45309' : '#6E6A78', valueWeight: r[5] ? '800' : '500',
|
||||
divider: i < rows.length - 1 ? '1px solid #EEEAF2' : '0' };
|
||||
}) };
|
||||
};
|
||||
return {
|
||||
groups: [
|
||||
grp('Маршрутизация', [
|
||||
['RU', P, 'Маршруты', 'Стабильно', 'AdvRouting.dc.html'],
|
||||
['APP', G, 'Приложения без VPN', '24', 'AdvApps.dc.html'],
|
||||
['≡', S, 'Правила', '5', 'AdvRules.dc.html']
|
||||
]),
|
||||
grp('Подключение', [
|
||||
['NL', B, 'Профили', '3', 'AdvProfile.dc.html'],
|
||||
['↻', P, 'Устойчивость', 'Резерв + мост', 'AdvProfile.dc.html']
|
||||
]),
|
||||
grp('Сеть', [
|
||||
['DNS', B, 'DNS', 'Cloudflare', 'Protection.dc.html'],
|
||||
['MTU', S, 'MTU и фрагментация', 'Авто', 'AdvProfile.dc.html']
|
||||
]),
|
||||
grp('Диагностика', [
|
||||
['?', P, 'Проверка сайта', '', 'AdvDiag.dc.html'],
|
||||
['!', A, 'Недоступные хосты', '3', 'AdvDiag.dc.html', true],
|
||||
['LOG', S, 'Журнал', '', 'AdvDiag.dc.html']
|
||||
]),
|
||||
grp('Безопасность', [
|
||||
['CA', S, 'Сертификаты', '2', 'AdvHub.dc.html']
|
||||
])
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,93 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Профиль</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#6D4AFF}a:hover{color:#4B2FD1}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 1240px; box-sizing: border-box; background: #F3F0F7; font-family: 'Manrope', system-ui, sans-serif; color: #1C1B20; overflow: hidden; font-variant-numeric: tabular-nums">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; padding: 14px 16px 0">
|
||||
<a href="AdvHub.dc.html" aria-label="Назад" style="width: 44px; height: 44px; border-radius: 22px; background: rgba(255,255,255,0.8); box-shadow: 0 1px 2px rgba(28,27,32,0.08); display: flex; align-items: center; justify-content: center; color: #1C1B20">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
|
||||
</a>
|
||||
<button aria-label="Поделиться по QR" style="width: 44px; height: 44px; border: 0; border-radius: 22px; background: rgba(255,255,255,0.8); box-shadow: 0 1px 2px rgba(28,27,32,0.08); display: flex; align-items: center; justify-content: center; color: #6D4AFF">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="4" width="6" height="6" rx="1"/><rect x="14" y="4" width="6" height="6" rx="1"/><rect x="4" y="14" width="6" height="6" rx="1"/><path d="M14 14h2v2M20 14v6h-6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<header style="padding: 44px 24px 18px; display: flex; align-items: flex-end; gap: 14px">
|
||||
<span style="width: 56px; height: 56px; border-radius: 18px; background: #6D4AFF; color: #FFFFFF; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 800">NL</span>
|
||||
<span style="display: flex; flex-direction: column; gap: 4px">
|
||||
<h1 style="margin: 0; font-size: 30px; font-weight: 800; letter-spacing: -0.03em">Амстердам</h1>
|
||||
<span style="display: flex; align-items: center; gap: 6px; font-size: 14px; color: #0E6B35; font-weight: 700"><span style="width: 8px; height: 8px; border-radius: 4px; background: #148A45"></span>Подключён · 38 мс</span>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div style="padding: 0 16px; display: flex; flex-direction: column; gap: 22px">
|
||||
<sc-for list="{{groups}}" as="g" hint-placeholder-count="4">
|
||||
<section style="display: flex; flex-direction: column; gap: 8px">
|
||||
<h2 style="margin: 0 12px; display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 700; color: #6E6A78">{{g.title}}<sc-if value="{{g.isNew}}" hint-placeholder-val="{{ false }}"><span style="padding: 1px 7px; border-radius: 6px; background: #6D4AFF; color: #FFFFFF; font-size: 10px; font-weight: 800">НОВОЕ</span></sc-if></h2>
|
||||
<div style="border-radius: 24px; background: #FFFFFF; overflow: hidden">
|
||||
<sc-for list="{{g.rows}}" as="r" hint-placeholder-count="3">
|
||||
<div style="display: flex; align-items: center; gap: 10px; margin-left: 16px; padding-right: 14px; min-height: 56px; border-bottom: {{r.divider}}">
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px; padding: 8px 0">
|
||||
<span style="font-size: 16px; font-weight: 600">{{r.title}}</span>
|
||||
<sc-if value="{{r.hasSub}}" hint-placeholder-val="{{ false }}"><span style="font-size: 12px; line-height: 1.35; color: #6E6A78">{{r.sub}}</span></sc-if>
|
||||
</span>
|
||||
<sc-if value="{{r.isToggle}}" hint-placeholder-val="{{ false }}">
|
||||
<button aria-pressed="{{r.on}}" aria-label="{{r.title}}" style="flex: none; width: 50px; height: 30px; border: 0; border-radius: 15px; background: {{r.track}}; padding: 3px; display: flex; justify-content: {{r.align}}"><span style="width: 24px; height: 24px; border-radius: 12px; background: #FFFFFF; box-shadow: 0 1px 3px rgba(0,0,0,0.2)"></span></button>
|
||||
</sc-if>
|
||||
<sc-if value="{{r.isValue}}" hint-placeholder-val="{{ true }}">
|
||||
<span style="font-size: 15px; color: #6E6A78; text-align: right; max-width: 170px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">{{r.value}}</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#B3AFBC" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
|
||||
</sc-if>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</section>
|
||||
</sc-for>
|
||||
|
||||
<div style="border-radius: 24px; background: #FFFFFF; overflow: hidden">
|
||||
<button style="width: 100%; height: 56px; border: 0; border-bottom: 1px solid #EEEAF2; background: transparent; color: #6D4AFF; font: 700 16px 'Manrope', sans-serif">Экспорт .sswan</button>
|
||||
<button style="width: 100%; height: 56px; border: 0; background: transparent; color: #C62828; font: 700 16px 'Manrope', sans-serif">Удалить профиль</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":1240}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var v = function (title, value, sub) { return { title: title, value: value, sub: sub || '', hasSub: !!sub, isValue: true, isToggle: false }; };
|
||||
var t = function (title, on, sub) {
|
||||
return { title: title, sub: sub || '', hasSub: !!sub, isValue: false, isToggle: true,
|
||||
on: on ? 'true' : 'false', track: on ? '#6D4AFF' : '#D6D2DD', align: on ? 'flex-end' : 'flex-start' };
|
||||
};
|
||||
var grp = function (title, rows, isNew) {
|
||||
rows.forEach(function (r, i) { r.divider = i < rows.length - 1 ? '1px solid #EEEAF2' : '0'; });
|
||||
return { title: title, rows: rows, isNew: !!isNew };
|
||||
};
|
||||
return {
|
||||
groups: [
|
||||
grp('Сервер', [v('Адрес', 'nl1.[ваш-домен]'), v('Порт', '4500'), v('Вход', 'Сертификат')]),
|
||||
grp('Устойчивость', [
|
||||
v('Запасные порты', '443, 53', 'Пробуем по очереди, если основной не отвечает'),
|
||||
v('Резервный профиль', 'Хельсинки'),
|
||||
t('Мост при белых списках', true, 'Переключаться на сервер с российским IP')
|
||||
], true),
|
||||
grp('Сеть', [v('DNS', 'Из профиля'), v('MTU', 'Авто · 1400'), t('Фрагментация IKE', true)]),
|
||||
grp('Маршруты профиля', [t('Использовать общие правила', true, 'Иначе — свои подсети для этого профиля')])
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,106 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Маршруты</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#6D4AFF}a:hover{color:#4B2FD1}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 1000px; box-sizing: border-box; background: #F3F0F7; font-family: 'Manrope', system-ui, sans-serif; color: #1C1B20; overflow: hidden; font-variant-numeric: tabular-nums">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; padding: 14px 16px 0">
|
||||
<a href="AdvHub.dc.html" aria-label="Назад" style="width: 44px; height: 44px; border-radius: 22px; background: rgba(255,255,255,0.8); box-shadow: 0 1px 2px rgba(28,27,32,0.08); display: flex; align-items: center; justify-content: center; color: #1C1B20">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
|
||||
</a>
|
||||
<button aria-pressed="true" aria-label="Раздельный туннель" style="width: 56px; height: 34px; border: 0; border-radius: 17px; background: #6D4AFF; padding: 4px; display: flex; justify-content: flex-end"><span style="width: 26px; height: 26px; border-radius: 13px; background: #FFFFFF; box-shadow: 0 1px 3px rgba(0,0,0,0.2)"></span></button>
|
||||
</div>
|
||||
<header style="padding: 44px 24px 20px">
|
||||
<h1 style="margin: 0; font-size: 34px; font-weight: 800; letter-spacing: -0.03em">Маршруты</h1>
|
||||
<p style="margin: 6px 0 0; font-size: 14px; color: #6E6A78">Что идёт мимо VPN</p>
|
||||
</header>
|
||||
|
||||
<div style="padding: 0 16px; display: flex; flex-direction: column; gap: 22px">
|
||||
<section style="padding: 18px; border-radius: 24px; background: #FFFFFF; display: flex; flex-direction: column; gap: 12px">
|
||||
<div style="display: flex; height: 10px; border-radius: 5px; overflow: hidden; gap: 3px">
|
||||
<span style="width: 72%; background: #6D4AFF; border-radius: 5px"></span>
|
||||
<span style="flex: 1; background: #3FBF7F; border-radius: 5px"></span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; font-size: 13px; font-weight: 700">
|
||||
<span style="display: flex; align-items: center; gap: 6px; color: #3B2C88"><span style="width: 8px; height: 8px; border-radius: 4px; background: #6D4AFF"></span>Через VPN · 72%</span>
|
||||
<span style="display: flex; align-items: center; gap: 6px; color: #0E6B35"><span style="width: 8px; height: 8px; border-radius: 4px; background: #3FBF7F"></span>Напрямую · 28%</span>
|
||||
</div>
|
||||
<span style="font-size: 14px; line-height: 1.45; color: #49454F">Напрямую идут сайты РФ, 24 приложения и 2 ваших правила.</span>
|
||||
</section>
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 8px">
|
||||
<h2 style="margin: 0 12px; font-size: 13px; font-weight: 700; color: #6E6A78">Сайты и сервисы РФ</h2>
|
||||
<div style="border-radius: 24px; background: #FFFFFF; padding: 14px; display: flex; flex-direction: column; gap: 12px">
|
||||
<div role="radiogroup" aria-label="Сайты РФ" style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); padding: 3px; border-radius: 14px; background: #EEEAF2">
|
||||
<button style="height: 40px; border: 0; border-radius: 11px; background: transparent; color: #49454F; font: 600 14px 'Manrope', sans-serif">Выкл</button>
|
||||
<button aria-pressed="true" style="height: 40px; border: 0; border-radius: 11px; background: #FFFFFF; color: #1C1B20; font: 800 14px 'Manrope', sans-serif; box-shadow: 0 1px 3px rgba(28,27,32,0.14)">Стабильно</button>
|
||||
<button style="height: 40px; border: 0; border-radius: 11px; background: transparent; color: #49454F; font: 600 14px 'Manrope', sans-serif">Гибко <span style="font-size: 10px; font-weight: 800; color: #B45309">β</span></button>
|
||||
</div>
|
||||
<span style="padding: 0 4px; font-size: 13px; line-height: 1.4; color: #6E6A78">Надёжно. Новые адреса применяются после переподключения.</span>
|
||||
<div style="display: flex; align-items: center; gap: 10px; padding: 10px 4px 0; border-top: 1px solid #EEEAF2">
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 1px">
|
||||
<span style="font-size: 15px; font-weight: 600">База адресов</span>
|
||||
<span style="font-size: 13px; color: #6E6A78">12 480 сетей · сегодня в 09:12</span>
|
||||
</span>
|
||||
<button style="height: 36px; padding: 0 14px; border: 0; border-radius: 18px; background: #EFEAFF; color: #6D4AFF; font: 700 14px 'Manrope', sans-serif">Обновить</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 8px">
|
||||
<h2 style="margin: 0 12px; font-size: 13px; font-weight: 700; color: #6E6A78">Исключения</h2>
|
||||
<div style="border-radius: 24px; background: #FFFFFF; overflow: hidden">
|
||||
<a href="AdvApps.dc.html" style="display: flex; align-items: center; gap: 14px; padding-left: 16px; height: 64px; text-decoration: none; color: #1C1B20">
|
||||
<span style="flex: none; width: 30px; height: 30px; border-radius: 9px; background: #148A45; color: #FFFFFF; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 800">APP</span>
|
||||
<span style="flex: 1; height: 64px; display: flex; align-items: center; gap: 8px; padding-right: 14px; border-bottom: 1px solid #EEEAF2">
|
||||
<span style="flex: 1; font-size: 16px; font-weight: 600">Приложения</span>
|
||||
<span style="display: flex">
|
||||
<span style="width: 24px; height: 24px; border-radius: 7px; background: #21A038; border: 2px solid #FFFFFF"></span>
|
||||
<span style="margin-left: -8px; width: 24px; height: 24px; border-radius: 7px; background: #1F5FBF; border: 2px solid #FFFFFF"></span>
|
||||
<span style="margin-left: -8px; width: 24px; height: 24px; border-radius: 7px; background: #7B2CBF; border: 2px solid #FFFFFF"></span>
|
||||
</span>
|
||||
<span style="font-size: 14px; color: #6E6A78">24</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#B3AFBC" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
|
||||
</span>
|
||||
</a>
|
||||
<a href="AdvRules.dc.html" style="display: flex; align-items: center; gap: 14px; padding-left: 16px; height: 64px; text-decoration: none; color: #1C1B20">
|
||||
<span style="flex: none; width: 30px; height: 30px; border-radius: 9px; background: #5E5A7D; color: #FFFFFF; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 800">≡</span>
|
||||
<span style="flex: 1; height: 64px; display: flex; align-items: center; gap: 8px; padding-right: 14px">
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 1px">
|
||||
<span style="font-size: 16px; font-weight: 600">Правила</span>
|
||||
<span style="font-size: 13px; color: #6E6A78">Домены, сети и шаблоны пакетов</span>
|
||||
</span>
|
||||
<span style="font-size: 14px; color: #6E6A78">5</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#B3AFBC" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<a href="AdvDiag.dc.html" style="display: flex; align-items: center; gap: 10px; height: 52px; padding: 0 16px; border-radius: 18px; background: #E8E4EE; color: #6E6A78; text-decoration: none">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>
|
||||
<span style="font-size: 16px; font-weight: 500">Проверить, как идёт сайт</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":1000}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,96 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Правила</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#6D4AFF}a:hover{color:#4B2FD1}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 940px; box-sizing: border-box; background: #F3F0F7; font-family: 'Manrope', system-ui, sans-serif; color: #1C1B20; overflow: hidden; font-variant-numeric: tabular-nums">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; padding: 14px 16px 0">
|
||||
<a href="AdvRouting.dc.html" aria-label="Назад" style="width: 44px; height: 44px; border-radius: 22px; background: rgba(255,255,255,0.8); box-shadow: 0 1px 2px rgba(28,27,32,0.08); display: flex; align-items: center; justify-content: center; color: #1C1B20">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
<header style="padding: 44px 24px 18px">
|
||||
<h1 style="margin: 0; font-size: 34px; font-weight: 800; letter-spacing: -0.03em">Правила</h1>
|
||||
<p style="margin: 6px 0 0; font-size: 14px; line-height: 1.4; color: #6E6A78">Сайты, сети и приложения — куда им идти. Правило важнее базы РФ.</p>
|
||||
</header>
|
||||
|
||||
<div style="padding: 0 16px; display: flex; flex-direction: column; gap: 20px">
|
||||
<div role="tablist" aria-label="Тип" style="display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); padding: 3px; border-radius: 14px; background: #E8E4EE">
|
||||
<button aria-selected="true" role="tab" style="height: 36px; border: 0; border-radius: 11px; background: #FFFFFF; color: #1C1B20; font: 800 13px 'Manrope', sans-serif; box-shadow: 0 1px 3px rgba(28,27,32,0.14)">Все</button>
|
||||
<button role="tab" style="height: 36px; border: 0; border-radius: 11px; background: transparent; color: #49454F; font: 600 13px 'Manrope', sans-serif">Сайты</button>
|
||||
<button role="tab" style="height: 36px; border: 0; border-radius: 11px; background: transparent; color: #49454F; font: 600 13px 'Manrope', sans-serif">Сети</button>
|
||||
<button role="tab" style="height: 36px; border: 0; border-radius: 11px; background: transparent; color: #49454F; font: 600 13px 'Manrope', sans-serif">Пакеты</button>
|
||||
</div>
|
||||
|
||||
<sc-for list="{{groups}}" as="g" hint-placeholder-count="2">
|
||||
<section style="display: flex; flex-direction: column; gap: 8px">
|
||||
<h2 style="margin: 0 12px; display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 700; color: #6E6A78"><span style="width: 8px; height: 8px; border-radius: 4px; background: {{g.dot}}"></span>{{g.title}}</h2>
|
||||
<div style="border-radius: 24px; background: #FFFFFF; overflow: hidden">
|
||||
<sc-for list="{{g.rows}}" as="r" hint-placeholder-count="3">
|
||||
<div style="display: flex; align-items: center; gap: 14px; padding-left: 16px; height: 64px">
|
||||
<span style="flex: none; width: 30px; height: 30px; border-radius: 9px; background: {{r.tile}}; color: {{r.tileFg}}; display: flex; align-items: center; justify-content: center; font-size: 9px; font-weight: 800">{{r.kind}}</span>
|
||||
<span style="flex: 1; height: 64px; display: flex; align-items: center; gap: 10px; padding-right: 14px; border-bottom: {{r.divider}}; min-width: 0">
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0">
|
||||
<span style="font-size: 15px; font-weight: 700; font-family: ui-monospace, 'Cascadia Mono', Menlo, monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">{{r.value}}</span>
|
||||
<span style="font-size: 12px; color: #6E6A78">{{r.sub}}</span>
|
||||
</span>
|
||||
<button aria-pressed="{{r.on}}" aria-label="Включить правило" style="flex: none; width: 50px; height: 30px; border: 0; border-radius: 15px; background: {{r.track}}; padding: 3px; display: flex; justify-content: {{r.align}}"><span style="width: 24px; height: 24px; border-radius: 12px; background: #FFFFFF; box-shadow: 0 1px 3px rgba(0,0,0,0.2)"></span></button>
|
||||
</span>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</section>
|
||||
</sc-for>
|
||||
</div>
|
||||
|
||||
<div style="position: absolute; left: 50%; bottom: 28px; transform: translateX(-50%); display: flex; align-items: center; gap: 6px; padding: 6px; border-radius: 30px; background: rgba(255,255,255,0.78); box-shadow: 0 8px 28px rgba(59,44,136,0.18), inset 0 0 0 1px rgba(255,255,255,0.9)">
|
||||
<button style="height: 48px; padding: 0 22px; border: 0; border-radius: 24px; background: #6D4AFF; color: #FFFFFF; font: 800 15px 'Manrope', sans-serif; display: flex; align-items: center; gap: 8px">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>Правило
|
||||
</button>
|
||||
<a href="AdvDiag.dc.html" aria-label="Проверить сайт" style="width: 48px; height: 48px; border-radius: 24px; display: flex; align-items: center; justify-content: center; color: #3B2C88">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":940}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var tiles = { WEB: ['#E3ECFF', '#2F6FDB'], NET: ['#EEEAF2', '#49454F'], PKG: ['#E2F5EA', '#148A45'] };
|
||||
var grp = function (title, dot, rows) {
|
||||
return { title: title, dot: dot, rows: rows.map(function (r, i) {
|
||||
return { kind: r[0], tile: tiles[r[0]][0], tileFg: tiles[r[0]][1], value: r[1], sub: r[2],
|
||||
on: r[3] ? 'true' : 'false', track: r[3] ? '#6D4AFF' : '#D6D2DD', align: r[3] ? 'flex-end' : 'flex-start',
|
||||
divider: i < rows.length - 1 ? '1px solid #EEEAF2' : '0' };
|
||||
}) };
|
||||
};
|
||||
return {
|
||||
groups: [
|
||||
grp('Всегда через VPN', '#6D4AFF', [
|
||||
['WEB', 'chatgpt.com', 'домен и поддомены', true],
|
||||
['NET', '130.49.213.176', 'адрес', true],
|
||||
['NET', '10.8.0.0/24', 'подсеть', false]
|
||||
]),
|
||||
grp('Всегда напрямую', '#3FBF7F', [
|
||||
['PKG', 'ru.*', 'пакеты · 14 приложений', true],
|
||||
['PKG', '*yandex*', 'пакеты · 6 приложений, 1 исключение', true],
|
||||
['WEB', '*.mos.ru', 'домен и поддомены', true]
|
||||
])
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,74 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Сценарии</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#6D4AFF}a:hover{color:#4B2FD1}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 844px; box-sizing: border-box; background: #FCF8FF; font-family: 'Manrope', system-ui, sans-serif; color: #1C1B20; overflow: hidden; font-variant-numeric: tabular-nums">
|
||||
<header style="display: flex; align-items: center; gap: 8px; padding: 16px 12px 8px">
|
||||
<a href="Main.dc.html" aria-label="Назад" style="width: 44px; height: 44px; border-radius: 22px; display: flex; align-items: center; justify-content: center; color: #1C1B20">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
|
||||
</a>
|
||||
<h1 style="margin: 0; flex: 1; font-size: 22px; font-weight: 800">Сценарии</h1>
|
||||
</header>
|
||||
|
||||
<div style="padding: 4px 16px 0; display: flex; flex-direction: column; gap: 10px">
|
||||
<p style="margin: 0 4px 4px; font-size: 14px; line-height: 1.45; color: #49454F">VPN сам включается и выключается по условиям. Сценарии выше в списке важнее.</p>
|
||||
<sc-for list="{{rules}}" as="r" hint-placeholder-count="4">
|
||||
<div style="display: flex; align-items: center; gap: 12px; padding: 14px; border-radius: 22px; background: #FFFFFF; border: 1px solid #EAE6EE">
|
||||
<span style="width: 44px; height: 44px; border-radius: 14px; background: {{r.iconBg}}; color: {{r.iconFg}}; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800">{{r.tag}}</span>
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 6px; min-width: 0">
|
||||
<span style="font-size: 13px; color: #5E5A7D; font-weight: 600">{{r.when}}</span>
|
||||
<span style="display: flex; align-items: center; gap: 6px; font-size: 15px; font-weight: 800">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#6D4AFF" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>{{r.then}}
|
||||
</span>
|
||||
</span>
|
||||
<button aria-pressed="{{r.on}}" aria-label="{{r.when}}" style="width: 52px; height: 32px; border: 0; border-radius: 16px; background: {{r.track}}; padding: 4px; display: flex; justify-content: {{r.align}}"><span style="width: 24px; height: 24px; border-radius: 12px; background: #FFFFFF"></span></button>
|
||||
</div>
|
||||
</sc-for>
|
||||
|
||||
<section style="margin-top: 6px; padding: 14px; border-radius: 22px; background: #F6F2FA; display: flex; flex-direction: column; gap: 10px">
|
||||
<h2 style="margin: 0; font-size: 15px; font-weight: 800">Быстрые шаблоны</h2>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 8px">
|
||||
<button style="height: 36px; padding: 0 14px; border-radius: 12px; border: 1px solid #C9C5D0; background: #FFFFFF; color: #1C1B20; font: 600 13px 'Manrope', sans-serif">Дома без VPN</button>
|
||||
<button style="height: 36px; padding: 0 14px; border-radius: 12px; border: 1px solid #C9C5D0; background: #FFFFFF; color: #1C1B20; font: 600 13px 'Manrope', sans-serif">В роуминге всегда</button>
|
||||
<button style="height: 36px; padding: 0 14px; border-radius: 12px; border: 1px solid #C9C5D0; background: #FFFFFF; color: #1C1B20; font: 600 13px 'Manrope', sans-serif">Ночью пауза</button>
|
||||
<button style="height: 36px; padding: 0 14px; border-radius: 12px; border: 1px solid #C9C5D0; background: #FFFFFF; color: #1C1B20; font: 600 13px 'Manrope', sans-serif">Для приложения</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<button style="position: absolute; right: 16px; bottom: 24px; height: 64px; padding: 0 22px; border: 0; border-radius: 20px; background: #6D4AFF; color: #FFFFFF; font: 800 16px 'Manrope', sans-serif; display: flex; align-items: center; gap: 10px; box-shadow: 0 6px 18px rgba(109,74,255,0.35)">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>Новый сценарий
|
||||
</button>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":844}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var r = function (tag, bg, fg, when, then, on) {
|
||||
return { tag: tag, iconBg: bg, iconFg: fg, when: when, then: then, on: on ? 'true' : 'false', track: on ? '#6D4AFF' : '#C9C5D0', align: on ? 'flex-end' : 'flex-start' };
|
||||
};
|
||||
return {
|
||||
rules: [
|
||||
r('Wi-Fi', '#E7DFFF', '#3B2C88', 'Подключён к Wi-Fi «Home_5G»', 'Выключить VPN', true),
|
||||
r('LTE', '#E7DFFF', '#3B2C88', 'Мобильная сеть или чужой Wi-Fi', 'Включить VPN', true),
|
||||
r('APP', '#FFE2B8', '#7A3A06', 'Открыт Telegram или YouTube', 'Включить VPN', true),
|
||||
r('01:00', '#F0ECF4', '#49454F', 'Каждую ночь 01:00–07:00', 'Пауза', false)
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,55 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Выбор приложений</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 1000px; box-sizing: border-box; background: #141318; font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden; display: flex; flex-direction: column">
|
||||
<header style="display: flex; align-items: center; gap: 8px; padding: 16px 12px 8px">
|
||||
<a href="D_ScenarioAdd.dc.html" aria-label="Назад" style="width: 44px; height: 44px; border-radius: 22px; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
|
||||
</a>
|
||||
<h1 style="margin: 0; flex: 1; font-size: 22px; font-weight: 800">Приложения</h1>
|
||||
<span style="font-size: 14px; font-weight: 700; color: #B69DFF; padding-right: 8px">Выбрано 2</span>
|
||||
</header>
|
||||
|
||||
<div style="flex: 1; padding: 0 16px 24px; display: flex; flex-direction: column; gap: 12px">
|
||||
<label style="display: flex; align-items: center; gap: 10px; height: 52px; padding: 0 16px; border-radius: 26px; background: #2B2930; color: #A9A3BF">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>
|
||||
<input type="text" placeholder="Поиск приложений" aria-label="Поиск приложений" style="flex: 1; min-width: 0; width: 100%; border: 0; background: transparent; font: 500 16px 'Manrope', sans-serif; color: #E5E1E9; outline: none">
|
||||
</label>
|
||||
<span style="padding: 0 6px; font-size: 13px; font-weight: 800; color: #B69DFF; letter-spacing: 0.04em">ЧАСТО ОТКРЫВАЕТЕ</span>
|
||||
<div style="border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35; overflow: hidden">
|
||||
<sc-for list="{{apps}}" as="a" hint-placeholder-count="7">
|
||||
<label style="display: flex; align-items: center; gap: 12px; padding: 0 16px; height: 60px; border-bottom: {{a.divider}}; cursor: pointer">
|
||||
<span style="flex: none; width: 36px; height: 36px; border-radius: 10px; background: {{a.c}}; color: #FFFFFF; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 800">{{a.l}}</span>
|
||||
<span style="flex: 1; font-size: 15px; font-weight: 700">{{a.n}}</span>
|
||||
<input type="checkbox" checked="{{a.on}}" aria-label="{{a.n}}" style="width: 22px; height: 22px; accent-color: #6D4AFF; margin: 0">
|
||||
</label>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="flex: 1"></div>
|
||||
<a href="D_ScenarioAdd.dc.html" style="height: 56px; border-radius: 18px; background: #6D4AFF; color: #FFFFFF; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 15px; font-weight: 800">Готово</a>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":1000}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var A = [['Telegram', 'T', '#1E88C8', true], ['YouTube', 'Y', '#C62828', true], ['Chrome', 'C', '#2E7D32'], ['WhatsApp', 'W', '#25A244'], ['Instagram', 'I', '#C13584'], ['Discord', 'D', '#5865F2'], ['Spotify', 'S', '#1DB954']];
|
||||
return { apps: A.map(function (x, i) { return { n: x[0], l: x[1], c: x[2], on: !!x[3], divider: i < A.length - 1 ? '1px solid #2B2930' : '0' }; }) };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,66 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Оформление</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 844px; box-sizing: border-box; background: #141318; font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden">
|
||||
<header style="display: flex; align-items: center; gap: 8px; padding: 16px 12px 8px">
|
||||
<a href="D_Settings.dc.html" aria-label="Назад" style="width: 44px; height: 44px; border-radius: 22px; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
|
||||
</a>
|
||||
<h1 style="margin: 0; flex: 1; font-size: 22px; font-weight: 800">Оформление</h1>
|
||||
</header>
|
||||
|
||||
<div style="padding: 4px 16px 0; display: flex; flex-direction: column; gap: 12px">
|
||||
<span style="padding: 0 6px; font-size: 13px; font-weight: 800; color: #B69DFF; letter-spacing: 0.04em">ТЕМА</span>
|
||||
<div role="radiogroup" aria-label="Тема" style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px">
|
||||
<sc-for list="{{themes}}" as="t" hint-placeholder-count="3">
|
||||
<button aria-pressed="{{t.on}}" style="display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 10px; border-radius: 20px; border: {{t.border}}; background: #1F1D24; font-family: 'Manrope', sans-serif; color: #E5E1E9">
|
||||
<span style="width: 100%; height: 120px; border-radius: 14px; background: {{t.bg}}; border: 1px solid #36333D; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; box-sizing: border-box">
|
||||
<span style="width: 34px; height: 34px; border-radius: 40% 60% 55% 45% / 50% 45% 55% 50%; background: {{t.accent}}"></span>
|
||||
<span style="width: 56px; height: 8px; border-radius: 4px; background: {{t.line}}"></span>
|
||||
<span style="width: 40px; height: 8px; border-radius: 4px; background: {{t.line}}"></span>
|
||||
</span>
|
||||
<span style="font-size: 13px; font-weight: 700">{{t.name}}</span>
|
||||
</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
|
||||
<section style="border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35; overflow: hidden; margin-top: 4px">
|
||||
<div style="display: flex; align-items: center; gap: 12px; padding: 12px 14px 12px 16px; border-bottom: 1px solid #2B2930">
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px">
|
||||
<span style="font-size: 15px; font-weight: 700">Цвета обоев (Material You)</span>
|
||||
<span style="font-size: 12px; color: #A9A3BF">Вместо фиолетового — цвета вашего телефона. Статусы остаются прежними.</span>
|
||||
</span>
|
||||
<button aria-pressed="false" aria-label="Цвета обоев" style="flex: none; width: 52px; height: 32px; border: 0; border-radius: 16px; background: #48454E; padding: 4px; display: flex; justify-content: flex-start"><span style="width: 24px; height: 24px; border-radius: 12px; background: #1F1D24"></span></button>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 12px; padding: 0 14px 0 16px; height: 60px">
|
||||
<span style="flex: 1; font-size: 15px; font-weight: 700">Язык</span>
|
||||
<span style="font-size: 14px; font-weight: 600; color: #A9A3BF">Как в системе</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#938F99" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":844}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var t = function (name, bg, accent, line, on) { return { name: name, bg: bg, accent: accent, line: line, on: on ? 'true' : 'false', border: on ? '2px solid #6D4AFF' : '1px solid #EDE8F2' }; };
|
||||
return { themes: [t('Как в системе', 'linear-gradient(90deg, #FCF8FF 50%, #141318 50%)', '#6D4AFF', '#C9C5D0', true), t('Светлая', '#FCF8FF', '#6D4AFF', '#E5E1E9'), t('Тёмная', '#141318', '#9C7CFF', '#36333D')] };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,126 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Приложения напрямую</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 1380px; box-sizing: border-box; background: #141318; font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden; font-variant-numeric: tabular-nums">
|
||||
<header style="display: flex; align-items: center; gap: 8px; padding: 16px 12px 8px">
|
||||
<a href="D_Routing.dc.html" aria-label="Назад" style="width: 44px; height: 44px; border-radius: 22px; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
|
||||
</a>
|
||||
<h1 style="margin: 0; flex: 1; font-size: 22px; font-weight: 800">Приложения напрямую</h1>
|
||||
</header>
|
||||
|
||||
<div style="padding: 0 16px; display: flex; flex-direction: column; gap: 12px">
|
||||
<p style="margin: 0 4px; font-size: 14px; line-height: 1.45; color: #CAC4D0">Включённые приложения ходят в сеть напрямую, мимо VPN. Банки и сервисы РФ часто не работают через VPN.</p>
|
||||
|
||||
<label style="display: flex; align-items: center; gap: 10px; height: 52px; padding: 0 16px; border-radius: 26px; background: #2B2930; color: #A9A3BF">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>
|
||||
<input type="text" placeholder="Поиск приложений" aria-label="Поиск приложений" style="flex: 1; min-width: 0; width: 100%; border: 0; background: transparent; font: 500 16px 'Manrope', sans-serif; color: #E5E1E9; outline: none">
|
||||
</label>
|
||||
|
||||
<div style="display: flex; gap: 8px">
|
||||
<button aria-pressed="true" style="height: 36px; padding: 0 12px; border-radius: 10px; border: 0; background: #36334A; color: #E0DAF5; font: 700 13px 'Manrope', sans-serif; display: flex; align-items: center; gap: 6px">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12l5 5 9-10"/></svg>Напрямую · 24
|
||||
</button>
|
||||
<button style="height: 36px; padding: 0 12px; border-radius: 10px; border: 1px solid #48454E; background: transparent; color: #E5E1E9; font: 600 13px 'Manrope', sans-serif">Через VPN · 87</button>
|
||||
<button style="height: 36px; padding: 0 12px; border-radius: 10px; border: 1px solid #48454E; background: transparent; color: #E5E1E9; font: 600 13px 'Manrope', sans-serif">Все</button>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 8px">
|
||||
<span style="padding: 0 4px; font-size: 13px; font-weight: 700; color: #CAC4D0">Быстро добавить</span>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 8px">
|
||||
<sc-for list="{{quick}}" as="q" hint-placeholder-count="7">
|
||||
<button style="height: 34px; padding: 0 12px; border-radius: 10px; border: {{q.border}}; background: {{q.bg}}; color: {{q.fg}}; font: 700 13px 'Manrope', sans-serif">{{q.label}}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<sc-for list="{{groups}}" as="g" hint-placeholder-count="3">
|
||||
<section style="display: flex; flex-direction: column; gap: 6px">
|
||||
<h2 style="margin: 4px 6px 0; font-size: 12px; font-weight: 800; color: #B69DFF; letter-spacing: 0.06em">{{g.title}}</h2>
|
||||
<div style="border-radius: 22px; background: #1F1D24; border: 1px solid #2E2B35; overflow: hidden">
|
||||
<sc-for list="{{g.apps}}" as="a" hint-placeholder-count="3">
|
||||
<div style="display: flex; align-items: center; gap: 12px; padding: 10px 14px; min-height: 60px; box-sizing: border-box; border-bottom: 1px solid #2B2930">
|
||||
<span style="width: 36px; height: 36px; border-radius: 10px; background: {{a.color}}; color: #FFFFFF; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 800">{{a.letter}}</span>
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0">
|
||||
<span style="font-size: 15px; font-weight: 700">{{a.name}}</span>
|
||||
<span style="font-size: 12px; color: {{a.reasonColor}}">{{a.reason}}</span>
|
||||
</span>
|
||||
<button aria-pressed="{{a.on}}" aria-label="{{a.name}}: напрямую, без VPN" style="width: 52px; height: 32px; border: 0; border-radius: 16px; background: {{a.track}}; padding: 4px; display: flex; justify-content: {{a.align}}"><span style="width: 24px; height: 24px; border-radius: 12px; background: #1F1D24"></span></button>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</section>
|
||||
</sc-for>
|
||||
|
||||
<label style="display: flex; align-items: center; gap: 12px; padding: 8px 6px; font-size: 14px; font-weight: 600; color: #E0DAF5">
|
||||
<input type="checkbox" style="width: 20px; height: 20px; accent-color: #6D4AFF; margin: 0">Показывать системные приложения
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":1380}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var chip = function (label, done) {
|
||||
return {
|
||||
label: done ? '✓ ' + label : label,
|
||||
bg: done ? '#3B2C88' : '#1F1D24',
|
||||
fg: done ? '#D0C2FF' : '#E5E1E9',
|
||||
border: done ? '0' : '1px solid #48454E'
|
||||
};
|
||||
};
|
||||
var app = function (name, letter, color, kind, tpl) {
|
||||
var reasons = {
|
||||
added: ['Напрямую · добавлено вами', '#7CE3B0'],
|
||||
tpl: ['Напрямую · по шаблону «' + tpl + '»', '#7CE3B0'],
|
||||
vpn: ['Через VPN', '#A9A3BF'],
|
||||
over: ['Исключение: через VPN (шаблон «' + tpl + '» не действует)', '#FFD8A8']
|
||||
};
|
||||
var on = kind === 'added' || kind === 'tpl';
|
||||
return {
|
||||
name: name, letter: letter, color: color,
|
||||
reason: reasons[kind][0], reasonColor: reasons[kind][1],
|
||||
on: on ? 'true' : 'false', track: on ? '#6D4AFF' : '#48454E', align: on ? 'flex-end' : 'flex-start'
|
||||
};
|
||||
};
|
||||
return {
|
||||
quick: [chip('Банки', true), chip('Госуслуги', true), chip('Маркетплейсы', true), chip('Связь', false), chip('Транспорт', false), chip('Сервисы', false), chip('Все приложения РФ', false)],
|
||||
groups: [
|
||||
{ title: 'БАНКИ', apps: [
|
||||
app('СберБанк Онлайн', 'С', '#21A038', 'tpl', 'ru.*'),
|
||||
app('Т-Банк', 'Т', '#3A3A3A', 'added'),
|
||||
app('Альфа-Банк', 'А', '#C62828', 'tpl', 'ru.*')
|
||||
] },
|
||||
{ title: 'ГОСУСЛУГИ И СЕРВИСЫ', apps: [
|
||||
app('Госуслуги', 'Г', '#1F5FBF', 'tpl', 'ru.*'),
|
||||
app('Яндекс Карты', 'Я', '#D84315', 'tpl', '*yandex*'),
|
||||
app('Яндекс Браузер', 'Я', '#5E35B1', 'over', '*yandex*')
|
||||
] },
|
||||
{ title: 'МАРКЕТПЛЕЙСЫ', apps: [
|
||||
app('Wildberries', 'W', '#7B2CBF', 'added'),
|
||||
app('Ozon', 'O', '#1565C0', 'added')
|
||||
] },
|
||||
{ title: 'ДРУГИЕ', apps: [
|
||||
app('Telegram', 'T', '#1E88C8', 'vpn'),
|
||||
app('YouTube', 'Y', '#C62828', 'vpn')
|
||||
] }
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,80 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Сценарии</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 1000px; box-sizing: border-box; background: #141318; font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden">
|
||||
<header style="display: flex; align-items: center; gap: 8px; padding: 16px 12px 8px">
|
||||
<a href="D_Settings.dc.html" aria-label="Назад" style="width: 44px; height: 44px; border-radius: 22px; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
|
||||
</a>
|
||||
<h1 style="margin: 0; flex: 1; font-size: 22px; font-weight: 800">Сценарии</h1>
|
||||
</header>
|
||||
|
||||
<div style="padding: 0 16px; display: flex; flex-direction: column; gap: 10px">
|
||||
<p style="margin: 0 4px 4px; font-size: 14px; line-height: 1.45; color: #CAC4D0">VPN сам включается, выключается или встаёт на паузу. Верхние сценарии важнее нижних.</p>
|
||||
<sc-for list="{{rules}}" as="r" hint-placeholder-count="4">
|
||||
<div style="display: flex; align-items: center; gap: 12px; padding: 14px; border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35">
|
||||
<span style="flex: none; width: 44px; height: 44px; border-radius: 14px; background: {{r.bg}}; color: {{r.fg}}; display: flex; align-items: center; justify-content: center">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="{{r.icon}}"/></svg>
|
||||
</span>
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 4px; min-width: 0">
|
||||
<span style="font-size: 13px; color: #A9A3BF; font-weight: 600">{{r.when}}</span>
|
||||
<span style="display: flex; align-items: center; gap: 6px; font-size: 15px; font-weight: 800"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6D4AFF" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>{{r.then}}</span>
|
||||
</span>
|
||||
<button aria-pressed="{{r.on}}" aria-label="{{r.when}}" style="flex: none; width: 52px; height: 32px; border: 0; border-radius: 16px; background: {{r.track}}; padding: 4px; display: flex; justify-content: {{r.align}}"><span style="width: 24px; height: 24px; border-radius: 12px; background: #1F1D24"></span></button>
|
||||
</div>
|
||||
</sc-for>
|
||||
<a href="D_Servers.dc.html" style="display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-radius: 18px; background: #1F1D24; border: 1px solid #2E2B35; text-decoration: none; color: #E5E1E9">
|
||||
<span style="flex: 1; font-size: 14px; font-weight: 700">Сервер для этих правил</span>
|
||||
<span style="font-size: 14px; font-weight: 600; color: #A9A3BF">Амстердам</span>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#A9A3BF" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
|
||||
</a>
|
||||
|
||||
<h2 style="margin: 10px 6px 0; font-size: 13px; font-weight: 800; color: #B69DFF; letter-spacing: 0.04em">ДОВЕРЕННЫЕ СЕТИ</h2>
|
||||
<div style="border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35; overflow: hidden">
|
||||
<sc-for list="{{nets}}" as="n" hint-placeholder-count="2">
|
||||
<div style="display: flex; align-items: center; gap: 12px; padding: 0 16px; height: 56px; border-bottom: 1px solid #2B2930">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#D0C2FF" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M2 9a15 15 0 0 1 20 0M5 13a10 10 0 0 1 14 0M9 17a4 4 0 0 1 6 0"/></svg>
|
||||
<span style="flex: 1; font-size: 15px; font-weight: 700">{{n}}</span>
|
||||
<button aria-label="Удалить сеть" style="width: 40px; height: 40px; border: 0; border-radius: 20px; background: transparent; color: #938F99; font: 700 18px 'Manrope', sans-serif">×</button>
|
||||
</div>
|
||||
</sc-for>
|
||||
<button style="width: 100%; height: 56px; border: 0; background: transparent; color: #B69DFF; font: 700 15px 'Manrope', sans-serif">+ Добавить текущую сеть</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="D_ScenarioAdd.dc.html" style="position: absolute; right: 16px; bottom: 24px; height: 60px; padding: 0 22px; border-radius: 20px; background: #6D4AFF; color: #FFFFFF; font-size: 16px; font-weight: 800; text-decoration: none; display: flex; align-items: center; box-shadow: 0 6px 18px rgba(109,74,255,0.35)">+ Сценарий</a>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":1000}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var r = function (icon, bg, fg, when, then, on) {
|
||||
return { icon: icon, bg: bg, fg: fg, when: when, then: then, on: on ? 'true' : 'false', track: on ? '#6D4AFF' : '#48454E', align: on ? 'flex-end' : 'flex-start' };
|
||||
};
|
||||
return {
|
||||
rules: [
|
||||
r('M2 9a15 15 0 0 1 20 0M5 13a10 10 0 0 1 14 0M9 17a4 4 0 0 1 6 0', '#3B2C88', '#D0C2FF', 'В доверенной Wi-Fi сети', 'Выключить VPN', true),
|
||||
r('M7 3h10v18H7zM11 18h2', '#3B2C88', '#D0C2FF', 'Мобильная сеть или чужой Wi-Fi', 'Включить VPN', true),
|
||||
r('M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z', '#4A3416', '#FFD8A8', 'Открыт Telegram или YouTube', 'Включить VPN', true),
|
||||
r('M12 7v5l3 2M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18', '#2B2930', '#CAC4D0', 'Каждую ночь 01:00–07:00', 'Пауза', false)
|
||||
],
|
||||
nets: ['Home_5G', 'Office']
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,75 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Экономия батареи</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 1000px; box-sizing: border-box; background: #141318; font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden; display: flex; flex-direction: column">
|
||||
<header style="display: flex; align-items: center; gap: 8px; padding: 16px 12px 8px">
|
||||
<a href="D_Permissions.dc.html" aria-label="Назад" style="width: 44px; height: 44px; border-radius: 22px; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
|
||||
</a>
|
||||
<h1 style="margin: 0; flex: 1; font-size: 22px; font-weight: 800">Без экономии батареи</h1>
|
||||
</header>
|
||||
|
||||
<div style="flex: 1; padding: 0 16px 28px; display: flex; flex-direction: column; gap: 12px">
|
||||
<p style="margin: 0 4px; font-size: 15px; line-height: 1.5; color: #E0DAF5">VPN почти не тратит заряд, но Android этого не знает и усыпляет его в фоне. Два шага:</p>
|
||||
|
||||
<section style="padding: 16px; border-radius: 24px; background: #1F1D24; border: 1px solid #4B3D99; display: flex; flex-direction: column; gap: 10px">
|
||||
<span style="display: flex; align-items: center; gap: 10px"><span style="width: 28px; height: 28px; border-radius: 14px; background: #6D4AFF; color: #FFFFFF; font-size: 13px; font-weight: 800; display: flex; align-items: center; justify-content: center">1</span><span style="font-size: 16px; font-weight: 800">Системное разрешение</span></span>
|
||||
<span style="font-size: 13px; line-height: 1.45; color: #CAC4D0">Появится окно «Разрешить работу в фоне?» — нажмите «Разрешить».</span>
|
||||
<button style="height: 48px; border: 0; border-radius: 14px; background: #6D4AFF; color: #FFFFFF; font: 800 14px 'Manrope', sans-serif">Разрешить</button>
|
||||
</section>
|
||||
|
||||
<section style="padding: 16px; border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35; display: flex; flex-direction: column; gap: 12px">
|
||||
<span style="display: flex; align-items: center; gap: 10px"><span style="width: 28px; height: 28px; border-radius: 14px; background: #6D4AFF; color: #FFFFFF; font-size: 13px; font-weight: 800; display: flex; align-items: center; justify-content: center">2</span><span style="font-size: 16px; font-weight: 800">Настройка телефона</span></span>
|
||||
<div style="display: flex; gap: 6px; flex-wrap: wrap">
|
||||
<sc-for list="{{brands}}" as="b" hint-placeholder-count="5">
|
||||
<button aria-pressed="{{b.on}}" style="height: 32px; padding: 0 12px; border-radius: 10px; border: {{b.border}}; background: {{b.bg}}; color: {{b.fg}}; font: 700 12px 'Manrope', sans-serif">{{b.name}}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
<span style="font-size: 12px; font-weight: 700; color: #B69DFF">Определили: Xiaomi · HyperOS</span>
|
||||
<ol style="margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 8px">
|
||||
<sc-for list="{{steps}}" as="s" hint-placeholder-count="3">
|
||||
<li style="display: flex; gap: 10px; font-size: 14px; line-height: 1.45"><span style="flex: none; width: 22px; height: 22px; border-radius: 11px; background: #2B2930; color: #D0C2FF; font-size: 12px; font-weight: 800; display: flex; align-items: center; justify-content: center">{{s.n}}</span><span>{{s.t}}</span></li>
|
||||
</sc-for>
|
||||
</ol>
|
||||
<button style="height: 48px; border-radius: 14px; border: 1px solid #48454E; background: transparent; color: #E5E1E9; font: 700 14px 'Manrope', sans-serif">Открыть настройки приложения</button>
|
||||
</section>
|
||||
|
||||
<div style="flex: 1"></div>
|
||||
<div style="display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-radius: 18px; background: #3A2A12; color: #F5C892; font-size: 13px; line-height: 1.45">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#FFB869" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: none"><path d="M12 4l9 16H3zM12 10v4M12 17.5v.5"/></svg>
|
||||
<span>Шаг 1 ещё не сделан — VPN может отключаться в фоне</span>
|
||||
</div>
|
||||
<a href="D_Permissions.dc.html" style="height: 56px; border-radius: 18px; background: #36334A; color: #E0DAF5; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 15px; font-weight: 800">Проверить снова</a>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":1000}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var b = function (name, on) { return { name: name, on: on ? 'true' : 'false', bg: on ? '#3B2C88' : '#1F1D24', fg: on ? '#FFFFFF' : '#E5E1E9', border: on ? '0' : '1px solid #48454E' }; };
|
||||
return {
|
||||
brands: [b('Xiaomi', true), b('Samsung'), b('Honor / Huawei'), b('Realme / OPPO'), b('Другой')],
|
||||
steps: [
|
||||
{ n: 1, t: 'Откройте настройки приложения кнопкой ниже' },
|
||||
{ n: 2, t: 'Экономия заряда → «Нет ограничений»' },
|
||||
{ n: 3, t: 'Включите «Автозапуск»' }
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,98 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Главная — сертификат истекает</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 844px; box-sizing: border-box; background: #141318; font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden; font-variant-numeric: tabular-nums">
|
||||
<header style="display: flex; align-items: center; justify-content: space-between; padding: 18px 20px 4px">
|
||||
<span style="display: flex; align-items: center; gap: 10px"><span style="width: 34px; height: 34px; border-radius: 10px; background: #6D4AFF; display: flex; align-items: center; justify-content: center"><svg width="26" height="26" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 2.6l7.6 2.8v6c0 5-3.3 8.4-7.6 10-4.3-1.6-7.6-5-7.6-10v-6z" fill="#FFFFFF"/><path d="M14.6 8.8c-.6-.95-1.6-1.45-2.7-1.45-1.5 0-2.6.85-2.6 2.05 0 2.8 5.5 1.6 5.5 4.5 0 1.3-1.2 2.25-2.8 2.25-1.2 0-2.2-.55-2.8-1.5" stroke="#6D4AFF" stroke-width="2.4" stroke-linecap="round"/></svg></span><span style="font-size: 22px; font-weight: 800; letter-spacing: -0.04em">shx<span style="font-weight: 500; color: #B69DFF">vpn</span></span></span>
|
||||
<span style="display: flex; gap: 8px">
|
||||
<a href="D_Inbox.dc.html" aria-label="События: 2 ждут решения" style="position: relative; width: 44px; height: 44px; border-radius: 22px; background: #2B2930; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15zM10 20a2 2 0 0 0 4 0"/></svg>
|
||||
<span style="position: absolute; top: 4px; right: 2px; min-width: 18px; height: 18px; padding: 0 4px; box-sizing: border-box; border-radius: 9px; background: #6D4AFF; border: 2px solid #141318; color: #FFFFFF; font-size: 10px; font-weight: 800; display: flex; align-items: center; justify-content: center">2</span>
|
||||
</a>
|
||||
<a href="D_Diag.dc.html" aria-label="Диагностика" style="width: 44px; height: 44px; border-radius: 22px; background: #2B2930; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l3-8 4 16 3-8h4"/></svg>
|
||||
</a>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<section style="display: flex; flex-direction: column; align-items: center; gap: 10px; padding-top: 8px">
|
||||
<button aria-label="Отключить VPN" style="width: 188px; height: 188px; border: 0; border-radius: 40% 60% 55% 45% / 50% 45% 55% 50%; background: #6D4AFF; color: #FFFFFF; display: flex; align-items: center; justify-content: center; box-shadow: 0 0 0 14px #3B2C88, 0 0 0 28px #2A2343; margin: 28px 0 22px; cursor: pointer">
|
||||
<svg width="68" height="68" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M12 3v9"/><path d="M6.3 6.3a8 8 0 1 0 11.4 0"/></svg>
|
||||
</button>
|
||||
<span style="font-size: 24px; font-weight: 800; letter-spacing: -0.02em">Защищено</span>
|
||||
<span style="display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 600; color: #A9A3BF"><span style="width: 8px; height: 8px; border-radius: 4px; background: #148A45"></span>01:24:37 · РФ-сайты напрямую</span>
|
||||
</section>
|
||||
|
||||
<div style="padding: 22px 16px 0; display: flex; flex-direction: column; gap: 10px">
|
||||
<a href="D_Servers.dc.html" style="display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35; text-decoration: none; color: #E5E1E9">
|
||||
<span style="width: 40px; height: 40px; border-radius: 20px; background: #3B2C88; color: #D0C2FF; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 14px">NL</span>
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px">
|
||||
<span style="font-size: 16px; font-weight: 700">Амстердам</span>
|
||||
<span style="font-size: 13px; color: #A9A3BF">IP 185.•••.•••.12 · 38 мс</span>
|
||||
</span>
|
||||
<span style="font-size: 13px; font-weight: 700; color: #B69DFF">Сменить</span>
|
||||
</a>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px">
|
||||
<sc-for list="{{actions}}" as="q" hint-placeholder-count="3">
|
||||
<a href="{{q.href}}" style="height: 72px; border-radius: 20px; background: #2B2930; color: #E0DAF5; text-decoration: none; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; font-size: 12px; font-weight: 700; text-align: center">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="{{q.icon}}"/></svg>{{q.label}}
|
||||
</a>
|
||||
</sc-for>
|
||||
</div>
|
||||
|
||||
<section role="status" style="padding: 16px; border-radius: 24px; background: #3A2A12; border: 1px solid #5A4220; display: flex; flex-direction: column; gap: 10px">
|
||||
<span style="font-size: 15px; font-weight: 800; color: #FFD8A8">Сертификат истекает через 7 дней</span>
|
||||
<span style="font-size: 13px; line-height: 1.45; color: #F5C892">«Амстердам» перестанет подключаться 18 октября. Попросите у администратора новый профиль и добавьте его.</span>
|
||||
<div style="display: flex; gap: 8px"><a href="D_ProfileUpdate.dc.html" style="height: 44px; padding: 0 14px; border-radius: 14px; background: #B45309; color: #FFFFFF; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 800; box-sizing: border-box; flex: 2">Обновить сейчас</a><a href="D_Home.dc.html" style="height: 44px; padding: 0 14px; border-radius: 14px; border: 1px solid #7A5A30; color: #FFD8A8; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 800; box-sizing: border-box; flex: 1">Позже</a></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Основная навигация" style="position: absolute; left: 0; right: 0; bottom: 0; height: 80px; background: #2B2930; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); align-items: center">
|
||||
<sc-for list="{{tabs}}" as="t" hint-placeholder-count="4">
|
||||
<a href="{{t.href}}" style="display: flex; flex-direction: column; align-items: center; gap: 4px; text-decoration: none; color: {{t.fg}}; font-size: 12px; font-weight: {{t.weight}}">
|
||||
<span style="width: 60px; height: 32px; border-radius: 16px; background: {{t.pill}}; display: flex; align-items: center; justify-content: center"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="{{t.icon}}"/></svg></span>{{t.label}}
|
||||
</a>
|
||||
</sc-for>
|
||||
</nav>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":844}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var tabs = [
|
||||
['Главная', 'D_Home.dc.html', 'M12 3v9M6.3 6.3a8 8 0 1 0 11.4 0'],
|
||||
['Серверы', 'D_Servers.dc.html', 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18'],
|
||||
['Маршруты', 'D_Routing.dc.html', 'M4 12h6l4-6h6M10 12l4 6h6'],
|
||||
['Настройки', 'D_Settings.dc.html', 'M4 7h10M18 7h2M4 17h4M12 17h8M14 5v4M8 15v4']
|
||||
];
|
||||
var active = 0;
|
||||
return {
|
||||
tabs: tabs.map(function (t, i) {
|
||||
var on = i === active;
|
||||
return { label: t[0], href: t[1], icon: t[2], fg: on ? '#E5E1E9' : '#CAC4D0', weight: on ? '800' : '600', pill: on ? '#3B2C88' : 'transparent' };
|
||||
}),
|
||||
actions: [
|
||||
{ label: 'Пауза 15 мин', href: 'D_Pause.dc.html', icon: 'M9 5v14M15 5v14' },
|
||||
{ label: 'Быстрейший', href: 'D_Servers.dc.html', icon: 'M13 2L4 14h7l-1 8 9-12h-7z' },
|
||||
{ label: 'Не открывается?', href: 'D_Diag.dc.html', icon: 'M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6M12 17v.5' }
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,68 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Сертификаты</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 1000px; box-sizing: border-box; background: #141318; font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden">
|
||||
<header style="display: flex; align-items: center; gap: 8px; padding: 16px 12px 8px">
|
||||
<a href="D_Settings.dc.html" aria-label="Назад" style="width: 44px; height: 44px; border-radius: 22px; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
|
||||
</a>
|
||||
<h1 style="margin: 0; flex: 1; font-size: 22px; font-weight: 800">Сертификаты</h1>
|
||||
</header>
|
||||
|
||||
<div style="padding: 0 16px; display: flex; flex-direction: column; gap: 12px">
|
||||
<p style="margin: 0 4px; font-size: 14px; line-height: 1.45; color: #CAC4D0">Нужны, чтобы убедиться, что вы подключаетесь к настоящему серверу. Обычно приходят вместе с профилем — трогать ничего не нужно.</p>
|
||||
<div role="tablist" aria-label="Источник" style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); padding: 4px; border-radius: 16px; background: #2B2930">
|
||||
<button role="tab" aria-selected="true" style="height: 40px; border: 0; border-radius: 12px; background: #1F1D24; color: #E5E1E9; font: 800 13px 'Manrope', sans-serif; box-shadow: 0 1px 3px rgba(28,27,32,0.12)">Из профилей · 2</button>
|
||||
<button role="tab" style="height: 40px; border: 0; border-radius: 12px; background: transparent; color: #CAC4D0; font: 600 13px 'Manrope', sans-serif">Добавленные</button>
|
||||
<button role="tab" style="height: 40px; border: 0; border-radius: 12px; background: transparent; color: #CAC4D0; font: 600 13px 'Manrope', sans-serif">Системные</button>
|
||||
</div>
|
||||
|
||||
<section style="border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35; overflow: hidden">
|
||||
<sc-for list="{{certs}}" as="c" hint-placeholder-count="2">
|
||||
<div style="display: flex; align-items: center; gap: 12px; padding: 12px 14px 12px 16px; border-bottom: {{c.divider}}">
|
||||
<span style="flex: none; width: 40px; height: 40px; border-radius: 14px; background: {{c.bg}}; color: {{c.fg}}; display: flex; align-items: center; justify-content: center">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5h16v10H4zM8 19l4-2 4 2v-4H8z"/></svg>
|
||||
</span>
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0">
|
||||
<span style="font-size: 15px; font-weight: 700">{{c.name}}</span>
|
||||
<span style="font-size: 12px; color: #A9A3BF">{{c.used}}</span>
|
||||
<span style="font-size: 12px; font-weight: 700; color: {{c.expColor}}">{{c.exp}}</span>
|
||||
</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#938F99" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
|
||||
</div>
|
||||
</sc-for>
|
||||
</section>
|
||||
|
||||
<button style="display: flex; align-items: center; justify-content: center; gap: 8px; height: 52px; border-radius: 18px; border: 1px solid #48454E; background: transparent; color: #E5E1E9; font: 700 14px 'Manrope', sans-serif">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>Добавить сертификат (.crt, .pem)
|
||||
</button>
|
||||
<p style="margin: 0 6px; font-size: 12px; line-height: 1.45; color: #A9A3BF">Системные — те, которым доверяет Android.</p>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":1000}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var C = [
|
||||
['SHX Root CA', 'Амстердам, Хельсинки, Франкфурт', 'действует до 01.2028', '#7CE3B0', '#12321F', '#7CE3B0'],
|
||||
['SHX Bridge CA', 'Москва (мост)', 'истекает через 7 дней — 18.10.2026', '#B45309', '#4A3416', '#FFD8A8']
|
||||
];
|
||||
return { certs: C.map(function (x, i) { return { name: x[0], used: 'используют: ' + x[1], exp: x[2], expColor: x[3], bg: x[4], fg: x[5], divider: i < C.length - 1 ? '1px solid #2B2930' : '0' }; }) };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,123 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Как ходит трафик</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 1420px; box-sizing: border-box; background: #141318; font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden; font-variant-numeric: tabular-nums">
|
||||
<header style="display: flex; align-items: center; gap: 8px; padding: 16px 12px 8px">
|
||||
<a href="D_Routing.dc.html" aria-label="Назад" style="width: 44px; height: 44px; border-radius: 22px; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
|
||||
</a>
|
||||
<h1 style="margin: 0; flex: 1; font-size: 22px; font-weight: 800">Как ходит трафик</h1>
|
||||
</header>
|
||||
|
||||
<div style="padding: 4px 16px 0; display: flex; flex-direction: column; gap: 12px">
|
||||
<div role="tablist" aria-label="Что проверить" style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); padding: 4px; border-radius: 16px; background: #2B2930">
|
||||
<button role="tab" aria-selected="true" style="height: 40px; border: 0; border-radius: 12px; background: #1F1D24; color: #E5E1E9; font: 800 14px 'Manrope', sans-serif; box-shadow: 0 1px 3px rgba(28,27,32,0.12)">Сайт</button>
|
||||
<button role="tab" style="height: 40px; border: 0; border-radius: 12px; background: transparent; color: #CAC4D0; font: 600 14px 'Manrope', sans-serif">IP / сеть</button>
|
||||
<button role="tab" style="height: 40px; border: 0; border-radius: 12px; background: transparent; color: #CAC4D0; font: 600 14px 'Manrope', sans-serif">Приложение</button>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 8px">
|
||||
<label style="flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; height: 52px; padding: 0 14px; border-radius: 16px; background: #1F1D24; border: 2px solid #6D4AFF; box-sizing: border-box">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#A9A3BF" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18"/></svg>
|
||||
<input type="text" value="youtube.com" aria-label="Сайт" style="flex: 1; min-width: 0; width: 100%; border: 0; background: transparent; font: 700 16px 'Manrope', sans-serif; color: #E5E1E9; outline: none">
|
||||
</label>
|
||||
<button style="flex: none; white-space: nowrap; height: 52px; padding: 0 16px; border: 0; border-radius: 16px; background: #6D4AFF; color: #FFFFFF; font: 700 14px 'Manrope', sans-serif">Проверить</button>
|
||||
</div>
|
||||
<div style="display: flex; gap: 6px; flex-wrap: wrap">
|
||||
<sc-for list="{{recent}}" as="r" hint-placeholder-count="4">
|
||||
<button style="height: 32px; padding: 0 12px; border-radius: 10px; border: 1px solid #3A3740; background: transparent; color: #CAC4D0; font: 600 12px 'Manrope', sans-serif">{{r}}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
|
||||
<section style="padding: 16px; border-radius: 24px; background: #3B2C88; display: flex; flex-direction: column; gap: 14px">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between">
|
||||
<span style="display: flex; flex-direction: column; gap: 2px">
|
||||
<span style="font-size: 12px; font-weight: 800; color: #D0C2FF; letter-spacing: 0.04em">ИТОГ</span>
|
||||
<span style="font-size: 22px; font-weight: 800; color: #EADDFF">Через VPN</span>
|
||||
</span>
|
||||
<span style="padding: 6px 12px; border-radius: 999px; background: #1F1D24; color: #D0C2FF; font-size: 13px; font-weight: 800">открывается · 41 мс</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 6px">
|
||||
<span style="flex: none; padding: 8px 10px; border-radius: 12px; background: #1F1D24; font-size: 12px; font-weight: 800">Телефон</span>
|
||||
<span style="flex: 1; height: 3px; border-radius: 2px; background: #6D4AFF"></span>
|
||||
<span style="flex: none; padding: 8px 10px; border-radius: 12px; background: #6D4AFF; color: #FFFFFF; font-size: 12px; font-weight: 800">VPN · NL</span>
|
||||
<span style="flex: 1; height: 3px; border-radius: 2px; background: #6D4AFF"></span>
|
||||
<span style="flex: none; padding: 8px 10px; border-radius: 12px; background: #1F1D24; font-size: 12px; font-weight: 800">youtube.com</span>
|
||||
</div>
|
||||
<span style="font-size: 13px; line-height: 1.45; color: #D0C2FF">Почему: адресов нет в базе РФ, своих правил для сайта нет — значит, по умолчанию через VPN.</span>
|
||||
</section>
|
||||
|
||||
<h2 style="margin: 6px 6px 0; font-size: 13px; font-weight: 800; color: #B69DFF; letter-spacing: 0.04em">ПРЯМОЙ ПУТЬ ДЛЯ СРАВНЕНИЯ</h2>
|
||||
<section style="border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35; padding: 4px 16px">
|
||||
<sc-for list="{{compare}}" as="c" hint-placeholder-count="3">
|
||||
<div style="display: flex; align-items: center; gap: 10px; height: 52px; border-bottom: {{c.divider}}">
|
||||
<span style="flex: 1; font-size: 14px; font-weight: 600">{{c.t}}</span>
|
||||
<span style="font-size: 13px; font-weight: 800; color: {{c.color}}">{{c.v}}</span>
|
||||
</div>
|
||||
</sc-for>
|
||||
</section>
|
||||
|
||||
<h2 style="margin: 6px 6px 0; font-size: 13px; font-weight: 800; color: #B69DFF; letter-spacing: 0.04em">DNS</h2>
|
||||
<section style="border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35; padding: 12px 16px; display: flex; flex-direction: column; gap: 10px">
|
||||
<div style="display: flex; flex-direction: column; gap: 3px">
|
||||
<span style="font-size: 13px; font-weight: 700; color: #CAC4D0">Через туннель</span>
|
||||
<span style="font-size: 14px; font-weight: 600; font-family: ui-monospace, 'Cascadia Mono', Menlo, monospace">142.250.•••.14 · 142.250.•••.46</span>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 3px">
|
||||
<span style="font-size: 13px; font-weight: 700; color: #CAC4D0">Через провайдера</span>
|
||||
<span style="font-size: 14px; font-weight: 600; font-family: ui-monospace, 'Cascadia Mono', Menlo, monospace">195.208.•••.3</span>
|
||||
</div>
|
||||
<span style="padding: 8px 12px; border-radius: 12px; background: #3A2A12; color: #F5C892; font-size: 13px; line-height: 1.4"><b>Ответы различаются.</b> Похоже, провайдер подменяет DNS — поэтому используем DNS туннеля.</span>
|
||||
</section>
|
||||
|
||||
<h2 style="margin: 6px 6px 0; font-size: 13px; font-weight: 800; color: #B69DFF; letter-spacing: 0.04em">АДРЕСА · 2</h2>
|
||||
<section style="border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35; overflow: hidden">
|
||||
<sc-for list="{{ips}}" as="i" hint-placeholder-count="2">
|
||||
<div style="display: flex; align-items: center; gap: 10px; padding: 10px 14px 10px 16px; border-bottom: {{i.divider}}">
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0">
|
||||
<span style="font-size: 14px; font-weight: 700; font-family: ui-monospace, 'Cascadia Mono', Menlo, monospace">{{i.ip}}</span>
|
||||
<span style="font-size: 12px; color: #A9A3BF">{{i.why}}</span>
|
||||
</span>
|
||||
<span style="flex: none; padding: 4px 10px; border-radius: 999px; background: #3B2C88; color: #D0C2FF; font-size: 12px; font-weight: 800">через VPN</span>
|
||||
</div>
|
||||
</sc-for>
|
||||
</section>
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 8px; padding-top: 4px">
|
||||
<span style="padding: 0 6px; font-size: 13px; font-weight: 700; color: #CAC4D0">Изменить для youtube.com</span>
|
||||
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px">
|
||||
<a href="D_Rules.dc.html" style="height: 52px; border-radius: 16px; background: #12321F; color: #7CE3B0; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 800">Всегда напрямую</a>
|
||||
<a href="D_Rules.dc.html" style="height: 52px; border-radius: 16px; background: #36334A; color: #E0DAF5; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 800">Всегда через VPN</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":1420}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var cmp = [['Напрямую', 'не открывается', '#B3261E'], ['Через VPN', '41 мс', '#7CE3B0'], ['Скорость напрямую', 'замедлено ~90%', '#B45309']];
|
||||
var ips = [['142.250.•••.14', 'не в базе РФ · по умолчанию'], ['142.250.•••.46', 'не в базе РФ · по умолчанию']];
|
||||
return {
|
||||
recent: ['gosuslugi.ru', '95.163.•••.12', 'Telegram', 'chatgpt.com'],
|
||||
compare: cmp.map(function (c, i) { return { t: c[0], v: c[1], color: c[2], divider: i < cmp.length - 1 ? '1px solid #2B2930' : '0' }; }),
|
||||
ips: ips.map(function (x, i) { return { ip: x[0], why: x[1], divider: i < ips.length - 1 ? '1px solid #2B2930' : '0' }; })
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,106 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Диагностика</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 1240px; box-sizing: border-box; background: #141318; font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden; font-variant-numeric: tabular-nums">
|
||||
<header style="display: flex; align-items: center; gap: 8px; padding: 16px 12px 8px">
|
||||
<a href="D_Home.dc.html" aria-label="Назад" style="width: 44px; height: 44px; border-radius: 22px; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
|
||||
</a>
|
||||
<h1 style="margin: 0; flex: 1; font-size: 22px; font-weight: 800">Диагностика</h1>
|
||||
<a href="D_Log.dc.html" style="flex: none; height: 40px; padding: 0 14px; border-radius: 20px; background: #2B2930; color: #D0C2FF; font-size: 13px; font-weight: 700; display: flex; align-items: center; justify-content: center; text-decoration: none; box-sizing: border-box">Журнал</a>
|
||||
</header>
|
||||
|
||||
<div style="padding: 4px 16px 0; display: flex; flex-direction: column; gap: 14px">
|
||||
<section style="padding: 16px; border-radius: 24px; background: #0F3D24; display: flex; gap: 12px; align-items: center">
|
||||
<span style="flex: none; width: 40px; height: 40px; border-radius: 20px; background: #148A45; color: #FFFFFF; display: flex; align-items: center; justify-content: center">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12l5 5 9-10"/></svg>
|
||||
</span>
|
||||
<span style="display: flex; flex-direction: column; gap: 2px">
|
||||
<span style="font-size: 17px; font-weight: 800; color: #A8F0C8">VPN работает нормально</span>
|
||||
<span style="font-size: 13px; color: #7CE3B0">Проверено 10 с назад</span>
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<section style="border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35; padding: 4px 16px">
|
||||
<sc-for list="{{steps}}" as="s" hint-placeholder-count="5">
|
||||
<div style="display: flex; align-items: center; gap: 12px; height: 50px; border-bottom: {{s.divider}}">
|
||||
<span style="width: 22px; height: 22px; border-radius: 11px; background: {{s.bg}}; color: {{s.fg}}; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 800">{{s.mark}}</span>
|
||||
<span style="flex: 1; font-size: 15px; font-weight: 600">{{s.title}}</span>
|
||||
<span style="font-size: 13px; color: #A9A3BF">{{s.value}}</span>
|
||||
</div>
|
||||
</sc-for>
|
||||
</section>
|
||||
|
||||
<h2 style="margin: 6px 6px 0; font-size: 13px; font-weight: 800; color: #B69DFF; letter-spacing: 0.04em">ЧТО-ТО НЕ ОТКРЫВАЕТСЯ?</h2>
|
||||
<section style="border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35; padding: 14px; display: flex; flex-direction: column; gap: 14px">
|
||||
<div style="display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px">
|
||||
<sc-for list="{{apps}}" as="a" hint-placeholder-count="5">
|
||||
<button style="display: flex; flex-direction: column; align-items: center; gap: 5px; border: 0; background: transparent; padding: 0; font-family: 'Manrope', sans-serif; color: #E5E1E9">
|
||||
<span style="width: 48px; height: 48px; border-radius: 15px; background: {{a.c}}; color: #FFFFFF; display: flex; align-items: center; justify-content: center; font-size: 17px; font-weight: 800; box-shadow: {{a.ring}}">{{a.l}}</span>
|
||||
<span style="font-size: 11px; font-weight: 600; max-width: 62px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">{{a.n}}</span>
|
||||
</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px">
|
||||
<label style="flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; height: 48px; padding: 0 12px; border-radius: 16px; background: #2B2930; color: #A9A3BF">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>
|
||||
<input type="text" placeholder="или сайт" aria-label="Сайт" style="flex: 1; min-width: 0; width: 100%; border: 0; background: transparent; font: 600 15px 'Manrope', sans-serif; color: #E5E1E9; outline: none">
|
||||
</label>
|
||||
<a href="D_Check.dc.html" style="flex: none; white-space: nowrap; height: 48px; padding: 0 16px; border-radius: 16px; background: #6D4AFF; color: #FFFFFF; font-size: 14px; font-weight: 700; display: flex; align-items: center; justify-content: center; text-decoration: none; box-sizing: border-box">Проверить</a>
|
||||
</div>
|
||||
<div role="status" style="padding: 12px; border-radius: 18px; background: #2A2343; display: flex; align-items: center; gap: 10px">
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px">
|
||||
<span style="font-size: 14px; font-weight: 800">СберБанк пущен напрямую</span>
|
||||
<span style="font-size: 12px; color: #CAC4D0">Банк не пускал через VPN</span>
|
||||
</span>
|
||||
<button style="flex: none; height: 36px; padding: 0 12px; border: 0; border-radius: 12px; background: #1F1D24; color: #D0C2FF; font: 700 13px 'Manrope', sans-serif">Отменить</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h2 style="margin: 6px 6px 0; display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 800; color: #B69DFF; letter-spacing: 0.04em">НЕДОСТУПНЫЕ ХОСТЫ<span style="padding: 1px 7px; border-radius: 6px; background: #4A3416; color: #FFD8A8; font-size: 11px; letter-spacing: 0">3</span></h2>
|
||||
<section style="border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35; overflow: hidden">
|
||||
<sc-for list="{{hosts}}" as="h" hint-placeholder-count="3">
|
||||
<div style="display: flex; align-items: center; gap: 10px; padding: 0 12px 0 16px; height: 60px; border-bottom: {{h.divider}}">
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0">
|
||||
<span style="font-size: 14px; font-weight: 700; font-family: ui-monospace, 'Cascadia Mono', Menlo, monospace">{{h.host}}</span>
|
||||
<span style="font-size: 12px; color: #A9A3BF">{{h.meta}}</span>
|
||||
</span>
|
||||
<button style="flex: none; height: 36px; padding: 0 12px; border: 0; border-radius: 12px; background: #4A3416; color: #FFD8A8; font: 700 13px 'Manrope', sans-serif">Через VPN</button>
|
||||
</div>
|
||||
</sc-for>
|
||||
</section>
|
||||
|
||||
<a href="D_Log.dc.html" style="height: 52px; border-radius: 18px; border: 1px solid #48454E; background: transparent; color: #E5E1E9; font-size: 15px; font-weight: 700; display: flex; align-items: center; justify-content: center; text-decoration: none; box-sizing: border-box">Отправить отчёт</a>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":1240}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var ok = { bg: '#0F3D24', fg: '#7CE3B0', mark: '✓' };
|
||||
var steps = [['Интернет', 'Wi-Fi'], ['Российские сайты', '21 мс'], ['Зарубежные сайты', 'через VPN'], ['Сервер VPN', '38 мс · порт 4500'], ['DNS', 'туннель']];
|
||||
var a = function (n, l, c, sel) { return { n: n, l: l, c: c, ring: sel ? '0 0 0 2px #1F1D24, 0 0 0 4px #6D4AFF' : 'none' }; };
|
||||
var hs = [['95.163.•••.12:443', 'отказов 14 · пауза 4 мин'], ['178.248.•••.70:443', 'отказов 9 · пауза 2 мин'], ['212.193.•••.5:5222', 'отказов 6 · пауза 40 с']];
|
||||
return {
|
||||
steps: steps.map(function (s, i) { return { bg: ok.bg, fg: ok.fg, mark: ok.mark, title: s[0], value: s[1], divider: i < steps.length - 1 ? '1px solid #2B2930' : '0' }; }),
|
||||
apps: [a('СберБанк', 'С', '#21A038', true), a('Госуслуги', 'Г', '#1F5FBF'), a('YouTube', 'Y', '#C62828'), a('Telegram', 'T', '#1E88C8'), a('Ещё', '+', '#938F99')],
|
||||
hosts: hs.map(function (h, i) { return { host: h[0], meta: h[1], divider: i < hs.length - 1 ? '1px solid #2B2930' : '0' }; })
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,93 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Главная — вход отклонён</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 844px; box-sizing: border-box; background: #141318; font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden; font-variant-numeric: tabular-nums">
|
||||
<header style="display: flex; align-items: center; justify-content: space-between; padding: 18px 20px 4px">
|
||||
<span style="display: flex; align-items: center; gap: 10px"><span style="width: 34px; height: 34px; border-radius: 10px; background: #6D4AFF; display: flex; align-items: center; justify-content: center"><svg width="26" height="26" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 2.6l7.6 2.8v6c0 5-3.3 8.4-7.6 10-4.3-1.6-7.6-5-7.6-10v-6z" fill="#FFFFFF"/><path d="M14.6 8.8c-.6-.95-1.6-1.45-2.7-1.45-1.5 0-2.6.85-2.6 2.05 0 2.8 5.5 1.6 5.5 4.5 0 1.3-1.2 2.25-2.8 2.25-1.2 0-2.2-.55-2.8-1.5" stroke="#6D4AFF" stroke-width="2.4" stroke-linecap="round"/></svg></span><span style="font-size: 22px; font-weight: 800; letter-spacing: -0.04em">shx<span style="font-weight: 500; color: #B69DFF">vpn</span></span></span>
|
||||
<span style="display: flex; gap: 8px">
|
||||
<a href="D_Inbox.dc.html" aria-label="События: 2 ждут решения" style="position: relative; width: 44px; height: 44px; border-radius: 22px; background: #2B2930; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15zM10 20a2 2 0 0 0 4 0"/></svg>
|
||||
<span style="position: absolute; top: 4px; right: 2px; min-width: 18px; height: 18px; padding: 0 4px; box-sizing: border-box; border-radius: 9px; background: #6D4AFF; border: 2px solid #141318; color: #FFFFFF; font-size: 10px; font-weight: 800; display: flex; align-items: center; justify-content: center">2</span>
|
||||
</a>
|
||||
<a href="D_Diag.dc.html" aria-label="Диагностика" style="width: 44px; height: 44px; border-radius: 22px; background: #2B2930; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l3-8 4 16 3-8h4"/></svg>
|
||||
</a>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<section style="display: flex; flex-direction: column; align-items: center; gap: 10px; padding-top: 8px">
|
||||
<button aria-label="Повторить подключение" style="width: 188px; height: 188px; border: 0; border-radius: 40% 60% 55% 45% / 50% 45% 55% 50%; background: #4A1C1C; color: #FFB4AB; display: flex; align-items: center; justify-content: center; box-shadow: 0 0 0 14px #3A1A1A, 0 0 0 28px #2C1616; margin: 28px 0 22px; cursor: pointer">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4l9 16H3zM12 10v4M12 17.5v.5"/></svg>
|
||||
</button>
|
||||
<span style="font-size: 24px; font-weight: 800; letter-spacing: -0.02em; color: #FFB4AB">Вход отклонён</span>
|
||||
<span style="max-width: 320px; text-align: center; font-size: 14px; font-weight: 600; line-height: 1.4; color: #FFB4AB">Амстердам · ошибка аутентификации</span>
|
||||
</section>
|
||||
|
||||
<div style="padding: 22px 16px 0; display: flex; flex-direction: column; gap: 10px">
|
||||
<a href="D_Servers.dc.html" style="display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35; text-decoration: none; color: #E5E1E9">
|
||||
<span style="width: 40px; height: 40px; border-radius: 20px; background: #3B2C88; color: #D0C2FF; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 14px">NL</span>
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px">
|
||||
<span style="font-size: 16px; font-weight: 700">Амстердам</span>
|
||||
<span style="font-size: 13px; color: #A9A3BF">IP 185.•••.•••.12 · 38 мс</span>
|
||||
</span>
|
||||
<span style="font-size: 13px; font-weight: 700; color: #B69DFF">Сменить</span>
|
||||
</a>
|
||||
|
||||
<section role="status" style="padding: 16px; border-radius: 24px; background: #3A1A1A; border: 1px solid #6B2A2A; display: flex; flex-direction: column; gap: 10px">
|
||||
<span style="font-size: 15px; font-weight: 800; color: #FFB4AB">Сервер не принял сертификат</span>
|
||||
<span style="font-size: 13px; line-height: 1.45; color: #FFB4AB">Скорее всего, у профиля закончился срок действия или администратор выпустил новый. Обновите профиль или выберите другой сервер.</span>
|
||||
<div style="display: flex; gap: 8px"><a href="D_ProfileUpdate.dc.html" style="height: 44px; padding: 0 14px; border-radius: 14px; background: #B3261E; color: #FFFFFF; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 800; box-sizing: border-box; flex: 2">Обновить профиль</a><a href="D_Log.dc.html" style="height: 44px; padding: 0 14px; border-radius: 14px; border: 1px solid #6B2A2A; color: #FFB4AB; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 800; box-sizing: border-box; flex: 1">Журнал</a></div>
|
||||
</section>
|
||||
|
||||
<a href="D_Servers.dc.html" style="height: 52px; border-radius: 18px; background: #2B2930; color: #E0DAF5; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 700">Подключиться к другому серверу</a>
|
||||
<a href="D_Home.dc.html" style="height: 44px; border-radius: 14px; color: #B69DFF; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 15px; font-weight: 700">Отключить VPN</a>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Основная навигация" style="position: absolute; left: 0; right: 0; bottom: 0; height: 80px; background: #2B2930; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); align-items: center">
|
||||
<sc-for list="{{tabs}}" as="t" hint-placeholder-count="4">
|
||||
<a href="{{t.href}}" style="display: flex; flex-direction: column; align-items: center; gap: 4px; text-decoration: none; color: {{t.fg}}; font-size: 12px; font-weight: {{t.weight}}">
|
||||
<span style="width: 60px; height: 32px; border-radius: 16px; background: {{t.pill}}; display: flex; align-items: center; justify-content: center"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="{{t.icon}}"/></svg></span>{{t.label}}
|
||||
</a>
|
||||
</sc-for>
|
||||
</nav>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":844}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var tabs = [
|
||||
['Главная', 'D_Home.dc.html', 'M12 3v9M6.3 6.3a8 8 0 1 0 11.4 0'],
|
||||
['Серверы', 'D_Servers.dc.html', 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18'],
|
||||
['Маршруты', 'D_Routing.dc.html', 'M4 12h6l4-6h6M10 12l4 6h6'],
|
||||
['Настройки', 'D_Settings.dc.html', 'M4 7h10M18 7h2M4 17h4M12 17h8M14 5v4M8 15v4']
|
||||
];
|
||||
var active = 0;
|
||||
return {
|
||||
tabs: tabs.map(function (t, i) {
|
||||
var on = i === active;
|
||||
return { label: t[0], href: t[1], icon: t[2], fg: on ? '#E5E1E9' : '#CAC4D0', weight: on ? '800' : '600', pill: on ? '#3B2C88' : 'transparent' };
|
||||
}),
|
||||
actions: [
|
||||
{ label: 'Пауза 15 мин', href: 'D_Pause.dc.html', icon: 'M9 5v14M15 5v14' },
|
||||
{ label: 'Быстрейший', href: 'D_Servers.dc.html', icon: 'M13 2L4 14h7l-1 8 9-12h-7z' },
|
||||
{ label: 'Не открывается?', href: 'D_Diag.dc.html', icon: 'M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6M12 17v.5' }
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,61 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Профиль не добавлен</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 844px; box-sizing: border-box; background: rgba(28,27,32,0.5); font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden; display: flex; flex-direction: column; justify-content: flex-end">
|
||||
<section role="alertdialog" aria-label="Профиль не добавлен" style="border-radius: 32px 32px 0 0; background: #141318; padding: 12px 16px 28px; display: flex; flex-direction: column; gap: 14px">
|
||||
<span style="align-self: center; width: 36px; height: 4px; border-radius: 2px; background: #48454E"></span>
|
||||
<div style="display: flex; align-items: center; gap: 14px; padding: 4px 4px 0">
|
||||
<span style="flex: none; width: 52px; height: 52px; border-radius: 26px; background: #4A1C1C; color: #FFB4AB; display: flex; align-items: center; justify-content: center">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 14a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1M14 10a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1M4 4l16 16"/></svg>
|
||||
</span>
|
||||
<span style="display: flex; flex-direction: column; gap: 2px">
|
||||
<h1 style="margin: 0; font-size: 22px; font-weight: 800; letter-spacing: -0.02em">Не удалось скачать профиль</h1>
|
||||
<span style="font-size: 13px; color: #A9A3BF; font-family: ui-monospace, Menlo, monospace">vpn.[ваш-домен]/nl1.sswan</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section style="padding: 14px 16px; border-radius: 22px; background: #1F1D24; border: 1px solid #2E2B35; display: flex; flex-direction: column; gap: 8px">
|
||||
<span style="font-size: 12px; font-weight: 800; color: #B69DFF; letter-spacing: 0.04em">ВОЗМОЖНЫЕ ПРИЧИНЫ</span>
|
||||
<sc-for list="{{reasons}}" as="r" hint-placeholder-count="3">
|
||||
<span style="display: flex; gap: 10px; font-size: 14px; line-height: 1.45"><span style="flex: none; width: 6px; height: 6px; margin-top: 8px; border-radius: 3px; background: #B3261E"></span><span><b>{{r.t}}</b> — <span style="color: #CAC4D0">{{r.d}}</span></span></span>
|
||||
</sc-for>
|
||||
</section>
|
||||
|
||||
<div style="padding: 12px 14px; border-radius: 18px; background: #2A2343; font-size: 13px; line-height: 1.45; color: #E0DAF5">Попросите у администратора файл .sswan или QR — их можно добавить без интернета.</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px">
|
||||
<a href="D_Import.dc.html" style="height: 52px; border-radius: 18px; background: #6D4AFF; color: #FFFFFF; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 800">Повторить</a>
|
||||
<a href="D_ImportQR.dc.html" style="height: 52px; border-radius: 18px; background: #36334A; color: #E0DAF5; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 700">Сканировать QR</a>
|
||||
</div>
|
||||
<a href="D_Log.dc.html" style="align-self: center; font-size: 13px; font-weight: 700; text-decoration: none">Подробности для поддержки</a>
|
||||
</section>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":844}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
return {
|
||||
reasons: [
|
||||
{ t: 'Сайт заблокирован', d: 'ссылка не открывается без VPN' },
|
||||
{ t: 'Нет интернета', d: 'проверьте мобильную сеть или Wi-Fi' },
|
||||
{ t: 'Ошибка сертификата сайта', d: 'HTTPS-соединение не прошло проверку' }
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,92 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Помощь</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 1240px; box-sizing: border-box; background: #141318; font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden">
|
||||
<header style="display: flex; align-items: center; gap: 8px; padding: 16px 12px 8px">
|
||||
<a href="D_Settings.dc.html" aria-label="Назад" style="width: 44px; height: 44px; border-radius: 22px; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
|
||||
</a>
|
||||
<h1 style="margin: 0; flex: 1; font-size: 22px; font-weight: 800">Помощь</h1>
|
||||
</header>
|
||||
|
||||
<div style="padding: 0 16px; display: flex; flex-direction: column; gap: 12px">
|
||||
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px">
|
||||
<a href="D_Diag.dc.html" style="height: 84px; padding: 14px; box-sizing: border-box; border-radius: 22px; background: #6D4AFF; color: #FFFFFF; text-decoration: none; display: flex; flex-direction: column; justify-content: space-between">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l3-8 4 16 3-8h4"/></svg>
|
||||
<span style="font-size: 14px; font-weight: 800">Проверить подключение</span>
|
||||
</a>
|
||||
<a href="D_Log.dc.html" style="height: 84px; padding: 14px; box-sizing: border-box; border-radius: 22px; background: #3B2C88; color: #EADDFF; text-decoration: none; display: flex; flex-direction: column; justify-content: space-between">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16v12H8l-4 4z"/></svg>
|
||||
<span style="font-size: 14px; font-weight: 800">Написать в поддержку</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h2 style="margin: 6px 6px 0; font-size: 13px; font-weight: 800; color: #B69DFF; letter-spacing: 0.04em">ЧАСТЫЕ ВОПРОСЫ</h2>
|
||||
<section style="border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35; overflow: hidden">
|
||||
<sc-for list="{{faq}}" as="q" hint-placeholder-count="6">
|
||||
<div style="border-bottom: {{q.divider}}">
|
||||
<button aria-expanded="{{q.openStr}}" style="width: 100%; display: flex; align-items: center; gap: 10px; padding: 0 16px; min-height: 58px; border: 0; background: transparent; text-align: left; font: 700 15px 'Manrope', sans-serif; color: #E5E1E9">
|
||||
<span style="flex: 1">{{q.q}}</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#938F99" stroke-width="2.4" stroke-linecap="round" aria-hidden="true" style="transform: {{q.rot}}"><path d="M6 9l6 6 6-6"/></svg>
|
||||
</button>
|
||||
<sc-if value="{{q.open}}" hint-placeholder-val="{{ false }}">
|
||||
<p style="margin: 0; padding: 0 16px 16px; font-size: 14px; line-height: 1.5; color: #CAC4D0">{{q.a}}</p>
|
||||
</sc-if>
|
||||
</div>
|
||||
</sc-for>
|
||||
</section>
|
||||
|
||||
<h2 style="margin: 6px 6px 0; font-size: 13px; font-weight: 800; color: #B69DFF; letter-spacing: 0.04em">О ПРИЛОЖЕНИИ</h2>
|
||||
<section style="border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35; overflow: hidden">
|
||||
<div style="display: flex; align-items: center; gap: 14px; padding: 16px">
|
||||
<span style="flex: none; width: 52px; height: 52px; border-radius: 15px; background: #6D4AFF; display: flex; align-items: center; justify-content: center">
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 2.6l7.6 2.8v6c0 5-3.3 8.4-7.6 10-4.3-1.6-7.6-5-7.6-10v-6z" fill="#FFFFFF"/><path d="M14.6 8.8c-.6-.95-1.6-1.45-2.7-1.45-1.5 0-2.6.85-2.6 2.05 0 2.8 5.5 1.6 5.5 4.5 0 1.3-1.2 2.25-2.8 2.25-1.2 0-2.2-.55-2.8-1.5" stroke="#6D4AFF" stroke-width="2.3" stroke-linecap="round"/></svg>
|
||||
</span>
|
||||
<span style="display: flex; flex-direction: column; gap: 2px">
|
||||
<span style="font-size: 17px; font-weight: 800">SHX VPN 2.17.0</span>
|
||||
<span style="font-size: 12px; color: #A9A3BF">сборка 172 · на основе strongSwan · GPLv2</span>
|
||||
</span>
|
||||
</div>
|
||||
<sc-for list="{{links}}" as="l" hint-placeholder-count="3">
|
||||
<a href="{{l.href}}" style="display: flex; align-items: center; gap: 10px; padding: 0 16px; height: 52px; border-top: 1px solid #2B2930; text-decoration: none; color: #E5E1E9; font-size: 15px; font-weight: 600">
|
||||
<span style="flex: 1">{{l.t}}</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#938F99" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
|
||||
</a>
|
||||
</sc-for>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":1240}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var Q = [
|
||||
['Банк или Госуслуги не открываются', 'Многие российские сервисы не пускают зарубежные адреса. Откройте «Не открывается?» на главной и выберите приложение — мы пустим его напрямую, мимо VPN.', true],
|
||||
['Где взять профиль .sswan?', 'Его выдаёт администратор вашего VPN: файлом, ссылкой или QR-кодом. Можно отсканировать QR с экрана друга.'],
|
||||
['Что такое мост?', 'Сервер с российским адресом. Когда оператор включает белые списки, обычные серверы недоступны, а мост — работает. Включите «Мост при белых списках» на странице сервера.'],
|
||||
['VPN сам отключается', 'Скорее всего, Android экономит батарею. Откройте Настройки → Стабильная работа и снимите ограничения.'],
|
||||
['Стабильный или Гибкий режим?', 'Стабильный надёжнее: новые адреса применяются после переподключения. Гибкий обновляет маршруты на лету, но пока в бете.'],
|
||||
['Видит ли SHX мои сайты?', 'Нет. С доступом к статистике приложение считает только объём трафика по приложениям; сайты и содержимое не сохраняются и никуда не отправляются.']
|
||||
];
|
||||
return {
|
||||
faq: Q.map(function (x, i) { return { q: x[0], a: x[1], open: !!x[2], openStr: x[2] ? 'true' : 'false', rot: x[2] ? 'rotate(180deg)' : 'none', divider: i < Q.length - 1 ? '1px solid #2B2930' : '0' }; }),
|
||||
links: [{ t: 'Проверить обновления', href: 'D_Update.dc.html' }, { t: 'Лицензии открытого ПО', href: 'D_Help.dc.html' }, { t: 'Исходный код strongSwan', href: 'D_Help.dc.html' }]
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,101 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Главная</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 844px; box-sizing: border-box; background: #141318; font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden; font-variant-numeric: tabular-nums">
|
||||
<header style="display: flex; align-items: center; justify-content: space-between; padding: 18px 20px 4px">
|
||||
<span style="display: flex; align-items: center; gap: 10px"><span style="width: 34px; height: 34px; border-radius: 10px; background: #6D4AFF; display: flex; align-items: center; justify-content: center"><svg width="26" height="26" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 2.6l7.6 2.8v6c0 5-3.3 8.4-7.6 10-4.3-1.6-7.6-5-7.6-10v-6z" fill="#FFFFFF"/><path d="M14.6 8.8c-.6-.95-1.6-1.45-2.7-1.45-1.5 0-2.6.85-2.6 2.05 0 2.8 5.5 1.6 5.5 4.5 0 1.3-1.2 2.25-2.8 2.25-1.2 0-2.2-.55-2.8-1.5" stroke="#6D4AFF" stroke-width="2.4" stroke-linecap="round"/></svg></span><span style="font-size: 22px; font-weight: 800; letter-spacing: -0.04em">shx<span style="font-weight: 500; color: #B69DFF">vpn</span></span></span>
|
||||
<span style="display: flex; gap: 8px">
|
||||
<a href="D_Inbox.dc.html" aria-label="События: 2 ждут решения" style="position: relative; width: 44px; height: 44px; border-radius: 22px; background: #2B2930; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15zM10 20a2 2 0 0 0 4 0"/></svg>
|
||||
<span style="position: absolute; top: 4px; right: 2px; min-width: 18px; height: 18px; padding: 0 4px; box-sizing: border-box; border-radius: 9px; background: #6D4AFF; border: 2px solid #141318; color: #FFFFFF; font-size: 10px; font-weight: 800; display: flex; align-items: center; justify-content: center">2</span>
|
||||
</a>
|
||||
<a href="D_Diag.dc.html" aria-label="Диагностика" style="width: 44px; height: 44px; border-radius: 22px; background: #2B2930; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l3-8 4 16 3-8h4"/></svg>
|
||||
</a>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<section style="display: flex; flex-direction: column; align-items: center; gap: 10px; padding-top: 8px">
|
||||
<button aria-label="Отключить VPN" style="width: 188px; height: 188px; border: 0; border-radius: 40% 60% 55% 45% / 50% 45% 55% 50%; background: #6D4AFF; color: #FFFFFF; display: flex; align-items: center; justify-content: center; box-shadow: 0 0 0 14px #3B2C88, 0 0 0 28px #2A2343; margin: 28px 0 22px; cursor: pointer">
|
||||
<svg width="68" height="68" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M12 3v9"/><path d="M6.3 6.3a8 8 0 1 0 11.4 0"/></svg>
|
||||
</button>
|
||||
<span style="font-size: 24px; font-weight: 800; letter-spacing: -0.02em">Защищено</span>
|
||||
<span style="display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 600; color: #A9A3BF"><span style="width: 8px; height: 8px; border-radius: 4px; background: #148A45"></span>01:24:37 · РФ-сайты напрямую</span>
|
||||
</section>
|
||||
|
||||
<div style="padding: 22px 16px 0; display: flex; flex-direction: column; gap: 10px">
|
||||
<a href="D_Servers.dc.html" style="display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35; text-decoration: none; color: #E5E1E9">
|
||||
<span style="width: 40px; height: 40px; border-radius: 20px; background: #3B2C88; color: #D0C2FF; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 14px">NL</span>
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px">
|
||||
<span style="font-size: 16px; font-weight: 700">Амстердам</span>
|
||||
<span style="font-size: 13px; color: #A9A3BF">IP 185.•••.•••.12 · 38 мс</span>
|
||||
</span>
|
||||
<span style="font-size: 13px; font-weight: 700; color: #B69DFF">Сменить</span>
|
||||
</a>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px">
|
||||
<sc-for list="{{actions}}" as="q" hint-placeholder-count="3">
|
||||
<a href="{{q.href}}" style="height: 72px; border-radius: 20px; background: #2B2930; color: #E0DAF5; text-decoration: none; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; font-size: 12px; font-weight: 700; text-align: center">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="{{q.icon}}"/></svg>{{q.label}}
|
||||
</a>
|
||||
</sc-for>
|
||||
</div>
|
||||
|
||||
<a href="D_Stats.dc.html" style="display: flex; align-items: center; gap: 12px; padding: 14px; border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35; text-decoration: none; color: #E5E1E9">
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 8px">
|
||||
<span style="display: flex; align-items: baseline; gap: 8px"><span style="font-size: 20px; font-weight: 800">1,24 ГБ</span><span style="font-size: 13px; color: #A9A3BF">сегодня</span></span>
|
||||
<span style="display: flex; height: 6px; border-radius: 3px; overflow: hidden; gap: 2px"><span style="width: 72%; background: #6D4AFF; border-radius: 3px"></span><span style="flex: 1; background: #3FBF7F; border-radius: 3px"></span></span>
|
||||
<span style="font-size: 12px; font-weight: 600; color: #A9A3BF">72% через VPN · 342 трекера заблокировано</span>
|
||||
</span>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#A9A3BF" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Основная навигация" style="position: absolute; left: 0; right: 0; bottom: 0; height: 80px; background: #2B2930; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); align-items: center">
|
||||
<sc-for list="{{tabs}}" as="t" hint-placeholder-count="4">
|
||||
<a href="{{t.href}}" style="display: flex; flex-direction: column; align-items: center; gap: 4px; text-decoration: none; color: {{t.fg}}; font-size: 12px; font-weight: {{t.weight}}">
|
||||
<span style="width: 60px; height: 32px; border-radius: 16px; background: {{t.pill}}; display: flex; align-items: center; justify-content: center"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="{{t.icon}}"/></svg></span>{{t.label}}
|
||||
</a>
|
||||
</sc-for>
|
||||
</nav>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":844}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var tabs = [
|
||||
['Главная', 'D_Home.dc.html', 'M12 3v9M6.3 6.3a8 8 0 1 0 11.4 0'],
|
||||
['Серверы', 'D_Servers.dc.html', 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18'],
|
||||
['Маршруты', 'D_Routing.dc.html', 'M4 12h6l4-6h6M10 12l4 6h6'],
|
||||
['Настройки', 'D_Settings.dc.html', 'M4 7h10M18 7h2M4 17h4M12 17h8M14 5v4M8 15v4']
|
||||
];
|
||||
var active = 0;
|
||||
return {
|
||||
tabs: tabs.map(function (t, i) {
|
||||
var on = i === active;
|
||||
return { label: t[0], href: t[1], icon: t[2], fg: on ? '#E5E1E9' : '#CAC4D0', weight: on ? '800' : '600', pill: on ? '#3B2C88' : 'transparent' };
|
||||
}),
|
||||
actions: [
|
||||
{ label: 'Пауза 15 мин', href: 'D_Pause.dc.html', icon: 'M9 5v14M15 5v14' },
|
||||
{ label: 'Быстрейший', href: 'D_Servers.dc.html', icon: 'M13 2L4 14h7l-1 8 9-12h-7z' },
|
||||
{ label: 'Не открывается?', href: 'D_Diag.dc.html', icon: 'M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6M12 17v.5' }
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,81 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Главная — проблема</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 844px; box-sizing: border-box; background: #141318; font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden; font-variant-numeric: tabular-nums">
|
||||
<header style="display: flex; align-items: center; justify-content: space-between; padding: 18px 20px 4px">
|
||||
<span style="display: flex; align-items: center; gap: 10px"><span style="width: 34px; height: 34px; border-radius: 10px; background: #6D4AFF; display: flex; align-items: center; justify-content: center"><svg width="26" height="26" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 2.6l7.6 2.8v6c0 5-3.3 8.4-7.6 10-4.3-1.6-7.6-5-7.6-10v-6z" fill="#FFFFFF"/><path d="M14.6 8.8c-.6-.95-1.6-1.45-2.7-1.45-1.5 0-2.6.85-2.6 2.05 0 2.8 5.5 1.6 5.5 4.5 0 1.3-1.2 2.25-2.8 2.25-1.2 0-2.2-.55-2.8-1.5" stroke="#6D4AFF" stroke-width="2.4" stroke-linecap="round"/></svg></span><span style="font-size: 22px; font-weight: 800; letter-spacing: -0.04em">shx<span style="font-weight: 500; color: #B69DFF">vpn</span></span></span>
|
||||
<a href="D_Diag.dc.html" aria-label="Диагностика: есть проблема" style="position: relative; width: 44px; height: 44px; border-radius: 22px; background: #2B2930; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l3-8 4 16 3-8h4"/></svg>
|
||||
<span style="position: absolute; top: 8px; right: 8px; width: 10px; height: 10px; border-radius: 5px; background: #B45309; border: 2px solid #2B2930"></span>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<section style="display: flex; flex-direction: column; align-items: center; gap: 10px; padding-top: 8px">
|
||||
<button aria-label="Подключить сейчас" style="width: 188px; height: 188px; border: 0; border-radius: 40% 60% 55% 45% / 50% 45% 55% 50%; background: #4A3416; color: #FFB869; display: flex; align-items: center; justify-content: center; box-shadow: 0 0 0 14px #33260F, 0 0 0 28px #2A2010; margin: 28px 0 22px; cursor: pointer">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
|
||||
</button>
|
||||
<span style="font-size: 22px; font-weight: 800; letter-spacing: -0.02em; color: #FFD8A8">Оператор включил белые списки</span>
|
||||
<span style="max-width: 320px; text-align: center; font-size: 14px; line-height: 1.45; color: #F5C892">Работают только российские сайты — банки и Госуслуги открываются. VPN подключится сам.</span>
|
||||
</section>
|
||||
|
||||
<div style="padding: 20px 16px 0; display: flex; flex-direction: column; gap: 10px">
|
||||
<div role="status" style="display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 24px; background: #1F1D24; border: 1px solid #5A4220">
|
||||
<span style="width: 10px; height: 10px; border-radius: 5px; background: #B45309"></span>
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px">
|
||||
<span style="font-size: 15px; font-weight: 700; color: #FFD8A8">Пробуем резервный сервер</span>
|
||||
<span style="font-size: 12px; color: #F5C892">Хельсинки · попытка 2 из 3</span>
|
||||
</span>
|
||||
<button style="flex: none; height: 40px; padding: 0 14px; border-radius: 12px; border: 1px solid #7A5A30; background: transparent; color: #FFD8A8; font: 700 13px 'Manrope', sans-serif">Стоп</button>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px">
|
||||
<a href="D_Diag.dc.html" style="height: 52px; border-radius: 18px; background: #B45309; color: #FFFFFF; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 800">Что делать</a>
|
||||
<button style="height: 52px; border: 0; border-radius: 18px; background: #2B2930; color: #E0DAF5; font: 700 14px 'Manrope', sans-serif">Через Wi-Fi</button>
|
||||
</div>
|
||||
<div role="status" style="display: flex; align-items: center; gap: 10px; padding: 10px 12px 10px 14px; border-radius: 18px; background: #211F26">
|
||||
<span style="flex: 1; font-size: 13px; font-weight: 600; color: #E0DAF5">Постоянный VPN выключен</span>
|
||||
<a href="D_KillSwitch.dc.html" style="flex: none; height: 36px; padding: 0 12px; border-radius: 12px; background: #3B2C88; color: #D0C2FF; font-size: 13px; font-weight: 700; display: flex; align-items: center; justify-content: center; text-decoration: none; box-sizing: border-box">Включить</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Основная навигация" style="position: absolute; left: 0; right: 0; bottom: 0; height: 80px; background: #2B2930; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); align-items: center">
|
||||
<sc-for list="{{tabs}}" as="t" hint-placeholder-count="4">
|
||||
<a href="{{t.href}}" style="display: flex; flex-direction: column; align-items: center; gap: 4px; text-decoration: none; color: {{t.fg}}; font-size: 12px; font-weight: {{t.weight}}">
|
||||
<span style="width: 60px; height: 32px; border-radius: 16px; background: {{t.pill}}; display: flex; align-items: center; justify-content: center"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="{{t.icon}}"/></svg></span>{{t.label}}
|
||||
</a>
|
||||
</sc-for>
|
||||
</nav>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":844}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var tabs = [
|
||||
['Главная', 'D_HomeAlert.dc.html', 'M12 3v9M6.3 6.3a8 8 0 1 0 11.4 0'],
|
||||
['Серверы', 'D_Servers.dc.html', 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18'],
|
||||
['Маршруты', 'D_Routing.dc.html', 'M4 12h6l4-6h6M10 12l4 6h6'],
|
||||
['Настройки', 'D_Settings.dc.html', 'M4 7h10M18 7h2M4 17h4M12 17h8M14 5v4M8 15v4']
|
||||
];
|
||||
return {
|
||||
tabs: tabs.map(function (t, i) {
|
||||
var on = i === 0;
|
||||
return { label: t[0], href: t[1], icon: t[2], fg: on ? '#E5E1E9' : '#CAC4D0', weight: on ? '800' : '600', pill: on ? '#4A3416' : 'transparent' };
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,88 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Главная — подключаемся</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 844px; box-sizing: border-box; background: #141318; font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden; font-variant-numeric: tabular-nums">
|
||||
<header style="display: flex; align-items: center; justify-content: space-between; padding: 18px 20px 4px">
|
||||
<span style="display: flex; align-items: center; gap: 10px"><span style="width: 34px; height: 34px; border-radius: 10px; background: #6D4AFF; display: flex; align-items: center; justify-content: center"><svg width="26" height="26" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 2.6l7.6 2.8v6c0 5-3.3 8.4-7.6 10-4.3-1.6-7.6-5-7.6-10v-6z" fill="#FFFFFF"/><path d="M14.6 8.8c-.6-.95-1.6-1.45-2.7-1.45-1.5 0-2.6.85-2.6 2.05 0 2.8 5.5 1.6 5.5 4.5 0 1.3-1.2 2.25-2.8 2.25-1.2 0-2.2-.55-2.8-1.5" stroke="#6D4AFF" stroke-width="2.4" stroke-linecap="round"/></svg></span><span style="font-size: 22px; font-weight: 800; letter-spacing: -0.04em">shx<span style="font-weight: 500; color: #B69DFF">vpn</span></span></span>
|
||||
<span style="display: flex; gap: 8px">
|
||||
<a href="D_Inbox.dc.html" aria-label="События: 2 ждут решения" style="position: relative; width: 44px; height: 44px; border-radius: 22px; background: #2B2930; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15zM10 20a2 2 0 0 0 4 0"/></svg>
|
||||
<span style="position: absolute; top: 4px; right: 2px; min-width: 18px; height: 18px; padding: 0 4px; box-sizing: border-box; border-radius: 9px; background: #6D4AFF; border: 2px solid #141318; color: #FFFFFF; font-size: 10px; font-weight: 800; display: flex; align-items: center; justify-content: center">2</span>
|
||||
</a>
|
||||
<a href="D_Diag.dc.html" aria-label="Диагностика" style="width: 44px; height: 44px; border-radius: 22px; background: #2B2930; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l3-8 4 16 3-8h4"/></svg>
|
||||
</a>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<section style="display: flex; flex-direction: column; align-items: center; gap: 10px; padding-top: 8px">
|
||||
<button aria-label="Отменить подключение" style="width: 188px; height: 188px; border: 0; border-radius: 40% 60% 55% 45% / 50% 45% 55% 50%; background: #3B2C88; color: #B69DFF; display: flex; align-items: center; justify-content: center; box-shadow: 0 0 0 14px #2A2343, 0 0 0 28px #221D33; margin: 28px 0 22px; cursor: pointer">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v9M6.3 6.3a8 8 0 1 0 11.4 0"/></svg>
|
||||
</button>
|
||||
<span style="font-size: 24px; font-weight: 800; letter-spacing: -0.02em; color: #E5E1E9">Подключаемся…</span>
|
||||
<span style="max-width: 320px; text-align: center; font-size: 14px; font-weight: 600; line-height: 1.4; color: #A9A3BF">Амстердам · шаг 3 из 4</span>
|
||||
</section>
|
||||
|
||||
<div style="padding: 22px 16px 0; display: flex; flex-direction: column; gap: 10px">
|
||||
<a href="D_Servers.dc.html" style="display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35; text-decoration: none; color: #E5E1E9">
|
||||
<span style="width: 40px; height: 40px; border-radius: 20px; background: #3B2C88; color: #D0C2FF; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 14px">NL</span>
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px">
|
||||
<span style="font-size: 16px; font-weight: 700">Амстердам</span>
|
||||
<span style="font-size: 13px; color: #A9A3BF">IP 185.•••.•••.12 · 38 мс</span>
|
||||
</span>
|
||||
<span style="font-size: 13px; font-weight: 700; color: #B69DFF">Сменить</span>
|
||||
</a>
|
||||
|
||||
<section style="padding: 8px 16px; border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35"><div style="display: flex; align-items: center; gap: 12px; height: 40px"><span style="width: 22px; height: 22px; border-radius: 11px; background: #12321F; color: #7CE3B0; font-size: 12px; font-weight: 800; display: flex; align-items: center; justify-content: center">✓</span><span style="flex: 1; font-size: 14px; font-weight: 600; color: #E5E1E9">Сеть</span><span style="font-size: 13px; color: #A9A3BF">LTE</span></div><div style="display: flex; align-items: center; gap: 12px; height: 40px"><span style="width: 22px; height: 22px; border-radius: 11px; background: #12321F; color: #7CE3B0; font-size: 12px; font-weight: 800; display: flex; align-items: center; justify-content: center">✓</span><span style="flex: 1; font-size: 14px; font-weight: 600; color: #E5E1E9">Сервер ответил</span><span style="font-size: 13px; color: #A9A3BF">38 мс</span></div><div style="display: flex; align-items: center; gap: 12px; height: 40px"><span style="width: 22px; height: 22px; border-radius: 11px; background: #6D4AFF; color: #FFFFFF; font-size: 12px; font-weight: 800; display: flex; align-items: center; justify-content: center">…</span><span style="flex: 1; font-size: 14px; font-weight: 800; color: #E5E1E9">Проверка входа</span><span style="font-size: 13px; color: #A9A3BF"></span></div><div style="display: flex; align-items: center; gap: 12px; height: 40px"><span style="width: 22px; height: 22px; border-radius: 11px; background: #2B2930; color: #938F99; font-size: 12px; font-weight: 800; display: flex; align-items: center; justify-content: center"></span><span style="flex: 1; font-size: 14px; font-weight: 600; color: #938F99">Туннель и маршруты</span><span style="font-size: 13px; color: #A9A3BF"></span></div></section>
|
||||
|
||||
<a href="D_Home.dc.html" style="height: 48px; border-radius: 16px; background: #2B2930; color: #E0DAF5; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 700">Отменить</a>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Основная навигация" style="position: absolute; left: 0; right: 0; bottom: 0; height: 80px; background: #2B2930; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); align-items: center">
|
||||
<sc-for list="{{tabs}}" as="t" hint-placeholder-count="4">
|
||||
<a href="{{t.href}}" style="display: flex; flex-direction: column; align-items: center; gap: 4px; text-decoration: none; color: {{t.fg}}; font-size: 12px; font-weight: {{t.weight}}">
|
||||
<span style="width: 60px; height: 32px; border-radius: 16px; background: {{t.pill}}; display: flex; align-items: center; justify-content: center"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="{{t.icon}}"/></svg></span>{{t.label}}
|
||||
</a>
|
||||
</sc-for>
|
||||
</nav>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":844}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var tabs = [
|
||||
['Главная', 'D_Home.dc.html', 'M12 3v9M6.3 6.3a8 8 0 1 0 11.4 0'],
|
||||
['Серверы', 'D_Servers.dc.html', 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18'],
|
||||
['Маршруты', 'D_Routing.dc.html', 'M4 12h6l4-6h6M10 12l4 6h6'],
|
||||
['Настройки', 'D_Settings.dc.html', 'M4 7h10M18 7h2M4 17h4M12 17h8M14 5v4M8 15v4']
|
||||
];
|
||||
var active = 0;
|
||||
return {
|
||||
tabs: tabs.map(function (t, i) {
|
||||
var on = i === active;
|
||||
return { label: t[0], href: t[1], icon: t[2], fg: on ? '#E5E1E9' : '#CAC4D0', weight: on ? '800' : '600', pill: on ? '#3B2C88' : 'transparent' };
|
||||
}),
|
||||
actions: [
|
||||
{ label: 'Пауза 15 мин', href: 'D_Pause.dc.html', icon: 'M9 5v14M15 5v14' },
|
||||
{ label: 'Быстрейший', href: 'D_Servers.dc.html', icon: 'M13 2L4 14h7l-1 8 9-12h-7z' },
|
||||
{ label: 'Не открывается?', href: 'D_Diag.dc.html', icon: 'M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6M12 17v.5' }
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,79 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Главная — нет серверов</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 844px; box-sizing: border-box; background: #141318; font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden; font-variant-numeric: tabular-nums">
|
||||
<header style="display: flex; align-items: center; justify-content: space-between; padding: 18px 20px 4px">
|
||||
<span style="display: flex; align-items: center; gap: 10px"><span style="width: 34px; height: 34px; border-radius: 10px; background: #6D4AFF; display: flex; align-items: center; justify-content: center"><svg width="26" height="26" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 2.6l7.6 2.8v6c0 5-3.3 8.4-7.6 10-4.3-1.6-7.6-5-7.6-10v-6z" fill="#FFFFFF"/><path d="M14.6 8.8c-.6-.95-1.6-1.45-2.7-1.45-1.5 0-2.6.85-2.6 2.05 0 2.8 5.5 1.6 5.5 4.5 0 1.3-1.2 2.25-2.8 2.25-1.2 0-2.2-.55-2.8-1.5" stroke="#6D4AFF" stroke-width="2.4" stroke-linecap="round"/></svg></span><span style="font-size: 22px; font-weight: 800; letter-spacing: -0.04em">shx<span style="font-weight: 500; color: #B69DFF">vpn</span></span></span>
|
||||
<span style="display: flex; gap: 8px">
|
||||
<a href="D_Inbox.dc.html" aria-label="События: 2 ждут решения" style="position: relative; width: 44px; height: 44px; border-radius: 22px; background: #2B2930; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15zM10 20a2 2 0 0 0 4 0"/></svg>
|
||||
<span style="position: absolute; top: 4px; right: 2px; min-width: 18px; height: 18px; padding: 0 4px; box-sizing: border-box; border-radius: 9px; background: #6D4AFF; border: 2px solid #141318; color: #FFFFFF; font-size: 10px; font-weight: 800; display: flex; align-items: center; justify-content: center">2</span>
|
||||
</a>
|
||||
<a href="D_Diag.dc.html" aria-label="Диагностика" style="width: 44px; height: 44px; border-radius: 22px; background: #2B2930; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l3-8 4 16 3-8h4"/></svg>
|
||||
</a>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<section style="display: flex; flex-direction: column; align-items: center; gap: 10px; padding-top: 8px">
|
||||
<button aria-label="Добавить сервер" style="width: 188px; height: 188px; border: 0; border-radius: 40% 60% 55% 45% / 50% 45% 55% 50%; background: #2B2930; color: #938F99; display: flex; align-items: center; justify-content: center; box-shadow: 0 0 0 14px #211F26, 0 0 0 28px #1A1820; margin: 28px 0 22px; cursor: pointer">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
|
||||
</button>
|
||||
<span style="font-size: 24px; font-weight: 800; letter-spacing: -0.02em; color: #E5E1E9">Добавьте сервер</span>
|
||||
<span style="max-width: 320px; text-align: center; font-size: 14px; font-weight: 600; line-height: 1.4; color: #A9A3BF">Чтобы подключиться, нужен профиль .sswan</span>
|
||||
</section>
|
||||
|
||||
<div style="padding: 22px 16px 0; display: flex; flex-direction: column; gap: 10px">
|
||||
<a href="D_ImportQR.dc.html" style="height: 60px; border-radius: 20px; background: #6D4AFF; color: #FFFFFF; text-decoration: none; display: flex; align-items: center; justify-content: center; gap: 10px; font-size: 16px; font-weight: 800"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 8V5a1 1 0 0 1 1-1h3M16 4h3a1 1 0 0 1 1 1v3M20 16v3a1 1 0 0 1-1 1h-3M8 20H5a1 1 0 0 1-1-1v-3M4 12h16"/></svg>Сканировать QR</a>
|
||||
<a href="D_Import.dc.html" style="height: 56px; border-radius: 18px; background: #36334A; color: #E0DAF5; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 15px; font-weight: 700">Ссылка, файл или вручную</a>
|
||||
<div style="padding: 14px 16px; border-radius: 20px; background: #1F1D24; border: 1px solid #2E2B35; display: flex; flex-direction: column; gap: 4px"><span style="font-size: 14px; font-weight: 800">Где взять профиль?</span><span style="font-size: 13px; line-height: 1.45; color: #A9A3BF">Его присылает администратор вашего VPN — файлом .sswan, ссылкой или QR-кодом. Можно отсканировать с экрана друга.</span></div>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Основная навигация" style="position: absolute; left: 0; right: 0; bottom: 0; height: 80px; background: #2B2930; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); align-items: center">
|
||||
<sc-for list="{{tabs}}" as="t" hint-placeholder-count="4">
|
||||
<a href="{{t.href}}" style="display: flex; flex-direction: column; align-items: center; gap: 4px; text-decoration: none; color: {{t.fg}}; font-size: 12px; font-weight: {{t.weight}}">
|
||||
<span style="width: 60px; height: 32px; border-radius: 16px; background: {{t.pill}}; display: flex; align-items: center; justify-content: center"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="{{t.icon}}"/></svg></span>{{t.label}}
|
||||
</a>
|
||||
</sc-for>
|
||||
</nav>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":844}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var tabs = [
|
||||
['Главная', 'D_Home.dc.html', 'M12 3v9M6.3 6.3a8 8 0 1 0 11.4 0'],
|
||||
['Серверы', 'D_Servers.dc.html', 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18'],
|
||||
['Маршруты', 'D_Routing.dc.html', 'M4 12h6l4-6h6M10 12l4 6h6'],
|
||||
['Настройки', 'D_Settings.dc.html', 'M4 7h10M18 7h2M4 17h4M12 17h8M14 5v4M8 15v4']
|
||||
];
|
||||
var active = 0;
|
||||
return {
|
||||
tabs: tabs.map(function (t, i) {
|
||||
var on = i === active;
|
||||
return { label: t[0], href: t[1], icon: t[2], fg: on ? '#E5E1E9' : '#CAC4D0', weight: on ? '800' : '600', pill: on ? '#3B2C88' : 'transparent' };
|
||||
}),
|
||||
actions: [
|
||||
{ label: 'Пауза 15 мин', href: 'D_Pause.dc.html', icon: 'M9 5v14M15 5v14' },
|
||||
{ label: 'Быстрейший', href: 'D_Servers.dc.html', icon: 'M13 2L4 14h7l-1 8 9-12h-7z' },
|
||||
{ label: 'Не открывается?', href: 'D_Diag.dc.html', icon: 'M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6M12 17v.5' }
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,70 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Главная — нет интернета</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 844px; box-sizing: border-box; background: #141318; font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden; font-variant-numeric: tabular-nums">
|
||||
<header style="display: flex; align-items: center; justify-content: space-between; padding: 18px 20px 4px">
|
||||
<span style="display: flex; align-items: center; gap: 10px"><span style="width: 34px; height: 34px; border-radius: 10px; background: #6D4AFF; display: flex; align-items: center; justify-content: center"><svg width="26" height="26" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 2.6l7.6 2.8v6c0 5-3.3 8.4-7.6 10-4.3-1.6-7.6-5-7.6-10v-6z" fill="#FFFFFF"/><path d="M14.6 8.8c-.6-.95-1.6-1.45-2.7-1.45-1.5 0-2.6.85-2.6 2.05 0 2.8 5.5 1.6 5.5 4.5 0 1.3-1.2 2.25-2.8 2.25-1.2 0-2.2-.55-2.8-1.5" stroke="#6D4AFF" stroke-width="2.4" stroke-linecap="round"/></svg></span><span style="font-size: 22px; font-weight: 800; letter-spacing: -0.04em">shx<span style="font-weight: 500; color: #B69DFF">vpn</span></span></span>
|
||||
<span style="display: flex; gap: 8px">
|
||||
<a href="D_Inbox.dc.html" aria-label="События: 2 ждут решения" style="position: relative; width: 44px; height: 44px; border-radius: 22px; background: #2B2930; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15zM10 20a2 2 0 0 0 4 0"/></svg>
|
||||
<span style="position: absolute; top: 4px; right: 2px; min-width: 18px; height: 18px; padding: 0 4px; box-sizing: border-box; border-radius: 9px; background: #6D4AFF; border: 2px solid #141318; color: #FFFFFF; font-size: 10px; font-weight: 800; display: flex; align-items: center; justify-content: center">2</span>
|
||||
</a>
|
||||
<a href="D_Diag.dc.html" aria-label="Диагностика" style="width: 44px; height: 44px; border-radius: 22px; background: #2B2930; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l3-8 4 16 3-8h4"/></svg>
|
||||
</a>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<section style="display: flex; flex-direction: column; align-items: center; gap: 10px; padding-top: 8px">
|
||||
<button aria-label="Отключить VPN" style="width: 188px; height: 188px; border: 0; border-radius: 40% 60% 55% 45% / 50% 45% 55% 50%; background: #2B2930; color: #CAC4D0; display: flex; align-items: center; justify-content: center; box-shadow: 0 0 0 14px #211F26, 0 0 0 28px #1A1820; margin: 28px 0 22px; cursor: pointer"><svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 8.8a15 15 0 0 1 4.4-2.7M10 5.1A15 15 0 0 1 22 8.8M5 12.9a10 10 0 0 1 4.6-2.6M14.6 10.5A10 10 0 0 1 19 12.9M8.5 16.4a5 5 0 0 1 7 0M12 20h.01M3 3l18 18"/></svg></button>
|
||||
<span style="font-size: 24px; font-weight: 800; letter-spacing: -0.02em">Нет интернета</span>
|
||||
<span style="max-width: 320px; text-align: center; font-size: 14px; font-weight: 600; line-height: 1.4; color: #A9A3BF">Проблема не в VPN. Подключимся сами, как только появится сеть</span>
|
||||
</section>
|
||||
|
||||
<div style="padding: 22px 16px 0; display: flex; flex-direction: column; gap: 10px">
|
||||
<section style="padding: 6px 16px; border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35"><div style="display: flex; align-items: center; gap: 12px; height: 38px"><span style="width: 22px; height: 22px; border-radius: 11px; background: #4A1C1C; color: #FFB4AB; font-size: 12px; font-weight: 800; display: flex; align-items: center; justify-content: center">×</span><span style="flex: 1; font-size: 14px; font-weight: 600">Wi-Fi</span><span style="font-size: 13px; color: #A9A3BF">выключен</span></div><div style="display: flex; align-items: center; gap: 12px; height: 38px"><span style="width: 22px; height: 22px; border-radius: 11px; background: #4A1C1C; color: #FFB4AB; font-size: 12px; font-weight: 800; display: flex; align-items: center; justify-content: center">×</span><span style="flex: 1; font-size: 14px; font-weight: 600">Мобильные данные</span><span style="font-size: 13px; color: #A9A3BF">нет сигнала</span></div><div style="display: flex; align-items: center; gap: 12px; height: 38px"><span style="width: 22px; height: 22px; border-radius: 11px; background: #2B2930; color: #938F99; font-size: 12px; font-weight: 800; display: flex; align-items: center; justify-content: center"></span><span style="flex: 1; font-size: 14px; font-weight: 600">Сервер Амстердам</span><span style="font-size: 13px; color: #A9A3BF">ждёт сеть</span></div></section>
|
||||
<section role="status" style="padding: 16px; border-radius: 24px; background: #2A2343; border: 1px solid #4B3D99; display: flex; flex-direction: column; gap: 10px"><span style="font-size: 15px; font-weight: 800; color: #D0C2FF">Приложения без сети — так и задумано</span><span style="font-size: 13px; line-height: 1.45; color: #D0C2FF">Включено «Без VPN — без сети»: пока туннеля нет, трафик не уйдёт напрямую.</span><div style="display: flex; gap: 8px"><a href="D_Settings.dc.html" style="flex: 1; min-width: 0; height: 48px; padding: 0 14px; border-radius: 16px; background: #6D4AFF; color: #FFFFFF; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 800; white-space: nowrap; box-sizing: border-box">Настройки сети</a><a href="D_KillSwitch.dc.html" style="flex: none; min-width: 0; height: 48px; padding: 0 14px; border-radius: 16px; background: #2B2930; color: #E0DAF5; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 800; white-space: nowrap; box-sizing: border-box">Подробнее</a></div></section>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Основная навигация" style="position: absolute; left: 0; right: 0; bottom: 0; height: 80px; background: #2B2930; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); align-items: center">
|
||||
<sc-for list="{{tabs}}" as="t" hint-placeholder-count="4">
|
||||
<a href="{{t.href}}" style="display: flex; flex-direction: column; align-items: center; gap: 4px; text-decoration: none; color: {{t.fg}}; font-size: 12px; font-weight: {{t.weight}}">
|
||||
<span style="width: 60px; height: 32px; border-radius: 16px; background: {{t.pill}}; display: flex; align-items: center; justify-content: center"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="{{t.icon}}"/></svg></span>{{t.label}}
|
||||
</a>
|
||||
</sc-for>
|
||||
</nav>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":844}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var tabs = [
|
||||
['Главная', 'D_Home.dc.html', 'M12 3v9M6.3 6.3a8 8 0 1 0 11.4 0'],
|
||||
['Серверы', 'D_Servers.dc.html', 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18'],
|
||||
['Маршруты', 'D_Routing.dc.html', 'M4 12h6l4-6h6M10 12l4 6h6'],
|
||||
['Настройки', 'D_Settings.dc.html', 'M4 7h10M18 7h2M4 17h4M12 17h8M14 5v4M8 15v4']
|
||||
];
|
||||
return {
|
||||
tabs: tabs.map(function (t, i) {
|
||||
var on = i === 0;
|
||||
return { label: t[0], href: t[1], icon: t[2], fg: on ? '#E5E1E9' : '#CAC4D0', weight: on ? '800' : '600', pill: on ? '#3B2C88' : 'transparent' };
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,88 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Главная — пауза</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 844px; box-sizing: border-box; background: #141318; font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden; font-variant-numeric: tabular-nums">
|
||||
<header style="display: flex; align-items: center; justify-content: space-between; padding: 18px 20px 4px">
|
||||
<span style="display: flex; align-items: center; gap: 10px"><span style="width: 34px; height: 34px; border-radius: 10px; background: #6D4AFF; display: flex; align-items: center; justify-content: center"><svg width="26" height="26" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 2.6l7.6 2.8v6c0 5-3.3 8.4-7.6 10-4.3-1.6-7.6-5-7.6-10v-6z" fill="#FFFFFF"/><path d="M14.6 8.8c-.6-.95-1.6-1.45-2.7-1.45-1.5 0-2.6.85-2.6 2.05 0 2.8 5.5 1.6 5.5 4.5 0 1.3-1.2 2.25-2.8 2.25-1.2 0-2.2-.55-2.8-1.5" stroke="#6D4AFF" stroke-width="2.4" stroke-linecap="round"/></svg></span><span style="font-size: 22px; font-weight: 800; letter-spacing: -0.04em">shx<span style="font-weight: 500; color: #B69DFF">vpn</span></span></span>
|
||||
<span style="display: flex; gap: 8px">
|
||||
<a href="D_Inbox.dc.html" aria-label="События: 2 ждут решения" style="position: relative; width: 44px; height: 44px; border-radius: 22px; background: #2B2930; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15zM10 20a2 2 0 0 0 4 0"/></svg>
|
||||
<span style="position: absolute; top: 4px; right: 2px; min-width: 18px; height: 18px; padding: 0 4px; box-sizing: border-box; border-radius: 9px; background: #6D4AFF; border: 2px solid #141318; color: #FFFFFF; font-size: 10px; font-weight: 800; display: flex; align-items: center; justify-content: center">2</span>
|
||||
</a>
|
||||
<a href="D_Diag.dc.html" aria-label="Диагностика" style="width: 44px; height: 44px; border-radius: 22px; background: #2B2930; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l3-8 4 16 3-8h4"/></svg>
|
||||
</a>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<section style="display: flex; flex-direction: column; align-items: center; gap: 10px; padding-top: 8px">
|
||||
<button aria-label="Включить VPN" style="width: 188px; height: 188px; border: 0; border-radius: 40% 60% 55% 45% / 50% 45% 55% 50%; background: #2B2930; color: #CAC4D0; display: flex; align-items: center; justify-content: center; box-shadow: 0 0 0 14px #211F26, 0 0 0 28px #1A1820; margin: 28px 0 22px; cursor: pointer">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 5v14M15 5v14"/></svg>
|
||||
</button>
|
||||
<span style="font-size: 24px; font-weight: 800; letter-spacing: -0.02em; color: #E5E1E9">Пауза · 12:40</span>
|
||||
<span style="max-width: 320px; text-align: center; font-size: 14px; font-weight: 600; line-height: 1.4; color: #A9A3BF">VPN включится сам в 14:37</span>
|
||||
</section>
|
||||
|
||||
<div style="padding: 22px 16px 0; display: flex; flex-direction: column; gap: 10px">
|
||||
<a href="D_Servers.dc.html" style="display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35; text-decoration: none; color: #E5E1E9">
|
||||
<span style="width: 40px; height: 40px; border-radius: 20px; background: #3B2C88; color: #D0C2FF; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 14px">NL</span>
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px">
|
||||
<span style="font-size: 16px; font-weight: 700">Амстердам</span>
|
||||
<span style="font-size: 13px; color: #A9A3BF">IP 185.•••.•••.12 · 38 мс</span>
|
||||
</span>
|
||||
<span style="font-size: 13px; font-weight: 700; color: #B69DFF">Сменить</span>
|
||||
</a>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px"><a href="D_Home.dc.html" style="height: 56px; border-radius: 18px; background: #6D4AFF; color: #FFFFFF; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 15px; font-weight: 800">Включить сейчас</a><a href="D_Pause.dc.html" style="height: 56px; border-radius: 18px; background: #2B2930; color: #E0DAF5; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 15px; font-weight: 700">+15 минут</a></div>
|
||||
|
||||
<div style="padding: 12px 14px; border-radius: 18px; background: #3A2A12; color: #F5C892; font-size: 13px; line-height: 1.45">Весь трафик сейчас идёт напрямую, без защиты. Банки и РФ-сервисы работают как обычно.</div>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Основная навигация" style="position: absolute; left: 0; right: 0; bottom: 0; height: 80px; background: #2B2930; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); align-items: center">
|
||||
<sc-for list="{{tabs}}" as="t" hint-placeholder-count="4">
|
||||
<a href="{{t.href}}" style="display: flex; flex-direction: column; align-items: center; gap: 4px; text-decoration: none; color: {{t.fg}}; font-size: 12px; font-weight: {{t.weight}}">
|
||||
<span style="width: 60px; height: 32px; border-radius: 16px; background: {{t.pill}}; display: flex; align-items: center; justify-content: center"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="{{t.icon}}"/></svg></span>{{t.label}}
|
||||
</a>
|
||||
</sc-for>
|
||||
</nav>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":844}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var tabs = [
|
||||
['Главная', 'D_Home.dc.html', 'M12 3v9M6.3 6.3a8 8 0 1 0 11.4 0'],
|
||||
['Серверы', 'D_Servers.dc.html', 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18'],
|
||||
['Маршруты', 'D_Routing.dc.html', 'M4 12h6l4-6h6M10 12l4 6h6'],
|
||||
['Настройки', 'D_Settings.dc.html', 'M4 7h10M18 7h2M4 17h4M12 17h8M14 5v4M8 15v4']
|
||||
];
|
||||
var active = 0;
|
||||
return {
|
||||
tabs: tabs.map(function (t, i) {
|
||||
var on = i === active;
|
||||
return { label: t[0], href: t[1], icon: t[2], fg: on ? '#E5E1E9' : '#CAC4D0', weight: on ? '800' : '600', pill: on ? '#3B2C88' : 'transparent' };
|
||||
}),
|
||||
actions: [
|
||||
{ label: 'Пауза 15 мин', href: 'D_Pause.dc.html', icon: 'M9 5v14M15 5v14' },
|
||||
{ label: 'Быстрейший', href: 'D_Servers.dc.html', icon: 'M13 2L4 14h7l-1 8 9-12h-7z' },
|
||||
{ label: 'Не открывается?', href: 'D_Diag.dc.html', icon: 'M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6M12 17v.5' }
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,106 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Главная — сценарий</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 844px; box-sizing: border-box; background: #141318; font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden; font-variant-numeric: tabular-nums">
|
||||
<header style="display: flex; align-items: center; justify-content: space-between; padding: 18px 20px 4px">
|
||||
<span style="display: flex; align-items: center; gap: 10px"><span style="width: 34px; height: 34px; border-radius: 10px; background: #6D4AFF; display: flex; align-items: center; justify-content: center"><svg width="26" height="26" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 2.6l7.6 2.8v6c0 5-3.3 8.4-7.6 10-4.3-1.6-7.6-5-7.6-10v-6z" fill="#FFFFFF"/><path d="M14.6 8.8c-.6-.95-1.6-1.45-2.7-1.45-1.5 0-2.6.85-2.6 2.05 0 2.8 5.5 1.6 5.5 4.5 0 1.3-1.2 2.25-2.8 2.25-1.2 0-2.2-.55-2.8-1.5" stroke="#6D4AFF" stroke-width="2.4" stroke-linecap="round"/></svg></span><span style="font-size: 22px; font-weight: 800; letter-spacing: -0.04em">shx<span style="font-weight: 500; color: #B69DFF">vpn</span></span></span>
|
||||
<span style="display: flex; gap: 8px">
|
||||
<a href="D_Inbox.dc.html" aria-label="События: 2 ждут решения" style="position: relative; width: 44px; height: 44px; border-radius: 22px; background: #2B2930; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15zM10 20a2 2 0 0 0 4 0"/></svg>
|
||||
<span style="position: absolute; top: 4px; right: 2px; min-width: 18px; height: 18px; padding: 0 4px; box-sizing: border-box; border-radius: 9px; background: #6D4AFF; border: 2px solid #141318; color: #FFFFFF; font-size: 10px; font-weight: 800; display: flex; align-items: center; justify-content: center">2</span>
|
||||
</a>
|
||||
<a href="D_Diag.dc.html" aria-label="Диагностика" style="width: 44px; height: 44px; border-radius: 22px; background: #2B2930; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l3-8 4 16 3-8h4"/></svg>
|
||||
</a>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<section style="display: flex; flex-direction: column; align-items: center; gap: 10px; padding-top: 8px">
|
||||
<button aria-label="Включить VPN" style="width: 188px; height: 188px; border: 0; border-radius: 40% 60% 55% 45% / 50% 45% 55% 50%; background: #36333D; color: #CAC4D0; display: flex; align-items: center; justify-content: center; box-shadow: 0 0 0 14px #2B2930, 0 0 0 28px #211F26; margin: 28px 0 22px; cursor: pointer">
|
||||
<svg width="68" height="68" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M12 3v9"/><path d="M6.3 6.3a8 8 0 1 0 11.4 0"/></svg>
|
||||
</button>
|
||||
<span style="font-size: 24px; font-weight: 800; letter-spacing: -0.02em">VPN выключен</span>
|
||||
<a href="D_Automation.dc.html" style="display: flex; align-items: center; gap: 8px; height: 40px; padding: 0 8px 0 14px; border-radius: 20px; background: #3B2C88; color: #EADDFF; text-decoration: none; font-size: 14px; font-weight: 700">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 9a15 15 0 0 1 20 0M5 13a10 10 0 0 1 14 0M9 17a4 4 0 0 1 6 0"/></svg>
|
||||
Сценарий · Home_5G
|
||||
<span style="width: 24px; height: 24px; border-radius: 12px; background: #1F1D24; display: flex; align-items: center; justify-content: center"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6D4AFF" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg></span>
|
||||
</a>
|
||||
<span style="font-size: 13px; color: #A9A3BF">Включится сам, когда уйдёте из этой сети</span>
|
||||
</section>
|
||||
|
||||
<div style="padding: 22px 16px 0; display: flex; flex-direction: column; gap: 10px">
|
||||
<a href="D_Servers.dc.html" style="display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35; text-decoration: none; color: #E5E1E9">
|
||||
<span style="width: 40px; height: 40px; border-radius: 20px; background: #3B2C88; color: #D0C2FF; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 14px">NL</span>
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px">
|
||||
<span style="font-size: 16px; font-weight: 700">Амстердам</span>
|
||||
<span style="font-size: 13px; color: #A9A3BF">подключится при выходе из Home_5G</span>
|
||||
</span>
|
||||
<span style="font-size: 13px; font-weight: 700; color: #B69DFF">Сменить</span>
|
||||
</a>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px">
|
||||
<sc-for list="{{actions}}" as="q" hint-placeholder-count="3">
|
||||
<a href="{{q.href}}" style="height: 72px; border-radius: 20px; background: #2B2930; color: #E0DAF5; text-decoration: none; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; font-size: 12px; font-weight: 700; text-align: center">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="{{q.icon}}"/></svg>{{q.label}}
|
||||
</a>
|
||||
</sc-for>
|
||||
</div>
|
||||
|
||||
<a href="D_Stats.dc.html" style="display: flex; align-items: center; gap: 12px; padding: 14px; border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35; text-decoration: none; color: #E5E1E9">
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 8px">
|
||||
<span style="display: flex; align-items: baseline; gap: 8px"><span style="font-size: 20px; font-weight: 800">1,24 ГБ</span><span style="font-size: 13px; color: #A9A3BF">сегодня</span></span>
|
||||
<span style="display: flex; height: 6px; border-radius: 3px; overflow: hidden; gap: 2px"><span style="width: 72%; background: #6D4AFF; border-radius: 3px"></span><span style="flex: 1; background: #3FBF7F; border-radius: 3px"></span></span>
|
||||
<span style="font-size: 12px; font-weight: 600; color: #A9A3BF">72% через VPN · 28% напрямую</span>
|
||||
</span>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#A9A3BF" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Основная навигация" style="position: absolute; left: 0; right: 0; bottom: 0; height: 80px; background: #2B2930; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); align-items: center">
|
||||
<sc-for list="{{tabs}}" as="t" hint-placeholder-count="4">
|
||||
<a href="{{t.href}}" style="display: flex; flex-direction: column; align-items: center; gap: 4px; text-decoration: none; color: {{t.fg}}; font-size: 12px; font-weight: {{t.weight}}">
|
||||
<span style="width: 60px; height: 32px; border-radius: 16px; background: {{t.pill}}; display: flex; align-items: center; justify-content: center"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="{{t.icon}}"/></svg></span>{{t.label}}
|
||||
</a>
|
||||
</sc-for>
|
||||
</nav>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":844}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var tabs = [
|
||||
['Главная', 'D_Home.dc.html', 'M12 3v9M6.3 6.3a8 8 0 1 0 11.4 0'],
|
||||
['Серверы', 'D_Servers.dc.html', 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18'],
|
||||
['Маршруты', 'D_Routing.dc.html', 'M4 12h6l4-6h6M10 12l4 6h6'],
|
||||
['Настройки', 'D_Settings.dc.html', 'M4 7h10M18 7h2M4 17h4M12 17h8M14 5v4M8 15v4']
|
||||
];
|
||||
var active = 0;
|
||||
return {
|
||||
tabs: tabs.map(function (t, i) {
|
||||
var on = i === active;
|
||||
return { label: t[0], href: t[1], icon: t[2], fg: on ? '#E5E1E9' : '#CAC4D0', weight: on ? '800' : '600', pill: on ? '#3B2C88' : 'transparent' };
|
||||
}),
|
||||
actions: [
|
||||
{ label: 'Включить сейчас', href: 'D_Home.dc.html', icon: 'M12 3v9M6.3 6.3a8 8 0 1 0 11.4 0' },
|
||||
{ label: 'Быстрейший', href: 'D_Servers.dc.html', icon: 'M13 2L4 14h7l-1 8 9-12h-7z' },
|
||||
{ label: 'Не открывается?', href: 'D_Diag.dc.html', icon: 'M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6M12 17v.5' }
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,71 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Главная — сервер не отвечает</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 844px; box-sizing: border-box; background: #141318; font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden; font-variant-numeric: tabular-nums">
|
||||
<header style="display: flex; align-items: center; justify-content: space-between; padding: 18px 20px 4px">
|
||||
<span style="display: flex; align-items: center; gap: 10px"><span style="width: 34px; height: 34px; border-radius: 10px; background: #6D4AFF; display: flex; align-items: center; justify-content: center"><svg width="26" height="26" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 2.6l7.6 2.8v6c0 5-3.3 8.4-7.6 10-4.3-1.6-7.6-5-7.6-10v-6z" fill="#FFFFFF"/><path d="M14.6 8.8c-.6-.95-1.6-1.45-2.7-1.45-1.5 0-2.6.85-2.6 2.05 0 2.8 5.5 1.6 5.5 4.5 0 1.3-1.2 2.25-2.8 2.25-1.2 0-2.2-.55-2.8-1.5" stroke="#6D4AFF" stroke-width="2.4" stroke-linecap="round"/></svg></span><span style="font-size: 22px; font-weight: 800; letter-spacing: -0.04em">shx<span style="font-weight: 500; color: #B69DFF">vpn</span></span></span>
|
||||
<span style="display: flex; gap: 8px">
|
||||
<a href="D_Inbox.dc.html" aria-label="События: 2 ждут решения" style="position: relative; width: 44px; height: 44px; border-radius: 22px; background: #2B2930; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15zM10 20a2 2 0 0 0 4 0"/></svg>
|
||||
<span style="position: absolute; top: 4px; right: 2px; min-width: 18px; height: 18px; padding: 0 4px; box-sizing: border-box; border-radius: 9px; background: #6D4AFF; border: 2px solid #141318; color: #FFFFFF; font-size: 10px; font-weight: 800; display: flex; align-items: center; justify-content: center">2</span>
|
||||
</a>
|
||||
<a href="D_Diag.dc.html" aria-label="Диагностика" style="width: 44px; height: 44px; border-radius: 22px; background: #2B2930; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l3-8 4 16 3-8h4"/></svg>
|
||||
</a>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<section style="display: flex; flex-direction: column; align-items: center; gap: 10px; padding-top: 8px">
|
||||
<button aria-label="Отключить VPN" style="width: 188px; height: 188px; border: 0; border-radius: 40% 60% 55% 45% / 50% 45% 55% 50%; background: #4A3416; color: #FFD8A8; display: flex; align-items: center; justify-content: center; box-shadow: 0 0 0 14px #33260F, 0 0 0 28px #2A2010; margin: 28px 0 22px; cursor: pointer"><svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 8v5M12 16.5v.5M10.3 3.9L2.4 18a2 2 0 0 0 1.7 3h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/></svg></button>
|
||||
<span style="font-size: 24px; font-weight: 800; letter-spacing: -0.02em">Сервер не отвечает</span>
|
||||
<span style="max-width: 320px; text-align: center; font-size: 14px; font-weight: 600; line-height: 1.4; color: #A9A3BF">Амстердам молчит 40 с · снова пробуем через 12 с</span>
|
||||
</section>
|
||||
|
||||
<div style="padding: 22px 16px 0; display: flex; flex-direction: column; gap: 10px">
|
||||
<section role="status" style="padding: 16px; border-radius: 24px; background: #3A2A12; border: 1px solid #5A4220; display: flex; flex-direction: column; gap: 10px"><span style="font-size: 15px; font-weight: 800; color: #F5C892">Интернет есть — не отвечает сам сервер</span><span style="font-size: 13px; line-height: 1.45; color: #F5C892">Возможно, он перегружен или недоступен из вашей сети. Франкфурт сейчас отвечает за 41 мс.</span><div style="display: flex; gap: 8px"><a href="D_Home.dc.html" style="flex: 1; min-width: 0; height: 48px; padding: 0 14px; border-radius: 16px; background: #6D4AFF; color: #FFFFFF; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 800; white-space: nowrap; box-sizing: border-box">Перейти на Франкфурт</a><a href="D_HomeConnecting.dc.html" style="flex: none; min-width: 0; height: 48px; padding: 0 14px; border-radius: 16px; background: #2B2930; color: #E0DAF5; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 800; white-space: nowrap; box-sizing: border-box">Повторить</a></div></section>
|
||||
<a href="D_Settings.dc.html" style="display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 20px; background: #1F1D24; border: 1px solid #2E2B35; text-decoration: none; color: #E5E1E9"><span style="flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px"><span style="font-size: 15px; font-weight: 700">Переключаться автоматически</span><span style="font-size: 13px; color: #A9A3BF; line-height: 1.4">Если сервер молчит дольше минуты — на ближайший рабочий</span></span><span style="flex: none; width: 52px; height: 32px; border-radius: 16px; background: #36333D; display: flex; align-items: center; padding: 0 4px; box-sizing: border-box; justify-content: flex-start"><span style="width: 24px; height: 24px; border-radius: 12px; background: #79767F"></span></span></a>
|
||||
<a href="D_Diag.dc.html" style="display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 20px; background: #1F1D24; border: 1px solid #2E2B35; text-decoration: none; color: #E5E1E9"><span style="flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px"><span style="font-size: 15px; font-weight: 700">Проверить, в чём дело</span><span style="font-size: 13px; color: #A9A3BF; line-height: 1.4">Сеть, сервер, DNS и белые списки за 10 секунд</span></span><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#A9A3BF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg></a>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Основная навигация" style="position: absolute; left: 0; right: 0; bottom: 0; height: 80px; background: #2B2930; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); align-items: center">
|
||||
<sc-for list="{{tabs}}" as="t" hint-placeholder-count="4">
|
||||
<a href="{{t.href}}" style="display: flex; flex-direction: column; align-items: center; gap: 4px; text-decoration: none; color: {{t.fg}}; font-size: 12px; font-weight: {{t.weight}}">
|
||||
<span style="width: 60px; height: 32px; border-radius: 16px; background: {{t.pill}}; display: flex; align-items: center; justify-content: center"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="{{t.icon}}"/></svg></span>{{t.label}}
|
||||
</a>
|
||||
</sc-for>
|
||||
</nav>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":844}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var tabs = [
|
||||
['Главная', 'D_Home.dc.html', 'M12 3v9M6.3 6.3a8 8 0 1 0 11.4 0'],
|
||||
['Серверы', 'D_Servers.dc.html', 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18'],
|
||||
['Маршруты', 'D_Routing.dc.html', 'M4 12h6l4-6h6M10 12l4 6h6'],
|
||||
['Настройки', 'D_Settings.dc.html', 'M4 7h10M18 7h2M4 17h4M12 17h8M14 5v4M8 15v4']
|
||||
];
|
||||
return {
|
||||
tabs: tabs.map(function (t, i) {
|
||||
var on = i === 0;
|
||||
return { label: t[0], href: t[1], icon: t[2], fg: on ? '#E5E1E9' : '#CAC4D0', weight: on ? '800' : '600', pill: on ? '#3B2C88' : 'transparent' };
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,63 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Добавить сервер</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 844px; box-sizing: border-box; background: rgba(28,27,32,0.5); font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden; display: flex; flex-direction: column; justify-content: flex-end">
|
||||
<section role="dialog" aria-label="Добавить сервер" style="border-radius: 32px 32px 0 0; background: #141318; padding: 12px 16px 28px; display: flex; flex-direction: column; gap: 14px">
|
||||
<span style="align-self: center; width: 36px; height: 4px; border-radius: 2px; background: #48454E"></span>
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; padding: 0 4px">
|
||||
<h1 style="margin: 0; font-size: 24px; font-weight: 800; letter-spacing: -0.02em">Добавить сервер</h1>
|
||||
<a href="D_Servers.dc.html" aria-label="Закрыть" style="width: 44px; height: 44px; border-radius: 22px; display: flex; align-items: center; justify-content: center; color: #CAC4D0">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<a href="D_ImportQR.dc.html" style="display: flex; align-items: center; gap: 14px; padding: 18px; border-radius: 24px; background: #6D4AFF; color: #FFFFFF; text-decoration: none">
|
||||
<span style="width: 48px; height: 48px; border-radius: 16px; background: rgba(255,255,255,0.18); display: flex; align-items: center; justify-content: center">
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 8V5a1 1 0 0 1 1-1h3M16 4h3a1 1 0 0 1 1 1v3M20 16v3a1 1 0 0 1-1 1h-3M8 20H5a1 1 0 0 1-1-1v-3M4 12h16"/></svg>
|
||||
</span>
|
||||
<span style="display: flex; flex-direction: column; gap: 2px">
|
||||
<span style="font-size: 17px; font-weight: 800">Сканировать QR</span>
|
||||
<span style="font-size: 13px; color: #E7DFFF">С экрана друга или с сайта</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 8px; padding: 14px; border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35">
|
||||
<label for="fi-url" style="font-size: 15px; font-weight: 800">Ссылка на профиль</label>
|
||||
<div style="display: flex; gap: 8px">
|
||||
<span style="flex: 1; min-width: 0; display: flex; align-items: center; height: 48px; padding: 0 12px; border-radius: 14px; background: #2B2930">
|
||||
<input id="fi-url" type="url" placeholder="https://…/profile.sswan" style="flex: 1; min-width: 0; width: 100%; border: 0; background: transparent; font: 500 15px 'Manrope', sans-serif; color: #E5E1E9; outline: none">
|
||||
</span>
|
||||
<a href="D_ImportPreview.dc.html" style="flex: none; white-space: nowrap; height: 48px; padding: 0 16px; border-radius: 14px; background: #36334A; color: #E0DAF5; text-decoration: none; display: flex; align-items: center; font-size: 14px; font-weight: 700">Вставить</a>
|
||||
</div>
|
||||
<span style="font-size: 12px; line-height: 1.4; color: #A9A3BF">Ссылку запомним и будем подтягивать изменения профиля</span>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px">
|
||||
<a href="D_ImportPreview.dc.html" style="height: 56px; border-radius: 18px; border: 1px solid #48454E; color: #E5E1E9; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 700; box-sizing: border-box">Файл .sswan</a>
|
||||
<a href="D_ManualAdd.dc.html" style="height: 56px; border-radius: 18px; border: 1px solid #48454E; background: transparent; color: #E5E1E9; font-size: 14px; font-weight: 700; display: flex; align-items: center; justify-content: center; text-decoration: none; box-sizing: border-box">Вручную</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":844}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,73 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Проверка профиля</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 1000px; box-sizing: border-box; background: rgba(28,27,32,0.5); font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden; display: flex; flex-direction: column; justify-content: flex-end">
|
||||
<section role="dialog" aria-label="Добавить сервер" style="border-radius: 32px 32px 0 0; background: #141318; padding: 12px 16px 28px; display: flex; flex-direction: column; gap: 14px">
|
||||
<span style="align-self: center; width: 36px; height: 4px; border-radius: 2px; background: #48454E"></span>
|
||||
<div style="display: flex; align-items: center; gap: 14px; padding: 4px 4px 0">
|
||||
<span style="width: 56px; height: 56px; border-radius: 28px; background: #3B2C88; color: #D0C2FF; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 800">NL</span>
|
||||
<span style="display: flex; flex-direction: column; gap: 2px">
|
||||
<h1 style="margin: 0; font-size: 24px; font-weight: 800; letter-spacing: -0.02em">Добавить сервер?</h1>
|
||||
<span style="font-size: 13px; color: #A9A3BF">Профиль прочитан, всё в порядке</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section style="border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35; overflow: hidden">
|
||||
<label style="display: flex; flex-direction: column; gap: 4px; padding: 12px 16px; border-bottom: 1px solid #2B2930">
|
||||
<span style="font-size: 12px; font-weight: 700; color: #A9A3BF">Название</span>
|
||||
<input type="text" value="Амстердам" style="border: 0; background: transparent; padding: 0; font: 700 16px 'Manrope', sans-serif; color: #E5E1E9; outline: none; min-width: 0; width: 100%">
|
||||
</label>
|
||||
<sc-for list="{{rows}}" as="r" hint-placeholder-count="4">
|
||||
<div style="display: flex; align-items: center; gap: 10px; padding: 0 16px; height: 52px; border-bottom: {{r.divider}}">
|
||||
<span style="flex: 1; font-size: 14px; font-weight: 600; color: #CAC4D0">{{r.k}}</span>
|
||||
<span style="font-size: 14px; font-weight: 700; color: {{r.color}}">{{r.v}}</span>
|
||||
</div>
|
||||
</sc-for>
|
||||
</section>
|
||||
|
||||
<section style="border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35; overflow: hidden">
|
||||
<div style="display: flex; align-items: center; gap: 10px; padding: 12px 14px 12px 16px; border-bottom: 1px solid #2B2930">
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px">
|
||||
<span style="font-size: 15px; font-weight: 700">Обновлять по ссылке</span>
|
||||
<span style="font-size: 12px; color: #A9A3BF">Есть только у профилей, добавленных по ссылке</span>
|
||||
</span>
|
||||
<button aria-pressed="true" aria-label="Обновлять по ссылке" style="flex: none; width: 52px; height: 32px; border: 0; border-radius: 16px; background: #6D4AFF; padding: 4px; display: flex; justify-content: flex-end"><span style="width: 24px; height: 24px; border-radius: 12px; background: #1F1D24"></span></button>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px; padding: 12px 14px 12px 16px">
|
||||
<span style="flex: 1; font-size: 15px; font-weight: 700">Подключиться сразу</span>
|
||||
<button aria-pressed="true" aria-label="Подключиться сразу" style="flex: none; width: 52px; height: 32px; border: 0; border-radius: 16px; background: #6D4AFF; padding: 4px; display: flex; justify-content: flex-end"><span style="width: 24px; height: 24px; border-radius: 12px; background: #1F1D24"></span></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div style="padding: 12px 14px; border-radius: 18px; background: #2A2343; font-size: 13px; line-height: 1.45; color: #E0DAF5"><b>Такой сервер уже есть</b> («Амстердам», добавлен 12 окт.). Новый профиль заменит его, ваши настройки останутся.</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 10px">
|
||||
<a href="D_Import.dc.html" style="height: 56px; border-radius: 18px; border: 1px solid #48454E; color: #E5E1E9; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 15px; font-weight: 700; box-sizing: border-box">Отмена</a>
|
||||
<a href="D_Servers.dc.html" style="height: 56px; border-radius: 18px; background: #6D4AFF; color: #FFFFFF; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 15px; font-weight: 800">Обновить сервер</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":1000}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var r = [['Сервер', 'nl1.[ваш-домен]', '#E5E1E9'], ['Тип', 'IKEv2', '#E5E1E9'], ['Вход', 'Сертификат в профиле', '#E5E1E9'], ['Источник', 'ссылка', '#E5E1E9'], ['Сертификат CA', 'входит в профиль', '#7CE3B0']];
|
||||
return { rows: r.map(function (x, i) { return { k: x[0], v: x[1], color: x[2], divider: i < r.length - 1 ? '1px solid #2B2930' : '0' }; }) };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,60 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Сканер QR</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#C4B2FF}a:hover{color:#FFFFFF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 844px; box-sizing: border-box; background: #0F0E13; font-family: 'Manrope', system-ui, sans-serif; color: #FFFFFF; overflow: hidden; display: flex; flex-direction: column">
|
||||
<header style="display: flex; align-items: center; justify-content: space-between; padding: 16px 12px">
|
||||
<a href="D_Import.dc.html" aria-label="Закрыть" style="width: 44px; height: 44px; border-radius: 22px; background: rgba(255,255,255,0.12); display: flex; align-items: center; justify-content: center; color: #FFFFFF">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
||||
</a>
|
||||
<button aria-label="Фонарик" style="width: 44px; height: 44px; border: 0; border-radius: 22px; background: rgba(255,255,255,0.12); color: #FFFFFF; display: flex; align-items: center; justify-content: center">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3h8l-2 6v11h-4V9z"/></svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div style="flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 28px">
|
||||
<div style="position: relative; width: 260px; height: 260px">
|
||||
<span style="position: absolute; left: 0; top: 0; width: 56px; height: 56px; border-left: 5px solid #9C7CFF; border-top: 5px solid #9C7CFF; border-radius: 22px 0 0 0"></span>
|
||||
<span style="position: absolute; right: 0; top: 0; width: 56px; height: 56px; border-right: 5px solid #9C7CFF; border-top: 5px solid #9C7CFF; border-radius: 0 22px 0 0"></span>
|
||||
<span style="position: absolute; left: 0; bottom: 0; width: 56px; height: 56px; border-left: 5px solid #9C7CFF; border-bottom: 5px solid #9C7CFF; border-radius: 0 0 0 22px"></span>
|
||||
<span style="position: absolute; right: 0; bottom: 0; width: 56px; height: 56px; border-right: 5px solid #9C7CFF; border-bottom: 5px solid #9C7CFF; border-radius: 0 0 22px 0"></span>
|
||||
<span style="position: absolute; left: 24px; right: 24px; top: 118px; height: 3px; border-radius: 2px; background: #9C7CFF; box-shadow: 0 0 18px #9C7CFF"></span>
|
||||
</div>
|
||||
<span style="max-width: 280px; text-align: center; font-size: 16px; font-weight: 700; line-height: 1.4">Наведите на QR с профилем .sswan или ссылкой на него</span>
|
||||
</div>
|
||||
|
||||
<div style="padding: 0 16px 28px; display: flex; flex-direction: column; gap: 10px">
|
||||
<a href="D_ImportPreview.dc.html" style="display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 22px; background: #1F1D24; color: #E5E1E9; text-decoration: none">
|
||||
<span style="width: 40px; height: 40px; border-radius: 20px; background: #148A45; color: #FFFFFF; display: flex; align-items: center; justify-content: center">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12l5 5 9-10"/></svg>
|
||||
</span>
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px">
|
||||
<span style="font-size: 15px; font-weight: 800">Найден профиль «Амстердам»</span>
|
||||
<span style="font-size: 12px; color: #A9A3BF">Нажмите, чтобы проверить и добавить</span>
|
||||
</span>
|
||||
</a>
|
||||
<button style="height: 48px; border: 0; border-radius: 16px; background: rgba(255,255,255,0.12); color: #FFFFFF; font: 700 14px 'Manrope', sans-serif">Выбрать картинку из галереи</button>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":844}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,108 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · События</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 1300px; box-sizing: border-box; background: #141318; font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden; font-variant-numeric: tabular-nums">
|
||||
<header style="display: flex; align-items: center; gap: 8px; padding: 16px 12px 8px">
|
||||
<a href="D_Home.dc.html" aria-label="Назад" style="width: 44px; height: 44px; border-radius: 22px; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
|
||||
</a>
|
||||
<h1 style="margin: 0; flex: 1; font-size: 22px; font-weight: 800">События</h1>
|
||||
<button style="flex: none; height: 40px; padding: 0 12px; border: 0; border-radius: 20px; background: transparent; color: #B69DFF; font: 700 13px 'Manrope', sans-serif">Прочитать все</button>
|
||||
</header>
|
||||
|
||||
<div style="padding: 4px 16px 0; display: flex; flex-direction: column; gap: 12px">
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap">
|
||||
<button aria-pressed="true" style="height: 34px; padding: 0 12px; border-radius: 12px; border: 0; background: #3B2C88; color: #FFFFFF; font: 700 13px 'Manrope', sans-serif">Все</button>
|
||||
<button style="height: 34px; padding: 0 12px; border-radius: 12px; border: 0; background: #4A3416; color: #FFD8A8; font: 700 13px 'Manrope', sans-serif">Ждут решения · 2</button>
|
||||
<button style="height: 34px; padding: 0 12px; border-radius: 12px; border: 1px solid #48454E; background: transparent; color: #E5E1E9; font: 600 13px 'Manrope', sans-serif">Подключение</button>
|
||||
<button style="height: 34px; padding: 0 12px; border-radius: 12px; border: 1px solid #48454E; background: transparent; color: #E5E1E9; font: 600 13px 'Manrope', sans-serif">Маршруты</button>
|
||||
</div>
|
||||
|
||||
<sc-for list="{{days}}" as="d" hint-placeholder-count="2">
|
||||
<section style="display: flex; flex-direction: column; gap: 8px">
|
||||
<h2 style="margin: 6px 6px 0; font-size: 13px; font-weight: 800; color: #B69DFF; letter-spacing: 0.04em">{{d.title}}</h2>
|
||||
<sc-for list="{{d.items}}" as="e" hint-placeholder-count="3">
|
||||
<article style="display: flex; gap: 12px; padding: 14px; border-radius: 22px; background: {{e.bg}}; border: 1px solid {{e.border}}">
|
||||
<span style="flex: none; width: 36px; height: 36px; border-radius: 12px; background: {{e.iconBg}}; color: {{e.iconFg}}; display: flex; align-items: center; justify-content: center">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="{{e.icon}}"/></svg>
|
||||
</span>
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 4px; min-width: 0">
|
||||
<span style="display: flex; align-items: baseline; gap: 8px">
|
||||
<span style="flex: 1; font-size: 15px; font-weight: 800">{{e.title}}</span>
|
||||
<span style="flex: none; font-size: 12px; color: #A9A3BF">{{e.time}}</span>
|
||||
</span>
|
||||
<span style="font-size: 13px; line-height: 1.4; color: #CAC4D0">{{e.text}}</span>
|
||||
<sc-if value="{{e.hasActions}}" hint-placeholder-val="{{ false }}">
|
||||
<span style="display: flex; gap: 8px; padding-top: 6px">
|
||||
<a href="{{e.href1}}" style="height: 36px; padding: 0 14px; border-radius: 12px; background: {{e.primaryBg}}; color: #FFFFFF; font-size: 13px; font-weight: 800; text-decoration: none; display: flex; align-items: center">{{e.primary}}</a>
|
||||
<sc-if value="{{e.hasSecondary}}" hint-placeholder-val="{{ false }}">
|
||||
<a href="{{e.href2}}" style="height: 36px; padding: 0 12px; border-radius: 12px; border: 1px solid #48454E; color: #E5E1E9; font-size: 13px; font-weight: 700; text-decoration: none; display: flex; align-items: center; box-sizing: border-box">{{e.secondary}}</a>
|
||||
</sc-if>
|
||||
</span>
|
||||
</sc-if>
|
||||
</span>
|
||||
</article>
|
||||
</sc-for>
|
||||
</section>
|
||||
</sc-for>
|
||||
|
||||
<p style="margin: 4px 6px 0; font-size: 12px; line-height: 1.45; color: #A9A3BF">Храним 30 дней на устройстве. Что приходит пушем, а что только сюда — в Настройки → Уведомления.</p>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":1300}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var K = {
|
||||
warn: { bg: '#3A2A12', border: '#5A4220', iconBg: '#B45309', iconFg: '#FFFFFF', primaryBg: '#B45309' },
|
||||
ask: { bg: '#1F1D24', border: '#4B3D99', iconBg: '#6D4AFF', iconFg: '#FFFFFF', primaryBg: '#6D4AFF' },
|
||||
info: { bg: '#1F1D24', border: '#2E2B35', iconBg: '#2B2930', iconFg: '#D0C2FF', primaryBg: '#6D4AFF' },
|
||||
ok: { bg: '#1F1D24', border: '#2E2B35', iconBg: '#12321F', iconFg: '#7CE3B0', primaryBg: '#6D4AFF' }
|
||||
};
|
||||
var I = {
|
||||
alert: 'M12 4l9 16H3zM12 10v4M12 17.5v.5',
|
||||
swap: 'M4 8h13l-3-3M20 16H7l3 3',
|
||||
app: 'M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z',
|
||||
route: 'M4 12h6l4-6h6M10 12l4 6h6',
|
||||
db: 'M4 6c0-1.7 3.6-3 8-3s8 1.3 8 3-3.6 3-8 3-8-1.3-8-3zM4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6',
|
||||
shield: 'M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z',
|
||||
down: 'M12 4v12M7 11l5 5 5-5M5 20h14'
|
||||
};
|
||||
var e = function (kind, icon, title, time, text, primary, secondary, href1, href2) {
|
||||
var k = K[kind];
|
||||
return { bg: k.bg, border: k.border, iconBg: k.iconBg, iconFg: k.iconFg, primaryBg: k.primaryBg, icon: I[icon],
|
||||
title: title, time: time, text: text, hasActions: !!primary, primary: primary || '', hasSecondary: !!secondary, secondary: secondary || '', href1: href1 || 'D_Inbox.dc.html', href2: href2 || 'D_Inbox.dc.html' };
|
||||
};
|
||||
return {
|
||||
days: [
|
||||
{ title: 'СЕГОДНЯ', items: [
|
||||
e('warn', 'alert', 'Белые списки у оператора', '14:02', 'Включили мост «Москва» — банки и Госуслуги работают, остальное через VPN.', 'Подробнее', null, 'D_Diag.dc.html'),
|
||||
e('ask', 'app', 'Новое приложение: ВкусВилл', '13:40', 'Похоже на российский сервис. Пустить напрямую, мимо VPN?', 'Да, напрямую', 'Нет', 'D_Recent.dc.html', 'D_Inbox.dc.html'),
|
||||
e('ask', 'route', 'Госуслуги не открываются через VPN', '12:15', '7 неудачных соединений за 5 минут.', 'Пустить напрямую', 'Проверить', 'D_Recent.dc.html', 'D_Check.dc.html'),
|
||||
e('info', 'swap', 'Переключились на резервный сервер', '09:31', 'Амстердам не ответил за 15 с → Хельсинки. Через 10 минут вернулись обратно.')
|
||||
] },
|
||||
{ title: 'ВЧЕРА', items: [
|
||||
e('ok', 'db', 'База адресов РФ обновлена', '22:00', '+214 сетей, всего 12 480.'),
|
||||
e('info', 'down', 'Доступна версия 2.17.0', '18:44', 'Серверы, история трафика, новые правила.', 'Обновить', null, 'D_Update.dc.html'),
|
||||
e('ok', 'shield', 'Итоги недели', '10:00', '18,4 ГБ трафика · 1 284 трекера заблокировано · 0 утечек без VPN.')
|
||||
] }
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,68 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Без VPN — без сети</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 844px; box-sizing: border-box; background: #141318; font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden; display: flex; flex-direction: column">
|
||||
<header style="display: flex; align-items: center; gap: 8px; padding: 16px 12px 8px">
|
||||
<a href="D_Settings.dc.html" aria-label="Назад" style="width: 44px; height: 44px; border-radius: 22px; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
|
||||
</a>
|
||||
<h1 style="margin: 0; flex: 1; font-size: 22px; font-weight: 800">Без VPN — без сети</h1>
|
||||
</header>
|
||||
|
||||
<div style="flex: 1; padding: 4px 16px 28px; display: flex; flex-direction: column; gap: 14px">
|
||||
<section style="display: flex; align-items: center; gap: 12px; padding: 16px; border-radius: 24px; background: #3A2A12; border: 1px solid #5A4220">
|
||||
<span style="flex: none; width: 44px; height: 44px; border-radius: 14px; background: #B45309; color: #FFFFFF; display: flex; align-items: center; justify-content: center">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 11V8a6 6 0 0 1 12 0v3M5 11h14v10H5z"/></svg>
|
||||
</span>
|
||||
<span style="display: flex; flex-direction: column; gap: 2px">
|
||||
<span style="font-size: 17px; font-weight: 800; color: #FFD8A8">Сейчас выключено</span>
|
||||
<span style="font-size: 13px; line-height: 1.4; color: #F5C892">При обрыве VPN приложения на секунды выйдут в сеть напрямую</span>
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<p style="margin: 0 4px; font-size: 15px; line-height: 1.5; color: #E0DAF5">Android может блокировать интернет, пока VPN не подключён. Включается в системных настройках — это займёт 20 секунд.</p>
|
||||
|
||||
<ol style="margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 10px">
|
||||
<sc-for list="{{steps}}" as="s" hint-placeholder-count="3">
|
||||
<li style="display: flex; align-items: flex-start; gap: 12px; padding: 14px; border-radius: 20px; background: #1F1D24; border: 1px solid #2E2B35">
|
||||
<span style="flex: none; width: 28px; height: 28px; border-radius: 14px; background: #6D4AFF; color: #FFFFFF; font-size: 13px; font-weight: 800; display: flex; align-items: center; justify-content: center">{{s.n}}</span>
|
||||
<span style="font-size: 14px; line-height: 1.45; color: #E5E1E9">{{s.t}}</span>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ol>
|
||||
|
||||
<div style="padding: 12px 14px; border-radius: 18px; background: #2A2343; font-size: 13px; line-height: 1.45; color: #E0DAF5">Приложения из списка «напрямую» продолжат работать через обычную сеть. Всё остальное, включая сайты РФ, будет ждать VPN.</div>
|
||||
|
||||
<div style="flex: 1"></div>
|
||||
<button style="height: 56px; border: 0; border-radius: 18px; background: #6D4AFF; color: #FFFFFF; font: 800 15px 'Manrope', sans-serif">Открыть настройки Android</button>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":844}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
return {
|
||||
steps: [
|
||||
{ n: 1, t: 'Нажмите кнопку ниже — откроется раздел VPN в настройках Android.' },
|
||||
{ n: 2, t: 'Нажмите на шестерёнку рядом с «SHX VPN».' },
|
||||
{ n: 3, t: 'Включите «Постоянный VPN» и «Блокировать подключения без VPN».' }
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,65 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Журнал</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 1000px; box-sizing: border-box; background: #141318; font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden; display: flex; flex-direction: column">
|
||||
<header style="display: flex; align-items: center; gap: 8px; padding: 16px 12px 8px">
|
||||
<a href="D_Diag.dc.html" aria-label="Назад" style="width: 44px; height: 44px; border-radius: 22px; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
|
||||
</a>
|
||||
<h1 style="margin: 0; flex: 1; font-size: 22px; font-weight: 800">Журнал</h1>
|
||||
<button aria-label="Скопировать журнал" style="width: 44px; height: 44px; border: 0; border-radius: 22px; background: #2B2930; color: #D0C2FF; display: flex; align-items: center; justify-content: center">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/></svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div style="flex: 1; padding: 0 16px 24px; display: flex; flex-direction: column; gap: 12px; min-height: 0">
|
||||
<div style="display: flex; gap: 8px">
|
||||
<button aria-pressed="true" style="height: 34px; padding: 0 12px; border-radius: 12px; border: 0; background: #3B2C88; color: #FFFFFF; font: 700 13px 'Manrope', sans-serif">Важное</button>
|
||||
<button aria-pressed="false" style="height: 34px; padding: 0 12px; border-radius: 12px; border: 1px solid #48454E; background: transparent; color: #E5E1E9; font: 600 13px 'Manrope', sans-serif">Всё</button>
|
||||
<button aria-pressed="false" style="height: 34px; padding: 0 12px; border-radius: 12px; border: 1px solid #48454E; background: transparent; color: #E5E1E9; font: 600 13px 'Manrope', sans-serif">Ошибки</button>
|
||||
</div>
|
||||
|
||||
<section style="flex: 1; min-height: 0; border-radius: 22px; background: #1C1B20; padding: 14px; display: flex; flex-direction: column; gap: 6px; overflow: hidden">
|
||||
<sc-for list="{{lines}}" as="l" hint-placeholder-count="14">
|
||||
<div style="display: flex; gap: 10px; font: 500 12px/1.45 ui-monospace, 'Cascadia Mono', Menlo, monospace">
|
||||
<span style="flex: none; color: #938F99">{{l.t}}</span>
|
||||
<span style="color: {{l.c}}">{{l.m}}</span>
|
||||
</div>
|
||||
</sc-for>
|
||||
</section>
|
||||
|
||||
<div style="padding: 12px 14px; border-radius: 18px; background: #2A2343; font-size: 13px; line-height: 1.45; color: #E0DAF5">В отчёт попадут журнал подключения и версия приложения.</div>
|
||||
<a href="D_Share.dc.html" style="height: 56px; border-radius: 18px; background: #6D4AFF; color: #FFFFFF; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 15px; font-weight: 800">Отправить отчёт</a>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":1000}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var W = '#36333D', G = '#7CE3B0', A = '#FFB869', P = '#C4B2FF';
|
||||
var L = [
|
||||
['14:02:11', 'Сеть: мобильная (LTE)', W], ['14:02:12', 'IKE_SA_INIT → nl1 :4500', W], ['14:02:27', 'Нет ответа 15 с — таймаут', A],
|
||||
['14:02:27', 'Проверка: только сайты РФ отвечают', A], ['14:02:28', 'Белые списки обнаружены', A], ['14:02:28', 'Переключение на «Москва (мост)»', P],
|
||||
['14:02:29', 'IKE_SA_INIT → msk-bridge :4500', W], ['14:02:29', 'IKE_AUTH: сертификат принят', G], ['14:02:30', 'CHILD_SA установлен', G],
|
||||
['14:02:30', 'Маршруты: РФ напрямую, 12 480 сетей', W], ['14:02:30', 'Приложений напрямую: 24', W], ['14:02:31', 'Туннель работает · 52 мс', G],
|
||||
['14:05:40', 'Госуслуги: 7 отказов через VPN', A], ['14:05:41', 'Подсказка: пустить напрямую', P]
|
||||
];
|
||||
return { lines: L.map(function (x) { return { t: x[0], m: x[1], c: x[2] }; }) };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,67 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Сервер вручную</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 1000px; box-sizing: border-box; background: #141318; font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden; display: flex; flex-direction: column">
|
||||
<header style="display: flex; align-items: center; gap: 8px; padding: 16px 12px 8px">
|
||||
<a href="D_Import.dc.html" aria-label="Закрыть" style="width: 44px; height: 44px; border-radius: 22px; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
||||
</a>
|
||||
<h1 style="margin: 0; flex: 1; font-size: 22px; font-weight: 800">Сервер вручную</h1>
|
||||
</header>
|
||||
|
||||
<div style="flex: 1; padding: 0 16px 24px; display: flex; flex-direction: column; gap: 12px">
|
||||
<sc-for list="{{fields}}" as="f" hint-placeholder-count="3">
|
||||
<label style="display: flex; flex-direction: column; gap: 4px; padding: 10px 14px; border-radius: 16px; background: #1F1D24; border: {{f.border}}">
|
||||
<span style="font-size: 12px; font-weight: 700; color: {{f.labelColor}}">{{f.label}}</span>
|
||||
<input type="text" value="{{f.value}}" placeholder="{{f.ph}}" style="border: 0; background: transparent; padding: 0; font: 600 16px 'Manrope', sans-serif; color: #E5E1E9; outline: none; min-width: 0; width: 100%">
|
||||
</label>
|
||||
</sc-for>
|
||||
|
||||
<span style="padding: 4px 4px 0; font-size: 13px; font-weight: 800; color: #B69DFF; letter-spacing: 0.04em">ВХОД</span>
|
||||
<div role="radiogroup" aria-label="Вход" style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); padding: 4px; border-radius: 16px; background: #2B2930">
|
||||
<button aria-pressed="true" style="height: 40px; border: 0; border-radius: 12px; background: #1F1D24; color: #E5E1E9; font: 800 13px 'Manrope', sans-serif; box-shadow: 0 1px 3px rgba(28,27,32,0.12)">Логин</button>
|
||||
<button aria-pressed="false" style="height: 40px; border: 0; border-radius: 12px; background: transparent; color: #CAC4D0; font: 600 13px 'Manrope', sans-serif">Сертификат</button>
|
||||
<button aria-pressed="false" style="height: 40px; border: 0; border-radius: 12px; background: transparent; color: #CAC4D0; font: 600 13px 'Manrope', sans-serif">Оба</button>
|
||||
</div>
|
||||
<label style="display: flex; flex-direction: column; gap: 4px; padding: 10px 14px; border-radius: 16px; background: #1F1D24; border: 1px solid #2E2B35">
|
||||
<span style="font-size: 12px; font-weight: 700; color: #A9A3BF">Имя пользователя</span>
|
||||
<input type="text" value="denis" style="border: 0; background: transparent; padding: 0; font: 600 16px 'Manrope', sans-serif; color: #E5E1E9; outline: none; min-width: 0; width: 100%">
|
||||
</label>
|
||||
<label style="display: flex; flex-direction: column; gap: 4px; padding: 10px 14px; border-radius: 16px; background: #1F1D24; border: 1px solid #2E2B35">
|
||||
<span style="font-size: 12px; font-weight: 700; color: #A9A3BF">Пароль</span>
|
||||
<input type="password" value="••••••••" style="border: 0; background: transparent; padding: 0; font: 600 16px 'Manrope', sans-serif; color: #E5E1E9; outline: none; min-width: 0; width: 100%">
|
||||
</label>
|
||||
|
||||
<button style="display: flex; align-items: center; gap: 10px; height: 52px; padding: 0 16px; border-radius: 16px; border: 1px solid #2E2B35; background: #1F1D24; font: 700 14px 'Manrope', sans-serif; color: #E5E1E9">
|
||||
<span style="flex: 1; text-align: left">Дополнительно: порт, MTU, DNS, идентификаторы</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#938F99" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>
|
||||
</button>
|
||||
|
||||
<div style="flex: 1"></div>
|
||||
<a href="D_ImportPreview.dc.html" style="height: 56px; border-radius: 18px; background: #6D4AFF; color: #FFFFFF; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 15px; font-weight: 800">Проверить и добавить</a>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":1000}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var f = function (label, value, ph, focus) { return { label: label, value: value, ph: ph, border: focus ? '2px solid #6D4AFF' : '1px solid #2E2B35', labelColor: focus ? '#B69DFF' : '#A9A3BF' }; };
|
||||
return { fields: [f('Название', 'Амстердам', 'Например, Работа'), f('Адрес сервера', 'nl1.[ваш-домен]', 'vpn.example.com', true), f('Тип', 'IKEv2', '')] };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,82 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Уведомления</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 1000px; box-sizing: border-box; background: #141318; font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden">
|
||||
<header style="display: flex; align-items: center; gap: 8px; padding: 16px 12px 8px">
|
||||
<a href="D_Settings.dc.html" aria-label="Назад" style="width: 44px; height: 44px; border-radius: 22px; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
|
||||
</a>
|
||||
<h1 style="margin: 0; flex: 1; font-size: 22px; font-weight: 800">Уведомления</h1>
|
||||
</header>
|
||||
|
||||
<div style="padding: 0 16px; display: flex; flex-direction: column; gap: 12px">
|
||||
<p style="margin: 0 4px; font-size: 14px; line-height: 1.45; color: #CAC4D0">Все события всегда сохраняются в «Событиях». Здесь — что ещё приходит пушем.</p>
|
||||
|
||||
<sc-for list="{{groups}}" as="g" hint-placeholder-count="2">
|
||||
<section style="display: flex; flex-direction: column; gap: 6px">
|
||||
<h2 style="margin: 6px 6px 0; font-size: 13px; font-weight: 800; color: #B69DFF; letter-spacing: 0.04em">{{g.title}}</h2>
|
||||
<div style="border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35; overflow: hidden">
|
||||
<sc-for list="{{g.rows}}" as="r" hint-placeholder-count="3">
|
||||
<div style="display: flex; align-items: center; gap: 12px; padding: 10px 14px 10px 16px; min-height: 66px; box-sizing: border-box; border-bottom: {{r.divider}}">
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0">
|
||||
<span style="display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 700">{{r.title}}<span style="padding: 1px 7px; border-radius: 6px; background: {{r.tagBg}}; color: {{r.tagFg}}; font-size: 10px; font-weight: 800">{{r.tag}}</span></span>
|
||||
<span style="font-size: 12px; line-height: 1.35; color: #A9A3BF">{{r.sub}}</span>
|
||||
</span>
|
||||
<button aria-pressed="{{r.on}}" aria-label="{{r.title}}" style="flex: none; width: 52px; height: 32px; border: 0; border-radius: 16px; background: {{r.track}}; padding: 4px; display: flex; justify-content: {{r.align}}"><span style="width: 24px; height: 24px; border-radius: 12px; background: #1F1D24"></span></button>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</section>
|
||||
</sc-for>
|
||||
|
||||
<a href="F_SysNotifs.dc.html" style="display: flex; align-items: center; gap: 10px; height: 52px; padding: 0 16px; border-radius: 18px; background: #2B2930; color: #E0DAF5; text-decoration: none; font-size: 14px; font-weight: 700">
|
||||
<span style="flex: 1">Звук и вид — в настройках Android</span>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#A9A3BF" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":1000}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var T = { 'ВСЕГДА': ['#3B2C88', '#D0C2FF'], 'ВАЖНО': ['#4A3416', '#FFD8A8'], 'ТИХО': ['#2B2930', '#CAC4D0'] };
|
||||
var row = function (title, sub, tag, on) {
|
||||
return { title: title, sub: sub, tag: tag, tagBg: T[tag][0], tagFg: T[tag][1], on: on ? 'true' : 'false', track: on ? '#6D4AFF' : '#48454E', align: on ? 'flex-end' : 'flex-start' };
|
||||
};
|
||||
var grp = function (title, rows) {
|
||||
rows.forEach(function (r, i) { r.divider = i < rows.length - 1 ? '1px solid #2B2930' : '0'; });
|
||||
return { title: title, rows: rows };
|
||||
};
|
||||
return {
|
||||
groups: [
|
||||
grp('ПОДКЛЮЧЕНИЕ', [
|
||||
row('Статус VPN', 'Постоянное уведомление: сервер, время, пауза. Требует Android', 'ВСЕГДА', true),
|
||||
row('Проблемы', 'Белые списки, обрыв, сервер не отвечает', 'ВАЖНО', true)
|
||||
]),
|
||||
grp('МАРШРУТЫ', [
|
||||
row('Новые приложения', 'Предложить пустить напрямую', 'ТИХО', true)
|
||||
]),
|
||||
grp('ПРОЧЕЕ', [
|
||||
row('Обновления приложения', 'Новая версия готова к установке', 'ТИХО', true),
|
||||
row('Итоги недели', 'Трафик за неделю, по понедельникам', 'ТИХО', false)
|
||||
])
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,59 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Онбординг</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 844px; box-sizing: border-box; background: #141318; font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden; display: flex; flex-direction: column; padding: 24px 24px 28px">
|
||||
<div style="display: flex; justify-content: flex-end">
|
||||
<a href="D_Home.dc.html" style="height: 44px; display: flex; align-items: center; padding: 0 8px; font-size: 15px; font-weight: 700; text-decoration: none; color: #A9A3BF">Пропустить</a>
|
||||
</div>
|
||||
|
||||
<div aria-hidden="true" style="position: relative; height: 290px; display: flex; align-items: center; justify-content: center; margin-top: 8px">
|
||||
<span style="position: absolute; width: 260px; height: 260px; border-radius: 42% 58% 50% 50% / 48% 44% 56% 52%; background: #3B2C88"></span>
|
||||
<span style="position: absolute; width: 180px; height: 180px; border-radius: 55% 45% 48% 52% / 50% 56% 44% 50%; background: #6D4AFF"></span>
|
||||
<span style="position: absolute; left: 34px; top: 46px; padding: 8px 12px; border-radius: 14px; background: #1F1D24; font-size: 13px; font-weight: 800; color: #D0C2FF; box-shadow: 0 4px 14px rgba(59,44,136,0.15)">Госуслуги → напрямую</span>
|
||||
<span style="position: absolute; right: 26px; bottom: 50px; padding: 8px 12px; border-radius: 14px; background: #1F1D24; font-size: 13px; font-weight: 800; color: #D0C2FF; box-shadow: 0 4px 14px rgba(59,44,136,0.15)">YouTube → через VPN</span>
|
||||
<svg width="76" height="76" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="position: relative"><path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z"/><path d="M9 12l2 2 4-4"/></svg>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 12px; margin-top: 20px">
|
||||
<span style="font-size: 13px; font-weight: 800; color: #B69DFF; letter-spacing: 0.06em">ШАГ 1 ИЗ 3</span>
|
||||
<h1 style="margin: 0; font-size: 30px; line-height: 1.15; font-weight: 800; letter-spacing: -0.02em">Российское — напрямую, остальное — через VPN</h1>
|
||||
<p style="margin: 0; font-size: 16px; line-height: 1.5; color: #CAC4D0">Банки и Госуслуги работают как обычно, а заблокированные сервисы открываются без переключений.</p>
|
||||
</div>
|
||||
|
||||
<div style="flex: 1"></div>
|
||||
|
||||
<div style="display: flex; justify-content: center; gap: 8px; margin-bottom: 20px" aria-label="Шаг 1 из 3">
|
||||
<span style="width: 28px; height: 8px; border-radius: 4px; background: #6D4AFF"></span>
|
||||
<span style="width: 8px; height: 8px; border-radius: 4px; background: #48454E"></span>
|
||||
<span style="width: 8px; height: 8px; border-radius: 4px; background: #48454E"></span>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 10px">
|
||||
<a href="D_Permissions.dc.html" style="height: 56px; border-radius: 18px; background: #6D4AFF; color: #FFFFFF; font-size: 16px; font-weight: 800; text-decoration: none; display: flex; align-items: center; justify-content: center">Начать</a>
|
||||
<a href="D_Import.dc.html" style="height: 56px; border-radius: 18px; background: #36334A; color: #E0DAF5; font-size: 16px; font-weight: 700; text-decoration: none; display: flex; align-items: center; justify-content: center; gap: 8px">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="4" width="6" height="6" rx="1"/><rect x="14" y="4" width="6" height="6" rx="1"/><rect x="4" y="14" width="6" height="6" rx="1"/><path d="M14 14h2v2M20 14v6h-6"/></svg>Сканировать QR
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":844}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,53 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Тёмная · Пауза VPN</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#B69DFF}a:hover{color:#D0C2FF}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="position: relative; width: 390px; height: 844px; box-sizing: border-box; background: rgba(28,27,32,0.5); font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden; display: flex; flex-direction: column; justify-content: flex-end">
|
||||
<section role="dialog" aria-label="Пауза VPN" style="border-radius: 32px 32px 0 0; background: #141318; padding: 12px 16px 28px; display: flex; flex-direction: column; gap: 14px">
|
||||
<span style="align-self: center; width: 36px; height: 4px; border-radius: 2px; background: #48454E"></span>
|
||||
<div style="display: flex; flex-direction: column; gap: 4px; padding: 4px 4px 0">
|
||||
<h1 style="margin: 0; font-size: 24px; font-weight: 800; letter-spacing: -0.02em">Пауза VPN</h1>
|
||||
<span style="font-size: 14px; line-height: 1.45; color: #A9A3BF">Весь трафик пойдёт напрямую. VPN включится сам.</span>
|
||||
</div>
|
||||
<div role="radiogroup" aria-label="На сколько" style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px">
|
||||
<sc-for list="{{opts}}" as="o" hint-placeholder-count="4">
|
||||
<button aria-pressed="{{o.on}}" style="height: 64px; border-radius: 20px; border: {{o.border}}; background: {{o.bg}}; color: #E5E1E9; font-family: 'Manrope', sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px">
|
||||
<span style="font-size: 17px; font-weight: 800">{{o.t}}</span>
|
||||
<span style="font-size: 12px; color: #A9A3BF">{{o.d}}</span>
|
||||
</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
<button style="display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 20px; border: 1px solid #2E2B35; background: #1F1D24; text-align: left; font-family: 'Manrope', sans-serif; color: #E5E1E9">
|
||||
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px">
|
||||
<span style="font-size: 15px; font-weight: 700">До смены сети</span>
|
||||
<span style="font-size: 12px; color: #A9A3BF">Включится, когда уйдёте с этого Wi-Fi</span>
|
||||
</span>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#938F99" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
|
||||
</button>
|
||||
<a href="D_Home.dc.html" style="height: 56px; border-radius: 18px; background: #6D4AFF; color: #FFFFFF; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 15px; font-weight: 800">Поставить на паузу 15 мин</a>
|
||||
<span style="text-align: center; font-size: 12px; line-height: 1.45; color: #A9A3BF">Отменить можно с главной, из шторки или виджета</span>
|
||||
</section>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":844}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
var o = function (t, d, on) { return { t: t, d: d, on: on ? 'true' : 'false', bg: on ? '#2A2343' : '#1F1D24', border: on ? '2px solid #6D4AFF' : '1px solid #2E2B35' }; };
|
||||
return { opts: [o('5 мин', 'до 14:27'), o('15 мин', 'до 14:37', true), o('1 час', 'до 15:22'), o('До утра', 'до 07:00')] };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loaded 100 of 209 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user