Предыстория: от раздражения к решению

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

Полезные ссылки

Комментарии (3)


  1. backlove01
    08.11.2025 08:47

    Так держать!


  1. leremin
    08.11.2025 08:47

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

    Про проблемы с Garmin не слышал, Zwift ошибочно под раздачу попал - у меня только мегафон туда не пускает. Strava - ну тут да.


    1. cyberscoper Автор
      08.11.2025 08:47

      Внесу ясность.
      Зачем это? У многих проблемы со входом в Zwift, MyWhoosh  из за того что были придушены в каких то случаях cloudflare а где то amazon.
      Да и средства обходов ну не идеально работают - как вывод что?

      Создался этот малый проект который решает эту проблему. Ибо апдейты скачиваются с моего сервера да и все данные хранятся и обрабатываются так же. Если в вашем регионе работает vless и подмены goodbyedpi, у кого-то нет)