Привет, Хабр.
Меня зовут Валиев Артур. Пока мы активно работаем над EvertyDesk, я продолжаю писать небольшой цикл статей о том, что рождается вокруг основной разработки. Скоро отдельно расскажу про наши новинки, трюки и инженерные находки, которые появились в процессе работы над продуктом, но сегодня хочу поговорить про другой проект - более бытовой, личный и очень знакомый многим разработчикам, поехали.
Речь про Android-приложение, которое я набросал в свободное время, чтобы решить свою конкретную боль: постоянную смену VLESS-конфигураций на Android TV, приставках и телефонах близких.
Сразу обозначу важную вещь: это не статья про обход ограничений. Это история про личный инструмент для управления своими устройствами и своими конфигурациями. В современных реалиях такие вещи иногда просто помогают спокойнее жить родным и близким. Возможно, кому-то из вас этот подход тоже окажется полезен.
Боль: телевизор — плохое место для администрирования
Если вы когда-нибудь пробовали настраивать что-то сложнее Wi-Fi на Android TV с пульта, то вы понимаете, о чём я. На телефоне вставить длинную vless://... ссылку — дело нескольких секунд. На телевизоре это превращается в маленькое испытание: открыть нужное приложение, попасть в нужный раздел, ввести или импортировать конфиг, проверить, что всё применилось, потом объяснить близким, что именно нажимать, если что-то снова перестало работать.
Проблема даже не в VLESS как таковом. Проблема в повторяемости. Я как программист плохо переношу ситуацию, когда нужно регулярно делать простое действие вручную. Особенно если это действие каждый раз одинаковое, скучное, легко ломается из-за человеческой ошибки и не несёт никакой интеллектуальной ценности.
В какой-то момент стало понятно: телевизор должен быть исполнителем. А управление должно жить там, где человеку удобно — на телефоне или на компьютере.
Так появилась идея Everty VLESS: личный control plane для своих Android TV и телефонов. Устройство один раз привязывается через QR-код или короткий код, а дальше с телефона можно отправлять ему VLESS-узлы, правила маршрутизации, список приложений для split-tunnel и порядок failover.
Что хотелось получить
Изначальный сценарий был очень простой:
Открываю приложение на Android TV.
Нажимаю «создать код привязки».
На экране появляется QR-код и короткий ручной код.
С телефона сканирую QR или ввожу код.
После этого управляю телевизором с телефона.
После привязки хотелось уметь делать несколько вещей: загружать несколько VLESS-конфигов, выбирать активный узел, задавать резервные узлы, управлять split-tunnel по приложениям, добавлять домены и IP/CIDR-правила, видеть состояние устройства и понимать, жив ли VPN прямо сейчас.
То есть это не «VPN-клиент ради VPN-клиента». Главная идея — убрать ручную настройку с устройств, где ручная настройка неудобна.
Общая архитектура
Авторизация и управление:
Android Phone (Контроллер) ──► Controller Token ──► WordPress REST API (личный wordpress, без ссылок ;)Получение команд (Опрос устройств):
WordPress REST API (личный wordpress) ◄── [Polling / Heartbeat] ──► Android TV / Windows Client
Проект состоит из нескольких частей:
Android-приложение на Kotlin и Jetpack Compose;
VPN-слой через Android
VpnService;Xray-core через
libv2ray;TUN -> SOCKS мост через
hev-socks5-tunnel;WordPress-плагин как backend/control plane;
Windows WPF-клиент как дополнительный пульт.
Почему WordPress? Потому что у меня уже был рабочий сайт, уже была база, уже был PHP-опыт, уже была инфраструктура. Я давно работаю с PHP и WordPress, быстро пишу под него плагины и отношусь к нему с определённой симпатией. Да, backend в идеале может быть любым, но для моей задачи WordPress оказался самым коротким путём от идеи к работающему инструменту.
Мне не хотелось арендовать отдельный VPS только ради личного control plane. Если у вас уже есть сайт или блог, можно поставить плагин туда и использовать его как backend. Для такого сценария это оказалось очень удобно.
WordPress как control plane
Плагин создаёт несколько таблиц: устройства, лицензии/аккаунты, узлы, политики, pairing-коды и события. В первой версии всё максимально просто: REST API, токены, polling и heartbeat.
Примерно так регистрируются основные endpoint’ы:
register_rest_route('everty/v1', '/pairing/start', [ 'methods' => 'POST', 'callback' => [$this, 'pairing_start'], 'permission_callback' => '__return_true', ]); register_rest_route('everty/v1', '/pairing/claim', [ 'methods' => 'POST', 'callback' => [$this, 'pairing_claim'], 'permission_callback' => '__return_true', ]); register_rest_route('everty/v1', '/device/config', [ 'methods' => 'GET', 'callback' => [$this, 'device_config'], 'permission_callback' => '__return_true', ]); register_rest_route('everty/v1', '/device/heartbeat', [ 'methods' => 'POST', 'callback' => [$this, 'device_heartbeat'], 'permission_callback' => '__return_true', ]);
Устройство ходит в API по deviceId + deviceToken, а управляющий клиент — по licenseKey + controllerToken. Это важный момент: обычный license key сам по себе не должен давать полный доступ к управлению устройствами. Для личного MVP можно было бы упростить, но я сразу хотел разделить «ключ аккаунта» и «токен контроллера».
Минимальная проверка заголовков на стороне WordPress выглядит так:
private function require_device_auth(WP_REST_Request $request) { global $wpdb; $device_id = sanitize_text_field($request->get_header('X-Everty-Device-Id')); $device_token = sanitize_text_field($request->get_header('X-Everty-Device-Token')); if (!$device_id || !$device_token) { return new WP_Error('unauthorized', 'Missing device credentials', ['status' => 401]); } $device = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$this->devices_table} WHERE device_id = %s LIMIT 1", $device_id )); if (!$device || !hash_equals($device->device_token, $device_token)) { return new WP_Error('unauthorized', 'Invalid device credentials', ['status' => 401]); } return $device; }
Для production здесь, конечно, нужен более жёсткий слой безопасности: rate limit, аудит, ограничение bootstrap-операций, нормальная модель пользователей, контроль токенов, возможно - подпись запросов. Но для личного сценария и быстрого MVP такая схема уже решает задачу.
Хитрость №1: домен должен жить в одном месте
Одна из неприятных вещей в маленьких проектах - домен часто сначала захардкожен «на пять минут», а потом внезапно оказывается в пяти местах. У меня он был в Android-клиенте, Windows-клиенте, плагине и примерах конфигурации. Это работало ровно до момента, когда захотелось сделать переносимую сборку а именно что-бы опубликовать эту статью и исходный код для читателей ;)
В итоге я вынес базовый адрес WordPress в одно место.
В Android домен берётся из WORDPRESS_BASE_URL через BuildConfig. В app/build.gradle.kts это выглядит примерно так:
android { defaultConfig { val wordpressBaseUrl = providers .gradleProperty("WORDPRESS_BASE_URL") .orElse("https://everty.ru") .get() buildConfigField( "String", "WORDPRESS_BASE_URL", "\"$wordpressBaseUrl\"" ) } buildFeatures { buildConfig = true } }
Дальше SyncSettings и весь VlessRepository используют уже не захардкоженную строку, а BuildConfig.WORDPRESS_BASE_URL:
data class SyncSettings( val wordpressBaseUrl: String = BuildConfig.WORDPRESS_BASE_URL, val deviceId: String = "", val deviceToken: String = "", val pairingCode: String = "", val pairingExpiresAt: Long = 0L, val syncMode: String = "DEVICE" ) В репозитории endpoint собирается из единого источника: private fun endpoint(path: String): String { val base = settings.wordpressBaseUrl.trimEnd('/') return base + path }
Windows-клиент тоже берёт дефолт из переменной окружения:
private static string GetDefaultWordPressBaseUrl() { var fromEnv = Environment.GetEnvironmentVariable("WORDPRESS_BASE_URL"); if (!string.IsNullOrWhiteSpace(fromEnv)) return fromEnv.TrimEnd('/'); return "https://everty.ru"; }
А WordPress-плагин отдаёт домен текущего сайта через home_url():
$payload = [ 'siteUrl' => home_url(), 'pairingCode' => $pairing_code, 'deviceName' => $device_name, 'expiresAt' => $expires_at, ];
И в .env.example достаточно оставить понятный пример:
WORDPRESS_BASE_URL=https://your-domain.example
Это маленькая вещь, но она сильно улучшает переносимость. Плагин можно поставить на свой сайт, указать домен в одном месте, собрать Android-клиент под себя и не охотиться за строками по проекту.
Привязка устройства через QR и код
На Android TV камера обычно отсутствует, поэтому нельзя рассчитывать только на QR. Я сделал оба варианта: QR-код для удобных случаев и короткий pairing code как fallback.
QR содержит примерно такой JSON:
{ "siteUrl": "https://your-domain.example", "pairingCode": "A7K4Q2", "deviceName": "Living Room TV", "expiresAt": 1710000000 }
На стороне устройства логика такая: создаём одноразовый код, показываем его на экране, ждём, пока controller его заберёт. Само устройство периодически проверяет статус привязки:
suspend fun checkPairingStatus(): Boolean = withContext(Dispatchers.IO) { val settings = settingsDao.get() ?: SyncSettings() if (settings.pairingCode.isBlank() || settings.deviceToken.isNotBlank()) { return@withContext false } val url = endpoint("/wp-json/everty/v1/pairing/status") + "?pairingCode=" + URLEncoder.encode(settings.pairingCode, "UTF-8") httpClient.newCall(Request.Builder().url(url).build()).execute().use { response -> if (!response.isSuccessful) return@withContext false val json = JSONObject(response.body?.string().orEmpty()) if (json.optString("status") != "claimed") { return@withContext false } settingsDao.insert( settings.copy( deviceId = json.optString("deviceId"), deviceToken = json.optString("deviceToken"), pairingCode = "", pairingExpiresAt = 0L ) ) pullDeviceConfig() } }
Тут есть простая, но полезная идея: пока устройство не привязано, оно опрашивает backend чаще. После привязки интервал можно увеличить
private fun startAutoSyncInterval(syncSettings: SyncSettings?) { syncPollingJob?.cancel() syncPollingJob = viewModelScope.launch { while (true) { val intervalMs = when { syncSettings?.pairingCode?.isNotEmpty() == true -> 2_000L syncSettings?.deviceToken?.isNotEmpty() == true -> 5_000L syncSettings?.syncMode == "CONTROLLER" -> 10_000L else -> 30_000L } delay(intervalMs) val beforeVersion = routePolicy.value.version val pulled = repository.pullDeviceConfig() val afterVersion = routePolicy.value.version if (pulled && afterVersion != beforeVersion) { restartVpn() } } } }
Почему polling, а не WebSocket? Потому что это Android TV, WordPress и личный сценарий. Polling проще переживает сон устройства, проще отлаживается, не требует отдельного постоянного процесса на сервере и нормально работает на обычном хостинге. Для красивого realtime-интерфейса WebSocket был бы приятнее, но для бытовой надёжности polling оказался практичнее.
VPN на Android: VpnService, Xray и TUN -> SOCKS
Самая интересная техническая часть — это VPN-слой. На Android нельзя просто «подменить сеть» как угодно. Нужно работать через VpnService, получить разрешение пользователя и поднять TUN-интерфейс.
Базовая сборка TUN выглядит так:
val builder = Builder() .setSession("EvertyVLESS") .setMtu(1500) .addAddress("26.26.26.1", 24) .addDnsServer("1.1.1.1") .addDnsServer("8.8.8.8") builder.addRoute("0.0.0.0", 0) installedAllowedApps.forEach { appPackage -> builder.addAllowedApplication(appPackage) } tunInterface = builder.establish()
Здесь есть важный момент: addAllowedApplication() позволяет сделать split-tunnel на уровне приложений. Для Android TV это очень полезно. Не всегда нужно гнать весь трафик через туннель. Иногда достаточно отправить через VPN только конкретные приложения, а всё остальное оставить напрямую.
Дальше поднимается Xray-core как локальный SOCKS5 proxy:
Libv2ray.initCoreEnv(filesDir.absolutePath, "") coreController = Libv2ray.newCoreController(handler) coreController!!.startLoop(configJson, -1)
Но VpnService даёт нам TUN, а Xray слушает локальный SOCKS. Нужно соединить эти два мира. Для этого используется hev-socks5-tunnel:
val cfg = File(filesDir, "hev.yaml") val tunFd = tunInterface!!.fd cfg.writeText( """ tunnel: name: tun0 mtu: 1500 ipv4: 26.26.26.1 route: default fd: $tunFd socks5: address: 127.0.0.1 port: 10808 udp: 'udp' dns: upstream: udp://1.1.1.1 """.trimIndent() ) TProxyService.TProxyStartService(cfg.absolutePath, tunFd)
Итоговая схема получается такой:
Android apps -> VpnService TUN -> hev-socks5-tunnel -> SOCKS5 127.0.0.1:10808 -> Xray-core -> VLESS server
На бумаге выглядит просто, но на практике в таких местах всегда начинается самое интересное: ABI, foreground service, battery optimization, разрешения Android, lifecycle сервиса и восстановление после сна.
Хитрость №2: Android VPN должен жить как foreground service
Если поднять VPN как обычный фоновый процесс, Android рано или поздно решит, что он умнее пользователя, и убьёт приложение. Поэтому VPN-сервис должен работать как foreground service с постоянным уведомлением.
Упрощённо:
private fun startAsForeground() { val notification = NotificationCompat.Builder(this, VPN_CHANNEL_ID) .setContentTitle("Everty VLESS") .setContentText("VPN is running") .setSmallIcon(R.drawable.ic_vpn) .setOngoing(true) .build() startForeground(VPN_NOTIFICATION_ID, notification) }
Это не делает приложение бессмертным, особенно на некоторых Android TV-прошивках, где производители любят агрессивно чистить фоновые процессы. Но это обязательный минимум, без которого стабильной работы ждать не стоит.
Ещё один нюанс: первый запуск VPN нельзя сделать полностью удалённо. Пользователь должен один раз подтвердить системный диалог VpnService.prepare(). После этого приложение уже может стартовать VPN программно, но первичное разрешение остаётся на самом устройстве. Это ограничение Android, и его нужно учитывать в UX.
Split-tunnel: приложения отдельно, домены отдельно
Для меня было важно, чтобы приложение умело не только «включить всё через VPN», но и работать точечно. На Android часть задачи решается через addAllowedApplication():
builder.addAllowedApplication("com.google.android.youtube.tv") builder.addAllowedApplication("com.teamsmart.videomanager.tv")
Но приложений недостаточно. Иногда нужно маршрутизировать отдельные домены или IP-диапазоны. Это уже удобнее делать внутри Xray config:
{ "routing": { "domainStrategy": "IPIfNonMatch", "rules": [ { "type": "field", "domain": [ "domain:youtube.com", "domain:googlevideo.com" ], "outboundTag": "proxy" }, { "type": "field", "ip": [ "142.250.0.0/15" ], "outboundTag": "proxy" }, { "type": "field", "network": "tcp,udp", "outboundTag": "direct" } ] } }
В итоге получается include-only режим: через VPN идут только выбранные приложения, домены и IP/CIDR. Всё остальное идёт напрямую. Для ТВ это часто лучше, чем full tunnel: меньше нагрузка на сервер, меньше неожиданных проблем с локальной сетью и меньше шансов сломать то, что вообще не требовало туннеля.
VLESS URI: не хранить как магическую строку
На первом этапе очень хочется просто хранить vless://... как строку и передавать её туда-сюда. Но дальше быстро выясняется, что нужно сортировать узлы, показывать имена, менять приоритеты, понимать transport/security и генерировать Xray config.
Поэтому лучше один раз разобрать URI в нормальную модель:
data class VlessNode( val id: String, val name: String, val uri: String, val host: String, val port: Int, val uuid: String, val security: String, val network: String, val sni: String?, val priority: Int, val enabled: Boolean )
А уже из этой модели собирать outbound для Xray:
fun buildVlessOutbound(node: VlessNode): JSONObject { return JSONObject() .put("tag", "proxy") .put("protocol", "vless") .put("settings", JSONObject() .put("vnext", JSONArray() .put(JSONObject() .put("address", node.host) .put("port", node.port) .put("users", JSONArray() .put(JSONObject() .put("id", node.uuid) .put("encryption", "none") ) ) ) ) ) .put("streamSettings", JSONObject() .put("network", node.network) .put("security", node.security) .put("tlsSettings", JSONObject() .put("serverName", node.sni ?: node.host) ) ) }
Это не самый полный генератор Xray-конфига, но подход правильный: UI и backend работают с сущностями, а не с неразобранными строками.
Failover: пусть телевизор сам выбирает живой узел
Ещё одна бытовая боль — резервные конфиги. Можно дать близким второй VLESS-конфиг и сказать: «Если первый не работает, включи второй». Но на практике это снова превращается в удалённую поддержку.
Поэтому в backend хранится ordered list узлов, а клиент умеет переключаться на здоровый профиль:
val healthy = chooseHealthyProfile() if (healthy != null && healthy.id != activeRemoteNodeId) { repository.selectProfile(healthy.id) restartVpn(healthy.id) } else { repository.sendHeartbeat( activeNodeId = activeRemoteNodeId, policyVersion = lastPolicyVersion, vpnActive = true ) }
Heartbeat в первой версии может быть простой: TCP connect, короткий SOCKS probe, проверка локального Xray-порта. Это не полноценный HA и не магия, но для домашнего использования уже сильно снижает количество ручных вмешательств.
Пример грубой проверки локального SOCKS:
private suspend fun isLocalSocksAlive(): Boolean = withContext(Dispatchers.IO) { try { Socket().use { socket -> socket.connect(InetSocketAddress("127.0.0.1", 10808), 1500) true } } catch (_: Exception) { false } }
Дальше можно усложнять: делать реальный HTTP probe через SOCKS, проверять конкретный endpoint, учитывать последние ошибки, добавлять cooldown для упавших узлов и не прыгать между серверами слишком часто.
Heartbeat: устройство должно говорить, что с ним происходит
Без обратной связи control plane быстро превращается в чёрный ящик. Поэтому клиент отправляет heartbeat:
val body = JSONObject() .put("activeNodeId", activeNodeId ?: JSONObject.NULL) .put("policyVersion", policyVersion) .put("vpnActive", vpnActive) .put("errors", errors) .toString() .toRequestBody(jsonMediaType) val request = Request.Builder() .url(endpoint("/wp-json/everty/v1/device/heartbeat")) .addHeader("X-Everty-Device-Id", settings.deviceId) .addHeader("X-Everty-Device-Token", settings.deviceToken) .post(body) .build() httpClient.newCall(request).execute().use { response -> response.isSuccessful }
На стороне пульта можно видеть, online устройство или offline, какой узел активен, какая версия политики применена, включён ли VPN и какие ошибки были последними. Это особенно важно, когда устройство стоит не у вас дома, а у родственников. Вы не хотите каждый раз спрашивать: «А что там написано на экране?» Лучше сразу видеть состояние.
Хитрость №3: policy version вместо "просто обнови конфиг"
Сначала кажется, что можно просто отдавать устройству текущий конфиг. Но потом появляются вопросы: применилось ли обновление, нужно ли перезапускать VPN, отличается ли локальная политика от серверной?
Для этого удобно использовать policyVersion.
Backend отдаёт конфигурацию с версией:
{ "policyVersion": 42, "nodes": [], "allowedApps": [], "domains": [], "cidrs": [] }
Клиент сравнивает версию до и после синхронизации:
val beforeVersion = routePolicy.value.version val pulled = repository.pullDeviceConfig() val afterVersion = routePolicy.value.version if (pulled && afterVersion != beforeVersion) { restartVpn() }
Это простая защита от лишних рестартов. VPN не нужно перезапускать при каждом polling-запросе. Он перезапускается только когда реально изменилась политика.
Android TV и ABI: самая неприятная часть
На Android всё становится веселее, когда дело доходит до native-библиотек. libv2ray.aar содержит libgojni.so под разные ABI:
armeabi-v7a, arm64-v8a, x86, x86_64
А вот libhev-socks5-tunnel.so в текущей сборке есть только под:
arm64-v8a
Из-за этого universal APK может устанавливаться на разные устройства, но VPN-туннель нормально стартует только там, где есть подходящая native-библиотека. На части старых или дешёвых Android TV можно получить ситуацию, когда приложение установилось, интерфейс работает, а туннель не поднимается.
Правильное решение — собрать hev-socks5-tunnel под все нужные ABI и явно контролировать packaging в Gradle:
android { defaultConfig { ndk { abiFilters += listOf( "armeabi-v7a", "arm64-v8a", "x86", "x86_64" ) } } }
Это один из тех моментов, которые легко недооценить, пока не начинаешь ставить приложение на реальные ТВ-приставки.
Windows-клиент: не настоящий split-tunnel, а системный proxy
Позже появился Windows WPF-клиент в том же стиле. Он умеет создать cloud account, привязать устройство по коду, загрузить VLESS nodes, отправить policy на Android-устройство, запустить локальный Xray HTTP/SOCKS proxy и включить системный proxy Windows.
Но здесь важно честно сказать: первая Windows-версия — это не настоящий per-app split-tunnel. Это системный proxy через Xray. Для полноценного split-tunnel на Windows нужна другая архитектура: WFP, TUN-драйвер или более сложный сетевой слой.
Зато для базового сценария «быстро поднять Xray и включить системный proxy» этого достаточно.
Замечу что клиент для Windows очень сырой и я пожалел что затеялся, прыжки по платформам очень много занимают времени и в этом сценарии лишние.
Почему мне всё ещё нравится WordPress в этой задаче
Я понимаю, что WordPress как backend для подобной штуки может вызвать улыбку. Но в реальности это оказался очень практичный выбор. У меня уже есть "https://свой сайт на wordpress ;)", есть понятный деплой, есть база данных, есть REST API, есть опыт разработки плагинов на PHP. Для личного control plane этого хватает.
Главное, что backend не является принципиальной частью идеи. Его можно заменить на что угодно: Laravel, Go, Node.js, Supabase, Firebase, свой VPS, serverless-функции. Но WordPress хорош тем, что у многих уже есть сайт или блог. Поставили плагин, указали домен, собрали клиент — и можно пользоваться.
Иногда инженерное решение хорошо не потому, что оно самое красивое, а потому что оно быстрее всего закрывает конкретную боль.
Что получилось хорошо
Главное удачное решение — телефон стал пультом для ТВ. Не нужно вводить VLESS-ссылки на телевизоре, не нужно объяснять близким, где что нажимать, не нужно каждый раз вручную менять конфиг на устройстве с неудобным вводом.
Второе удачное решение — QR плюс ручной код. QR удобен, когда есть камера. Ручной код спасает, когда камеры нет или сканирование неудобно. Для Android TV это обязательный fallback.
Третье — split-tunnel. Для телевизора это часто практичнее full tunnel. Можно отправлять через VPN только нужные приложения и домены, а остальное оставлять напрямую.
Четвёртое — несколько узлов и failover. Это не enterprise HA, но для домашнего сценария уже удобно: если один сервер умер, устройство может переключиться на резервный без ручной настройки.
Пятое — WordPress-плагин как backend. Он позволил не поднимать отдельную инфраструктуру, а использовать уже существующий сайт.
Что ещё нужно доработать
Самая важная техническая задача — собрать hev-socks5-tunnel под все Android TV ABI. Без этого часть устройств будет выпадать из поддержки.
Вторая задача — добавить удалённую команду vpnDesiredState = on/off, чтобы после первичного разрешения VPN можно было включать и выключать с пульта.
Третья — улучшить TV UX под D-pad: крупнее фокус, меньше мелких элементов, меньше экранов, больше понятных состояний.
Четвёртая — сделать более умный health-check: не только TCP connect, но и короткий probe через SOCKS/VLESS.
Пятая — усилить WordPress backend для более публичного использования: rate limit, аудит, защита bootstrap endpoint’ов, нормальная модель аккаунтов и токенов.
Итог

Этот проект появился не потому, что мне хотелось написать VPN-клиент. Он появился потому, что я устал делать одно и то же вручную.
Просто не хотелось каждый раз менять конфиги на телевизорах, объяснять близким последовательность действий, вводить длинные строки с пульта и держать в голове, где какой сервер сейчас активен. Поэтому я сделал маленький личный центр управления: Android TV показывает QR или код, телефон привязывает устройство, а дальше вся настройка уезжает туда, где ей и место — в удобный интерфейс управляющего клиента.
В комплекте идёт WordPress-плагин. Его можно поставить на свой сайт или блог, указать свой домен через WORDPRESS_BASE_URL и использовать как backend. При этом сама идея не привязана к WordPress: backend может быть любым. Просто в моём случае WordPress оказался самым быстрым и удобным способом решить задачу без аренды отдельного VPS.
Для меня это и есть хорошая инженерия: не обязательно строить идеальную систему, чтобы решить реальную проблему. Иногда достаточно убрать одну регулярную рутину из своей жизни. А если инструмент после этого начинает помогать ещё и близким — значит, вечер был потрачен не зря.
Спасибо, что дочитали до конца. Надеюсь, мой опыт окажется кому-то полезен. До скорых встреч - с вами был Валиев Артур, хороших выходных! ;)
info@everty.ru
