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