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

Неделя бета-теста GChat: звонки, iCloud и когда билд наконец собрался

Конец апреля 2026. Звонки то работают то падают, Xcode ругается на детрит iCloud, а мессенджер уже в руках друзей. Честно про фиксы, регрессы и что осталось добить.

  • gchat
  • flutter
  • ios
  • beta

Прошлая неделя для GChat выдалась плотной. Не в смысле «я написал сто коммитов за ночь», а в смысле «сегодня звонки ништяк, завтра приложение закрывается на старте вызова, послезавтра билд вообще не собирается из-за синхронизации диска». Так и живём: бета в четырёх карманах и на сервере в Coolify.

Расписываю как есть, чтобы через полгода не пришлось вспоминать по ощущениям.

Звонки и WebRTC: мистика оказалась чужим плагином

Мы несколько раз крутили состояние сессии, аудиосессию на iOS, UI входящего звонка. Иногда после правок всё складывалось идеально, иногда приложение умирало сразу после нажатия «Позвонить». Серверные логи при этом были нормальные: звонок создавался, push уходил. Значит проблема была на клиенте.

Разбор по стеку из Xcode упёрся не в наш Dart и не в FastAPI, а в flutter_webrtc: функция postEvent асинхронно доставляет событие на main thread и вызывает sink(event). Если к моменту выполнения блока Flutter уже освободил EventChannel, sink становится nil и получаешь классический EXC_BAD_ACCESS. Аннотация _Nonnull в коде плагина тут ни при чём, реальность другая.

Фикс из свежего upstream того же плагина простой: проверить sink на nil до и внутри dispatch. Я завёл его локально и пересобрал клиент. После этого перестало ощущаться как лотерея: если сервер принял звонок, клиент не должен падать только потому что WebRTC успел послать событие после того как канал уже закрыли.

Урок на память: когда краш стабильно сидит в системном символе вроде __postEvent_block_invoke, имеет смысл смотреть нативный код зависимости, а не только свои последние два коммита.

Интерфейс чата: мелочи, которые заметны пользователю

Параллельно шли UX-штуки, которые в тикете звучат как «поправить отступ», а по факту меняют ощущение от приложения.

Капсулы текстовых сообщений пережимали несколько итераций: лишний padding, несоответствие ширины контенту, реакции, которые раздували голосовую плитку. Для голосовых мы вынесли реакции в оверлей, чтобы капсула не расползалась.

Свайп влево для ответа в какой-то момент цеплял только свои сообщения из-за направления жеста. Привели к поведению как в Telegram: всегда влево, независимо от того кто автор.

Цвета строк звонков в истории чата: пропущенный красным, входящий зелёным, исходящий синим. Мелочь, но глаз сразу считывает контекст.

Панель эмодзи: закрытие по тапу снаружи, первая вкладка не «Recents» если там пусто, и менее дёрганное переключение обратно на клавиатуру. Звучит просто, пока не попробуешь согласовать фокус, inset клавиатуры и анимацию на iOS.

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

Файлы: PDF и документы открываются через системный просмотр, для mp3 в чате сделали встроенный плеер на том же аудиодвижке, что и голосовые.

Онлайн-статус и зомби на бэкенде

Друзья жаловались: человек давно закрыл приложение, а в шапке всё ещё «онлайн». Клиент мы подстраховали таймером при уходе в фон (плюс отключение сокета по логике жизненного цикла). На сервере добили крайний случай: если воркер с WebSocket умер жёстко, запись в Redis про активное соединение могла остаться навсегда. Тогда даже умный janitor не мог понять что пользователя уже нет.

Добавили TTL на ключ соединений и периодическое продление пока сессия живая. Плюс проход reconciler по множеству онлайн-пользователей и реальному счётчику сокетов. После деплоя картина стала честнее: офлайн не висит днями без причины.

iCloud и сборка iOS: отдельный квест

Часть проекта жила в папке, которую синхронизирует iCloud Drive. Для обычных Markdown-файлов это терпимо. Для Pods, DerivedData и бинарей Flutter это ад: расширенные атрибуты, дубликаты каталогов вроде nanopb 2, вылезающие из конфликтов синхронизации, и Xcode внезапно видит тридцать пять дублирующихся символов.

Мы вынесли мобильный клиент в ~/Developer/, оставив репозиторий бэкенда и лендинга там где удобно работать с GitHub. Для подписи пришлось обойти детрит в артефактах (обёртка вокруг codesign с очисткой через ditto). Это не «красота инженерии», это цена рабочего места на ноутбуке с синком.

Git один раз попытался умереть от файлов вида refs/remotes/origin/HEAD 2. Удаление дубликатов из .git вернуло репозиторию здравый смысл. Если у тебя iCloud на всём домашнем каталоге, проверяй что не плодятся «копии» служебных файлов.

Что ещё болит и не стыдно сказать вслух

Видеокружки: сервер режет один блоб примерно двенадцать мегабайт, а клиент долгое время жил с другим ощущением лимита и без явного сжатия под «кружок». Итог понятный: длинный ролик в высоком битрейте упирается в потолок и не уходит, хотя в логах загрузка могла бы выглядеть почти успешной. Переключение камеры для кружка мы закладывали в сервис записи, но заметная кнопка в интерфейсе всё ещё требует аккуратной доработки жестов (нельзя честно флипнуть камеру посреди записи тем API, которым мы пользуемся сейчас). Это следующий заход, не повод делать вид что проблемы нет.


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