F1: warmPageYdoc now returns didSync (true only on the real 'synced' event,
false on the 8s timeout / error, which are now logged). The tree menu gates
the 'available offline' success toast on result.ok AND didSync, and pushes
'editor' into the failed set otherwise — so a page whose Yjs body never
warmed is no longer reported as fully offline-available.
F2: tests for the page/space/currentUser failure labels and the didSync
true/false paths.
F3: correct the mobile-app-plan / mobile-bootstrap present-state sections to
match shipped code (Capacitor, CORS allowlist, Swagger, offline reading,
/l in the SW denylist).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
32 KiB
Мобильное приложение gitmost — исследование и план
Статус: исследовательский + проектный документ. Контекст: gitmost — форк Docmost, чистое веб-приложение. Отдельного мобильного (нативного/устанавливаемого) приложения нет. Цель: определить путь к мобильным приложениям — iOS обязательно, Android как пойдёт. Оффлайн-чтение уже реализовано (
apps/client/src/features/offline/, этапы M0…M2 — персист TanStack Query в IndexedDB + Yj/y-indexeddbтело документа); полноценная двусторонняя синхронизация (этапы M3…M4) ещё впереди.
Документ фиксирует, что уже есть в коде, почему путь к мобилке предопределён устройством продукта, сравнивает варианты и описывает рекомендуемый план с привязкой к файлам.
1. TL;DR
- Capacitor-обвязка заведена, собранного нативного приложения ещё нет. В
монорепо добавлены
@capacitor/core|android|ios|cliи корневойcapacitor.config.ts(бутстрап этого PR, см. §12). Сгенерированные нативные проекты (ios/,android/) намеренно не хранятся в VCS, и сборки в App Store / Play ещё нет — это первый шаг к мобильному клиенту, а не готовое приложение. - Адаптивная веб-версия — есть, и довольно проработанная. Веб-клиент
открывается с телефона как mobile-friendly сайт: сворачиваемый сайдбар-drawer,
отдельные мобильные компоненты (история, поиск, хлебные крошки), responsive-
примитивы Mantine, mobile-tuned
viewport. Это готовый фундамент UI. - Ядро продукта — веб-редактор — нативно не воспроизвести. TipTap 3 (ProseMirror) + совместное редактирование на Yjs/Hocuspocus плотно сшиты с React. Production-порта Yjs под Swift/Kotlin нет. Любой реалистичный путь оставляет редактор в WebView.
- API уже готов к нативному клиенту. Сервер принимает JWT не только из
cookie, но и из заголовка
Authorization: Bearer. Есть точка входа для вебсокета совместного редактирования (POST /auth/collab-token). - Рекомендуемый путь — Capacitor: обернуть существующий React-SPA в нативную оболочку (iOS + Android из одного кода), добавить нативные плагины (push, биометрия, share, файлы). Эволюция в гибрид (нативная навигация + WebView-редактор) делается потом инкрементально, без переписывания.
- Оффлайн-чтение уже реализовано (Yjs +
y-indexeddb+ персист TanStack Query в IndexedDB, этапы M0…M2 — см.apps/client/src/features/offline/). Полная двусторонняя синхронизация записи (этапы M3…M4) ведётся отдельно; мобильное приложение этот план переиспользует, а не дублирует. - Главный блокер — не технический, а лицензионный. AGPL форка несовместима
с условиями App Store, если зашивать веб-клиент в бинарник: DRM/usage-rules
Apple = «дополнительные ограничения», запрещённые AGPLv3 §10. Развязки —
грузить клиент с сервера (не из
.ipa), PWA или sideload. Детали и матрица — в §9; закрывать до кода обёртки.
2. Текущее состояние (как есть)
2.1. Стек
| Слой | Технологии |
|---|---|
| Бэкенд | NestJS 11 + Fastify, Kysely/Postgres, Redis/BullMQ. API в стиле RPC-POST (соглашение Docmost). Аутентификация — JWT. |
| Фронт | React 18 + Vite + Mantine + TanStack Query + i18next. Обычный SPA. |
| Ядро (редактор) | TipTap 3 (ProseMirror) + совместное редактирование на Yjs через Hocuspocus — см. page-editor.tsx. |
| Оффлайн-фундамент | yjs + y-indexeddb уже в зависимостях клиента (локальная CRDT-копия тела документа). |
2.2. Capacitor-обвязка заведена (собранного приложения ещё нет)
Корневой package.json содержит @capacitor/core|android|ios (и @capacitor/cli
в devDependencies), в корне лежит capacitor.config.ts.
react-native, cordova, expo по-прежнему не используются. Сгенерированные
нативные проекты (ios/, android/) намеренно не коммитятся — это бутстрап
оболочки, а не готовый бинарник.
2.3. Адаптивная веб-версия — есть
| Что | Где |
|---|---|
Адаптивная оболочка Mantine AppShell с breakpoint: "sm", раздельные состояния collapsed.mobile / collapsed.desktop |
global-app-shell.tsx (L85–99) |
Отдельный мобильный сайдбар-drawer (mobileSidebarAtom отделён от desktopSidebarAtom), авто-закрытие при навигации по дереву |
sidebar-atom.ts, space-tree-row.tsx (L147–148) |
| Мобильная модалка истории + свой CSS | history-modal.tsx (L17–19), history-modal-mobile.tsx |
| Мобильный контрол поиска | search-control.tsx (L38–42) |
Мобильный рендер хлебных крошек через useMediaQuery |
breadcrumb.tsx (L41) |
Responsive-примитивы hiddenFrom/visibleFrom (~16 мест), медиа-запросы в CSS-модулях |
по всему apps/client/src |
Mobile-tuned viewport (width=device-width, user-scalable=no) |
index.html (L8) |
Важно: адаптив проверялся в мобильном браузере, а не в WebView нативной оболочки. Перед сборкой приложения нужно прогнать UI как PWA/в WebView и отловить отличия (жесты, экранная клавиатура/IME в редакторе, safe-area).
2.4. Готовность API к нативному клиенту
- Bearer-токен уже поддержан. JWT извлекается из cookie или из заголовка
Authorization: см. jwt.strategy.ts (L27–29). Серверная сторона нативной авторизации менять не нужно. (Подтверждено мобильным бутстрапом.) - Токен можно вернуть в теле логина (opt-in).
loginпо-прежнему кладёт JWT вhttpOnly-cookie, а при флагеreturnTokenдополнительно возвращает его в теле ответа (data.authToken) для нативных клиентов; веб-клиент остаётся на cookie. Реализовано мобильным бутстрапом. - Точка входа вебсокета коллаборации:
POST /auth/collab-token(L187–193). (Подтверждено мобильным бутстрапом.) - CORS — явный allowlist. Вместо безусловного
app.enableCors()теперь настраиваемый whitelist черезCORS_ALLOWED_ORIGINSплюс автоматически разрешённые нативные WebView-origin'ы (Capacitor/Ionic/localhost). Реализовано мобильным бутстрапом. - OpenAPI/Swagger — опционально. Swagger UI доступен на
/api/docsза флагомSWAGGER_ENABLED(по умолчанию выключен), что даёт авто-генерацию типизированного клиента. Реализовано мобильным бутстрапом.
3. Почему путь к мобилке предопределён
Три факта диктуют решение независимо от моды:
- Редактор практически невозможно переписать нативно. ProseMirror + весь
набор TipTap-расширений + Yjs-CRDT — это не «поле ввода». Нативного
production-порта Yjs под Swift/Kotlin нет (есть Rust
yrsс биндингами, но это отдельный тяжёлый проект). Переписывание ядра нативно = годы и вечное расхождение с веб-версией. Вывод: редактор остаётся в WebView. - API уже умеет нативного клиента (Bearer, collab-token).
- Оффлайн-фундамент уже заложен на веб-уровне (Yjs +
y-indexeddb), и он работает внутри WebView.
4. Три возможных пути
| Путь | Суть | Плюсы | Минусы | Вердикт |
|---|---|---|---|---|
| A. Полностью нативно (Swift/Kotlin) | Переписать всё, включая редактор и CRDT-синк | Максимально нативный UX | Воспроизвести ProseMirror + расширения + Yjs; несоразмерные трудозатраты; вечное отставание от веба | ❌ Не наш случай |
| B. WebView-обёртка SPA (Capacitor) | Обернуть существующий React-клиент в нативную оболочку, native-возможности — плагинами | Реюз ~100% кода (редактор, коллаборация, оффлайн); один кодовый бэйз → iOS+Android; быстро | Менее «нативно»; риск отказа App Store за «просто сайт» (4.2) — лечится нативной ценностью | ✅ Рекомендуется |
| C. Гибрид: нативная оболочка + WebView-редактор | Навигация/списки/поиск/логин — нативно (React Native/Swift), экран редактирования — web в WebView | Лучший UX; путь Notion/Linear | Заметно больше работы; нужен мост JS↔native | ⚖️ Цель эволюции из B |
5. Рекомендуемый путь
B (Capacitor) как первый релиз, с заложенной эволюцией в C.
Почему:
- Capacitor создан под сценарий «есть веб-приложение → хочу его в App Store с нативными возможностями». Переиспользуется весь React-клиент и, главное, редактор — то, что нативно не сделать.
- Один кодовый бэйз закрывает «iOS обязательно» и «Android как пойдёт» одновременно, без второй команды.
- Адаптивная вёрстка уже есть (см. §2.3) — переверстывать под телефон с нуля не нужно; работа смещается в нативную обвязку.
- Оффлайн-будущее подготовлено (Yjs +
y-indexeddb); оффлайн-синхронизация ведётся отдельным планом (этапы M0…M4). - Когда упрётесь в UX отдельных экранов — их по одному выносят в нативную оболочку, оставив редактор в WebView. То есть B → C делается инкрементально.
Почему не чистый React Native сразу: редактор всё равно придётся держать в WebView (ядро web-only), но при этом теряется прямой реюз остального React-кода и появляется мост как обязательная сложность с первого дня — для iOS-first старта это лишний оверхед.
Альтернатива: если критичен максимально нативный UX с первого релиза и есть ресурс — сразу путь C на React Native (Expo) с WebView только под редактор. Это сознательный размен «больше работы сейчас» за «более нативное ощущение».
⚠️ Лицензионная оговорка к iOS. Обычный Capacitor зашивает веб-билд
apps/client в .ipa — для публикации в App Store это нарушает AGPL
(см. §9). Выбор Capacitor для Android остаётся в силе, но на iOS
веб-клиент нельзя бандлить в бинарник: либо грузить его с сервера
(server.url), либо PWA. То есть рекомендация «B (Capacitor)» применима к
Android как есть, а к iOS — только в конфигурации без зашитого AGPL.
6. Что доработать на бэкенде
Часть уже сделана бутстрапом этого PR (см. §2.4), осталось нативно-инфраструктурное:
- Выдача токена в теле ответа для нативного хранения — ✅ сделано. Логин
по-прежнему кладёт JWT в
httpOnly-cookie, а при opt-in флагеreturnTokenдополнительно возвращает токен в теле (data.authToken) для нативного хранения в Keychain/Keystore и отправки какAuthorization: Bearer. Сервер принимал Bearer и раньше; добавлена именно выдача. Файлы: auth.controller.ts. - CORS — ✅ сделано. Безусловный
app.enableCors()заменён на явный allowlist:buildCorsAllowlist/isOriginAllowedсобирают доверенные origin'ы изCORS_ALLOWED_ORIGINSплюс нативные WebView-origin'ы; main.ts передаёт их вapp.enableCors({ origin: callback, credentials: true }). - Push-уведомления. Модуль
notificationуже есть — добавить регистрацию device-token и интеграцию APNs (iOS) / FCM (Android). (Ещё не сделано.) - OpenAPI/Swagger — ✅ сделано (opt-in). Подключён
@nestjs/swagger; Swagger UI доступен на/api/docsза флагомSWAGGER_ENABLED(по умолчанию выключен), что даёт авто-генерацию типизированного клиента.
7. Android-специфика
На пути Capacitor Android едет почти бесплатно (npx cap add android из того же
веб-билда), но есть нюансы:
- Движок в плюс. Android System WebView (Chromium) обновляется через Play Store независимо от ОС и обычно свежее iOS WKWebView. Более рискованный движок по совместимости — это iOS, а не Android.
- Фрагментация. Дешёвые/старые устройства с малой памятью и устаревшим WebView; стек тяжёлый (ProseMirror + Yjs + mermaid + katex + excalidraw) — тестировать на бюджетных аппаратах.
- Обвязка под Android: аппаратная/жестовая кнопка «Назад» (навигация внутри приложения, а не выход), FCM для push, Android App Links (вместо iOS Universal Links), подписание и Play Console.
- Главный риск именно для Android — ввод текста в ProseMirror на Gboard/IME.
Историческая боль
contenteditableна Android (прыжки курсора, дубли символов при композиции). Стало лучше, но проверять в первую очередь и рано. - Магазин. Google Play лояльнее к webview-обёрткам, чем App Store; риск «отклонят как просто сайт» для Play практически неактуален.
8. iOS-специфика
- WKWebView на движке WebKit жёстко привязан к версии ОС — это более рискованный по совместимости движок (тестировать прежде всего его).
- App Store guideline 4.2 (minimum functionality). Чистая webview-обёртка рискует отклонением «это просто сайт». Лечится реальной нативной ценностью: push, share-extension, биометрический разблок, оффлайн-кэш — всё это Capacitor даёт плагинами.
- safe-area под «чёлку»/системные панели, поведение экранной клавиатуры в редакторе.
9. Лицензионный блокер: AGPL ↔ App Store (iOS)
Это не инженерная, а лицензионная задача — закрывать её надо до кода обёртки, иначе можно сделать приложение, которое некуда легально опубликовать. Ниже — инженерно-лицензионный разбор, не юридическая консультация; финально подтверждать у того, кто разбирается в лицензиях.
9.1. Суть конфликта
gitmost — форк Docmost под AGPL-3.0 (константа форка: «100% open, AGPL-only»). Две вещи несовместимы:
- AGPLv3 §10 (последний абзац) запрещает накладывать на получателя кода любые дополнительные ограничения сверх самой лицензии.
- Стандартный EULA App Store ровно их и накладывает: FairPlay/DRM, привязка установки к Apple ID с лимитом устройств (usage rules), запрет свободного перераспространения бинарника.
Приняв условия Apple, чтобы попасть в App Store, вы нарушаете AGPL кода, который раздаёте.
9.2. Почему это бьёт именно по форку
Запрет «дополнительных ограничений» связывает лицензиатов, но не самого правообладателя: владелец 100% копирайта может опубликовать свой код в App Store. Но в gitmost бóльшая часть копирайта принадлежит upstream-Docmost и контрибьюторам — вы выступаете дистрибьютором чужого AGPL-кода и не можете единолично добавить App-Store-исключение.
Прецеденты: VLC (удалён из App Store в 2011 по жалобе на конфликт GPL с условиями стора; вернулся только после перелицензирования и согласия правообладателей), GNU Go — снят по той же причине. Это не теоретический риск.
9.3. Ключевой принцип развязки: лицензия смотрит на .ipa, а не на устройство
Определяющее — что раздаёт сам Apple (.ipa под FairPlay) и кто раздаёт
AGPL-байты, а не то, окажутся ли они в итоге на устройстве:
- AGPL внутри
.ipa→ получен под ограничениями Apple → нарушение. - AGPL скачан с вашего сервера → получен от вас под AGPL (исходники открыты, §13 выполнен) → ограничения Apple на него не накладываются, даже если бандл кэшируется в песочнице приложения.
Следствие: офлайн на iOS легально достижим — если кэшированный бандл пришёл с
вашего сервера, а не из .ipa. Ограничение тут не лицензионное, а в ревью
Apple (см. §9.5).
9.4. Варианты «грузить веб-клиент с сервера»
A. WebView навигируется на хостед-клиент (server.url). Capacitor умеет
server: { url: 'https://app.example.com' } — оболочка грузит WebView с удалённого
URL, мост и нативные плагины по-прежнему инжектятся. В .ipa — ноль AGPL.
- Плюс: лицензионно самый чистый; origin = ваш домен, поэтому cookie/CORS
работают как в браузере (боль
capacitor://localhost↔ API из §6 исчезает — токен в body/Keychain может и не понадобиться). - Минус: холодный старт требует сети; сервер лёг → приложение кирпич; офлайна по умолчанию нет.
B. OTA: пустой шелл скачивает и кэширует бандл. Шелл при первом запуске тянет
JS-бандл с вашего сервера и кэширует как веб-ассеты (механизм Cordova/CodePush).
Open-source self-host-вариант — @capgo/capacitor-updater (важно для AGPL-проекта:
без привязки к проприетарному Appflow).
- Плюс: даёт офлайн — кэш AGPL легален, т.к. распространён вами, а не Apple.
- Минус: упирается в политику Apple по hot-update (§9.5).
Не-обходы (мифы): «никто не засудит» — это нарушение, а не обход; «LGPL-нуть обёртку» — не помогает (проблема в AGPL-веб-клиенте, а не в обёртке); «mere aggregation» — не катит: зашитый бандл это комбинированное распространяемое произведение, а не простая агрегация.
9.5. Гейты Apple
| # | Guideline | Суть | Влияние |
|---|---|---|---|
| 1 | 2.5.2 (исполняемый код) | Скачивать/исполнять нативный код нельзя, но есть исключение для скриптов, исполняемых встроенным WebKit/JavascriptCore, если они не меняют назначение приложения | Загрузка веб-клиента в WKWebView под исключение попадает: вариант A — чистый, B — терпимый, но с границами |
| 2 | 4.2 (minimum functionality) | Чистый WebView-«просто сайт» рискует отклонением | Лечится нативной ценностью в оболочке (push/APNs, биометрия, share, файлы — ваш нативный код, не AGPL) |
| 3 | конфликт двух гейтов | «Лицензионно чистый» вариант (пустой шелл качает всё) — самый рискованный для ревью; «безопасный для ревью» (зашить веб-билд в .ipa) — лицензионное нарушение |
Совместить (офлайн) + (чистая AGPL) + (низкий риск ревью) в одной конфигурации нельзя — выбираете любые два |
Безопасность: раз исполняете удалённый код — только HTTPS, желательно cert-pinning (подмена сервера = произвольный JS в WebView пользователя).
9.6. Итоговая матрица распространения iOS
| Конфигурация | AGPL-чистота | Офлайн | Риск ревью Apple |
|---|---|---|---|
A. server.url на хостед-клиент |
✅ чистая | ❌ нет | средний (4.2, лечится плагинами) |
| B. OTA пустой шелл + кэш бандла | ✅ чистая | ✅ есть | выше (2.5.2 + 4.2) |
Зашить веб-билд в .ipa (обычный Capacitor) |
❌ нарушение | ✅ | низкий |
| PWA | ✅ чистая | ✅ | App Store не нужен |
| Sideload / EU DMA-маркетплейсы (iOS 17.4+) | ✅ чистая | ✅ | вне App Store; только ЕС |
Вывод: для iOS PWA — самое дешёвое решение, закрывающее всё сразу. Если
присутствие именно в App Store критично — вариант A (server.url + нативные
плагины под 4.2) легальный и реалистичный ценой «онлайн для холодного старта».
Офлайн в App Store (вариант B) технически и лицензионно возможен, но это
максимальный риск на ревью — закладывать только если офлайн на iOS обязателен.
Совместить «App Store + зашитый офлайн AGPL» легально нельзя, пока копирайт не ваш.
10. Оффлайн в будущем
Оффлайн-чтение уже реализовано (этапы M0…M2 этого PR), позиция хорошая:
- Тело документа уже редактируется через Yjs (CRDT) +
y-indexeddb— локальная копия и автослияние правок работают, в том числе в WebView. - Чтение «вокруг тела» (навигация, заголовки, комментарии, дерево, текущий
пользователь) теперь читается оффлайн из персистентного кэша TanStack Query в
IndexedDB; см.
apps/client/src/features/offline/(в т.ч.make-offline.ts— ручной прогрев страницы в оффлайн). Полная двусторонняя синхронизация записи (этапы M3…M4) ещё впереди. - Мобильное приложение переиспользует этот план, а не строит оффлайн заново. Нюанс Android: System WebView под нехваткой места может чистить хранилище → для оффлайна, возможно, понадобится дублировать критичные данные в нативное хранилище, чтобы локальные копии не вычищались.
11. Открытые вопросы (зафиксировать до старта)
- Q1. Путь: Capacitor (B) с эволюцией в гибрид, или сразу React Native (C)? Рекомендация — B.
- Q2. Мобильная авторизация: отдельный логин-флоу с токеном в body + Keychain/ Keystore + Bearer (рекомендуется) или попытка работать через cookie в WebView?
- Q3. Push: APNs + FCM сразу или iOS-first?
- Q4. Подключать ли OpenAPI/Swagger для генерации мобильного клиента?
- Q5. Когда включать оффлайн (этапы M0…M4) относительно первого мобильного релиза?
- Q6. iOS-дистрибуция при AGPL (§9): App Store через
server.url(онлайн-клиент, без зашитого AGPL), PWA или sideload/EU-маркетплейсы? Этот лицензионный путь нужно подтвердить до кода обёртки. Рекомендация — PWA для iOS, Capacitor для Android.
12. Чеклист первого шага (бутстрап Capacitor, iOS-first)
- Закрыть лицензионный путь iOS (§9) ДО кода обёртки: выбрать
server.url/ PWA / sideload и подтвердить у разбирающегося в лицензиях. - Не бандлить AGPL-веб-клиент в iOS
.ipa(DRM/usage-rules App Store ⟂ AGPLv3 §10) — на iOS грузить клиент с сервера или идти через PWA. - Прогнать существующий адаптивный UI как PWA/в WebView, отловить отличия (жесты, IME в редакторе, safe-area).
- Добавить Capacitor в монорепо, нацелить на веб-билд
apps/client(Android — зашитый билд; iOS —server.url/PWA без зашитого AGPL, см. §9). npx cap add ios(Android —npx cap add android, когда будет готова обвязка) — нативные проекты генерируются локально и намеренно не хранятся в VCS (см. §9).- Бэкенд: мобильный логин-флоу с токеном в body; хранить токен в Keychain/
Keystore; слать
Authorization: Bearer. - Бэкенд: явный CORS-whitelist под мобильные origin'ы.
- Native-плагины под App Store 4.2: push, биометрия, share, файлы.
- Push: APNs (iOS); FCM добавить вместе с Android.
- Проверить вебсокет коллаборации из WebView (
/auth/collab-token+ Hocuspocus). - (Опционально) Подключить
@nestjs/swagger.