YPtun
- понедельник, 19 октября 2026 г. в 00:00:12
Проект начинался как Olcbox, маленький конфигуратор для одного экспериментального транспорта. Следы того времени до сих пор живут в коде: версия передаётся в Gradle флагом -Polcbox.version, пакеты называются org.olcbox.app, а главный сервис называется OlcboxVpnService. Переименовывать всё это ни у кого не поднимается рука, и ниже будет понятно почему: одно только переименование applicationId заняло два релиза.
Сейчас это YPtun, открытый сетевой клиент под Android, Windows и Linux, версия для iOS в бете. Внутри Xray и sing‑box, Hysteria2, AmneziaWG, TrustTunnel, TURN‑транспорт, DNS‑туннель и ещё несколько экспериментальных протоколов. Лицензия GPL-3.0, на момент написания актуальна версия 3.6.9.
Сравнивать протоколы по скорости мы не будем. Эта статья про внутренности: как заставить полдюжины Go‑библиотек жить в одном процессе, почему десктопная сборка весила четверть гигабайта, как bsdiff упёрся в предел массива Java на 2 ГиБ, почему пинг в уведомлении всегда показывал ноль и как мы переносили данные пользователей между двумя разными applicationId так, чтобы этим не смогло воспользоваться чужое приложение.
Сеть, в которой работает клиент, бывает очень разной. Домашний провайдер без ограничений. Корпоративный файрвол, который пропускает только HTTPS на утверждённый список адресов. Гостиничный Wi‑Fi, где до авторизации наружу ходит только DNS. Мобильная сеть, в которой UDP режется или теряется, а QUIC не работает вовсе. Канал с потерями в 10%, на котором TCP‑соединение задыхается.
Универсального протокола под все эти условия нет. У каждого своя слабость, и они почти не пересекаются.

VLESS с Reality хорошо прячется за чужим TLS‑рукопожатием, но бесполезен, если наружу разрешён только короткий список сервисов. AmneziaWG не похож на обычный WireGuard и не определяется по сигнатуре рукопожатия, но это по‑прежнему UDP на произвольный адрес. Hysteria2 вытягивает скорость из канала с потерями, пока QUIC не закрыт целиком. TrustTunnel умеет ходить поверх обычного HTTP/2 и переживает закрытый QUIC. TURN‑транспорт заворачивает WireGuard в TURN‑релеи, которые обычно открыты, потому что на них держатся звонки. DNS‑туннель медленный, зато работает там, где наружу ходит только DNS.
Пользователю незачем разбираться, что именно сегодня происходит с его сетью. Ему нужна кнопка подключения и возможность переключиться на другой способ, не меняя приложение и не теряя настройки. Отсюда главное требование к архитектуре: движки взаимозаменяемы, а всё вокруг них общее. Маршрутизация, DNS, раздельный туннель, подписки и автовыбор сервера не должны знать, что сейчас крутится под капотом.

TUN на каждой платформе свой. На Android это VpnService, на Windows драйвер Wintun, на Linux обычный /dev/net/tun плюс policy routing через ip rule, на iOS NetworkExtension. Пакеты из TUN превращаются обратно в TCP‑ и UDP‑соединения (этим занимается hev-socks5-tunnel) и уходят в локальный SOCKS5, за которым сидит выбранный движок.
Такое разделение оказалось самым полезным решением за всю историю проекта. Чтобы добавить новый транспорт, его достаточно научить поднимать локальный SOCKS5. Перехват IPv6, раздельный туннель по приложениям, правила маршрутизации и DNS он получает бесплатно, и ни одной строчки платформенного кода под него писать не нужно.
Локальный SOCKS5 слушает только 127.0.0.1 и всегда требует логин и пароль. Без авторизации любое приложение на устройстве могло бы найти открытый порт и отправить свой трафик в туннель в обход раздельного туннелирования. Пароль пришлось протащить через все ядра, включая те, где авторизация на входе изначально не предусматривалась, и через iOS‑версию, где она в какой‑то момент потерялась и её пришлось возвращать отдельным исправлением.
Почти все ядра написаны на Go и попадают в Android‑приложение через gomobile bind. Первая очевидная идея была собрать каждое ядро в свой .aar и подключить их рядом. Она не работает.

Каждый .aar от gomobile приносит собственную libgojni.so и собственный набор сгенерированных Java‑классов вроде go.Seq. В одном процессе оказываются несколько Go‑рантаймов и дубликаты классов, после чего приложение падает.
Лечится это отдельным Go‑модулем, у которого ровно одна задача: собрать все ядра в один граф зависимостей.
// Package kazcores exists only to pull both native cores into a single Go module so // `gomobile bind` produces ONE .aar with a single shared Go runtime (one go.Seq / one // libgojni.so). package kazcores import ( _ "github.com/olc/awgproxy/awg" _ "github.com/openlibrecommunity/olcrtc/mobile" _ "github.com/sagernet/sing-box/experimental/libbox" _ "github.com/samosvalishe/free-turn-proxy/freeturn" _ "wg-turn-client/wdttmobile" _ "masterdnsvpn-go/mdnsmobile" )
Пустые импорты держат зависимости в графе, а сам gomobile bind вызывается с полным списком пакетов. Xray подключается через отдельный мост в том же модуле. На выходе один AAR и один рантайм на весь процесс.
Полная сборка под три ABI идёт долго, поэтому Gradle‑таска следит за входными Go‑файлами и пересобирает библиотеку, только если менялся Go‑код. Без этого правка цвета кнопки стоила бы нескольких минут ожидания.
На десктопе этой проблемы нет: там ядра собираются в обычные исполняемые файлы под каждую платформу и запускаются отдельными процессами. Зато есть другая: всё нужно собрать под Windows x64 и ARM64, Linux amd64 и arm64. Один из TURN‑клиентов, csqtt, собирается под Windows ARM64 кросс‑компиляцией через zig, а CI отдельно проверяет, что получившийся бинарник действительно запускается на раннере windows-11-arm. Без этой проверки собранный, но не стартующий файл легко уезжает в релиз.
Все ядра подключены через replace на локальные копии. gomobile всё равно нужен путь в файловой системе, а заодно это позволяет держать собственные патчи. Их немного, но у каждого своя история, и все они описаны прямо в go.mod, чтобы при следующем обновлении никто их не потерял.
В Xray три патча, и все про обратную совместимость конфигов. За несколько релизов апстрим превратил предупреждение о VLESS и Trojan без TLS в жёсткую ошибку, переименовал ключи sessionPlacement и sessionKey в XHTTP и убрал proxySettings у исходящих соединений. Для разработчиков ядра это нормальная эволюция. Для клиента, в который люди импортируют подписки годичной давности, это значит, что после обновления ядра у части пользователей перестаёт работать вообще всё, причём без понятной причины.
Поэтому у нас VLESS без TLS на публичный адрес снова выдаёт предупреждение и запускается, старые ключи XHTTP читаются как алиасы новых, а proxySettings автоматически переезжает в streamSettings.sockopt.dialerProxy:
// Vendored xray-core v26.9.30, carrying three local patches (see ../xray-core): // 1. infra/conf/xray.go - VLESS/Trojan without TLS to a public address warns instead of // failing the whole config (upstream #6303 made it a hard error). // 2. infra/conf/transport_method.go - xhttp "sessionPlacement"/"sessionKey" kept as // read-only aliases of the renamed "sessionID*" keys (upstream #6258). // 3. infra/conf/xray.go - outbound "proxySettings" (removed in 26.9.8) is migrated onto // "streamSettings.sockopt.dialerProxy" instead of erroring out. // Re-apply all three on the next re-vendor. replace github.com/xtls/xray-core => ../xray-core
Патч в MasterDnsVPN другого рода. На Android каждый сокет, который VPN‑приложение открывает наружу, нужно пропустить через VpnService.protect(). Иначе он уходит в собственный туннель приложения и зацикливается. DNS‑туннель открывает UDP‑сокеты к резолверам глубоко внутри библиотеки, а апстрим про Android ничего не знает. Пришлось завести дозвон через свою функцию DialUDPProtected, которая отдаёт сокет в protect() сразу после создания. MasterDNS, кстати, появился на месте dnstt, который мы использовали раньше: у него свой ARQ‑транспорт, он умеет работать с несколькими резолверами сразу и дублировать пакеты, что для DNS с его потерями очень заметно.
FakeDNS отвечает приложению синтетическим адресом из выделенного пула, а настоящий резолв делает уже на сервере. Это быстро (ответ мгновенный, без похода в сеть) и не раскрывает запросы локальному резолверу. Работает отлично, пока синтетический адрес не попадает под чьё‑то чужое правило.

У нас такое правило было. Частные адреса шли напрямую, чтобы не ломать доступ к роутеру и принтеру в локальной сети. Пул FakeDNS по умолчанию это 198.18.0.0/15, диапазон, зарезервированный под тестирование сетевого оборудования. Он входит в распространённые наборы «частных» адресов, например в geoip:private. В итоге соединение к синтетическому адресу уходило в обход туннеля, попадало неизвестно куда, и приложения начинали жаловаться на недоверенный сертификат. Со стороны это выглядело как сломанный TLS, и искали мы сначала совсем не там.
Исправление состоит из двух частей. Пул FakeDNS теперь явно исключён из правила обхода локальной сети, а таблица соответствий переживает переподключение. Без второго после смены сети приложения продолжали стучаться на старые синтетические адреса, которые новое ядро уже не помнило.
На iOS вылезла третья часть: адрес самого TUN‑интерфейса тоже не должен попадать ни в пул FakeDNS, ни в диапазоны обхода LAN. А позже выяснилось, что FakeDNS из импортированного полного конфига Xray терялся, если профиль запускался на sing‑box. Теперь клиент вытаскивает из конфига пул и dns.hosts в отдельную структуру, и профиль одинаково работает на любом из двух ядер: sing‑box умеет FakeDNS нативно.
Ещё одна DNS‑история, на этот раз про DoH. Пользователь пишет в настройках https://cloudflare-dns.com/dns-query. Чтобы отправить запрос на этот адрес, Xray сначала должен узнать IP cloudflare-dns.com. А узнать его он пытается через тот самый DNS, который сейчас настраивается. Получается курица и яйцо: каждый некэшированный домен залипает примерно на 4 секунды, пока не отработает таймаут и не сработает запасной путь.
Решение простое и немного неэлегантное. Хорошо известные хосты DoH (Cloudflare, Google, Quad9, AliDNS) клиент сам переписывает в IP‑форму. Для неизвестных хостов поведение прежнее. Заодно DNS‑поля в настройках научились принимать DoH, DoT и DoQ, а адрес вида tls:// для Xray превращается в DoH на том же хосте: DoT в DNS‑модуле Xray нет.
И последнее про DNS. Удалённые DNS‑серверы Xray теперь всегда идут через туннель, что бы ни было написано в профиле маршрутизации. Иначе профиль с правилом вида «такие‑то подсети напрямую» мог случайно отправить DNS‑запросы в обход туннеля, если адрес DNS‑сервера попадал в эти подсети.
Профили маршрутизации в YPtun совместимы с форматом Happ: правила по geoip:, geosite:, asn:, доменам и подсетям. sing‑box получает их в виде бинарных rule‑set (.srs), которые скачиваются при старте.
Однажды пришёл профиль с geoip:telegram, и ядро перестало запускаться. Выяснилось, что в репозитории SagerNet лежат только страновые наборы вроде geoip-de.srs, а на категории telegram, cloudflare и private там отвечают 404. Теперь не‑страновые наборы берутся из MetaCubeX/meta‑rules‑dat, а страновые и пользовательский адрес базы работают по‑старому.
Из той же серии: rule‑set нужно скачивать через туннель, иначе в сети, где источник недоступен, ядро не может даже стартовать. Но если исходящий прокси сам построен на WireGuard поверх TURN, скачивание через него ломается, и для этого случая пришлось сделать откат на прямое соединение.
В уведомлении Android показывается текущая скорость и пинг. Пинг в автоматическом режиме долго показывал красивые ноль миллисекунд, и какое‑то время это никого не смущало.
Причина оказалась в том, что именно мы измеряли. Автоматический режим засекал время SOCKS‑команды CONNECT к локальному прокси. А локальный SOCKS5 отвечает на CONNECT успехом сразу, ещё до того, как соединение до удалённой стороны реально установлено. То есть мы честно и очень точно измеряли задержку петлевого интерфейса.
Теперь пинг меряется настоящим HTTP‑запросом через туннель до первого байта ответа. Это медленнее и шумнее, зато показывает то, что чувствует пользователь.
С пингом серверов в списке была похожая, но более тонкая история. Для протоколов Xray задержку меряет функция ядра MeasureDelay, и первый запрос через свежий экземпляр ядра несёт разовую стоимость прогрева. Наши цифры получались заметно выше, чем у других клиентов на тех же серверах. Сначала мы стали мерить дважды и брать лучший результат, потом откатили это как поведение по умолчанию. В итоге выбор отдали пользователю. Можно выбрать точность (один, два или три замера, побеждает лучший) и таймаут от 2 до 15 секунд, по умолчанию 7.
В клиенте есть каталог публичных серверов из открытых списков. Сами списки шумные: значительная часть адресов в них мёртвая, дублируется или отвечает, но не работает.

Первая версия проверки делала обычный пинг: TCP, ICMP или HTTP. Сервер, который ответил, считался рабочим. Пользователи подключались и получали ошибку. Оказалось, TCP‑пинг доказывает только то, что порт открыт. Сервер может принимать соединения и при этом не пропускать трафик: неверный ключ, просроченный аккаунт, сломанная конфигурация на той стороне.
Теперь после пинга идёт второй этап: настоящий HTTP‑запрос сквозь сам прокси с таймаутом 15 секунд. Отсев стал жёстче, но в списке остаются серверы, которые реально работают. Дубли выкидываются до выборки, а не после, иначе часть лимита тратилась бы на копии одних и тех же серверов.
И последняя деталь, которую мы поймали только в 3.6.9. Проверка шла через Xray, а запускался найденный сервер иногда на sing‑box, если для этого протокола он выбирался автоматически. Разные ядра по‑разному разбирают пограничные конфиги, и сервер, прошедший проверку, мог не подключиться. Теперь публичные серверы запускаются на том же ядре, которым проверялись.
Интерфейс и почти вся логика написаны на Kotlin Multiplatform с Compose. В commonMain лежат парсеры ссылок и подписок, генерация конфигов под каждое ядро, профили маршрутизации, состояние экранов и даже этапы обновления. Платформенный код занимается только тем, что иначе не сделать: VpnService на Android, Wintun и UAC на Windows, ip rule и resolvectl на Linux, NetworkExtension на iOS.
Десктоп при этом работает на JVM, и это сразу видно по размеру. Первая портативная сборка под Windows вместе со встроенным рантаймом весила 249 МБ.
Сначала из неё выкинули неиспользуемые классы material-icons. Это пакет на тысячи иконок, из которых приложению нужны десятки. Получилось 214 МБ.
Потом jar‑файлы внутри дистрибутива стали храниться без сжатия, а весь payload целиком сжимается zstd. Звучит странно, но zstd гораздо лучше жмёт сырое содержимое классов, чем уже упакованные deflate‑архивы. Вышло 167 МБ, а тёплый старт заодно ускорился примерно на 10%.
Последний шаг касался JIT: оставили только C1. GUI‑приложение большую часть времени ничего не делает, и оптимизации C2 ему почти не нужны, а окно стало появляться примерно на 20% быстрее.
Если вы думаете, что упаковали JVM‑приложение и оно просто работает у всех, вот неполный список того, что ломалось у реальных пользователей портативной версии.
Чужие переменные окружения. JAVA_TOOL_OPTIONS и JAVAOPTIONS оставляют в системе другие Java‑программы. Встроенный рантайм честно их подхватывал и падал на незнакомых флагах. Теперь лаунчер вычищает эти переменные вместе с JDK_JAVA_OPTIONS и убирает посторонние Java из PATH.
Java Access Bridge. Если на компьютере включён Access Bridge (его используют программы экранного доступа), в домашней папке лежит .accessibility.properties, который просит AWT загрузить класс поддержки. В нашем урезанном рантайме этого класса нет, и JVM падает с «Failed to launch JVM». Лечится одной строкой: пустое системное свойство javax.accessibility.assistive_technologies перекрывает файл.
Белое окно на старых видеокартах. Compose на десктопе рисует через Skiko, который по умолчанию выбирает аппаратный рендеринг. На части старых GPU окно открывается, но остаётся залито одним цветом. Теперь на Windows сторожевой таймер замечает однотонное окно, сохраняет режим SOFTWARE_COMPAT в %LOCALAPPDATA%\YPtun\render-api.txt и один раз перезапускает приложение. Если переменная SKIKO_RENDER_API задана явно, она главнее.
Недоступный %LOCALAPPDATA%. Портативная версия распаковывает себя при первом запуске. На некоторых машинах эта папка недоступна для записи или исполнения из‑за политик. Лаунчер откатывается на %TEMP%, а затем на папку рядом с exe. Вывод приложения пишется в launch-output.txt, а стандартный диалог ошибки jpackage закрывается автоматически, чтобы лаунчер мог попробовать следующий вариант. Если всё‑таки ничего не вышло, рядом появляется launch-diagnostic.txt с причиной: лаунчер следит за первыми секундами работы, проверяет загрузку jvm.dll и даёт ссылку на VC++ Redistributable.
Портативная версия хранит данные в одном файле YPtun.data рядом с exe, чтобы её можно было носить на флешке со всеми подписками. Файл зашифрован AES-256-GCM. Ключ при этом вшит в приложение, и мы не делаем вид, что это защита: задача шифрования в том, чтобы ссылки подписок нельзя было случайно прочитать или испортить в текстовом редакторе. Целостность проверяется тегом GCM, а повреждённый файл не удаляется, а откладывается в сторону для разбора.
Гонять полный установщик на каждый патч‑релиз плохо, особенно через медленный канал. На Android для этого используется archive‑patcher от Google.

archive‑patcher понимает структуру ZIP. Он разжимает старый и новый APK, строит bsdiff‑патч по несжатому содержимому и запоминает параметры deflate. На устройстве патч восстанавливает несжатые данные, а затем они сжимаются заново с теми же параметрами. Результат обязан совпасть с опубликованным APK байт в байт, иначе не сойдётся подпись и система откажется ставить обновление. Обычный бинарный diff по сжатым файлам вышел бы почти размером с сам APK.
В 3.6.8 генератор патчей упал на универсальном APK. bsdiff строит суффиксный массив старого файла, и на каждый байт входа нужно 4 байта массива. Несжатый образ универсального APK со всеми ядрами под три ABI перевалил за 537 МБ, а 537 МБ умножить на 4 это уже больше 2 ГиБ, то есть больше максимального размера одного Java‑массива. Сортировщик суффиксов просто отказывался работать.
Исправили со стороны генератора: для больших входов суффиксный массив уходит в файл, отображённый в память кусками по 1 ГиБ. Для маленьких входов используется прежний путь. Чтобы убедиться, что ничего не сломалось, опубликованный ранее патч для arm64 перегенерировали через новый код и сравнили побайтно.
Для таких проверок у нас уже был сквозной тест на релизных артефактах. Он берёт дельты, опубликованные в релизе, применяет их к предыдущей версии и проверяет, что получается ровно следующий релиз, отдельно для APK и для образа Windows‑приложения. На десктопе дельта сначала покрывала только код, а теперь охватывает весь образ, включая лаунчер и рантайм. Обновление скачивает несколько мегабайт вместо сотен, а в интерфейсе видны этапы: загрузка патча, применение, установка.
Когда проект переименовался, встал вопрос applicationId. Android не умеет переименовывать пакет установленного приложения. Сборка с другим id для системы просто другое приложение: поверх старого обновлением она не встанет ни при каких условиях. А у пользователей в старом приложении подписки, профили маршрутизации, свои серверы.

Поэтому переезд идёт в два релиза. Сначала выходит релиз‑мост: id ещё старый, но в сборке появляется ContentProvider, который умеет отдать файлы состояния. Он ничего не делает, пока его не спросят. Затем выходит релиз с новым id. Встроенный апдейтер старого приложения скачивает APK как обычно, система ставит его как новое приложение рядом, и при первом запуске новое забирает данные старого через провайдер. После удачного переноса один раз показывается системный диалог удаления старой версии: два VPN‑клиента на одном устройстве конкурируют за один слот VpnService.
Есть две тонкости. Первая про порядок: перенос должен случиться синхронно в Application.onCreate до первого обращения к DataStore. Иначе DataStore закэширует пустые настройки в памяти, и подложенный файл увидят только со следующего запуска.
Вторая про безопасность, и она интереснее. Провайдер защищён разрешением уровня signature, то есть читать его может только сборка, подписанная тем же ключом. Но это отвечает на вопрос «кто читает», а не «у кого мы читаем». На устройстве, где старой версии нет, authority org.olcbox.app.migration может зарегистрировать любое приложение и подсунуть новой версии свои конфиги. А конфиг сетевого клиента определяет, куда пойдёт весь трафик устройства. Поэтому перед переносом новая версия проверяет, что authority принадлежит именно старому пакету и что этот пакет подписан официальным ключом:
private fun providerIsAuthentic(context: Context): Boolean = runCatching { val owner = context.packageManager.resolveContentProvider(AUTHORITY, 0)?.packageName owner == LEGACY_PACKAGE && IntegrityGuard.isOfficialPackage(context, LEGACY_PACKAGE) }.getOrDefault(false)
Любая ошибка в процессе означает, что переноса просто не будет. Пользователь настроит всё заново, но ронять старт приложения из‑за миграции нельзя.
Сервер ушёл в перезагрузку, ноутбук переключился с Wi‑Fi на кабель, у телефона пропала мобильная сеть. В идеале человек ничего из этого не замечает.

Если процесс ядра завершился, клиент перезапускает его с нарастающей паузой: 2, 4, 8, 16 секунд и дальше по 30, пока сервер не вернётся или пользователь не нажмёт «Отключить».
Хуже, когда ядро живо, а трафик не идёт. Это типично для WireGuard: процесс работает, сокет открыт, но сервер на той стороне перезагрузился и про сессию забыл. Снаружи всё выглядит нормально. Поэтому раз в 10 секунд клиент открывает пробное TCP‑соединение через туннель и после трёх неудач подряд переподключается.
Третий случай это смена сети: новый интерфейс, новый IP. Соединение перезапускается уже на новой сети, а если интернета нет совсем, клиент ждёт его появления и не тратит попытки впустую.
С этими механизмами вылез ещё один баг. Остановка и запуск соединения идут под общим мьютексом, и внутри него выполнялись остановка ядер и восстановление системного прокси Windows. Если какой‑то шаг не возвращался (например, зависал вызов WinINET), интерфейс навсегда оставался в состоянии «Отключение», а все последующие подключения, включая смену сервера, вставали в очередь за ним. Теперь шаги разборки ограничены дедлайном в 10 секунд, а статус меняется на «Отключение» сразу, не дожидаясь мьютекса.
На Android всё проще благодаря системным колбэкам ConnectivityManager. Если текущая сеть пропала, а запасная уже доступна, туннель переключается на неё сразу. Пауза после смены сети раньше была 1,5 секунды, сейчас 0,6.
Для экспериментальных транспортов (TURN, MasterDNS, OpenFlux, snolc) нужен свой сервер, и поднимать его руками умеют немногие. Поэтому в приложении есть установщик по SSH.

Человек вводит адрес VPS и данные для входа: пароль или ключ, в том числе защищённый парольной фразой. Клиент сам заливает бинарник, проверяет, не занят ли порт, открывает его в файрволе (включая ufw), запускает сервер как службу systemd, убеждается, что сервер реально отвечает снаружи, и возвращает готовую ссылку для подключения.
Шаг с проверкой ответа появился не сразу. Ранняя версия установщика для MasterDNS молча ставила сервер, который запускался, но не принимал соединения, и пользователь узнавал об этом только при попытке подключиться.
Бинарник сначала заливается одним файлом. На передачу частями клиент переходит, только если цельная не прошла: на плохих каналах длинная передача рвётся, а резать файл заранее значит замедлять всех остальных. Каждую типовую проблему (занятый порт, капча на стороне внешнего сервиса, нода старой версии) установщик сообщает отдельным понятным текстом, а не общим таймаутом.
Ресурсы, которых нет в APK. Уведомление со скоростью получило собственный layout и логотип, но на устройствах оно выглядело стандартным. Ресурсы лежали в модуле sharedUI и просто не доходили до APK, а то, что доходило, вырезал shrinker ресурсов, потому что не видел прямых ссылок из кода. Помогли перенос ресурсов в androidApp и keep.xml.
Иконки, которые прячут приложение. В настройках можно выбрать одну из девяти альтернативных иконок. Реализовано это через activity-alias: включается нужный алиас, остальные выключаются. Если по какой‑то причине выключены все алиасы, приложение пропадает из лаунчера, и запустить его можно только из настроек системы. Теперь при старте клиент проверяет, что хотя бы один алиас включён, и иначе возвращает стандартный.
Установщик Windows и Restart Manager. Установщик через Windows Restart Manager пытался закрыть запущенное приложение и часто сообщал «не удалось закрыть программу», хотя закрывать было уже нечего. Restart Manager отключили, и установщик сам завершает все процессы, запущенные из каталога установки.
Ссылки извне. Схемы вроде openflux:// и qwdtt:// зарегистрированы через intent‑filter, поэтому ссылка из мессенджера сразу открывает импорт в приложении, а не браузер.
iOS пока живёт в TestFlight. NetworkExtension с его ограничениями по памяти и жизненному циклу оказался отдельным миром, и история iOS‑ветки читается как длинный список fix(ios): .... Гонки при переключении серверов, когда новый туннель стартовал раньше, чем старый освобождал порт. Кнопка подключения, зависавшая из‑за параллельных переходов состояния, пока их не выстроили в очередь. DNS, который замирал на TURN‑транспорте из‑за принудительного DNS через TCP. Сжатые ссылки, которые iOS‑версия отдавала в формате, не понятном уже выпущенным Android‑сборкам.
Сборка и загрузка в TestFlight идут через CI с автоматической подписью Apple Distribution, но запускаются только вручную, чтобы каждый пуш в бету не превращался в новую сборку для тестировщиков.
olcRTC, с которого всё началось, переведён в статус устаревшего. Он работает, но больше не развивается и будет удалён в одной из следующих версий: его задачи лучше решают другие транспорты.
Расширение для Chrome закрыто. Поддерживать его параллельно с четырьмя платформами не хватало рук.
Двойной замер пинга по умолчанию, о котором шла речь выше, тоже откатили и превратили в настройку.
Код, релизы и issue: github.com/yanisplugg/yptun. Новости проекта в Telegram.
Если вы тоже воевали с gomobile, Wintun, bsdiff на больших файлах или NetworkExtension, приходите в комментарии. Особенно интересно, как другие клиенты живут с ломающими изменениями конфигов при обновлении Xray: держать патчи в вендоренной копии работает, но красивым это решение не назовёшь.