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 без розв’язку між ними, або що SessionStart через ущільнення нічого не змінює.

Оскільки файл лише дописується, видалити щось у застосунку означає запам’ятати, що саме видалено. Wendo зберігає «надгробки» на самому телефоні, для запису або для хоста. Якщо агент знову напише в ту сесію, запис з’явиться знову. Якщо хост не відповідає, застосунок тримає останнє, що від нього прочитав, а не очищає список.

Із закритим застосунком на Android

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

Підтвердження туди й назад

PermissionRequest є єдиною подією, на якій хук лишається чекати. Повний шлях:

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

Делікатна частина в тому, що відбувається, коли ви не відповідаєте. Очікування триває 90 секунд за замовчуванням (WENDO_APPROVAL_TIMEOUT). Якщо час вийшов, хук нічого не друкує, і Claude Code, не отримавши відповіді від хука, ставить своє звичайне питання в терміналі. Хук записує розв’язок timeout і завершується. Якщо ви раніше відповіли в терміналі, Claude Code скасовує хук, і trap записує розв’язок як local.

Правило дизайну було таке: збій телефона ніколи не має блокувати агента. Розряджена батарея, немає зв’язку чи застосунок закритий, найгірший випадок однаковий: питання в терміналі, як завжди.

Лишається одна шпарина: відповісти з телефона саме тоді, коли хук уже здався. Щоб її закрити, після того як відповідь записано, та сама SSH-команда чекає до 3 секунд, чи з’явиться рядок розв’язку з цим id. Якщо розв’язок 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 без відбитка теж працює, з довірою при першому з’єднанні, а 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 ще немає в магазинах застосунків.