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 каже, що виправив збій під час редагування клавіатурних скорочень. Так і є — але збій був внесений і виправлений в одному й тому самому pull request, з різницею у дванадцять хвилин, і не дістався жодного користувача. Справжня історія — це каскад надійності, що його породив: маленька, правильна зміна того, коли скорочення призупиняються, і баг розбирання React, що випав з одного рядка, покликаного зробити редактор безпечнішим.

Інженерія
React
Десктоп