
Прибираємо backend зі шляху завантаження
GeekBye записує твій екран і зберігає відео у твій Google Drive. Перша версія проганяла кожен запис через власні сервери GeekBye дорогою туди; релізом пізніше файл ішов прямо з твоєї машини на Drive, а backend був понижений до тримання єдиного вказівника. Цікава частина в тому, як мало коду насправді містить «пряма, відновлювана» версія — бо відновлюваність прийшла з видалення proxy, а не з його написання.
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(), — очевидний дизайн і цілком розумна перша версія:
POST /api/geekbye/recordings, щоб створити запис у backend з метаданими запису.- Прочитати все відео в пам'ять і відправити його POST-ом на backend як multipart —
const fileBuffer = fs.readFileSync(filePath)— влучаючи вPOST /api/geekbye/recordings/${id}/upload. Backend потім вивантажує цей файл на Drive і повертаєdriveUrl. 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-аналізу транскрипта.» Новий шлях:
POST /api/geekbye/recordings— лише метадані (заголовок, тривалість, розмір файлу,format: 'webm'). Коментар у коді прямолінійний:// Create backend record (metadata only — no file upload).- Вивантажити відео прямо на Google Drive, повністю оминаючи посередника-backend.
PATCH /api/geekbye/recordings/${backendRecordingId}з отриманим{ driveUrl, driveFolderId }— запису в backend повідомляють, де опинився файл.- Транскрипт усе ще йде в
…/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 — справжні межові випадки прямого шляху, і, що прикметно, жоден з них не про фрагментацію завантаження. Коли делегуєш важку частину, баги, що тобі лишаються, — це баги життєвого циклу.
Три речі, яких нас навчив цей реліз
- Питай, куди течуть байти, а не лише чи воно працює. Proxy з v1.8.11 працював. Але проганяти кожен запис через власні сервери коштує тобі трафіку, пам'яті і відновлюваності — за файл, який ти не зберігаєш. Перекреслити шлях даних так, щоб клієнт говорив із призначенням напряму, — часто і є вся оптимізація.
- Найкраще відновлюване завантаження — те, що ти не писав. Передача потоку зрілому SDK купила повну відновлювану сесію за два рядки коду. Інстинкт вручну зліпити фрагментацію й арифметику зміщень дав би більше коду і менше надійності.
- Коли делегуєш ядро, успадковуєш життєвий цикл, а не алгоритми. Справжніми багами прямого клієнта були «переініціалізуй синглтон після перезапуску» і «не блокуй виклик connect», а не байтові зміщення. Це гарна угода — баги життєвого циклу видимі й локальні; баги протоколу ні те, ні інше.
Про попередній розділ історії v1 — народжений і виправлений за дванадцять хвилин (v1.8.10); а про всю дугу — анатомію доставки програмного забезпечення до досконалості.