Files
gitmost/docs/mobile-app-plan.md
claude_code b639cb2d8c fix(offline,server,docs): apply PR #116 review findings to offline-sync
Carries the still-applicable findings from the PR #116 review into PR #120,
since #120 includes the mobile-bootstrap commit. CORS hardening (removing the
unconditional localhost/capacitor origins) is intentionally left out of scope.

Service worker routing (latent bug fix + testability):
- vite.config.ts: anchor Workbox path matching to a segment boundary
  (^/<seg>(/|$)) instead of startsWith, so siblings like /apidocs,
  /collaborators, /socket.iox are no longer mis-routed as API/realtime and
  forced NetworkOnly; align navigateFallbackDenylist with the same anchors.
- new apps/client/src/pwa/sw-strategy.ts holds the canonical predicates
  (isApiPath, isCollabOrSocketPath) + unit tests; the vite.config regexes
  mirror it inline (Workbox generateSW serializes urlPattern fns standalone,
  so they cannot import the module).

Server CORS (R1 extraction + coverage):
- extract buildCorsAllowlist / isOriginAllowed into cors.util.ts with unit
  tests (evil-origin rejected, WebView/no-Origin allowed); main.ts rewired to
  use them with byte-for-byte identical behavior.

Privacy — clear offline cache on logout:
- new clear-offline-cache.ts purges the persisted query cache
  (idb-keyval gitmost-rq-cache), the Yjs page.* IndexedDB databases, and the
  service-worker api-get-cache; wired into handleLogout (best-effort, before
  the redirect) so a previous user's private data does not linger locally.

Conventions & docs:
- prettier fixes on main.ts and login.dto.ts.
- CHANGELOG: document offline reading, returnToken opt-in, optional Swagger,
  new env vars, logout cache-clear, and the CORS open->allowlist breaking
  change.
- docs/mobile-app-plan.md: correct the now-false §2.4 claims and update the
  §12 checklist (native cap add ios left unchecked — generated locally,
  gitignored).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 05:30:56 +03:00

30 KiB

Мобильное приложение gitmost — исследование и план

Статус: исследовательский + проектный документ. Контекст: gitmost — форк Docmost, чистое веб-приложение. Отдельного мобильного (нативного/устанавливаемого) приложения нет. Цель: определить путь к мобильным приложениям — iOS обязательно, Android как пойдёт — с заделом на оффлайн в будущем (оффлайн сейчас не требуется).

Документ фиксирует, что уже есть в коде, почему путь к мобилке предопределён устройством продукта, сравнивает варианты и описывает рекомендуемый план с привязкой к файлам.


1. TL;DR

  1. Нативного приложения нет. В проекте отсутствуют Capacitor, React Native, Cordova и т.п. Мобильного клиента ещё не начинали.
  2. Адаптивная веб-версия — есть, и довольно проработанная. Веб-клиент открывается с телефона как mobile-friendly сайт: сворачиваемый сайдбар-drawer, отдельные мобильные компоненты (история, поиск, хлебные крошки), responsive- примитивы Mantine, mobile-tuned viewport. Это готовый фундамент UI.
  3. Ядро продукта — веб-редактор — нативно не воспроизвести. TipTap 3 (ProseMirror) + совместное редактирование на Yjs/Hocuspocus плотно сшиты с React. Production-порта Yjs под Swift/Kotlin нет. Любой реалистичный путь оставляет редактор в WebView.
  4. API уже готов к нативному клиенту. Сервер принимает JWT не только из cookie, но и из заголовка Authorization: Bearer. Есть точка входа для вебсокета совместного редактирования (POST /auth/collab-token).
  5. Рекомендуемый путь — Capacitor: обернуть существующий React-SPA в нативную оболочку (iOS + Android из одного кода), добавить нативные плагины (push, биометрия, share, файлы). Эволюция в гибрид (нативная навигация + WebView-редактор) делается потом инкрементально, без переписывания.
  6. Оффлайн-будущее уже заложено (Yjs + y-indexeddb). Детальный план — в offline-sync-plan.md; мобильное приложение этот план переиспользует, а не дублирует.
  7. Главный блокер — не технический, а лицензионный. 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. Мобильного приложения нет

В package.json и apps/*/package.json нет capacitor, react-native, cordova, expo. Нативной оболочки в репозитории не заведено.

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. Почему путь к мобилке предопределён

Три факта диктуют решение независимо от моды:

  1. Редактор практически невозможно переписать нативно. ProseMirror + весь набор TipTap-расширений + Yjs-CRDT — это не «поле ввода». Нативного production-порта Yjs под Swift/Kotlin нет (есть Rust yrs с биндингами, но это отдельный тяжёлый проект). Переписывание ядра нативно = годы и вечное расхождение с веб-версией. Вывод: редактор остаётся в WebView.
  2. API уже умеет нативного клиента (Bearer, collab-token).
  3. Оффлайн-фундамент уже заложен на веб-уровне (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); см. offline-sync-plan.md.
  • Когда упрётесь в 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. Что доработать на бэкенде

Немного, но конкретно:

  1. Выдача токена в теле ответа для нативного хранения. Сейчас логин кладёт JWT только в httpOnly-cookie и не возвращает его в body. На мобиле httpOnly-cookie между разными origin (capacitor://localhost ↔ API) — боль с SameSite/CORS. Чище: мобильный логин-флоу, возвращающий JWT в ответе, чтобы хранить его в Keychain/Keystore и слать как Authorization: Bearer. Сервер уже принимает Bearer — менять надо только выдачу. Файлы: auth.controller.ts.
  2. CORS. Сейчас app.enableCors() (L144) без конфигурации. Под мобильные origin'ы и для безопасности задать явный whitelist.
  3. Push-уведомления. Модуль notification уже есть — добавить регистрацию device-token и интеграцию APNs (iOS) / FCM (Android).
  4. Опционально — OpenAPI/Swagger. Сейчас спецификации нет; добавить @nestjs/swagger дёшево и сильно ускорит мобильную разработку (типизированный клиент).

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. Оффлайн в будущем

Оффлайн сейчас не требуется, но позиция хорошая:

  • Тело документа уже редактируется через Yjs (CRDT) + y-indexeddb — локальная копия и автослияние правок работают, в том числе в WebView.
  • «Полностью онлайн» — это всё вокруг тела (навигация, заголовки, комментарии, CRUD, вложения, авторизация). Их оффлайн-синхронизация описана отдельным планом с этапами M0…M4 — см. offline-sync-plan.md.
  • Мобильное приложение переиспользует этот план, а не строит оффлайн заново. Нюанс 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 из offline-sync-plan.md) относительно первого мобильного релиза?
  • 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.