LirafyБлогWendo ↗
Русский

Блог

Мобильное приложение без бэкенда: уведомления и подтверждения Claude Code через SSH и файлы

Wendo — мобильное приложение без бэкенда: между твоим компьютером и телефоном нет нашего сервера. Когда Claude Code просит разрешения или заканчивает задачу, shell-скрипт на твоей машине записывает событие в файл, а приложение читает его по SSH тем же ключом, которым открывает терминал. Ответ на запрос подтверждения возвращается тем же путём, ещё одним файлом.

Если тебе нужно это настроить, всё есть в гайде об уведомлениях Claude Code на телефоне.

Зачем мобильное приложение без бэкенда

Wendo — SSH-клиент: чтобы он работал, твой телефон и так должен достучаться до компьютера и пройти на нём аутентификацию. Раз этот канал открыт и зашифрован, сервер посередине ради уведомлений был бы лишней деталью.

Без него получаются три вещи, которых мы и добивались:

  • То, что делает твой агент (команда, которую он хочет выполнить, файл, который тронет, последнее сообщение), не проходит ни через одну нашу машину. Оно остаётся на твоём компьютере, пока телефон не запросит.
  • Аккаунтов нет. Нет почты, которую надо хранить, и пароля, который может утечь. Личность — это твой SSH-ключ.
  • Нет инфраструктуры, за которую надо платить и которую надо поддерживать с каждым новым пользователем.

Взамен всё, что делал бы сервер (хранить историю, уведомлять при закрытом приложении, связывать устройства, проверять покупки), приходится решать тем, что уже есть на обоих концах.

Контракт: одна строка JSON на событие

В центре всего — ~/.wendo/events.jsonl. Установщик регистрирует wendo-hook.sh на шесть событий Claude Code (PermissionRequest, Notification, Stop, UserPromptSubmit, SessionStart и SessionEnd), и при каждом срабатывании хук читает JSON, который Claude Code передаёт ему на стандартный ввод, и дописывает строку в файл. Строка запроса разрешения выглядит примерно так (сокращённо):

{"id":"1793530000-4242-12345","time":1793530000,"host":"portatil","agent":"claude",
 "event":"PermissionRequest","category":"approval_required","kind":"permission",
 "title":"Claude Code — wendo","message":"Bash: flutter test","sessionId":"…",
 "toolName":"Bash","pendingActionId":"p1793530000-4242-6789",
 "expiresAt":1793530090000,"options":["Approve","Deny"],
 "mux":{"kind":"tmux","session":"dev","window":"1","pane":"%3","socket":"/tmp/tmux-501/default"}}

Файл только растёт в конец. Строки никто не редактирует: решённый запрос записывается новой строкой типа resolution. Это сильно упрощает конкурентность: несколько хуков могут писать одновременно, а короткие строки дописываются атомарно. Когда в файле больше 1000 строк, хук оставляет последние 500 (с flock, если он есть, чтобы ротацию делал только один за раз). Папка создаётся с umask 077, у файла права 600: читать его может только твой пользователь.

Поле mux говорит, в какой панели tmux, zellij или herdr работает агент, чтобы приложение могло перенести тебя туда.

Как читает приложение: опрос по SSH

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

tail -n 1000 ~/.wendo/events.jsonl 2>/dev/null; true

Частота зависит от состояния входящих. Пока какая-то строка ждёт ответа, приложение перечитывает каждые 5 секунд, потому что хук быстро истекает. Когда агенты работают, каждые 15. Если ничего живого нет, читает только при открытии вкладки. После твоего ответа перечитывает через 1,2 секунды и ещё раз через 3, чтобы увидеть результат.

Получив строки, inbox.dart сортирует их по времени и сворачивает в записи: одна на хост и сессию. Здесь живёт почти вся логика, которая в другом приложении была бы на сервере. Например, он решает, что на запрос ответили в терминале, если после него пришёл Stop или UserPromptSubmit без resolution между ними, или что SessionStart после сжатия контекста ничего не меняет.

Раз файл только дописывается, удаление в приложении означает запомнить, что удалено. Wendo хранит «надгробия» на самом телефоне, для записи или для хоста. Если агент снова пишет в эту сессию, запись появляется вновь. Если хост не отвечает, приложение сохраняет то, что прочитало с него в последний раз, а не очищает список.

При закрытом приложении на Android

На Android есть задача WorkManager, которая просыпается не чаще раза в 15 минут (система не даёт просить меньше и может откладывать её ради батареи), читает входящие так же, как открытое приложение, и показывает локальные уведомления о новом. Это чтение никогда не запрашивает биометрию: если SSH-ключ защищён и заблокирован, этот хост просто не читается. То, о чём уже уведомили, сохраняется в хранилище телефона, чтобы открытое приложение и фоновая задача не повторяли одно и то же уведомление. На iOS такой задачи нет.

Подтверждение туда и обратно

PermissionRequest — единственное событие, на котором хук ждёт. Весь путь:

  1. Хук генерирует pendingActionId (буквы, цифры, _ и -; приложение проверяет его так же, прежде чем подставить в путь), вычисляет expiresAt и пишет строку с options: ["Approve","Deny"].
  2. Приложение видит запись с pendingActionId, без resolution и не истёкшую, и показывает кнопки.
  3. При нажатии приложение пишет {"decision":"approve"} в ~/.wendo/answers/<id>.tmp и переименовывает его в ~/.wendo/answers/<id>. Переименование не даёт хуку прочитать недописанный ответ.
  4. Хук проверяет этот файл каждые полсекунды. Найдя его, печатает решение в формате, который ждёт Claude Code, и дописывает свою строку resolution:
{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}

Тонкое место — что происходит, если ты не отвечаешь. Ожидание длится 90 секунд по умолчанию (WENDO_APPROVAL_TIMEOUT). Когда время выходит, хук ничего не печатает, и Claude Code, не получив ответа от хука, задаёт обычный вопрос в терминале. Хук записывает resolution timeout и завершается. Если ты ответишь раньше в терминале, Claude Code отменит хук, а trap запишет resolution как local.

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

Остаётся дыра: ответ с телефона ровно в тот момент, когда хук уже сдался. Чтобы её закрыть, после записи ответа та же SSH-команда до 3 секунд ждёт строку resolution с этим id. Если resolution — timeout или файл никто не забрал, она его удаляет, и приложение пишет «Истекло — хост перейдёт в локальный режим», а не считает ответ отправленным.

Входящие Wendo с ожидающим подтверждением от Claude Code (npm test в my-app) и кнопками «Отклонить» и «Разрешить» Путь разрешения: Claude Code просит разрешение, хук записывает его в events.jsonl, Wendo читает его по SSH, ты отвечаешь на телефоне, и хук возвращает Claude Code allow или deny

Push-уведомления без своего сервера: ntfy, и то по желанию

Чтобы уведомить при закрытом приложении на iOS или раньше, чем через 15 минут на Android, кто-то должен протолкнуть уведомление, а для этого нужен push-сервис. Мы выбрали ntfy: публикация делается одним HTTP-запросом, и его можно поднять у себя.

Если задать NTFY_TOPIC, хук публикует уведомления о запросах разрешения, об оповещениях и о завершённых задачах (не о начале и конце сессии и не о каждом промпте). В уведомлении есть ссылка wendo://attach?event=…&host=…&sid=… с панелью мультиплексора, чтобы по нажатию приложение открыло нужное место.

Приложение читает и топик (/json?poll=1&since=24h) как ещё один источник входящих, но главным остаётся прочитанное по SSH. ntfy знает только, что ты был кому-то нужен, но не знает, решено ли это, поэтому если SSH-чтение хоста не удалось, его запросы разрешения из ntfy не учитываются. И вот цена для приватности: на публичном сервере топик служит паролем, а уведомление содержит проект и фрагмент команды. Поэтому топик должен быть длинным и случайным, и поэтому есть NTFY_URL для собственного сервера.

Как связать телефон с компьютером по QR без сервера

Как без аккаунтов добавить компьютер на телефон? QR-кодом, который генерирует wendo-pair.sh и в котором лежит URL:

wendo://pair?h=<host>&p=<puerto>&u=<usuario>&n=<nombre>&hk=<huella>

hk — SHA256-отпечаток ключа хоста ed25519, полученный через ssh-keyscan от sshd на этом порту. Приложение проверяет его строгим шаблоном (SHA256: и 43 символа base64) и закрепляет за хостом. Первое подключение проверяется, а не принимается вслепую, и если однажды сервер покажет другой ключ, соединение будет отклонено. QR без отпечатка по-прежнему работает, с доверием при первом подключении. С неправильно оформленным отпечатком — нет. Последний шаг — установить публичный ключ телефона в authorized_keys командой, которую показывает приложение. Ничего из этого не выходит за пределы твоей сети.

Pro без сервера лицензий

Продавать подписку без бэкенда — самая неудобная часть. Wendo Pro оплачивается через биллинг Google Play и App Store, а проверка живёт в нативном модуле, не в Dart. На Android (ProLicense.kt) каждая покупка приходит со своим JSON и RSA-подписью, сделанной лицензионным ключом приложения, и проверяется публичным ключом. Ещё проверяется, что приложение не переподписано, а состояние хранится запечатанным ключом из Keystore, так что правка файла Pro не даёт. Каждая полная синхронизация с магазином его продлевает. Без неё Pro держится 7 дней с подпиской или 30 с пожизненной покупкой, и перевод часов назад этот срок не увеличивает. На iOS проверку делает нативный код через StoreKit 2. 5 пробных подтверждений бесплатного плана тоже считает нативная часть.

Сам код честно говорит, где предел: с root и Frida или патчем приложения всё это обходится, потому что без сервера, который спрашивает Google, помешать этому нельзя. Цель скромнее: чтобы не хватало инструмента в один клик.

Реальные ограничения приложения без бэкенда

  • Телефон должен достучаться до компьютера. В одной сети Wi-Fi хватает локального IP, снаружи нужно что-то вроде Tailscale. Если компьютер выключен или спит, приложению некого спрашивать.
  • Уведомления приходят с задержкой опроса: несколько секунд при открытом приложении (5, если что-то ждёт ответа, 15, если просто работают агенты), до 15 минут и больше в фоне на Android, а на iOS при закрытом приложении только через ntfy.
  • Каждое чтение — это SSH-соединение. С несколькими хостами это несколько соединений каждые несколько секунд, пока что-то ждёт.
  • То, что ты удалил на одном телефоне, помнит только этот телефон.
  • Хуку нужен jq на компьютере, и пока он регистрируется только в Claude Code.
  • Публичный ntfy видит то, что ты публикуешь в топик. Если это смущает, подними свой.

Как эти части складываются в приложение, можно посмотреть на странице Wendo (терминал, рабочие пространства tmux, режим чата), а справочник по переменным хука есть в документации об уведомлениях и подтверждениях. SSH, на котором всё это держится, мы разобрали в статье SSH, Mosh и Eternal Terminal во Flutter, а ещё одну часть, которая работает без сервера, диктовку, — в статье Whisper на телефоне без интернета. Wendo пока нет в магазинах приложений.