Capacitor: от веба к мобильным приложениям. Часть 3. OTA обновления в обход сторов
- вторник, 24 марта 2026 г. в 00:00:04
Примерно год назад я написал статью о том, как настроить OTA-обновления в Capacitor-приложении с помощью capacitor-updater. Подход работал, но со временем код стал разрастаться в одном компоненте и превратился в ту самую "кашу", с которой обычно начинают, а потом рефакторят. В этой статье еще раз разберем механизм обновления, и как я переписал систему обновлений с нуля — с нормальной архитектурой, двумя стратегиями обновления и отдельным слоем для работы с GitHub Releases.
Если вы делаете мобильное приложение на Capacitor, рано или поздно столкнётесь с одной неприятной особенностью: даже ради правки опечатки в тексте нужно собрать релиз, пройти ревью в Google Play или App Store и ждать. Google Play — несколько часов, App Store — несколько дней. Если вы ещё и в RuStore или AppGallery, умножайте на количество магазинов.
Но Capacitor-приложение состоит из двух частей: нативной оболочки (Java/Kotlin, Swift/ObjC) и JavaScript-бандла, который грузится в WebView. Нативная часть меняется редко — только когда добавляете новые плагины или меняете конфигурацию. А весь продуктовый код, вся бизнес-логика — это JavaScript. И вот его можно обновлять без ревью.
Магазины это явно разрешают.
Google Play: приложения, использующие интерпретируемые языки (JavaScript, Python, Lua), могут получать обновления кода без повторной публикации — при условии, что обновление не меняет основное назначение приложения и не нарушает правил магазина.
Apple App Store: интерпретируемый код разрешено загружать и запускать, если он не изменяет основную функциональность приложения, не создаёт альтернативный магазин приложений и не обходит механизмы безопасности.
Граница простая: JavaScript — можно, нативный код — нельзя. Если обновление требует изменений в android/ или ios/ директориях — это нативное обновление, и оно идёт через магазин.
Capacitor при запуске грузит JavaScript из папки dist/ (или build/, зависит от конфига). Плагин @capgo/capacitor-updater позволяет скачать новую версию этой папки в виде ZIP-архива и заменить текущую. При следующем запуске приложение загрузит уже новый бандл.
Первый запуск Обновление Следующий запуск ───────────── ────────── ──────────────── dist/ v1.0.2 → скачали dist/ v1.0.3 → dist/ v1.0.3 (встроен в APK) (лежит в storage) (из storage)
Если что-то пошло не так и новый бандл крашит приложение — плагин откатывается к предыдущей версии автоматически. Для этого нужно вызвать CapacitorUpdater.notifyAppReady() в течение 10 секунд после старта. Это сигнал "всё ок, не откатывай".
npm install @capgo/capacitor-updater npx cap sync
В capacitor.config.ts отключаем автообновление — будем управлять процессом вручную:
// capacitor.config.ts const config: CapacitorConfig = { // ... plugins: { CapacitorUpdater: { autoUpdate: false, }, }, };
Бандл для публикации собирается через Capgo CLI:
npm run build:dist npx @capgo/cli bundle zip io.your.app --name app-bundle.zip
Получившийся app-bundle.zip нужно где-то разместить — чтобы приложение могло его скачать. Я использую GitHub Releases: бесплатный CDN, версионирование из коробки и API для получения последнего релиза. Об этом подробнее ниже.
В прошлой версии весь код жил в одном AppUpdateProvider. Он умел одно: скачать бандл и применить его. Никакого разделения на нативные и патч-обновления, никакой истории релизов в UI, прогресс загрузки "из головы", зависимости захардкожены прямо в компоненте.
Сейчас картина другая:
Две стратегии обновления — патч (OTA через Capgo) и нативное (через магазин)
GitHub Releases как источник правды о версиях и ченджлоге
FSD + Ports & Adapters — бизнес-логика изолирована, технологии подключаются через интерфейсы
Zustand-стор вместо локального стейта в компоненте
Прогресс загрузки через события плагина
Правила магазинов по-прежнему те же: Google Play и Apple разрешают OTA только для JavaScript-кода, без изменений нативной части. Если нужно обновить нативный код — путь один: через магазин.
Всё живёт в двух местах: src/features/app-update/ (бизнес-логика) и src/shared/api/ (адаптеры к внешним сервисам).
features/app-update/ model/ types.ts # статусы и стратегии contracts.ts # интерфейсы зависимостей use-cases.ts # оркестрация store.ts # Zustand-стор hooks.ts # React-хуки для UI ui/ AppUpdateInfo.tsx AppUpdateActionButton.tsx ReleaseChangesList.tsx shared/api/ app-updater/ capgo-updater-runtime.ts # обёртка над @capgo/capacitor-updater github/ github-runtime.ts # запросы к GitHub API
Главный принцип: фича не знает, что такое Capgo или GitHub. Она знает только контракты — интерфейсы, которые ей передают снаружи.
Первое, что я добавил — различение типов обновлений по semver:
// features/app-update/model/types.ts export const UPDATE_STRATEGIES = { NONE: 'NONE', NATIVE: 'NATIVE', // major или minor — идём в магазин PATCH: 'PATCH', // только patch — скачиваем бандл } as const; export const APP_UPDATE_STATUSES = { IDLE: 'IDLE', CHECKING: 'CHECKING', AVAILABLE: 'AVAILABLE', DOWNLOADING: 'DOWNLOADING', DOWNLOADED: 'DOWNLOADED', UP_TO_DATE: 'UP_TO_DATE', ERROR: 'ERROR', } as const;
Логика определения стратегии в use-case:
// features/app-update/model/use-cases.ts const isReleaseHasNativeUpdate = (current: SemVer, remote: SemVer): boolean => remote.major > current.major || remote.minor > current.minor; const isReleaseHasPatchUpdate = (current: SemVer, remote: SemVer): boolean => remote.major === current.major && remote.minor === current.minor && remote.patch > current.patch;
Если вышла версия 1.1.0, а у пользователя 1.0.2 — это нативное обновление, OTA тут не поможет. Если вышла 1.0.3 — качаем бандл.
В прошлой реализации URL бандла нужно было обновлять вручную в конфиге. Теперь источник правды — GitHub API. Один запрос возвращает всё нужное: версию, описание релиза и ссылку на app-bundle.zip.
// shared/api/github/github-runtime.ts export const createGithubRuntime = (): GithubRuntime => ({ getLatestReleaseInfo: async () => { try { const response = await fetch(config.APP_RELEASE_URL); const data: GithubLatestReleaseResponse = await response.json(); return { status: true, data }; } catch (error) { return { status: false, error: String(error) }; } }, });
В репозитории с релизами при публикации нужно приложить app-bundle.zip к ассетам:
npm run build:patch # под капотом: vite build + npx @capgo/cli bundle zip io.paperflow.app --name app-bundle.zip
Use-case сам находит нужный ассет и достаёт ссылку:
export const checkForAvailableUpdate = async (deps: AppUpdateDependencies): Promise<CheckResult> => { const releaseResult = await deps.githubRuntime.getLatestReleaseInfo(); if (!releaseResult.status) return { isAvailable: false, error: releaseResult.error }; const { tag_name, body, assets } = releaseResult.data; const current = parseSemver(deps.currentVersion); const remote = parseSemver(tag_name); if (!current || !remote) return { isAvailable: false, error: 'Failed to parse semantic versions' }; if (isReleaseHasNativeUpdate(current, remote)) { return { isAvailable: true, strategy: UPDATE_STRATEGIES.NATIVE, remoteVersion: tag_name, releaseDescription: body }; } if (isReleaseHasPatchUpdate(current, remote)) { const bundleAsset = assets.find(a => a.name === 'app-bundle.zip'); if (!bundleAsset) return { isAvailable: false, error: 'Patch bundle not found in release assets' }; return { isAvailable: true, strategy: UPDATE_STRATEGIES.PATCH, remoteVersion: tag_name, releaseDescription: body, bundleDownloadUrl: bundleAsset.browser_download_url, }; } return { isAvailable: false }; };
В прошлой статье я работал с CapacitorUpdater напрямую из компонента. Теперь это обёртка за интерфейсом:
// shared/api/app-updater/capgo-updater-runtime.ts export const createCapgoUpdaterRuntime = (): CapgoUpdaterRuntime => ({ downloadBundle: (url, version) => CapacitorUpdater.download({ url, version }), notifyAppReady: () => CapacitorUpdater.notifyAppReady(), setCurrentBundle: (bundle) => CapacitorUpdater.set(bundle), addDownloadListener: (listener) => CapacitorUpdater.addListener('download', listener), addDownloadFailedListener: (listener) => CapacitorUpdater.addListener('downloadFailed', listener), });
Зачем? Чтобы тестировать use-cases без реального плагина — в тестах подсовываю мок, который реализует тот же интерфейс.
Zustand-стор управляет всем состоянием. Ключевой момент — подписка на события Capgo перед началом загрузки:
// features/app-update/model/store.ts startDownloadUpdate: async () => { const { bundleDownloadUrl, remoteVersion } = get(); if (!bundleDownloadUrl || !remoteVersion) return; set({ status: APP_UPDATE_STATUSES.DOWNLOADING, downloadProgress: 0 }); const { capgoRuntime } = getAppUpdateDependencies(); // Подписываемся на прогресс до вызова download await capgoRuntime.addDownloadListener(({ percent }) => { set({ downloadProgress: percent }); }); await capgoRuntime.addDownloadFailedListener(() => { set({ status: APP_UPDATE_STATUSES.ERROR, error: 'Download failed' }); }); const bundle = await capgoRuntime.downloadBundle(bundleDownloadUrl, remoteVersion); set({ status: APP_UPDATE_STATUSES.DOWNLOADED, downloadedBundle: bundle }); },
После скачивания пользователь нажимает "Обновить" — применяем бандл и сворачиваем приложение:
resetApplicationToUpdate: async () => { const { downloadedBundle } = get(); if (!downloadedBundle) return; const { capgoRuntime, appRuntime } = getAppUpdateDependencies(); await capgoRuntime.setCurrentBundle(downloadedBundle); // CapacitorUpdater.set() await appRuntime.minimizeApp(); // App.minimizeApp() },
minimizeApp() — это важная деталь. Вместо немедленного перезапуска приложение уходит в фон. При следующем открытии загружается уже новый бандл. Пользователь не видит "мигания" — всё выглядит как обычный переход между приложениями.
В main.tsx после рендера приложения:
// app/main.tsx ReactDOM.createRoot(document.getElementById('root')!).render(<App />); try { await initializeUpdater(); } catch (e) { console.error('Updater initialization failed:', e); }
initializeUpdater создаёт все зависимости и запускает проверку:
// app/bootstrap/init-updater.ts export const initializeUpdater = async () => { const dependencies = createAppUpdateDependencies(); setupAppUpdateStore(dependencies); await initializeAppUpdateStore(); // notifyAppReady() + checkForUpdate() };
notifyAppReady() нужно вызвать в течение 10 секунд после запуска — иначе Capgo откатит бандл к предыдущему. Это защита от бесконечного цикла крашей при битом обновлении.
Компонент AppUpdateInfo рендерится на странице настроек и показывается только когда есть что показывать:
// features/app-update/ui/AppUpdateInfo.tsx export const AppUpdateInfo = () => { const status = useAppUpdateStatus(); const isVisible = [ APP_UPDATE_STATUSES.AVAILABLE, APP_UPDATE_STATUSES.DOWNLOADING, APP_UPDATE_STATUSES.DOWNLOADED, ].includes(status); if (!isVisible) return null; return ( <Card> <ReleaseChangesList /> <AppUpdateActionButton /> </Card> ); };
Кнопка меняет поведение в зависимости от статуса и стратегии:
AVAILABLE + PATCH → "Скачать 1.0.3" (запускает загрузку бандла)
AVAILABLE + NATIVE → "Обновить до 1.1.0" (открывает магазин)
DOWNLOADING → прогресс-бар с процентом
DOWNLOADED → "Обновить приложение" (применяет бандл)
ReleaseChangesList парсит body из GitHub-релиза и вытаскивает список изменений — пользователь видит, что именно изменилось.
OTA-обновления в Capacitor по-прежнему работают на @capgo/capacitor-updater — плагин не изменился. Изменилось то, как обёрнута логика вокруг него. GitHub Releases как источник версий дают бесплатный CDN для бандлов, историю релизов для пользователей и единый источник правды для CI/CD.
На этом у меня все. Пишите любые интересующие вас вопросы в комментарии и в личку.
Ссылки: