[feature][epic] Мобильное приложение (iOS-first, Capacitor) + offline-режим и синхронизация (PWA / outbox / Yjs) #195
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
ЧАСТЬ 1. МОБИЛЬНОЕ ПРИЛОЖЕНИЕ
Мобильное приложение gitmost — исследование и план
Документ фиксирует, что уже есть в коде, почему путь к мобилке предопределён
устройством продукта, сравнивает варианты и описывает рекомендуемый план с
привязкой к файлам.
1. TL;DR
Cordova и т.п. Мобильного клиента ещё не начинали.
открывается с телефона как mobile-friendly сайт: сворачиваемый сайдбар-drawer,
отдельные мобильные компоненты (история, поиск, хлебные крошки), responsive-
примитивы Mantine, mobile-tuned
viewport. Это готовый фундамент UI.(ProseMirror) + совместное редактирование на Yjs/Hocuspocus плотно сшиты с
React. Production-порта Yjs под Swift/Kotlin нет. Любой реалистичный путь
оставляет редактор в WebView.
cookie, но и из заголовка
Authorization: Bearer. Есть точка входа длявебсокета совместного редактирования (
POST /auth/collab-token).нативную оболочку (iOS + Android из одного кода), добавить нативные плагины
(push, биометрия, share, файлы). Эволюция в гибрид (нативная навигация +
WebView-редактор) делается потом инкрементально, без переписывания.
y-indexeddb). Детальный план —в offline-sync-plan.md; мобильное приложение этот
план переиспользует, а не дублирует.
с условиями App Store, если зашивать веб-клиент в бинарник: DRM/usage-rules
Apple = «дополнительные ограничения», запрещённые AGPLv3 §10. Развязки —
грузить клиент с сервера (не из
.ipa), PWA или sideload. Детали и матрица —в §9; закрывать до кода обёртки.
2. Текущее состояние (как есть)
2.1. Стек
yjs+y-indexeddbуже в зависимостях клиента (локальная CRDT-копия тела документа).2.2. Мобильного приложения нет
В
package.jsonиapps/*/package.jsonнетcapacitor,react-native,cordova,expo. Нативной оболочки в репозитории не заведено.2.3. Адаптивная веб-версия — есть
AppShellсbreakpoint: "sm", раздельные состоянияcollapsed.mobile/collapsed.desktopmobileSidebarAtomотделён отdesktopSidebarAtom), авто-закрытие при навигации по деревуhistory-modal-mobile.tsxuseMediaQueryhiddenFrom/visibleFrom(~16 мест), медиа-запросы в CSS-модуляхapps/client/srcwidth=device-width, user-scalable=no)2.4. Готовность API к нативному клиенту
Authorization: см. jwt.strategy.ts (L27–29).Серверная сторона нативной авторизации менять не нужно.
login(L55–105) кладёт JWT только в
httpOnly-cookie (setAuthCookieL222–230).POST /auth/collab-token(L187–193).app.enableCors()(L144).@nestjs/swaggerне подключён) — авто-генерациитипизированного клиента сейчас нет.
3. Почему путь к мобилке предопределён
Три факта диктуют решение независимо от моды:
набор TipTap-расширений + Yjs-CRDT — это не «поле ввода». Нативного
production-порта Yjs под Swift/Kotlin нет (есть Rust
yrsс биндингами, ноэто отдельный тяжёлый проект). Переписывание ядра нативно = годы и вечное
расхождение с веб-версией. Вывод: редактор остаётся в WebView.
y-indexeddb),и он работает внутри WebView.
4. Три возможных пути
5. Рекомендуемый путь
B (Capacitor) как первый релиз, с заложенной эволюцией в C.
Почему:
нативными возможностями». Переиспользуется весь React-клиент и, главное,
редактор — то, что нативно не сделать.
одновременно, без второй команды.
не нужно; работа смещается в нативную обвязку.
y-indexeddb); см.offline-sync-plan.md.
оболочку, оставив редактор в WebView. То есть B → C делается инкрементально.
Почему не чистый React Native сразу: редактор всё равно придётся держать в
WebView (ядро web-only), но при этом теряется прямой реюз остального React-кода
и появляется мост как обязательная сложность с первого дня — для iOS-first
старта это лишний оверхед.
⚠️ Лицензионная оговорка к iOS. Обычный Capacitor зашивает веб-билд
apps/clientв.ipa— для публикации в App Store это нарушает AGPL(см. §9). Выбор Capacitor для Android остаётся в силе, но на iOS
веб-клиент нельзя бандлить в бинарник: либо грузить его с сервера
(
server.url), либо PWA. То есть рекомендация «B (Capacitor)» применима кAndroid как есть, а к iOS — только в конфигурации без зашитого AGPL.
6. Что доработать на бэкенде
Немного, но конкретно:
JWT только в
httpOnly-cookie и не возвращает его в body. На мобилеhttpOnly-cookie между разными origin (capacitor://localhost↔ API) — больс SameSite/CORS. Чище: мобильный логин-флоу, возвращающий JWT в ответе, чтобы
хранить его в Keychain/Keystore и слать как
Authorization: Bearer. Серверуже принимает Bearer — менять надо только выдачу.
Файлы: auth.controller.ts.
app.enableCors()(L144) безконфигурации. Под мобильные origin'ы и для безопасности задать явный whitelist.
notificationуже есть — добавить регистрациюdevice-token и интеграцию APNs (iOS) / FCM (Android).
@nestjs/swaggerдёшево и сильно ускорит мобильную разработку(типизированный клиент).
7. Android-специфика
На пути Capacitor Android едет почти бесплатно (
npx cap add androidиз того жевеб-билда), но есть нюансы:
Store независимо от ОС и обычно свежее iOS WKWebView. Более рискованный движок
по совместимости — это iOS, а не Android.
WebView; стек тяжёлый (ProseMirror + Yjs + mermaid + katex + excalidraw) —
тестировать на бюджетных аппаратах.
приложения, а не выход), FCM для push, Android App Links (вместо iOS
Universal Links), подписание и Play Console.
Историческая боль
contenteditableна Android (прыжки курсора, дубли символовпри композиции). Стало лучше, но проверять в первую очередь и рано.
«отклонят как просто сайт» для Play практически неактуален.
8. iOS-специфика
рискованный по совместимости движок (тестировать прежде всего его).
рискует отклонением «это просто сайт». Лечится реальной нативной ценностью:
push, share-extension, биометрический разблок, оффлайн-кэш — всё это Capacitor
даёт плагинами.
редакторе.
9. Лицензионный блокер: AGPL ↔ App Store (iOS)
9.1. Суть конфликта
gitmost — форк Docmost под AGPL-3.0 (константа форка: «100% open, AGPL-only»).
Две вещи несовместимы:
любые дополнительные ограничения сверх самой лицензии.
привязка установки к 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-байты, а не то, окажутся ли они в итоге на устройстве:
.ipa→ получен под ограничениями Apple → нарушение.§13 выполнен) → ограничения Apple на него не накладываются, даже если бандл
кэшируется в песочнице приложения.
Следствие: офлайн на iOS легально достижим — если кэшированный бандл пришёл с
вашего сервера, а не из
.ipa. Ограничение тут не лицензионное, а в ревьюApple (см. §9.5).
9.4. Варианты «грузить веб-клиент с сервера»
A. WebView навигируется на хостед-клиент (
server.url). Capacitor умеетserver: { url: 'https://app.example.com' }— оболочка грузит WebView с удалённогоURL, мост и нативные плагины по-прежнему инжектятся. В
.ipa— ноль AGPL.работают как в браузере (боль
capacitor://localhost↔ API из §6 исчезает —токен в body/Keychain может и не понадобиться).
умолчанию нет.
B. OTA: пустой шелл скачивает и кэширует бандл. Шелл при первом запуске тянет
JS-бандл с вашего сервера и кэширует как веб-ассеты (механизм Cordova/CodePush).
Open-source self-host-вариант —
@capgo/capacitor-updater(важно для AGPL-проекта:без привязки к проприетарному Appflow).
Не-обходы (мифы): «никто не засудит» — это нарушение, а не обход; «LGPL-нуть
обёртку» — не помогает (проблема в AGPL-веб-клиенте, а не в обёртке); «mere
aggregation» — не катит: зашитый бандл это комбинированное распространяемое
произведение, а не простая агрегация.
9.5. Гейты Apple
WKWebViewпод исключение попадает: вариант A — чистый, B — терпимый, но с границами.ipa) — лицензионное нарушениеБезопасность: раз исполняете удалённый код — только HTTPS, желательно cert-pinning
(подмена сервера = произвольный JS в WebView пользователя).
9.6. Итоговая матрица распространения iOS
server.urlна хостед-клиент.ipa(обычный Capacitor)Вывод: для iOS PWA — самое дешёвое решение, закрывающее всё сразу. Если
присутствие именно в App Store критично — вариант A (
server.url+ нативныеплагины под 4.2) легальный и реалистичный ценой «онлайн для холодного старта».
Офлайн в App Store (вариант B) технически и лицензионно возможен, но это
максимальный риск на ревью — закладывать только если офлайн на iOS обязателен.
Совместить «App Store + зашитый офлайн AGPL» легально нельзя, пока копирайт не ваш.
10. Оффлайн в будущем
Оффлайн сейчас не требуется, но позиция хорошая:
y-indexeddb— локальнаякопия и автослияние правок работают, в том числе в WebView.
CRUD, вложения, авторизация). Их оффлайн-синхронизация описана отдельным
планом с этапами M0…M4 — см. offline-sync-plan.md.
Нюанс Android: System WebView под нехваткой места может чистить хранилище →
для оффлайна, возможно, понадобится дублировать критичные данные в нативное
хранилище, чтобы локальные копии не вычищались.
11. Открытые вопросы (зафиксировать до старта)
Рекомендация — B.
Keystore + Bearer (рекомендуется) или попытка работать через cookie в WebView?
первого мобильного релиза?
server.url(онлайн-клиент, без зашитого AGPL), PWA или sideload/EU-маркетплейсы? Этот
лицензионный путь нужно подтвердить до кода обёртки. Рекомендация — PWA для
iOS, Capacitor для Android.
12. Чеклист первого шага (бутстрап Capacitor, iOS-first)
server.url/ PWA / sideload и подтвердить у разбирающегося в лицензиях..ipa(DRM/usage-rules App Store ⟂AGPLv3 §10) — на iOS грузить клиент с сервера или идти через PWA.
(жесты, IME в редакторе, safe-area).
apps/client(Android — зашитый билд; iOS —
server.url/PWA без зашитого AGPL, см. §9).npx cap add ios(Android —npx cap add android, когда будет готова обвязка).Keystore; слать
Authorization: Bearer./auth/collab-token+ Hocuspocus).@nestjs/swagger.ЧАСТЬ 2. OFFLINE-РЕЖИМ И СИНХРОНИЗАЦИЯ
Offline-режим и синхронизация правок в gitmost
Документ описывает текущее устройство, целевую архитектуру и пошаговый план
реализации с привязкой к конкретным файлам. Его можно взять и реализовывать
по этапам M0…M4.
1. TL;DR
(CRDT) + Hocuspocus, а на клиенте уже подключён
y-indexeddb. Правки телауже открытой страницы переживают потерю сети и сами мёржатся при
реконнекте — без конфликтов.
приложения, навигация (дерево/список), заголовки страниц, комментарии,
создание/перемещение/удаление страниц, вложения, авторизация.
локальный кэш + outbox (очередь мутаций) + правила разрешения конфликтов.
(унаследовано от Docmost). Forkmost добавляет только косметику.
есть реальная невыполненная часть. Без него установленное приложение без
сети покажет пустой экран.
2. Текущее состояние (как есть)
2.1. Контур A: тело документа — CRDT, почти готово
Y.Doc, к нему цепляютсяIndexeddbPersistence("page.<id>")(локальная копия) иHocuspocusProvider(WS-синк).onStoreDocumentхранит в Postgres бинарныйydoc(Y state update) плюс отрендеренный tiptap-JSONcontent+textContent. ВonLoadDocumentподнимаетydocобратно.Почему это и есть оффлайн-редактирование: Yjs — CRDT, апдейты коммутативны.
Пока клиент оффлайн, изменения копятся в
Y.Docи в IndexedDB; при возвратесети
HocuspocusProviderобменивается state-векторами и детерминированносливает правки. Конфликтов «кто кого перезаписал» в теле документа нет.
2.2. Контур B: структурные данные — обычный REST, оффлайн недоступен
/pages/update, дебаунс 500 мс. НЕ Yjs./pages/*/comments/*features/*/servicesСостояние клиента:
queryClientэкспортируется,
retry:false,staleTime: 5 мин. Персистентности на дискнет. При перезагрузке без сети читать нечего.
/api,withCredentials. На401→redirectToLogin(). Важно для оффлайна:редирект на логин при сетевой ошибке недопустим (см. M4).
2.3. PWA: что уже есть
(
display: standalone, иконки).(
apple-mobile-web-app-capable,mobile-web-app-capable,theme-colorи т.д.).vite-plugin-pwa, Workbox, precache.2.4. Полезные примитивы, которые уже есть в проекте
page.service.ts
использует
generateJitteredKeyBetweenизfractional-indexing-jittered.Позиция — это строковый ключ (
position: string), «jittered»-вариантспециально снижает коллизии при конкурентных/оффлайн-вставках. Это готовый
offline-friendly примитив для перемещений в дереве.
nanoid.utils.ts —
generateSlugId(10 симв.) иnanoIdGen. ID можно генерировать на клиенте ипринимать на сервере (нужно для оффлайн-создания, см. M3).
3. Целевая архитектура
Два независимых канала синхронизации:
IndexedDB и проигрываются на сервер при реконнекте; конфликты решаются
явными правилами (LWW / per-entity).
4. План реализации по этапам
Этапы инкрементальны: каждый даёт пользователю ощутимый результат и может быть
смёржен отдельно. Рекомендуемый порядок — строго M0 → M4.
M0 — PWA shell (фундамент: приложение запускается без сети)
Зачем: без service worker установленное приложение без сети не загрузится.
Это разблокирует всё остальное.
Что сделать:
vite-plugin-pwa(Workbox под капотом) вvite.config.ts.
registerType: 'autoUpdate'илиprompt(см. риск R3).workbox.globPatterns— прекэш JS/CSS/wasm/шрифтов/иконок.manifest: falseили генерация из существующегоmanifest.json (не дублировать).
index.htmlдля SPA-роутов.CacheFirstдля статики,NetworkOnlyдля/api/**и
/collabна этом этапе (REST-кэш появится в M2; SW не должен молчаотдавать устаревшие ответы API).
(
registerSWизvirtual:pwa-register).orientation,msapplication-*). Опционально, на оффлайн не влияет.Файлы:
apps/client/vite.config.ts,apps/client/src/main.tsx,apps/client/public/manifest.json,apps/client/index.html.Критерий приёмки: приложение устанавливается, после первой загрузки
открывается без сети (виден shell/лэйаут, а не пустой экран);
обновление версии SW не ломает открытую сессию.
Риск: низкий. Изолированный слой, кода приложения не трогает.
M1 — Укрепление оффлайна тела документа (Контур A)
Зачем: убрать известные грабли Yjs и сделать поведение предсказуемым.
Что сделать:
persistence.extension.ts
onLoadDocumentпри пустомpage.ydocпересобирает документ изpage.contentчерезTiptapTransformer.toYdoc(...). Если это сработает,пока оффлайн-клиент держит свой
Y.Docсо своими client-id, при мёржевозможно дублирование контента (классическая Yjs-ловушка).
ydocвсегда персистится (после первого сохранения онесть) и ветка rebuild не выполняется для страниц, у которых живут
оффлайн-клиенты. Минимум — единожды мигрировать
content → ydocдля всехстраниц и далее считать
ydocединственным источником правды для тела.yjsConnectionStatusAtomиisLocalSynced/isRemoteSyncedвpage-editor.tsx.
Показать состояние («оффлайн», «есть несинхронизированные правки»,
«синхронизировано»).
title-editor.tsx
сохраняет заголовок REST-ом (дебаунс 500 мс) — оффлайн это не работает и
расходится с телом. Варианты:
Y.Doc(чистое CRDT-решение), либодо старта M3 (см. открытый вопрос Q1).
Файлы:
apps/server/src/collaboration/extensions/persistence.extension.ts,apps/client/src/features/editor/page-editor.tsx,apps/client/src/features/editor/title-editor.tsx(если вариант a).Критерий приёмки: правки тела уже открытой страницы, сделанные оффлайн,
после реконнекта появляются на сервере и у других клиентов без дублей и потерь;
в UI виден статус синка.
Риск: средний (Yjs-семантика, миграция
content → ydoc).M2 — Оффлайн-чтение и навигация (Контур B, read-path)
Зачем: оффлайн нужно видеть дерево, список и метаданные, иначе некуда
переходить; и нужно префетчить страницы «на оффлайн».
Что сделать:
queryClientизmain.tsx в
PersistQueryClientProviderс IndexedDB-persister(
@tanstack/query-persist-client-core+ idb-хранилище).комментарии. Выставить разумный
maxAge/gcTime.buster) по версии приложения, чтобы не «залипал»после деплоя.
метаданных и прогрев
IndexeddbPersistenceдля тел страниц (открыть/подгрузить
ydocкаждой целевой страницы заранее), т.к. сейчас локальнолежат только ранее открытые страницы.
StaleWhileRevalidate/NetworkFirstс фолбэком на кэш. Мутации (POST) —по-прежнему мимо кэша (их берёт на себя M3).
Файлы:
apps/client/src/main.tsx, новый модульapps/client/src/lib/offline/(persister, prefetch), точечно — хуки списков/дерева в
features/page/tree.Критерий приёмки: после прогрева и ухода в оффлайн пользователь видит дерево
и список, открывает заранее подготовленные страницы и читает их тело и
комментарии.
Риск: средний (консистентность кэша, инвалидция после деплоя).
M3 — Outbox для мутаций (Контур B, write-path) — ядро оффлайн-синка
Зачем: дать оффлайн-создание/редактирование структурных данных с
последующим проигрыванием на сервер.
Что сделать:
{ id, entity, op, payload, clientId, baseVersion, createdAt, status }.Использовать offline/paused mutations TanStack Query
(
onlineManager+queryClient.resumePausedMutations()+ персист пауз),либо отдельный модуль
apps/client/src/lib/offline/outbox.ts.генерировать
id/slugIdна клиенте тем же алфавитом, что иnanoid.utils.ts.
Для позиций в дереве —
generateJitteredKeyBetweenизfractional-indexing-jittered(тот же пакет, что на сервере)./pages/create,/comments/createи т.д. должны принимать клиентскийidи бытьидемпотентными по нему (повторная отправка из очереди не должна плодить
дубликаты). Точки входа:
page-service.ts,
comment-service.ts
и соответствующие контроллеры сервера.
неуспешном проигрывании после реконнекта — откат/пометка конфликта.
createdAt, с экспоненциальнымbackoff и идемпотентностью.
Файлы: новый
apps/client/src/lib/offline/outbox.ts, обёртки надfeatures/*/services/*, серверные контроллеры/сервисы соответствующихсущностей (idempotent upsert).
Критерий приёмки: оффлайн можно создать страницу, отредактировать заголовок,
оставить комментарий, переместить страницу; после реконнекта всё появляется на
сервере один раз (без дублей), конфликты разрешаются по заданным правилам.
Риск: высокий (это самостоятельный класс багов синхронизации; требует
серверных изменений и тестов на конфликты).
M4 — Вложения и оффлайн-авторизация
Что сделать:
кэш (Cache API/IndexedDB), в документ вставляется ссылка на локальный
ресурс; при реконнекте файл доуплоадивается, ссылка переписывается на
серверную. Точка входа —
features/attachments.api-client.ts
401/сетевые ошибкине должны выкидывать на логин при отсутствии сети — отличать «нет сети»
от «реально разлогинен». Collab-токен (JWT с TTL,
page-editor.tsx L166–181)
оффлайн не обновить — синк должен просто ждать реконнекта, не ломая
локальную работу.
Критерий приёмки: оффлайн-вставка картинки доезжает после реконнекта;
протухший токен/нет сети не выкидывают пользователя из приложения и не теряют
локальные правки.
Риск: средний.
5. Правила разрешения конфликтов (Контур B)
CRDT здесь нет, правила задаём явно по типам сущностей:
clientId. Простейший случай.updatedAt.generateJitteredKeyBetween), что снижает коллизии вставок. Нужен серверный реконсилер для «родитель удалён, а ребёнок перемещён» и конкурентных move: правило «удаление побеждает перемещение» (или наоборот — зафиксировать), плюс перегенерация позиции при коллизии.6. Подводные камни (читать до старта)
content → toYdocвonLoadDocumentопасна для долго-оффлайновых клиентов. Закрыть в M1.версионироваться по версии приложения (
buster/globPatternsхэши), иначепользователь застрянет на старом UI/данных.
autoUpdateможет перезагрузить вкладку снесохранёнными правками. Для редактора предпочтительнее
prompt-стратегия(показать «доступно обновление», применить по согласию).
повторно (реконнект/ретрай). Без серверного upsert по
clientId— дубли.занять много места. Нужны лимиты/очистка (LRU).
401→redirectToLogin.Оффлайн это выкинет пользователя и потеряет контекст — чинить в M4.
7. Зависимости (npm)
vite-plugin-pwa(+ Workbox)@tanstack/query-persist-client-coreidbилиidb-keyvalfractional-indexing-jitteredyjs,y-indexeddb,@hocuspocus/provider— уже в проекте, доустанавливатьне нужно.
8. Объём работ vs ценность (для приоритизации)
Прагматичный путь: довести M0+M1 (это ~80% «редактирую то, что открыл»),
затем M2/M3 инкрементально. Полный синк-движок (RxDB / ElectricSQL / PowerSync /
Replicache / TanStack DB) рассматривать только если оффлайн станет ключевым
сценарием продукта — это существенный рефакторинг данных и бэкенда.
9. Открытые вопросы (зафиксировать до реализации)
outbox (M3, вариант b)? Рекомендация — (a), меньше конфликтных правил.
явный конфликт в UI?
autoUpdateилиprompt?Рекомендация —
prompt.держать оффлайн, политика вытеснения).
«полный»)? От этого зависит, переписывать ли REST-слой.
10. Чеклист реализации
vite-plugin-pwaподключён, SW регистрируется, app-shell в precache,/apiи/collab—NetworkOnly.content → ydoc.ydoc).Частично реализуется в PR #120 (feat(offline): offline-sync (M0–M2) + mobile-app-bootstrap, ветка feature/offline-sync). PR закрывает offline-этапы M0–M2 и базовый bootstrap мобильного приложения; остальное (M3–M4 и полноценная мобильная обвязка из плана) остаётся в этом issue.