← Ко всем заметкам

GChat после апрельской беты: Mac как отдельное устройство и звонки, которые можно расследовать

Май-июнь 2026. GChat вырос из сборки для друзей в pre-release: отдельный Mac-клиент, профили, честный офлайн и телеметрия звонков вместо гадания по ощущениям.

  • gchat
  • flutter
  • ios
  • android
  • webrtc
  • macos
  • beta

В апреле я писал про неделю, где звонки то оживали, то убивали клиент, а Xcode спорил с iCloud так, будто это отдельный вид спорта. С тех пор прошёл месяц с хвостом. GChat уже не ощущается как «сборка для четырёх карманов»: друзья сидят в чатах, звонят iPhone ↔ Android, присылают баги, и эти баги стали взрослыми.

Раньше жалоба звучала как «что-то не так со звуком». Теперь чаще так: «на iPhone я слышу гудки, Android принял вызов, но голоса нет», «после принятия системный рингтон не замолчал», «без интернета приложение ведёт себя так, будто я не залогинен». Это уже не туман. Это материал для расследования.

Ниже — что изменилось с 27 апреля до текущей сборки 0.9.106 в канале pre-release. Не пресс-релиз, а инженерный дневник: что починили, где пришлось переписать слой целиком, и что честно осталось болеть.

От беты к pre-release

Снаружи изменение маленькое, но оно задаёт ожидания. Вкладка «Защита» стала «Настройки», щит заменили на шестерёнку, в футере вместо pre-beta теперь pre-release, а рядом появился support@gchat.tech — тап открывает почтовый клиент.

Это всё ещё не релиз в стор, но уже и не «мы просто играемся». Продукт живёт у людей, а значит у людей должен быть понятный способ сказать: «вот здесь сломалось».

Mac перестал быть телефоном на большом экране

Самый крупный сдвиг за май — настоящая мульти-девайсность.

Раньше логика была ближе к «телефон главный, всё остальное рядом». Теперь каждое устройство — отдельная криптографическая личность: свои ключи, свои сессии шифрования, свой device_id. Когда я отправляю сообщение человеку с iPhone и Mac, сервер не получает один общий текст и не «раздаёт копии». Клиент заранее шифрует отдельный вариант для каждого устройства получателя.

Если совсем просто: Mac больше не зеркало телефона. Он самостоятельный участник переписки.

Для входа на Mac появились два нормальных пути:

  • Recovery Phrase — восстановление через фразу, из которой расшифровывается identity material.
  • Phone Export — QR-паринг с телефона, после которого Mac живёт сам.

Телефон и Mac могут работать одновременно. Это ближе к Signal, чем к «открой веб-версию и сканируй QR каждый вечер».

Цена была ожидаемой: пришлось переписать отправку и приём на мобилке, добить дедупликацию, починить расшифровку, когда у собеседника два активных устройства. Один раз мы честно словили регрессию: между телефонами перестали расшифровываться сообщения. Приоритет сразу ушёл туда, потому что E2E без расшифровки — это не мессенджер, а дорогая форма молчания.

Отдельно: iOS-приложение на Mac через «iPhone apps on Mac» теперь закрыто полноэкранной заглушкой. macOS-версия будет отдельным клиентом, а не костылём поверх iPad-билда.

Профили и «Люди» — уже не просто список контактов

Долгое время профиль в GChat был почти служебной карточкой: аватар и @handle. За май это стало похоже на нормальный социальный слой.

Появились:

  • bio в настройках и карточке профиля;
  • история аватаров, где старые фото не исчезают после замены главного;
  • вкладка «Люди» с фото, @тегом, временем последней активности, галочками верификации и сторис;
  • переход в профиль по тапу на аватар в шапке 1:1 чата;
  • быстрые действия «написать» и «позвонить» из профиля.

Backend под это получил миграции под bio и avatar history. На бумаге звучит как UI-слой, но по ощущению это важнее. GChat перестаёт быть «чатами с криптографией» и становится приложением, где у людей есть присутствие. Шифрование остаётся под капотом, но пользователь не должен каждую минуту помнить, что он сидит в инженерном эксперименте.

Чат: меньше «работает», больше «можно жить»

Параллельно с профилями шла пачка вещей, которые заметны только когда пользуешься приложением каждый день:

  • кнопка прокрутки вниз;
  • плавающая дата при скролле;
  • микро-анимации и haptic-отклик;
  • мультивыбор сообщений;
  • локальный поиск по чату;
  • превью в ответе;
  • редактирование сообщений без ломания E2E-протокола;
  • оптимизация фото-превью и задел под thumbnail-cache.

Часть изменений скучная и поэтому важная: SQLite-архив для старых сообщений, локальный индекс, медиа-кэш, transit-only backend. Сервер не хранит историю чатов, он занимается доставкой. Для приватности это хорошо, но ответственность переезжает в клиент: поиск, кэш, архив и аккуратное восстановление состояния должны работать на устройстве.

Вот здесь мессенджер становится взрослым не из-за одной красивой фичи, а из-за суммы мелочей. Когда дата не прыгает, фото не съедают память, поиск находит старую фразу, а ответ показывает превью, мозг перестаёт замечать интерфейс. Это и есть цель.

Звонки: от «кажется, сеть плохая» к диагностике

В апрельском посте основная боль была грубая: звонки могли крашить клиент из-за flutter_webrtc. В мае проблема стала тоньше. Приложение уже не падало, но качество и маршрутизация аудио всё ещё могли вести себя странно:

  • iPhone звонит Android, гудки идут, но собеседника не слышно;
  • на видеозвонке громкая связь показывает «включена», а звук остаётся в разговорном динамике;
  • CallKit продолжает играть системный рингтон, хотя пользователь уже принял вызов внутри приложения.

Разбор упёрся в связку AVAudioSession, ringback-звука и flutter_webrtc. Если объяснять без Apple-магии: iOS очень ревниво управляет аудио. Пока приложение проигрывает гудки, система может держать один режим звука; когда WebRTC пытается забрать микрофон и динамик, ему нужно заново подтвердить, что это уже VoIP-звонок, а не обычный плеер.

Решение: нативный MethodChannel на iOS, который реактивирует VoIP-сессию и жёстко переключает output. Плюс повторное подтверждение аудиомаршрута сразу после остановки гудков, не дожидаясь состояния connected.

Параллельно на клиенте появился более взрослый WebRTC-слой:

  • live-сбор getStats() — RTT, bitrate, candidate path, fps, loss;
  • debug-overlay на экране звонка по long-press;
  • prewarm TURN/ICE при открытии 1:1 чата;
  • лимиты на video bitrate и framerate;
  • защита от конфликтов renegotiation и ICE restart;
  • умный кэш ICE, чтобы плохой NAT не «запекался» на десять минут.

Пассивная телеметрия — главный выигрыш

Самое важное: мы перестали гадать, почему звонок развалился, и начали собирать доказательства.

Клиент отправляет на backend не звук и не содержимое разговора, а технические следы качества:

  • samples примерно каждые 15 секунд: RTT, kbps in/out, relay/srflx path, fps, loss, jitter;
  • summary при завершении: время подключения, секунды без аудио, relay seconds, ICE restarts, причина падения;
  • lifecycle events: offer_sent, call_accepted, answer_received, ice_connected, connected, ice_restart_*, watchdog timeouts.

Backend сохраняет это в таблицы call_quality_samples, call_quality_summary и call_quality_events. Для расследований есть admin endpoint GET /v1/admin/calls/{call_id}/quality: обе стороны звонка, timeline, samples, events и предварительный diagnosis.

Теперь жалоба «меня не слышно» раскладывается по фактам:

  • у caller audio_out = 0 — вероятнее всего, микрофон или аудиосессия на его устройстве;
  • у caller audio_out > 0, а у callee audio_in = 0 — смотрим сеть, ICE или TURN;
  • у обоих audio_in > 0, но человек всё равно жалуется — вероятен output route или audio focus.

Это огромная разница. Без телеметрии ты споришь с ощущениями. С телеметрией ты смотришь на пару устройств и понимаешь, где именно умер сигнал.

Следующий шаг — admin dashboard с фильтрами и графиками. Пока хватает JSON endpoint и SQL, но долго так жить не хочется.

Сеть, офлайн и «Подключение…»

Ещё одна боль беты: открыл приложение без сети — и тебя выкинуло на регистрацию, хотя ты уже залогинен. Сейчас сессия восстанавливается из secure storage, показывается «Подключение…», а кэшированные чаты остаются доступны. Тот же индикатор появляется в шапке открытого чата, когда сокет отвалился.

Это не самая громкая фича, зато одна из самых человеческих. Пользователь не обязан понимать, что backend недоступен, WebSocket переподключается, а токены живут отдельно от UI. Он должен открыть приложение и увидеть: «я на месте, данные не пропали, сеть сейчас вернётся».

CallKit тоже стал спокойнее: системный UI звонка гасится, когда пользователь принял вызов внутри приложения. Иначе получаются два рингтона и ощущение, что телефон спорит сам с собой.

Push-уведомления ушли в Variant A: в уведомлении есть hint типа сообщения, но нет текста и приватного содержимого. Так iOS и Android могут показать что-то осмысленное, не превращая push в утечку.

Медиа: mini-player на всём shell’е

Появился нижний mini-player на всём shell’е: чаты, люди, звонки, настройки. У него есть обложка, marquee-название, play/pause, прогресс-линия и компактные контролы. Он работает и для музыки из плейлистов, и для голосовых сообщений в чате.

На уровне тикета это звучит почти декоративно. На уровне ежедневного использования — нет. Когда голосовое продолжает играть при переходе между экранами, а музыка не теряется из-за любого тапа, приложение начинает ощущаться цельным. Не «открыл, отправил, закрыл», а место, где можно жить несколько минут подряд и не упираться в швы.

Что ещё открыто

Нормально говорить не только о том, что получилось.

  • Видеокружки всё ещё упираются в лимит блоба и отсутствие нормального transcode под «кружок». Длинный ролик в высоком битрейте может не уйти.
  • Mac-клиент активно разрабатывается. Мобильный fan-out и recovery paths уже есть, но desktop ещё не в сторе.
  • Call-quality dashboard пока отсутствует. JSON есть, красивого UI для расследований нет.
  • Retention и агрегаты по звонкам — daily failed %, p95 connect time и похожие метрики — в планах.
  • Thumbnail-cache и media-index для галереи чата остаются следующим большим профитом по памяти и скорости.

Месяц после апрельской беты был не про идеальность. Он был про переход из «у меня на телефоне вроде работает» в состояние, где у GChat есть независимые устройства, нормальные профили, честный офлайн и звонки, которые можно расследовать.

Мне нравится эта стадия. Она уже не романтичная, потому что баги становятся конкретными и иногда неприятными. Но именно здесь продукт перестаёт быть демкой и начинает вести себя как вещь, которой не стыдно пользоваться каждый день.