В июле я попросил Claude написать мне таймер рабочего дня: 45 минут сидя, две минуты у турника, 45 стоя, обед, и так до вечера. Один HTML-файл, ноль зависимостей, открыл в браузере — работает. Я назвал его «Смена» и честно прожил с ним день. К обеду выяснилось главное: телефон его не будит. Экран погас — вкладка уснула, setTimeout заморозился, никакого сигнала. Web Notifications на мобильном не ринг-тон, а строка в шторке, и то если вкладка жива. Push требует сервер и подписку. PWA на iOS — отдельная история с теми же ограничениями.

Так появилась Kapsula — маленькое приложение для Android и iOS, которое берёт один HTML-файл (вставленный код, файл или URL) и запускает его как проект с настоящими будильниками операционной системы. Ниже — что внутри, какие грабли собрал и почему у моста всего пять методов.

Демо: вставил код → Run → 3 минуты → уведомление на локскрине
Демо: вставил код → Run → 3 минуты → уведомление на локскрине

Ограничение, которое я поставил себе сам: никакого сервера

Первое решение было не техническим. Я хотел, чтобы приложение жило в режиме «вечера и выходные» и не требовало от меня ни хостинга, ни бэкенда, ни аккаунтов у пользователей. Значит:

  • никакого push через сервер — только локальные уведомления ОС;

  • никакой синхронизации — всё на устройстве;

  • никакой аналитики — метрики беру из консолей сторов;

  • пользовательский код не должен иметь возможности утащить что-то из оболочки или других проектов.

Это сразу отсекло половину «очевидных» фич и сделало архитектуру очень простой: оболочка (Capacitor + четыре плагина) и песочница, в которой исполняется чужой код.

Почему Capacitor, а не React Native и не «чистый» WebView

У меня за спиной был другой проект на React Native, с которого я вынес в основном список граблей: Gradle-версии, JDK, подписи, батарейные оптимизации Samsung. Для Kapsula нужен был именно WebView — продукт и есть «запускалка HTML». Capacitor даёт WebView, нативный мост и готовые плагины (LocalNotifications, Preferences, Haptics, Filesystem), а всё остальное — обычный веб. Оболочка написана на ванильном JS без сборщика: index.html, shell.js на три тысячи строк и клиент моста на четыре сотни. Сборка — это npx cap copy и gradlew assembleRelease; для iOS — тот же код и xcodebuild.

Один неочевидный плюс: одна и та же веб-часть работает в обоих сторах без адаптаций, и iOS-порт занял один вечер (плюс ещё один на карточку в App Store Connect).

Песочница: iframe без allow-same-origin

Пользовательский код исполняется в <iframe> с атрибутом

<iframe sandbox="allow-scripts allow-forms allow-modals allow-popups">

Ключевое — отсутствие allow-same-origin. Из-за этого документ получает непрозрачный origin (null), и:

  • у него нет доступа к localStorage, cookies и IndexedDB оболочки (и вообще ни к чему — его origin уникален и пуст);

  • он не может дотянуться до parent.document;

  • fetch наружу блокируется политикой оболочки (в версии 0.2 — намеренно; kapsula.fetch с разрешением на проект — в планах).

Первое следствие неприятное: localStorage в песочнице не переживает перезапуск, потому что origin каждый раз новый. Поэтому хранилище — только через мост (kapsula.storage), и оно привязано к проекту, а не к origin. Второе следствие приятное: код из чата с ИИ, который я не читал, физически не может сделать ничего за пределами своего iframe, кроме того, что мост явно разрешает.

Проверка гипотезы «iframe достаточно» была первым вопросом проекта: AudioContext внутри работает, postMessage работает, дальше можно было не смотреть в сторону отдельного WebView на проект.

Мост: postMessage, промисы и таймаут 15 секунд

В <head> каждого проекта оболочка инжектит kapsula-client.js. Это обычный RPC поверх postMessage: каждый вызов получает id, ответ приходит сообщением {kapsula: true, type: 'reply', id, ok, result}. Пока оболочка не прислала hello, вызовы ждут промис kapsula.ready; если ответ не пришёл за 15 секунд — промис отклоняется.

Весь API умещается в комментарий в начале файла:

kapsula.app.info()                          // {name, projectId, platform, version, lang, limits}
kapsula.notify.schedule({at, title, body})  // {id} — точный будильник ОС
kapsula.notify.cancel(id?)                  // без id — все свои
kapsula.notify.list()
kapsula.sound.play('beep' | 'triple' | 'alarm')
kapsula.haptics.vibrate(ms?)
kapsula.storage.get(key) / set(key, value) / remove(key)

Почему так мало: каждый метод — это то, чего не умеет вкладка браузера. Всё, что вкладка умеет (DOM, таймеры, Canvas, Web Audio), я не дублирую. И ещё: маленький API легко объяснить языковой модели. Об этом ниже.

Единственное, что нужно помнить автору мини-аппа, — файл должен работать и без Kapsula:

const K = window.kapsula || null;   // null в обычном браузере
if (K) await K.notify.schedule({ at: endAt, title: 'Чай готов' });

Так один и тот же tea.html открывается на десктопе как страница и на телефоне как приложение с будильником.

Будильники на Android: точность, каналы и баг «без звука»

Будильник — это LocalNotifications с allowWhileIdle и разрешением USE_EXACT_ALARM (Android 13+ даёт его alarm-приложениям без диалога; на 12 — SCHEDULE_EXACT_ALARM). На живом Samsung с Android 13 уведомление приходит секунда в секунду, в том числе когда приложение убито: процесс поднимает система. В логах расхождение между «план» и «показ» — 13 мс.

Теперь грабли, все три собраны на одном Note20 Ultra.

1. Тихий повторный будильник. Плагин ставит FLAG_ONLY_ALERT_ONCE, а id уведомления у проекта фиксированный (номер слота). Если предыдущее уведомление ещё висит в шторке, новое с тем же id считается обновлением и не звучит. Вибрацию при этом я чувствовал — но это оказался самсунговский NotificationReminder, а не моё уведомление. Фикс в одну строку: перед scheduleremoveDeliveredNotifications с тем же id.

2. Android помнит удалённые каналы. Я сменил звук канала на свой (три ноты, «Кап-су-ла»), удалил канал и создал заново с тем же id — и получил старый дефолтный звук. Настройки канала живут и после его удаления. Поэтому у id канала есть версия (kapsula.<project>.v2), и при смене звука версия поднимается, а старые каналы не трогаются — на них может висеть уже запланированное.

3. Battery optimization. Классика OEM-прошивок: в «оптимизируемых» приложениях будильник может уехать. Kapsula показывает янтарную карточку «проверь оптимизацию батареи» и ведёт в системный экран. Нюанс Samsung: в этом списке приложение видно только после переключения фильтра на «Все» — пришлось написать это прямо в подсказке.

Будильники на iOS: звук в уведомлении, а не в канале

На iOS каналов нет, и звук задаётся на самом уведомлении: sound: 'kapsula_alarm.wav' в параметрах schedule (файл лежит в бандле). Триггер — по дате (UNCalendarNotificationTrigger под капотом плагина), точности хватает. Честная оговорка: симулятор звук не воспроизводит, так что первая проверка «на слух» была уже через TestFlight на живом iPhone.

Отдельная неожиданность была не в коде, а в App Store Connect: «да» на вопрос о неограниченном веб-доступе (проект по URL загружает что угодно) автоматически даёт рейтинг 16+. В Google Play по той же механике — 18+. Это честно, и это не мешает взрослой аудитории, но знать заранее полезно.

Правило, без которого ИИ-код теряет состояние

Самый частый баг в мини-аппах, которые пишут модели: состояние в переменных JS и относительное время (setTimeout(fn, 3*60*1000)). Оболочка имеет право пересоздать песочницу — например, когда пользователь тапнул по уведомлению и проект открылся заново. Всё, что было в памяти, исчезает, а таймер «на три минуты» после перезапуска стартует сначала.

Правило для мини-аппа получилось из трёх пунктов:

  1. Состояние — только в kapsula.storage (и рендер — из него).

  2. Время — абсолютное: хранить endAt, а не «осталось N секунд».

  3. Перед новым будильником — cancel, потом schedule.

Чайный таймер из галереи — 60 строк, и все три пункта в нём видны.

«Обучить» модель: промпт вместо SDK

Поскольку код пишут ChatGPT и Claude, а не люди, самый важный артефакт проекта — не приложение, а публичное описание API и промпт. На сайте лежит страница /prompt: «скопируй это в чат — получишь HTML, который Kapsula запустит». В промпте — список методов, правила песочницы (нет localStorage, нет fetch, всё инлайном) и правило состояния выше. Модели справляются с первого раза; самый частый промах — fetch до внешнего API, который песочница режет.

Клиент моста вынесен в открытый репозиторий и на npm (kapsula-client) — не потому что его кто-то установит через npm (оболочка инжектит его сама), а чтобы у типов и документации был стабильный адрес, на который можно сослаться из промпта и который индексируют поисковики и ИИ-краулеры.

Локализация за вечер

Сюрпризом оказалось, насколько дёшево дать 13 языков, когда весь UI — это один словарь строк. Оболочка, демо-мини-апп и сайт переведены на 13 языков, карточки в сторах — на 12. Язык прокинут в мост (app.info().lang), чтобы мини-апп мог подстроиться. Генерацию переводов делала модель, я правил терминологию; самым трудоёмким оказалось не перевести, а заполнить формы в консолях сторов.

Что не сделано и что дальше

Чего в версии 0.2 нет осознанно: сетевых запросов из песочницы, фоновых скриптов без UI (на Android это WorkManager с интервалом ≥15 минут, на iOS — «когда система захочет», честного паритета нет), пользовательских звуков (каналы неизменяемы — см. грабли выше), облака.

Ближайшее — цикл «код в чате → телефон» за десять секунд: deep link kapsula://add?code=… и кнопка «Открыть в Kapsula» в галерее, вход через системное «Поделиться» из приложения ChatGPT/Claude, dev-панель, которая показывает ошибки JS из песочницы, и kapsula.fetch с разрешением на проект.

Монетизация — позже и простая: бесплатно один свой проект плюс демо, Pro одной покупкой снимает лимит. Серверных фич в Pro не будет по определению — это принцип, а не недосмотр.

Ссылки

Я автор, отвечаю на вопросы в комментариях. Особенно интересны отчёты с Xiaomi/Huawei: на Samsung всё проверено живьём, остальные OEM-прошивки — только по отзывам.

Комментарии (4)


  1. CrashLogger
    27.08.2026 10:42

    Сколько страданий ради функции, которую любой Android/iOS джун напишет за час без всяких нейронок


    1. fedorovbtc Автор
      27.08.2026 10:42

      Будильник — да, напишет. Но это не будильник, а раннер: кидаешь любой HTML из чата — он получает будильники ОС, звук и хранилище. Нужно скачать всего одно приложение и ничего не надо пересобирать. А страданий особо не было, я вечерами развлекался :)


  1. akozlovskiy
    27.08.2026 10:42

    Два вопроса:

    • Зачем? Можно было попросить ИИ не писать будильник на HTML, а затем крутить вокруг него нативные бриджи, а писать будильник уже на том же самом RN, Flutter, KMP либо нативе. Либо если будильник был уже написан ранее, проще его переписать на нативный стек тем же ИИ. А ещё таких будильников можно накачать себе под завязку из любого стора и на любой вкус: с аниме кошечками/меллстроем/миньонами. Понятно, что хочется что-то своё, но вышло то по итогу тоже не совсем своё.

    • А зачем было просить ИИ писать статью?


    1. fedorovbtc Автор
      27.08.2026 10:42

      Про натив — резонно, и для одного будильника так и надо. Но смысл в том чтоб любой запускать следующий такой файл без пересборки: сегодня таймер, завтра трекер чего-нибудь. Зачем скачивать кучу разных не совсем удобных когда можно сгенерить то что нужно и быстро запустить

      Про статью — а просто накидывал черновик с ии, жаль что получилось как у робота)