Steven
Steven7 мин. чтения

Убираем backend с пути загрузки

GeekBye записывает твой экран и сохраняет видео в твой Google Drive. Первая версия прогоняла каждую запись через собственные серверы GeekBye по пути туда; релизом позже файл шёл прямо с твоей машины на Drive, а backend был понижен до хранения единственного указателя. Интересная часть в том, как мало кода на самом деле содержит «прямая, возобновляемая» версия — потому что возобновляемость пришла из удаления proxy, а не из его написания.

Инженерия
Архитектура
Десктоп
Релизы GeekBye
Убираем backend с пути загрузки

GeekBye умеет записывать твой экран — видео, системный звук и микрофон — и автоматически складывать готовую запись в твой Google Drive. Между v1.8.11 и v1.8.13 обращённое к пользователю описание функции почти не изменилось, но путь, которым видео добирается до Drive, был переложен полностью. В первой версии твоя запись путешествовала через серверы GeekBye. Во второй она шла прямо с твоей машины на твой Drive, и backend GeekBye ни разу не видел ни одного её байта. Эта переправа — вся история, и приятная часть в том, что «лучшая» версия содержит заметно меньше кода, чем та, которую она заменила.

Конвейер, от начала до конца

Запись происходит в рендерере. ScreenRecordingService.ts создаёт MediaRecorder поверх потока захвата с { mimeType: 'video/webm;codecs=vp9,opus', videoBitsPerSecond: 1_000_000 } и запускает его с односекундным срезом времени (CHUNK_INTERVAL_MS = 1000). Каждый blob ondataavailable отправляется через IPC в главный процесс, где обработчик recording:save-chunk дописывает его в файл на диске с помощью fs.promises.appendFile. Стоит отметить сразу, потому что это соблазнительно прочитать неверно: этот односекундный ритм — ритм записи на диск, а не размер фрагмента загрузки. К моменту, когда загрузка начинается, на диске лежит единственный готовый файл WebM.

Между двумя релизами различается только то, что происходит с этим готовым файлом.

Как было: всё через backend (v1.8.11)

Путь загрузки в v1.8.11, в CloudUploader.processRecording(), — очевидный дизайн и совершенно разумная первая версия:

  1. POST /api/geekbye/recordings, чтобы создать запись в backend с метаданными записи.
  2. Прочитать всё видео в память и отправить его POST-ом на backend как multipart — const fileBuffer = fs.readFileSync(filePath) — попадая в POST /api/geekbye/recordings/${id}/upload. Backend затем выгружает этот файл на Drive и возвращает driveUrl.
  3. POST …/process, чтобы отправить транскрипт на анализ AI.

Несущая деталь — шаг 2: клиент загружает всю запись в память, и каждый её байт транзитом проходит через собственные серверы GeekBye по пути на Drive. Для короткого клипа это нормально. Для длинной записи экрана это три проблемы разом — давление на память клиента со стороны readFileSync, стоимость трафика и ретрансляции на backend за файл, который он даже не хранит, и единственный монолитный POST, которому приходится начинать с нуля, если сеть икнёт на полпути.

Стоит также быть честным, что это «как было» не было каким-то нетронутым первоначальным дизайном. Функция v1.8.11 сплющила в себе разворот, случившийся в пределах одной недели: ранняя версия выгружала на Cloudflare R2 через предподписанные URL и конвертировала WebM в MP4 встроенным ffmpeg-static; это было заменено потоком Drive через backend; а потом ffmpeg-конвертацию вырвали полностью в пользу выгрузки WebM как есть (коммит отмечает, что это снизило битрейт и дало примерно вчетверо меньшие файлы — прикидка на глаз, не бенчмарк). Так что даже «как было» уже однажды научилось удалять нативную зависимость. Следующий релиз удалил бы и proxy.

Как стало: прямо на Drive (v1.8.13)

v1.8.13 переписывает CloudUploader.processRecording() вокруг одного предложения из коммита, что его выпустил: выгружай прямо на Drive, «оставляя backend только для записи метаданных + AI-анализа транскрипта.» Новый путь:

  1. POST /api/geekbye/recordingsтолько метаданные (заголовок, длительность, размер файла, format: 'webm'). Комментарий в коде прямолинеен: // Create backend record (metadata only — no file upload).
  2. Выгрузить видео прямо на Google Drive, полностью минуя посредника-backend.
  3. PATCH /api/geekbye/recordings/${backendRecordingId} с получившимся { driveUrl, driveFolderId } — записи в backend сообщают, где оказался файл.
  4. Транскрипт всё ещё идёт в …/process на анализ.

Backend прошёл путь от нахождения в пути файла до того, чтобы ему сообщали о файле постфактум. Он держит строку и указатель; байты живут только на диске пользователя и в его Drive.

Становясь настоящим клиентом Drive

Чтобы клиент говорил с Drive напрямую, ему нужно быть настоящим клиентом Google Drive API, и v1.8.13 добавляет для этого механику: новый DriveService (клиент Drive), новый DriveAuthRepository, опирающийся на новую SQLite-таблицу drive_auth, и SDK googleapis как зависимость.

Авторизация — это чистая передача полномочия. Эндпоинт конфигурации приложения на backend передаёт клиентские учётные данные Google OAuth — clientId и clientSecret — вниз в приложение, которые CloudUploader читает и передаёт в driveService.initialize(...). Оттуда десктопное приложение само проводит весь поток OAuth: поднимает временный http-сервер на случайном порту 127.0.0.1, открывает экран согласия в системном браузере через shell.openExternal, ловит редирект на /callback и меняет код на токены с помощью google-auth-library. Эти токены сохраняются локально — saveDriveAuth(access_token, refresh_token, expiry_date) в drive_auth — с областью drive.file, что позволяет приложению управлять только теми файлами, которые оно создаёт. Обновление тоже локальное: слушатель oauth2Client.on('tokens', …) пишет обновлённые токены прямо обратно в таблицу. Backend передаёт полномочие один раз, а затем остаётся в стороне.

Часть про «возобновляемость», честно

Вот деталь, насчёт которой мне больше всего хочется быть прямым, потому что это ровно та вещь, которую changelog округляет. Примечание к релизу говорит «возобновляемые загрузки», и это правда с точки зрения пользователя. Но GeekBye не реализовал протокол возобновляемости. Нигде в исходниках GeekBye нет ни константы размера фрагмента, ни трекера байтового смещения, ни обработки Content-Range / 308 Resume Incomplete. Поищи их грепом — и не получишь ничего: они живут внутри SDK googleapis.

Вот что делает собственный код GeekBye:

const fileStats = statSync(videoFilePath)
const videoStream = createReadStream(videoFilePath)

const videoRes = await this.drive.files.create(
  {
    requestBody: { name: `${title}.webm`, parents: [folderId] },
    media: { mimeType: 'video/webm', body: videoStream },
    fields: 'id',
  },
  {
    onUploadProgress: (evt) => {
      const percent = Math.round((evt.bytesRead / fileStats.size) * 100)
      onProgress(percent)
    },
  },
)

Всю работу делают два выбора. Во-первых, media.body — это поток (createReadStream), а не буфер, — и передача потока это в точности то, что заставляет googleapis договориться о возобновляемой сессии за тебя, вместо того чтобы делать всё за один раз. Во-вторых, прогресс считывается из колбэка onUploadProgress SDK относительно fileStats.size. Это вся реализация «возобновляемой загрузки» на уровне приложения: поток внутрь, прогресс наружу. Трудный протокол — открыть сессию, загружать фрагментами, возобновить со смещения после обрыва — это работа SDK, и верным инженерным решением было дать ему выполнить эту работу, а не изобретать её заново.

Что переосмысляет весь релиз. Улучшение надёжности пришло не из написания движка загрузки. Оно пришло из удаления proxy, что стоял перед одним таким, — а раньше, на той же неделе, из удаления шага ffmpeg, стоявшего перед тем. Меньше кода, больше устойчивости, потому что устойчивой вещью была видавшая виды библиотека в тот миг, когда ты перестал вручную кормить её буфером.

Куски, что по-настоящему принадлежат GeekBye

Передача трансфера SDK не значит, что строить было нечего. Два куска настоящей логики приложения его окружают.

Откат осиротевшей папки. Каждая запись получает свою подпапку Drive (созданную с mimeType: 'application/vnd.google-apps.folder'), и транскрипт ложится туда вторым файлом рядом с видео. Если загрузка бросает исключение, catch удаляет эту папку — this.drive.files.delete({ fileId: folderId }), логируется как Rolled back orphaned Drive folder — так что неудавшаяся загрузка не оставляет следа пустых папок в твоём Drive. Создание контейнера прежде, чем ты знаешь, что операция удастся, — маленькая уязвимость; сделать так, чтобы путь ошибки его подчищал, — это исправление.

Жизненный цикл вокруг синглтона. DriveService — это синглтон, держащий в памяти клиент OAuth, и этот клиент в памяти не переживает перезапуск приложения, хотя токены переживают (они в базе данных). Так что CloudUploader заново инициализирует DriveService из сохранённых токенов перед каждой загрузкой, а значит запись, сделанная сразу после перезапуска, всё ещё выгружается, не прося тебя переподключиться. А ревизия превратила обработчик drive-connect в fire-and-forget, потому что блокирующий вызов connect тормозил опрос статуса рендерером — UI не мог показать «подключение…», если вызов connect держал поток. Эти два — реинициализация из хранилища и неблокирующий connect — настоящие граничные случаи прямого пути, и, что примечательно, ни один из них не про фрагментацию загрузки. Когда делегируешь трудную часть, баги, что тебе остаются, — это баги жизненного цикла.

Три вещи, которым нас научил этот релиз

  1. Спрашивай, куда текут байты, а не только работает ли оно. Proxy из v1.8.11 работал. Но прогонять каждую запись через собственные серверы стоит тебе трафика, памяти и возобновляемости — за файл, который ты не хранишь. Перечертить путь данных так, чтобы клиент говорил с назначением напрямую, — часто и есть вся оптимизация.
  2. Лучшая возобновляемая загрузка — та, что ты не писал. Передача потока зрелому SDK купила полную возобновляемую сессию за две строки кода. Инстинкт вручную склепать фрагментацию и арифметику смещений дал бы больше кода и меньше надёжности.
  3. Когда делегируешь ядро, наследуешь жизненный цикл, а не алгоритмы. Настоящими багами прямого клиента были «переинициализируй синглтон после перезапуска» и «не блокируй вызов connect», а не байтовые смещения. Это хорошая сделка — баги жизненного цикла видимы и локальны; баги протокола ни то, ни другое.

Про предыдущую главу истории v1 — рождён и исправлен за двенадцать минут (v1.8.10); а про всю дугу — анатомию доведения софта до совершенства.

Похожие статьи

Три глагола, что держат Web Audio живым
Steven
Steven8 мин. чтения

Три глагола, что держат Web Audio живым

Два точечных релиза GeekBye, разделённые двумя месяцами и в двух разных файлах, преподали нашему аудиокоду один и тот же урок с противоположных концов: перестань обращаться с браузерным AudioContext как с одноразовым. Один релиз научился делать resume() контексту, который macOS тихо приостановил посреди записи; другой научился suspend() вместо close(), чтобы идущие подряд сессии перестали врезаться в навязанный Chromium потолок примерно в шесть контекстов. Resume, suspend, close — вот и весь сюжет.

Инженерия
Аудио
Десктоп
Отличить звонок от открытого приложения
Steven
Steven8 мин. чтения

Отличить звонок от открытого приложения

GeekBye умеет заметить, что ты присоединился к видеовстрече, и предложить её записать. Обнаружение оказывается лёгкой половиной — нативный Swift-бинарник читает заголовки окон каждые десять секунд. Трудная половина — точность: не срабатывать, когда Zoom просто открыт, не предлагать записать встречу, которую ты уже записываешь, и не заглушать микрофон в звонке, в котором ты на самом деле сидишь. Три релиза, и каждый из них — предохранитель, которому пришлось научиться не побеждать самого себя.

Инженерия
macOS
Десктоп
Рождён и исправлен за двенадцать минут
Steven
Steven7 мин. чтения

Рождён и исправлен за двенадцать минут

Changelog GeekBye v1.8.10 говорит, что исправлена авария при редактировании клавиатурных сочетаний. Так и есть — но авария была внесена и исправлена в одном и том же пул-реквесте, с разницей в двенадцать минут, и не добралась ни до одного пользователя. Настоящая история — это каскад надёжности, что её породил: маленькое, верное изменение того, когда сочетания приостанавливаются, и баг разборки React, что выпал из одной строки, призванной сделать редактор безопаснее.

Инженерия
React
Десктоп