Предыстория: от раздражения к решению
Последние пару лет я регулярно слышал от знакомых велосипедистов одни и те же жалобы на Zwift:
"Опять нужно включать VPN, чтобы тренировка загрузилась в Strava"
"Каждый месяц платить 20 евро становится дорого с текущим курсом да и проблема оплатить"
"Strava заблокирована, VPN работает через раз"
После очередного разговора о том, что "да, Zwift классный, но проблем много", я подумал: а что, если создать альтернативный лаунчер, который решит хотя бы часть этих проблем?
Так началась разработка reZwift.

Постановка задачи
Я поставил перед собой несколько технических целей:
1. Серверная обработка интеграций
Проблема: Strava заблокирован в России, Garmin Connect работает, пользователям приходится менять страну.
Решение: Вынести всю работу с тренировками в Garmin Connect от туда тренировка будет автоматически через их сервера выгружаться.

2. Современный веб-интерфейс
Проблема: Оригинальный лаунчер Zwift выглядит устаревшим и не локализован.
Решение: Создать веб-лаунчер с современным UI/UX на русском языке.

3. Безопасное хранение данных
Проблема: Нужно хранить учетные данные для интеграций (Garmin, Intervals.icu).
Решение: Использовать шифрование AES-256-CFB с индивидуальными ключами для каждого пользователя.
Технический стек
После анализа требований выбрал следующий стек:
# Backend
Flask 3.0.0 # Легковесный веб-фреймворк
Flask-Login # Управление сессиями
SQLite # База данных для пользователей
Cryptography # AES-256 шифрование
# Интеграции
garth # Garmin Connect API
requests # HTTP клиент для APIs
fitparse # Парсинг FIT файлов
# Frontend
Jinja2 # Шаблонизатор
CSS3 + Vanilla JS # Без фреймворков для производительности
Почему Flask? Zwift использует собственный протокол через протобуф, и мне нужен был легкий способ добавить веб-интерфейс поверх существующей логики.
Архитектура решения
Структура проекта
rezwift/
├── zwift.py # Основной сервер Flask
├── cdn/
│ ├── static/web/launcher/ # HTML шаблоны
│ └── gameassets/ # Статика (лого, иконки)
├── storage/
│ ├── zwift.db # База пользователей
│ ├── credentials-key.bin # Ключи шифрования
│ └── [user_id]/ # FIT файлы тренировок
└── requirements.txt
Как работает загрузка тренировок
Самая интересная часть — серверная обработка загрузок:
def garmin_upload(username, fit_file_path):
"""Загрузка тренировки в Garmin Connect"""
try:
# 1. Расшифровываем учетные данные
email, password = decrypt_credentials(username, 'garmin')
# 2. Авторизуемся в Garmin
client = GarminClient()
client.login(email, password)
# 3. Загружаем FIT файл
with open(fit_file_path, 'rb') as f:
response = client.upload_activity(f)
# 4. Логируем результат
log_upload_result(username, 'garmin', response)
except Exception as e:
log_upload_error(username, 'garmin', str(e))
Ключевой момент: Эта функция выполняется в отдельном потоке на сервере после завершения тренировки. Клиент ничего не делает — всю работу берет на себя backend.
Результат: пользователю не нужен VPN, даже если Garmin Connect заблокирован в его стране.
Безопасность: шифрование учетных данных
Хранить пароли от Garmin в открытом виде — плохая идея. Реализовал шифрование:
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
import os
def encrypt_credentials(data, key):
"""Шифрует данные с использованием AES-256-CFB"""
iv = os.urandom(16)
cipher = Cipher(
algorithms.AES(key),
modes.CFB(iv),
backend=default_backend()
)
encryptor = cipher.encryptor()
encrypted = encryptor.update(data.encode()) + encryptor.finalize()
return iv + encrypted
def decrypt_credentials(encrypted_data, key):
"""Расшифровывает данные"""
iv = encrypted_data[:16]
cipher = Cipher(
algorithms.AES(key),
modes.CFB(iv),
backend=default_backend()
)
decryptor = cipher.decryptor()
return (decryptor.update(encrypted_data[16:]) +
decryptor.finalize()).decode()
Каждый пользователь получает уникальный ключ при регистрации. Ключи хранятся в credentials-key.bin и никогда не передаются клиенту.
Frontend: современный дизайн на чистом CSS
Хотел избежать тяжелых фреймворков типа React/Vue. Лаунчер должен быть быстрым и компактным.
Цветовая схема
Создал фирменную черно-оранжевую палитру:
:root {
--orange-primary: #FF6B00;
--orange-bright: #FF8C00;
--yellow-glow: #FFD700;
--bg-black: #000000;
--bg-dark: #0a0a0a;
}
Glassmorphism эффекты
.glass-card {
background: rgba(255, 107, 0, 0.08);
backdrop-filter: blur(20px);
border: 2px solid rgba(255, 140, 0, 0.3);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.6),
0 0 25px rgba(255, 140, 0, 0.12);
}
"Бегущий свет" на кнопках
.btn-orange::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.4),
transparent
);
transition: left 0.6s;
}
.btn-orange:hover::before {
left: 100%;
}
Результат: неоновый эффект при наведении, как у киберпанк-интерфейсов.
Интеграция с Garmin Connect

Самая сложная часть — работа с Garmin API. Официального API для загрузки тренировок нет, пришлось использовать библиотеку garth, которая эмулирует работу официального приложения.
import garth
from garth.exc import GarthHTTPError
def setup_garmin_client(email, password):
"""Инициализация клиента Garmin"""
try:
# Авторизация
garth.login(email, password)
# Сохраняем токены для повторного использования
tokens = garth.client.oauth2_token
save_garmin_tokens(email, tokens)
return True
except GarthHTTPError as e:
if e.status == 401:
raise ValueError("Неверный email или пароль")
raise
def upload_to_garmin(fit_file_path, email):
"""Загрузка FIT файла"""
# Восстанавливаем сессию из токенов
tokens = load_garmin_tokens(email)
garth.client.oauth2_token = tokens
# Загружаем файл
with open(fit_file_path, 'rb') as f:
response = garth.client.upload(f)
return response
Проблема с токенами: Garmin токены живут ограниченное время. Реализовал систему автоматического refresh:
def refresh_garmin_token(email):
"""Обновление протухшего токена"""
try:
garth.client.refresh_oauth2()
tokens = garth.client.oauth2_token
save_garmin_tokens(email, tokens)
except:
# Токен протух окончательно, нужна повторная авторизация
return False
return True
Интеграция с Intervals.icu
С Intervals. icu проще — у них есть нормальный REST API:
def intervals_upload(athlete_id, api_key, fit_file_path):
"""Загрузка в Intervals.icu"""
url = f"https://intervals.icu/api/v1/athlete/{athlete_id}/activities"
headers = {
"Authorization": f"Basic {api_key}",
"Content-Type": "application/octet-stream"
}
with open(fit_file_path, 'rb') as f:
response = requests.post(url, headers=headers, data=f)
if response.status_code == 200:
return response.json()
else:
raise Exception(f"Upload failed: {response.text}")
Оптимизация для российских пользователей

1. Локализация терминов
Не просто перевёл, а адаптировал:
Оригинал |
Стандартный перевод |
Мой вариант |
|---|---|---|
Ghost |
Призрак |
✅ Понятно |
Power Curve |
Кривая мощности |
✅ Звучит |
FTP Test |
FTP тест |
Тест порога |
Pace Partner |
Темповый партнер |
Пейсмейкер |
2. Компактный дизайн
Оригинальный лаунчер Zwift не влезает в маленькие экраны. Сделал адаптивный дизайн:
/* Компактные отступы */
.form-group {
margin-bottom: 12px; /* вместо 20px */
}
/* Маленькие экраны */
@media (max-height: 600px) {
.logo-img {
max-width: 80px; /* вместо 140px */
}
}
/* Кастомный скроллбар */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #FFD700, #FF8C00);
}
3. Информация о работе в России
Добавил информационные блоки:
<div class="info-card success">
<strong>✅ Работает в России!</strong>
<p>Garmin Connect доступен без VPN. Все тренировки загружаются автоматически через сервер.</p>
</div>
Как это работает технически
Ключевой момент архитектуры — перенаправление запросов игры на собственный сервер через модификацию файла hosts. Zwift-клиент обращается к доменам типа secure.zwift.com, cdn.zwift.com, launcher.zwift.com, но благодаря записям в hosts-файле эти запросы перехватываются и обрабатываются локальным сервером:
185.217.199.111 us-or-rly101.zwift.com
185.217.199.111 secure.zwift.com
185.217.199.111 cdn.zwift.com
185.217.199.111 launcher.zwift.com
Сервер эмулирует ответы официальных API Zwift, добавляя при этом свою логику обработки — сохранение FIT-файлов, автоматическую загрузку в интеграции, кастомные маршруты. Всё это происходит прозрачно для игры, которая "думает", что общается с оригинальными серверами.
Подробные инструкции по установке и настройке, включая автоматический скрипт настройки hosts и импорт сертификата, доступны в нашем Telegram-канале.
Проблемы, с которыми столкнулся
1. Flask и статические файлы
Flask по умолчанию отдаёт статику из /static, но мне нужно было совместить это со структурой Zwift:
app = Flask(
__name__,
static_folder='cdn/gameassets',
static_url_path='/gameassets',
template_folder='cdn/static/web/launcher'
)
2. Роуты с параметрами
В шаблонах нужно было передавать username:
# Было
@app.route('/logout')
def logout():
# Ошибка: откуда брать username?
# Стало
@app.route('/logout/')
def logout(username):
# Работает
3. Jinja2 и url_for
Ошибки типа BuildError: Could not build url for endpoint 'sign_up':
# Неправильно (функция signup, а не sign_up)
{{ url_for('sign_up') }}
# Правильно
{{ url_for('signup') }}
Метрики производительности
После запуска собрал статистику:
Метрика |
Значение |
|---|---|
Время загрузки лаунчера |
120ms |
Размер CSS (minified) |
8.2KB |
Размер JS |
0KB (не используется) |
Время загрузки в Garmin |
2-3 сек |
Время загрузки в Intervals |
1-2 сек |
Результат
Что получилось в итоге:
✅ Веб-лаунчер на Flask с современным дизайном
✅ Серверная обработка загрузок тренировок
✅ AES-256 шифрование учетных данных
✅ Компактный UI для маленьких экранов
Планы на будущее
Что хочу добавить:
TrainingPeaks интеграцию (у них сложный OAuth)
Telegram bot для уведомлений о загрузках
Темную/светлую тему (сейчас только тёмная)
Выводы
Проект начинался как "решу проблему для себя", а превратился в полноценный лаунчер с современным стеком.
Что я узнал:
Как работать с Garmin Connect API без официальной документации
Тонкости шифрования в Python
Как делать красивый UI без React
Особенности Flask и Jinja2
Неожиданные сложности:
Garmin токены протухают непредсказуемо
Flask роуты с параметрами — это не так просто, как кажется
Glassmorphism эффекты жрут производительность на слабых компах
Что можно было сделать лучше:
Использовать TypeScript вместо чистого JS
Добавить unit-тесты
Реализовать CI/CD
Если у вас есть вопросы по реализации или хотите обсудить технические детали пишите в комментариях!
P.S. Код проекта доступен по запросу. Проект не аффилирован с Zwift Inc.
Полезные ссылки
Telegram канал — обсуждение и поддержка
Документация Garth — библиотека для Garmin Connect
Intervals.icu API — официальная документация
Комментарии (3)

leremin
08.11.2025 08:47Пользуюсь всем перечисленным. Все равно не понял зачем это. Zwift со своих серверов выгружает тренировки. Вот у MyWhoosh проблемы, она в Strava от клиента выгружает - тут да, нужны средства обхода.
Про проблемы с Garmin не слышал, Zwift ошибочно под раздачу попал - у меня только мегафон туда не пускает. Strava - ну тут да.

cyberscoper Автор
08.11.2025 08:47Внесу ясность.
Зачем это? У многих проблемы со входом в Zwift, MyWhoosh из за того что были придушены в каких то случаях cloudflare а где то amazon.
Да и средства обходов ну не идеально работают - как вывод что?
Создался этот малый проект который решает эту проблему. Ибо апдейты скачиваются с моего сервера да и все данные хранятся и обрабатываются так же. Если в вашем регионе работает vless и подмены goodbyedpi, у кого-то нет)
backlove01
Так держать!