Я сделал менеджер задач Dela, который ставится на свой сервер. Задачи шифруются на устройстве, сервер хранит и раздаёт только зашифрованный текст, а телефон и компьютер сводят правки сами, по отдельным полям с метками гибридных часов. Весь код написал Claude Code.
Задачи я веду в Todoist, на главном экране телефона у меня стоит его виджет со списком. Ещё часть задач я вёл в Obsidian. В бесплатной версии Todoist ограничено число проектов, а платить за подписку я не хотел. Мне был нужен свой менеджер задач, содержимое которого не может прочитать никто, кроме меня, даже сервер, на котором оно лежит. Ещё он должен был синхронизировать телефон и компьютер, открываться без сети и уметь то, чем я пользуюсь в Todoist каждый день: добавлять задачу одной фразой и показывать список задач на виджете.
Dela работает в браузере, на Windows и на Android, код открыт под лицензией AGPL-3.0. Сервер ставится через Docker. Свой сервер я поднял на VPS, где уже работают другие мои проекты. Регистрация там закрыта, и пользуюсь им только я.

Что видит сервер
На одном VPS с Dela у меня работают боты и сайты. Если кто-то получит root через один из них, база задач должна остаться для него набором шифротекстов. Поэтому шифрование в Dela включено всегда и выключить его нельзя.
Сервер видит |
Сервер не видит |
|---|---|
Адреса почты, устройства, участников общих проектов |
Названия задач, описания, комментарии и файлы |
Сколько операций пришло и их метки времени |
Названия проектов, разделов и меток |
Размер каждой операции с точностью до 64 байт |
Сроки, повторы, приоритеты |
Время напоминаний |
Какую таблицу, запись и поле меняет операция |
Ключи выводятся из пароля на устройстве:
пароль ──Argon2id(соль)──► 64 байта ├─ 32: секрет входа → серверу (сервер хранит его хеш) └─ 32: ключ обёртки (KEK) → расшифровывает мастер-ключ мастер-ключ (случайный, 32 байта) ├─ обёрнут KEK и ключом восстановления, хранится на сервере ├─ шифрует секретный ключ X25519 пользователя └─ шифрует ключи личных пространств
Сам пароль на сервер не уходит. Из секрета входа сервер не может получить ключ обёртки. При смене пароля перешифровывается только мастер-ключ, задачи остаются как есть. Ключ восстановления состоит из 32 случайных байт и показывается один раз при регистрации. Без пароля и без этого ключа задачи расшифровать нельзя, у сервера нет способа восстановить доступ.
Соль для Argon2id сервер выдаёт по адресу почты до входа. Для адреса, которого нет в базе, он отвечает поддельной солью, вычисленной HMAC от своего секрета. Ответ получается одинаковым при каждом запросе, и по нему нельзя проверить, зарегистрирован ли адрес.
Синхронизация по полям
Сначала я выбрал единицу изменения: новое значение одного поля одной записи.
interface Op { table: string // "task", "project", ... record: string // UUIDv7, создаётся на клиенте field: string // "title", "due", ... value: Json hlc: string // метка гибридных часов }
Каждая операция получает метку гибридных часов (HLC) из трёх частей: физическое время в миллисекундах, счётчик для операций внутри одной миллисекунды и id устройства. Все части фиксированной ширины, и метки сравниваются как строки. Для каждой ячейки (table, record, field) побеждает значение с большей меткой.
Допустим, телефон без сети, и в 10:15:02 я переношу на нём задачу на завтра. В 10:15:40 я переименовываю ту же задачу на компьютере. Метки у этих операций такие:
телефон, поле due: 001791947702120-0000-a1f04c22d9e8b713 компьютер, поле title: 001791947740004-0000-5e9d0b7c31aa6f40
Это разные ячейки, и после синхронизации у задачи будет и новое название, и новый срок. Если бы оба устройства меняли название, осталось бы название с компьютера, потому что его метка больше. Метки двух разных устройств не совпадают никогда: в конце каждой стоит id устройства.
На часы устройства полагаться нельзя. Сервер отклоняет операции, у которых время больше его собственного на 60 секунд, и сообщает своё время. Клиент запоминает поправку и прибавляет её к Date.now().
Из этой схемы вышли ещё четыре правила модели:
Метки задачи хранятся отдельными ячейками
label:<id>со значениямиtrueиfalse. Две метки, добавленные на разных устройствах без сети, складываются.У «Входящих» id совпадает с id пространства. Два устройства, которые создали «Входящие» без сети, создают одну запись.
Порядок задач в списке задаётся дробными ключами, и перестановка задачи меняет одно поле. При одинаковых ключах после двух вставок в одно место порядок решает id записи.
Если без сети задача A ушла внутрь B, а B внутрь A, цикл разрывается при чтении: перемещение с самой старой меткой не учитывается.
Конверт и журнал на сервере
На сервер операция уходит конвертом:
slot = BLAKE2b-128(ключ слотов, JSON [table, record, field]), hex payload = nonce(24) ‖ XChaCha20-Poly1305( ключ шифрования, pad64(JSON {t, r, f, v}), AAD = space ‖ slot ‖ hlc)
Слот это хеш адреса ячейки с ключом, который знают только участники пространства. Сервер видит, что два конверта относятся к одной ячейке, но не знает, к какой. Метка лежит открыто, потому что по ней сервер решает, какой конверт новее. Слот и метка входят в AAD, и если сервер подменит метку или переложит значение в другую ячейку, расшифровка конверта завершится ошибкой.
Для каждого слота сервер хранит только последний конверт и выдаёт конверты по возрастанию порядкового номера seq. Если для слота уже лежит конверт с меткой не меньше пришедшей, новый отбрасывается, и повторная отправка той же операции ничего не меняет. Клиент запрашивает конверты с номером больше своего курсора, страницами. По WebSocket сервер шлёт только сигнал «в пространстве N есть новое».
Правила сервера сначала появились в ядре как эталонная реализация packages/core/src/relay.ts, и сервер на SQLite проходит те же тесты. Сходимость проверяет детерминированная симуляция. В ней от 2 до 5 устройств с часами, сдвинутыми до 30 секунд в каждую сторону, пишут в 12 записей, теряют сеть, теряют подтверждения отправки и забирают изменения страницами от 1 до 10 конвертов. В конце все устройства и сервер должны совпасть с эталоном: для каждой ячейки значение с наибольшей меткой среди всех созданных операций. Тест прогоняет 60 таких сценариев по 2000 шагов, один на 50 000 шагов и 5 сценариев поверх зашифрованных конвертов.
Общие проекты и смена ключа
У общего проекта отдельное пространство со своим ключом. Владелец получает открытый ключ участника по адресу почты, шифрует для него ключ проекта через crypto_box своим секретным ключом и показывает отпечаток ключа участника для сверки по другому каналу. Участник при первом получении закрепляет открытый ключ владельца.
В первой версии ключ для участника запечатывался анонимно через crypto_box_seal. Аудит показал, что так сервер может выдать участнику ключ, который сгенерировал сам: участник не мог проверить, кто запечатал ключ, и начал бы шифровать свои правки ключом, известным серверу. После исправления ключ от другого отправителя Dela не принимает. Участник видит отпечаток и может вручную доверять новому владельцу, если прежний удалил аккаунт.
Когда владелец убирает участника, Dela меняет ключ проекта: перешифровывает всё содержимое новым ключом и раздаёт его оставшимся участникам. Убранный участник сохраняет то, что уже видел, но новых изменений прочитать не может. Перед сменой владелец сверяет список участников с сервера с закреплёнными ключами. Если у кого-то ключ изменился, смена ждёт подтверждения отпечатков, иначе сервер мог бы подставить в список свой ключ.
Смена ключа большого проекта идёт частями: rotate/begin, загрузка перешифрованных конвертов пачками и rotate/:id/commit с номером последнего конверта, который видел владелец. Если за это время в проект пришли новые правки, сервер отвечает отказом, владелец догружает их и повторяет смену. Отправка со старой версией ключа получает 409 key_rotated. Очередь исходящих операций хранится на устройстве открытой и шифруется в момент отправки, и клиент отправляет ту же очередь заново новым ключом.
При каждом удалении участника владелец перешифровывает весь проект и загружает его на сервер заново, и время смены ключа растёт вместе с размером проекта.
Задача одной фразой
Быстрое добавление разбирает фразу на русском и английском, пока я печатаю, и подсвечивает распознанные части. Клик по подсветке оставляет слово в названии. В русской раскладке № работает как #, а !1 как p1. Я прогнал разборщик из репозитория на нескольких фразах, время запуска 14 октября 2026 года, среда, 12:00:
Фраза |
Название |
Срок |
Повтор и остальное |
|---|---|---|---|
позвонить маме завтра в 19 №Дом каждую неделю !1 |
позвонить маме |
15 октября, 19:00 |
каждый четверг, проект «Дом», приоритет 1 |
оплатить интернет 25 числа каждого месяца |
оплатить интернет |
25 октября |
каждый месяц 25 числа |
полить цветы каждые 3 дня после выполнения |
полить цветы |
14 октября |
раз в 3 дня от даты выполнения |
созвон с командой по будням в 10:00 на 30 минут |
созвон с командой |
15 октября, 10:00 |
по будням, длительность 30 минут |
отчёт в пятницу дедлайн 20 октября @звонок |
отчёт |
16 октября |
дедлайн 20 октября, метка «звонок» |
pay rent every last day of the month |
pay rent |
31 октября |
последний день каждого месяца |
Повтор хранится в формате RRULE вместе с исходной фразой. Повтор «после выполнения» считает следующую дату от дня, когда задачу отметили выполненной. Если время повтора сегодня уже прошло, как у созвона в 10:00 при запуске в 12:00, первый срок встаёт на следующий подходящий день.
Фильтры принимают синтаксис Todoist и русские слова: (today | overdue) & p1, #Работа & !@ждёт, без даты & !подзадачи. Импорт из Todoist работает по токену API из браузера, сервер Dela токен не видит.

Телефон и компьютер
Приложения для Android (Capacitor 8) и Windows (Tauri 2) запускают тот же собранный веб-клиент на React, что и браузер. Нативный код есть там, где без него не обойтись: виджет, плитка быстрого добавления, голосовой ввод, напоминания, трей и глобальная клавиша Ctrl+Shift+Space на Windows.
Напоминания Android ставит в своё расписание: ближайшие 60 на неделю вперёд. Они приходят при закрытом приложении и без связи с сервером. Для задач, которые я добавил на компьютере, пока приложение на телефоне закрыто, есть push через UnifiedPush, например через ntfy, без сервисов Google. Сервер знает только время отправки. Текст напоминания зашифрован AES-GCM ключом, который знает только этот телефон, а на экране блокировки уведомление показывается без названия задачи.
Приложение можно закрыть PIN-кодом. PIN хешируется Argon2id, после пятой ошибки подряд появляются паузы от 30 секунд до 15 минут, а после десятой данные на устройстве стираются, и войти можно только с паролем. На Android поверх PIN-кода работает отпечаток пальца.
Как писался код
Весь код написал Claude Code, модель Claude от Anthropic. Отдельной подготовки не было: идею я придумал и в тот же день сел за проект. До первой строки кода мы с Claude составили документ с архитектурой и план из четырёх фаз, у каждой фазы свой критерий готовности. После этого я ставил задачи по плану, выбирал из вариантов, которые предлагал ассистент, и проверял сборки на своём Samsung Galaxy S24 FE. Стиль интерфейса я выбрал из нескольких набросков. Панель вкладок внизу и жесты появились после того, как я написал, что на телефоне элементы слишком мелкие и жестов нет. Виджет я попросил сделать по образцу виджета Todoist, с выбором списка, темы и размера шрифта.
Проверка на телефоне
Модульные и сквозные тесты гоняют веб-клиент в браузере, нативную часть Android они не проверяют. Я попросил Claude проверить приложение на моём телефоне. Он поставил отладочную сборку через USB, подключился к WebView через инструменты разработчика и проходил сценарии командами adb shell input: смахивания, долгие нажатия, ввод PIN-кода. Расписание напоминаний и уведомления он смотрел через dumpsys.
Виджет на главном экране не получал задач. Плагин Capacitor код получал так:
async function widgetPlugin() { const { registerPlugin } = await import('@capacitor/core'); return registerPlugin('DelaWidget'); }
registerPlugin возвращает прокси, а возврат значения из асинхронной функции вызывает у него метод then(). Такого метода у плагина нет, и вызов падал с ошибкой "DelaWidget.then()" is not implemented on android. Та же ошибка была в плагине UnifiedPush. Теперь прокси лежит в обёртке, и await его не трогает.
После исправления виджет показывал «Загрузка…» без конца. Разделитель строк в разметке был обычным <View>, а виджеты Android такой класс не пропускают, в журнале лаунчера стояло Class not allowed to be inflated android.view.View. Разделитель стал FrameLayout.
Напоминание о тестовой задаче на 05:21 не пришло. Без разрешения на точное время Android ставит будильник с окном около 50 секунд. В 05:21:00 срабатывал таймер в самом приложении, пересчитывал расписание и снимал будильник, время которого уже наступило. В dumpsys alarm это видно как Reason=alarm_cancelled в 05:21:00.944. Теперь приложение снимает только будущие будильники, которые больше не нужны. Заодно я убрал переход на системный экран «Будильники и напоминания» при каждом пересчёте, его открывал плагин уведомлений без объяснений. Теперь в настройках есть кнопка «Разрешить точное время».
Ограничения
В браузере данные лежат в IndexedDB расшифрованными, ключи аккаунта обёрнуты неизвлекаемым ключом WebCrypto. Блокировка PIN-кодом закрывает экран приложения и не шифрует данные на устройстве ещё раз. Виджет Android хранит показанные задачи без шифрования в закрытой папке приложения. Автор комментария в общем проекте не подписан, и участники доверяют друг другу в этом. Голосовой ввод идёт через системное распознавание речи Android, и сервис распознавания может отправить запись в сеть, хотя Dela просит распознавать на устройстве.
Если сервер захватят, он не прочитает задачи, но может подменить код веб-версии, которую отдаёт браузеру, и перехватить пароль при следующем входе через браузер. Приложения для Windows и Android несут свой код и от этого защищены. Установщик для Windows пока не подписан, и SmartScreen спрашивает подтверждение. Приложения для iOS нет. Проверял я всё на одном телефоне, Galaxy S24 FE.
Установка
Для своего сервера нужны Docker и домен, запись DNS которого указывает на сервер. Сертификат получает встроенный Caddy:
mkdir dela && cd dela curl -fsSLO https://raw.githubusercontent.com/olerast67/dela/main/docker-compose.yml curl -fsSL https://raw.githubusercontent.com/olerast67/dela/main/.env.example -o .env nano .env # почта администратора и домен docker compose --profile https up -d
Первым может зарегистрироваться только адрес из .env, остальные регистрируются по приглашениям. Контейнер работает с файловой системой только для чтения, без привилегий Linux, и слушает порт на 127.0.0.1. Раз в сутки и перед каждой миграцией сервер сохраняет копию базы.
Попробовать без сервера можно в демо: задачи шифруются и остаются в этом браузере.
Ссылки
Исходный код: https://github.com/olerast67/dela
Выпуск 0.5.0 (APK и установщик для Windows): https://github.com/olerast67/dela/releases/tag/v0.5.0
Демо без сервера: https://olerast67.github.io/dela/demo/
Мой YouTube-канал: https://www.youtube.com/@olerast
Если вы поставите сервер, напишите в issues на GitHub, на какой системе он работает, как вы его ставили (встроенный Caddy или свой прокси) и что не заработало, с выводом docker compose logs dela. Для Android мне нужны модель телефона, версия Android и приходят ли напоминания вовремя при закрытом приложении.
Комментарии (2)

AndreyDmitriev
14.10.2026 01:49А по бюджету и затратам эти девятнадцать часов во сколько обошлись, если не секрет?
HRTv41
Почему вы решили написать клон Todoist?