javascript

Capacitor: от веба к мобильным приложениям. Часть 3. OTA обновления в обход сторов

  • вторник, 24 марта 2026 г. в 00:00:04
https://habr.com/ru/articles/1013754/

Примерно год назад я написал статью о том, как настроить 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 — качаем бандл.

Источник правды — GitHub Releases

В прошлой реализации 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 };
};

Адаптер для Capgo

В прошлой статье я работал с 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 откатит бандл к предыдущему. Это защита от бесконечного цикла крашей при битом обновлении.

UI

Компонент 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.

На этом у меня все. Пишите любые интересующие вас вопросы в комментарии и в личку.

Ссылки: