# Мобильное приложение 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](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](../apps/client/src/features/editor/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](../apps/client/src/components/layouts/global/global-app-shell.tsx) (L85–99) | | Отдельный мобильный сайдбар-drawer (`mobileSidebarAtom` отделён от `desktopSidebarAtom`), авто-закрытие при навигации по дереву | [sidebar-atom.ts](../apps/client/src/components/layouts/global/hooks/atoms/sidebar-atom.ts), [space-tree-row.tsx](../apps/client/src/features/page/tree/components/space-tree-row.tsx) (L147–148) | | Мобильная модалка истории + свой CSS | [history-modal.tsx](../apps/client/src/features/page-history/components/history-modal.tsx) (L17–19), `history-modal-mobile.tsx` | | Мобильный контрол поиска | [search-control.tsx](../apps/client/src/features/search/components/search-control.tsx) (L38–42) | | Мобильный рендер хлебных крошек через `useMediaQuery` | [breadcrumb.tsx](../apps/client/src/features/page/components/breadcrumbs/breadcrumb.tsx) (L41) | | Responsive-примитивы `hiddenFrom`/`visibleFrom` (~16 мест), медиа-запросы в CSS-модулях | по всему `apps/client/src` | | Mobile-tuned viewport (`width=device-width, user-scalable=no`) | [index.html](../apps/client/index.html) (L8) | > Важно: адаптив проверялся в мобильном **браузере**, а не в WebView нативной > оболочки. Перед сборкой приложения нужно прогнать UI как PWA/в WebView и > отловить отличия (жесты, экранная клавиатура/IME в редакторе, safe-area). ### 2.4. Готовность API к нативному клиенту - **Bearer-токен уже поддержан.** JWT извлекается из cookie **или** из заголовка `Authorization`: см. [jwt.strategy.ts](../apps/server/src/core/auth/strategies/jwt.strategy.ts) (L27–29). Серверная сторона нативной авторизации менять не нужно. - **Токен сейчас не возвращается в теле логина.** [`login`](../apps/server/src/core/auth/auth.controller.ts) (L55–105) кладёт JWT только в `httpOnly`-cookie ([`setAuthCookie`](../apps/server/src/core/auth/auth.controller.ts) L222–230). - **Точка входа вебсокета коллаборации:** [`POST /auth/collab-token`](../apps/server/src/core/auth/auth.controller.ts) (L187–193). - **CORS открыт без конфигурации:** [`app.enableCors()`](../apps/server/src/main.ts) (L144). - **OpenAPI/Swagger отсутствует** (`@nestjs/swagger` не подключён) — авто-генерации типизированного клиента сейчас нет. --- ## 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](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](../apps/server/src/core/auth/auth.controller.ts). 2. **CORS.** Сейчас [`app.enableCors()`](../apps/server/src/main.ts) (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](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`, когда будет готова обвязка). - [ ] Бэкенд: мобильный логин-флоу с токеном в 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`.