Предыстория: от раздражения к решению
Последние пару лет я регулярно слышал от знакомых велосипедистов одни и те же жалобы на 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 — официальная документация