Compare commits

..
880 Commits
Author SHA256 Message Date
AidarKC 23969121e5 cripto utils 2026-09-20 11:20:32 +03:00
AidarKC 4da2d7129a 1 2026-09-19 10:18:45 +03:00
AidarKC 2be5ad0b7d Выровнять верхние панели UI 2026-09-18 00:00:42 +03:00
AidarKC f44ab75c64 Улучшить аватары и граф связей 2026-09-17 23:35:53 +03:00
AidarKC 7aa05dc609 Добавить страницу донатов канала 2026-09-17 22:43:49 +03:00
AidarKC 7b81d140ec Исправить read-state каналов и импорт Solana PDA 2026-09-17 21:10:48 +03:00
AidarKC 1be1d56599 Добавить импорт архивов с доверенных серверов 2026-09-12 16:59:35 +03:00
AidarKC 2df0a78eb2 мелкое Изменения формулировки в документах 2026-09-12 16:49:47 +03:00
AidarKC c3253dceac Архивация в Arweave 2026-09-12 10:17:23 +03:00
AidarKC ef068eca81 Доработать вложенные файлы и архив проекта 2026-09-11 14:33:21 +03:00
AidarKC f9ae811702 Первая версия вложенных файлов работает 2026-09-11 02:38:02 +03:00
AidarKC ea0098d705 Доработать граф связей 2026-09-11 00:43:39 +03:00
AidarKC 4991a146d5 Связи - интересная версия 2026-09-10 19:12:11 +03:00
AidarKC 849250bfa8 Связи - интересно но надо дорабатывать 2026-09-10 14:52:20 +03:00
AidarKC d83f1d4cce Исправить скрытие разделителя новых DM 2026-09-10 12:41:33 +03:00
AidarKC 4c7e71f21f Исправить синхронизацию прочитанных каналов 2026-09-10 12:33:24 +03:00
AidarKC 107f85b818 Исправить обновление непрочитанных в UI 2026-09-10 12:18:39 +03:00
AidarKC 3a466b4c38 Исправить подпись состояния чтения канала 2026-09-10 11:08:30 +03:00
AidarKC 08663fa339 Исправить статус сохранения чтения канала 2026-09-09 19:06:43 +03:00
AidarKC 8e86872aa7 Добавить состояние чтения каналов 2026-09-09 18:59:54 +03:00
AidarKC 2d059e9ff5 Добавить просмотр лайков сообщений канала 2026-09-09 17:01:13 +03:00
AidarKC 9f77c54955 Обновить каналы и перенести черновики 2026-09-09 15:17:31 +03:00
AidarKC f827c3e493 Исправить повторную авторизацию при входе 2026-09-09 10:23:36 +03:00
AidarKC ae2f2fac41 Доработать UI и счётчики 2026-09-08 22:33:06 +04:00
AidarKC 1f70d36e74 Убрать нижнюю кнопку в каналах 2026-09-08 17:54:34 +04:00
tram 7d27bfdcaf Обновить UI профиля и навигации 2026-09-08 16:13:04 +03:00
tram 023d61a1e9 Обновить профиль пользователя 2026-09-03 15:37:28 +03:00
AidarKC 85d90a7f95 Исправить синхронизацию прочтения каналов 2026-09-03 14:36:44 +04:00
AidarKC e0295eebde Добавить смену профилей, пока не работает 2026-09-03 12:51:46 +04:00
AidarKC ebc9143593 Добавить смену профилей, но она пока не работает 2026-09-03 12:37:23 +04:00
AidarKC e7c8fd748c Обновить DM, сеть и оффлайн-бандл 2026-09-03 12:26:50 +04:00
AidarKC aff601f61a Display new notification count 2026-09-02 19:20:04 +04:00
tram 0c5089fa79 Исправить профиль и список каналов 2026-09-02 16:32:22 +03:00
tram e5556f3706 Вернуть профиль пользователя 2026-09-02 15:31:04 +03:00
tram 09c7f8541e Вернуть профильные элементы 2026-09-02 15:02:48 +03:00
tram 8eb2ef438c Восстановить профиль пользователя 2026-09-02 14:53:11 +03:00
tram 34abe14151 Доработать профиль пользователя 2026-09-02 14:42:58 +03:00
AidarKC 90e37c198f Adjust DM contact card spacing 2026-09-02 14:58:17 +04:00
AidarKC 1373283947 Refine direct message profile data 2026-09-02 14:56:33 +04:00
AidarKC 065616a18b Update DM and profile handling 2026-09-02 14:24:44 +04:00
AidarKC 53cd55a2a0 Fix DM name fallbacks and chat header 2026-09-02 14:23:42 +04:00
AidarKC 079be37fff Починить ListUserProfileChannels для PostgreSQL 2026-09-02 13:09:05 +04:00
AidarKC 806a8c57d4 Merge remote-tracking branch 'origin/main' 2026-09-02 12:52:54 +04:00
tram 797e769cc3 Merge branch 'ui-artem'
# Conflicts:
#	shine-UI/styles/components.css
2026-09-02 11:45:49 +03:00
tram 822a72e246 Доработать профиль пользователя 2026-09-02 11:43:47 +03:00
AidarKC 58b564b385 Промежуточная версия: UI еще доделать и мелкие ошибки 2026-09-01 19:07:59 +04:00
AidarKC 7aa9e2f6a4 Merge branch 'Доделать-связи-и-профиль' 2026-09-01 19:07:41 +04:00
AidarKC f645686d87 Revert "Исправить отображение имени в личных сообщениях"
This reverts commit 6fcaa5d3d4.
2026-09-01 19:00:05 +04:00
AidarKC 6fcaa5d3d4 Исправить отображение имени в личных сообщениях 2026-09-01 18:55:20 +04:00
AidarKC 5d19e17a0b Исправить открытие личных сообщений 2026-09-01 18:50:18 +04:00
AidarKC 52a9f1ac1d Обновить UI сети и профиль 2026-09-01 18:45:32 +04:00
AidarKC 3dec5e3225 Merge branch 'main' into Доделать-связи-и-профиль 2026-09-01 18:35:21 +04:00
AidarKC 90ded1612a Merge remote-tracking branch 'origin/main' 2026-09-01 18:35:14 +04:00
tram 7ab9fb7c28 Обновить UI сети и серверные экраны 2026-09-01 17:23:31 +03:00
AidarKC 6f499c68e2 Merge branch 'main' into Доделать-связи-и-профиль 2026-09-01 18:19:47 +04:00
AidarKC 19beba8fd7 Обновить версии после merge в main 2026-09-01 18:17:42 +04:00
AidarKC f7e33802c8 Merge branch 'Доделать-связи-и-профиль' 2026-09-01 18:17:20 +04:00
AidarKC c03d94105c Доделать профиль, связи и серверные миграции 2026-09-01 18:14:55 +04:00
AidarKC 632d56737b UI: доработать профиль и связи 2026-09-01 15:24:39 +04:00
tram 8801b93973 Обновить UI сети и серверные экраны 2026-09-01 14:04:12 +03:00
AidarKC 390a2b988e Доделать связи и профиль 2026-09-01 14:59:42 +04:00
AidarKC a66d7a64e6 Исправить дублирование SVG_CHEVRON в личных сообщениях 2026-09-01 13:49:51 +04:00
AidarKC a8596d3350 Смерджить расширение функционала каналов в main 2026-09-01 13:14:18 +04:00
AidarKC 8a5dabee95 Переделал UI отображения связей 2026-09-01 12:57:04 +04:00
AidarKC da859468d3 Обновить стили списка каналов 2026-08-29 18:03:01 +04:00
AidarKC 3e4583bbe9 Обновить UI для меню и языка 2026-08-29 15:56:01 +04:00
AidarKC b47440dd72 Обновить UI и документы личных сообщений 2026-08-29 15:12:13 +04:00
AidarKC 357a05f7ec Улучшен вход пользователя в аккаунт 2026-08-28 16:51:14 +04:00
AidarKC f2fdb8195c Сделали кнопки стартовой страницы и настроек темнее
Перевёл общий синий акцент на более тёмный оттенок и применил его к кнопкам стартовой страницы и экранам настроек.

Проверено: deploy на t1 server/UI.
Не проверяли: ручной визуальный прогон в браузере и остальные экраны.
2026-08-28 15:07:43 +04:00
AidarKC 8a0275d962 Убрали старый sync и обновили bundle
Что сделано: вычистили неиспользуемый user-settings sync/DM sync хвост, сохранили сборку, обновили bundle.sh так, чтобы gradle-wrapper.jar всегда попадал в архив.

Проверено: compileJava и deploy на t2 (server + UI).
Не проверяли: полные интеграционные сценарии, ручные UI-флоу и продовый деплой.
2026-08-28 14:51:47 +04:00
AidarKC ef1fd9f579 Зафиксировали текущие изменения UI и документации 2026-08-28 12:55:28 +04:00
AidarKC f6b95d94bc Увеличили аватары в списках чатов и каналов 2026-08-26 12:39:49 +04:00
AidarKC 2a65e6d359 Стандартизировали размеры аватаров в UI 2026-08-26 12:33:18 +04:00
AidarKC 83f87ba22c Вернули логотип из исходного SVG 2026-08-26 12:05:42 +04:00
AidarKC 01321f5200 Добавили счётчик подписчиков в каналах и подписки пользователя 2026-08-26 11:11:46 +04:00
AidarKC 3a5851939e Починить межсерверную репликацию личных сообщений
Репликация личных сообщений между серверами теперь работает корректно.
2026-08-25 19:31:38 +04:00
AidarKC 7d929e626e Исправить определение клиентского DM-вызова 2026-08-25 19:30:44 +04:00
AidarKC d8e0c77951 Сохранить текущий снимок DM-синхронизации 2026-08-24 17:50:36 +04:00
AidarKC 78ddb8ffb6 Исправить рендер messages-list и поднять версию для main 2026-08-23 12:07:37 +04:00
AidarKC db37f35d09 Версии: поднять client и server для merge в main 2026-08-23 11:38:07 +04:00
AidarKC d8f5fe4fd0 Merge branch 'расширение-функционала-каналов' 2026-08-23 11:37:52 +04:00
AidarKC 015fade4c0 Смерджить main в ветку расширения функционала каналов 2026-08-22 20:16:16 +04:00
AidarKC da14b51e80 Исправить стартовый bootstrap DM 2026-08-22 20:13:14 +04:00
AidarKC 7a5ac01d1c UI: render DM inbox from dialogs 2026-08-22 18:47:14 +04:00
AidarKC 5e6d64e965 Split DM dialog migration to v11 2026-08-22 18:38:07 +04:00
AidarKC c425fa41aa UI: keep local shell updates 2026-08-22 18:34:40 +04:00
AidarKC 781157299f Server-side DM dialogs state 2026-08-22 18:31:32 +04:00
tram 0f30317bb4 Поднять бейдж канала и опустить время 2026-08-22 17:00:26 +03:00
tram 1f1bc0a7b9 Убрать меню из строк каналов 2026-08-22 16:51:37 +03:00
tram 60c8a6608a Вернуть channels-list к версии ui-artem 2026-08-22 16:47:12 +03:00
tram 86adaf8c6b Слить main с актуальными UI-изменениями 2026-08-22 16:36:47 +03:00
tram 731688d16e Merge ui-artem into main 2026-08-22 16:35:44 +03:00
tram 63b66c48d8 Доточить позиционирование UI 2026-08-22 16:35:41 +03:00
tram ea19e511c0 Доработать аватары и меню UI 2026-08-22 13:27:08 +03:00
tram f5e401cff8 Пересобрать верхний UI-бар 2026-08-22 12:34:07 +03:00
tram bc8c7f318b Обновить UI-кнопки и прокрутку 2026-08-22 11:28:12 +03:00
AidarKC 585750007d Merge remote-tracking branch 'origin/main' 2026-08-21 15:16:42 +04:00
AidarKC 6e80ac976a Merge branch 'расширение-функционала-каналов' 2026-08-21 15:16:13 +04:00
AidarKC c70f18fcf4 Remove bottom CTA from channel list 2026-08-21 15:12:36 +04:00
tram d3e6aa2be2 Добавить поддержку уведомлений 2026-08-21 14:11:47 +03:00
AidarKC b7a869c514 Make unread divider visible in channel view 2026-08-21 15:10:39 +04:00
tram 19362b950a Доработать UI уведомлений 2026-08-21 14:06:19 +03:00
AidarKC 0a4c31fb36 Refine unread marker and reset registration flow 2026-08-21 15:04:29 +04:00
AidarKC fef7694b48 Show unread count in channel list 2026-08-21 14:59:03 +04:00
AidarKC 60206e21df Accept user settings cursor writes on fallback 2026-08-21 14:52:45 +04:00
AidarKC b9b77c66ce Restore unread baseline and read tracking 2026-08-21 14:24:46 +04:00
AidarKC 745a0e39d7 Fix unread channel counts and badges 2026-08-21 13:45:49 +04:00
AidarKC 23bdadab56 Merge branch 'расширение-функционала-каналов' 2026-08-20 08:23:19 +04:00
AidarKC 47a4844ce8 Merge remote-tracking branch 'origin/main' into расширение-функционала-каналов 2026-08-20 08:14:52 +04:00
tram 8741be6cba Улучшить раскладку чата при клавиатуре 2026-08-20 07:12:46 +03:00
AidarKC 4656a03ea9 Исправить автоскролл и TDZ в канале 2026-08-19 20:38:03 +04:00
AidarKC 5763eb828e Исправить открытие канала и ключ настройки 2026-08-19 20:34:41 +04:00
AidarKC 6a5c20a165 Добавить user_settings и синхронизацию настроек 2026-08-19 19:45:09 +04:00
AidarKC fac166f186 Удалить legacy-стек прямых сообщений 2026-08-19 18:11:00 +04:00
AidarKC 091291bfa2 Синхронизировать локальный main с origin/main 2026-08-19 14:33:01 +04:00
AidarKC 288fde67e8 Merge ветки расширения функционала каналов в main 2026-08-19 14:31:03 +04:00
AidarKC ce30b77328 Синхронизировать регистр логинов и доработать UI каналов 2026-08-19 14:30:29 +04:00
AidarKC 8c4e404f23 Merge ветки расширения функционала каналов в main 2026-08-12 19:38:17 +04:00
AidarKC e8a4713f6e UI: свайпы вкладок и навигация тредов 2026-08-12 19:37:29 +04:00
AidarKC b4b23afc10 Доработать верхнее меню списка каналов 2026-08-12 17:12:57 +04:00
AidarKC 8f32e82d14 Сократить служебные теги SHiNE и сохранить обратную совместимость 2026-08-12 13:14:23 +04:00
AidarKC cf391bdd80 main: обновить версии до 1.6.0 и 1.5.0 2026-08-12 00:17:48 +04:00
AidarKC 7120d6a9db Merge branch 'расширение-функционала-каналов' into main 2026-08-12 00:17:34 +04:00
AidarKC 4d80ab639e Порядок навели с документацией ТУДУ 2026-08-11 20:20:10 +04:00
AidarKC 95bbd2852e Регистрация: пополнение в том же окне и тестовый top-up 2026-08-11 20:19:46 +04:00
AidarKC 4b0a934e51 Ограничил access servers до двух в UI и сервере 2026-08-11 17:21:07 +04:00
AidarKC ee5d86003b UI: уменьшение splash logo и правки diary preview 2026-08-11 17:03:46 +04:00
AidarKC bc11b60147 UI: правки клавиатуры и дневника 2026-08-11 16:26:31 +04:00
AidarKC 801a4697ea UI: общий верхний и нижний тулбар 2026-08-11 15:22:10 +04:00
AidarKC 628a970e6f Доработать UI звонков и нижний тулбар 2026-08-11 13:29:29 +04:00
AidarKC 566ea942dc Починить включение видео у принимающей стороны 2026-08-11 12:49:35 +04:00
AidarKC e047c2d54e Подготовить обычный звонок к позднему апгрейду в видео 2026-08-11 12:43:17 +04:00
AidarKC 3fdd3d22e2 Переименовать режимы видеозвонков в меню чата 2026-08-10 18:25:10 +04:00
AidarKC 08e0fa9206 Вернуть стабильный flow видеозвонка 2026-08-10 18:20:35 +04:00
AidarKC ed9e6ce676 Исправить отображение версий в дневнике и тредах 2026-08-10 17:55:27 +04:00
AidarKC a7bf07130e Добавить режимы аудио и видеозвонка в чат 2026-08-10 15:22:43 +04:00
AidarKC 7c31662698 Зафиксировать текущую схему видеозвонка 2026-08-10 15:16:57 +04:00
AidarKC 15df06a0bc Оптимизировать видеозаглушку звонка 2026-08-10 15:02:35 +04:00
AidarKC 47b5f8db7b Починить двустороннее видео в звонках 2026-08-10 15:00:44 +04:00
AidarKC 0e763987aa Добавить базовую поддержку видеозвонка 2026-08-10 13:53:04 +04:00
AidarKC 37e8ea27d0 Доработать оглавление канала и отображение дневника 2026-08-10 03:05:23 +04:00
AidarKC 67d63256c2 Добавить дневник действий и статусы материалов 2026-08-10 01:34:17 +04:00
AidarKC ee185cf20a Добавить рейтинг и типы материалов в каналах 2026-08-10 01:16:10 +04:00
AidarKC 3552e05e4c UI: обновить экран канала и треда 2026-08-09 21:26:08 +04:00
AidarKC 735bb55b78 Убрать отправку DM по Enter 2026-08-09 21:02:44 +04:00
AidarKC 60f8a50a97 Подправить иконку звонка в чате 2026-08-09 20:57:04 +04:00
AidarKC ffd01814b0 Уточнить уведомления и карточку звонка 2026-08-09 20:54:52 +04:00
AidarKC c511910b8f Перевести звонки на SendSignal с client key 2026-08-09 20:09:54 +04:00
AidarKC 5c38f8f0d8 Deploy: обновить backup-схему и игнор архивов 2026-08-09 19:56:25 +04:00
AidarKC d512442325 Сервер: добавить новые TEXT типы и STATUS_ACTION 2026-08-09 18:59:59 +04:00
aidar 43f54c90d2 Улучшить окно создания канала и окно загрузки в Arweave
Улучшенно окно создания канала и окно загрузки в Arweave/Turbo, исправленны мелкие баги UI и маршрутов SHiNE.
2026-08-07 11:16:44 +00:00
AidarKC 57a99b478a UI: улучшить создание канала и окно загрузки 2026-08-07 15:15:56 +04:00
AidarKC 3f402fbde7 UI: обновить ссылки SHiNE и форму канала 2026-08-07 14:38:27 +04:00
AidarKC 98140c1f71 UI: создание канала и ошибки Solana 2026-08-07 14:20:18 +04:00
aidar f00de85e38 Merge pull request 'Обновить запуск UI и упростить регистрацию' (#1) from milana-ui-redesign into main
добавлен стартовый экран UI до первого успешного подключения;
упрощена форма регистрации и удалён временный режим промокодов;
обновлены FAQ и документация переноса экранов в Figma;
версия клиента увеличена до 1.5.1.
2026-08-07 09:45:46 +00:00
malvviiina b686b12c6a Обновить запуск UI и упростить регистрацию 2026-08-06 22:53:44 +03:00
AidarKC e4b903b13c вкладки в каналах 2026-08-06 22:45:15 +04:00
AidarKC 72f576a70e Версия UI 1.5.0 для merge в main 2026-08-06 22:44:11 +04:00
AidarKC c4f2c7be63 UI вложений и черновики форматов каналов 2026-08-06 12:39:33 +04:00
AidarKC 75b75fd223 Добавить Turbo-загрузку и превью для вложений 2026-08-04 19:09:54 +04:00
AidarKC 391b18a5ac Deploy: задать production VAPID keys для UI 2026-08-03 08:34:34 +04:00
AidarKC 8de0f8e87c UI: писать SHiNE в ссылках каналов 2026-08-01 10:12:11 +04:00
AidarKC adb0b76559 UI: запоминать Arweave-кошелёк загрузчика 2026-08-01 09:59:25 +04:00
AidarKC b6df25c02c UI: скрыть неактивные стрелки вложений 2026-08-01 09:47:09 +04:00
AidarKC 620186db1c Документация: уточнить AddBlock для вложений и профиля каналов 2026-08-01 09:43:51 +04:00
AidarKC 21fac8046e UI: сделать плитки каналов почти во всю ширину 2026-08-01 02:59:15 +04:00
AidarKC de7c35f587 UI: расширить плитки каналов 2026-08-01 02:52:25 +04:00
AidarKC a23c979045 UI: обрезать аватары каналов кругом 2026-08-01 02:42:11 +04:00
AidarKC 13da7287b1 UI: скрыть аватары из истории вложений 2026-08-01 02:40:08 +04:00
AidarKC e8398df9fc Каналы: исправить редактирование профиля 2026-08-01 02:29:11 +04:00
AidarKC 8303682bd7 Каналы: создавать профиль одним блоком 2026-08-01 02:19:29 +04:00
AidarKC 8125d1a124 Каналы: исправить загрузку meta-событий 2026-08-01 02:04:48 +04:00
AidarKC 110ff5e12e Каналы: добавить профиль через TEXT_CHANNEL_META 2026-08-01 01:57:41 +04:00
AidarKC 24afcba20a UI: скрыть действия у удалённых сообщений 2026-08-01 01:02:49 +04:00
AidarKC d5f41d12e8 UI: добавить просмотр данных блокчейна сообщения 2026-07-31 17:41:30 +04:00
AidarKC d640dd66e2 UI: улучшить превью и файлы вложений 2026-07-31 17:22:48 +04:00
AidarKC 05bd9f57d6 UI: закрепить панель журнала загрузок 2026-07-31 17:14:01 +04:00
AidarKC 60ab476ec7 UI: сделать плитки загрузок тоньше 2026-07-31 17:07:33 +04:00
AidarKC bcae285ab3 UI: поправить экран загрузки файлов 2026-07-31 16:59:04 +04:00
AidarKC cd67176b41 UI: уплотнить журнал загрузок Arweave 2026-07-31 13:50:17 +04:00
AidarKC 98eba54621 UI: добавить журнал загрузок Arweave 2026-07-31 13:13:58 +04:00
AidarKC c530627078 UI: добавить карусель вложений 2026-07-31 12:49:24 +04:00
AidarKC f961947e1e Документация и UI: уточнить формат вложений 2026-07-30 11:14:19 +04:00
AidarKC e9e6628b21 UI: добавить вложения Arweave в каналы 2026-07-30 11:04:50 +04:00
AidarKC c7684d6fe1 Merge migration-postgres into main 2026-07-30 09:48:13 +04:00
AidarKC 7d475ab403 Merge remote-tracking branch 'git2/main' into migration-postgres
# Conflicts:
#	VERSION.properties
#	deploy/scripts/production_server2_ui.sh
2026-07-30 09:47:28 +04:00
AidarKC 143adcbbe4 Добавить догоняющую синхронизацию DM между access-серверами 2026-07-29 13:59:40 +04:00
AidarKC ca8b6a33ba UI: показать пополнение при нехватке rent 2026-07-29 12:51:11 +04:00
AidarKC 2f73cdb0f9 UI: реальная смена серверов доступа 2026-07-29 12:08:02 +04:00
AidarKC d1e06d9ef1 UI: исправить модалку серверов доступа 2026-07-28 16:40:22 +04:00
AidarKC 0ed78e2202 UI и API: экран серверов доступа и фильтр SearchUsers 2026-07-28 16:20:29 +04:00
AidarKC 64818c586b Сервер: локальный роутинг и relay DM по access servers 2026-07-28 15:53:24 +04:00
AidarKC 578f5cad4a Перевести deploy и PostgreSQL схемы на актуальное состояние 2026-07-28 15:32:43 +04:00
AidarKC d2ff277b16 Деплой: добавить VAPID override для server2 2026-07-27 20:29:39 +04:00
AidarKC df4f9b3f8e Деплой: убрать root-owned app.log из systemd 2026-07-27 19:57:27 +04:00
AidarKC be7f53a9ae Сервер: убрать мертвый AddUser хвост 2026-07-27 19:42:17 +04:00
AidarKC 6ed91a105e Сервер: дочистить legacy-слои и доки 2026-07-27 19:29:10 +04:00
AidarKC 0db3c3af5a Сервер: переименовать слой current users 2026-07-27 19:16:04 +04:00
AidarKC 23748504e6 Сервер: удалить manual import и AddUser слой 2026-07-27 19:06:08 +04:00
AidarKC f74fecddd8 Сервер: читать пользователей напрямую из PDA current 2026-07-27 18:04:39 +04:00
AidarKC 618a30c2ab Сервер: дочистить PostgreSQL runtime и документацию 2026-07-27 18:01:15 +04:00
AidarKC b5116474c7 Сервер: разрешить replay старых числовых каналов 2026-07-26 16:57:04 +04:00
AidarKC 91e7239866 Сервер: удалить SQLite runtime и оставить только PostgreSQL 2026-07-25 22:09:22 +04:00
AidarKC 1dddb5fb3c Сервер: уточнить правила имён каналов и поиск треда 2026-07-25 21:16:15 +04:00
AidarKC 8ddf592ffc Сервер: починить поиск сообщения в GetMessageThread 2026-07-25 20:31:06 +04:00
AidarKC 59a1117ed3 Сервер: починить обновление server.version в jar 2026-07-25 03:33:18 +04:00
AidarKC 3bb6fa1e59 Сервер: отвязать PostgreSQL DM runtime от signed_messages_v2 2026-07-25 03:29:58 +04:00
AidarKC 5af98ac911 Сервер: починить PostgreSQL schema v1 для пустой Solana sync базы 2026-07-24 22:30:36 +04:00
AidarKC f197196c21 Сервер: успокоить AddBlock sync при уже догнанном партнере 2026-07-24 22:26:59 +04:00
AidarKC ce5595bc16 Сервер: починить восстановление chain-state из PostgreSQL sync-таблиц 2026-07-24 22:22:37 +04:00
AidarKC 1406111f22 Сервер: исправить PostgreSQL schema v1 для автозапуска 2026-07-24 21:31:20 +04:00
AidarKC bfdada6792 Сервер: исправить автоинициализацию PostgreSQL schema v1 2026-07-24 21:24:06 +04:00
AidarKC 5326ad85d8 Сервер: начать перенос runtime БД на PostgreSQL 2026-07-24 21:20:36 +04:00
AidarKC f9dd245481 Deploy: расширить relay-range coturn 2026-07-24 19:43:52 +04:00
AidarKC 20677a9090 UI: исправить hidden для кнопок звонка 2026-07-24 19:34:24 +04:00
AidarKC d54cb7507a UI: разделить входящий и активный звонок 2026-07-24 19:28:50 +04:00
AidarKC d6b313b582 UI: входящий звонок без лишних кнопок 2026-07-24 19:18:42 +04:00
AidarKC f31cdb413b UI: стадии и звук звонка 2026-07-24 19:12:18 +04:00
AidarKC 69faf55b2b UI: иконки управления звонком 2026-07-24 15:02:56 +04:00
AidarKC 4d9e42205f UI: сворачивание активного звонка 2026-07-24 15:00:01 +04:00
AidarKC 5df69d73c7 Встроить синхронизацию Solana users в сервер 2026-07-23 23:49:39 +04:00
AidarKC 4704f8485b UI: понятная ошибка при нехватке SOL для пополнения лимита 2026-07-23 22:49:16 +04:00
AidarKC 809c216a5c Deploy: обновить production Solana RPC 2026-07-22 20:12:36 +04:00
AidarKC 415a40e436 Версии: поднять main до 1.3.0 2026-07-22 20:12:06 +04:00
AidarKC d233f1d73f Merge branch 'регитсрация' into main 2026-07-22 20:11:30 +04:00
AidarKC 9a7019a2ce Документация: вынести правила коммитов и версий 2026-07-22 20:10:20 +04:00
AidarKC 07b1623c5e UI: уточнить шаг пополнения регистрации 2026-07-22 19:28:46 +04:00
AidarKC 681437acb1 UI: убрать голосовой ввод и связанные настройки 2026-07-22 19:18:56 +04:00
AidarKC a183a586cb UI: индикатор загрузки истории чата 2026-07-22 19:07:15 +04:00
AidarKC af62b51ef5 UI: правки личного чата и настроек 2026-07-22 18:59:09 +04:00
AidarKC f640451257 Доработать профильные кнопки и навигацию 2026-07-22 15:20:05 +04:00
AidarKC a56252c361 Finalize DM docs and queue routing 2026-07-22 15:13:13 +04:00
AidarKC 38141ea5c7 Refresh profile header action icons 2026-07-22 15:12:48 +04:00
AidarKC d24d1c178b Shorten public UI routes 2026-07-22 14:12:10 +04:00
AidarKC 93673e5786 Improve direct message history and SQLite resilience 2026-07-22 13:09:42 +04:00
AidarKC 2f3b1571e5 Упростить просмотр билетов второй и третьей очереди 2026-07-20 22:13:19 +04:00
AidarKC 65703f0fc4 Вернуть прежний логотип стартового экрана 2026-07-20 21:34:44 +04:00
AidarKC 4e674af4ed Убрать артефакты анимации логотипа 2026-07-20 21:23:15 +04:00
AidarKC f9c2d7c63a Оптимизировать логотип стартового экрана 2026-07-20 21:19:55 +04:00
AidarKC 968d85d738 Показывать очередь билетов mainnet на тестовом UI 2026-07-20 21:04:11 +04:00
AidarKC d230b61c0b Сделать backup production устойчивым к отсутствующим файлам 2026-07-20 20:00:29 +04:00
AidarKC 59047a8d0e Вынести продуктовый репозиторий из локальной обвязки агента 2026-07-20 18:49:28 +04:00
AidarKC 81493ac4be Очистить репозиторий перед переносом продукта 2026-07-20 18:46:56 +04:00
AidarKC cf33f9a3bd Merge remote-tracking branch 'origin/main'
# Conflicts:
#	VERSION.properties
2026-07-20 17:30:31 +04:00
AidarKC d2f65b169c Навести порядок в deploy и документации проекта 2026-07-20 17:28:35 +04:00
AidarKC fa1f7358b7 Смержить production-структуру документации
# Conflicts:
#	VERSION.properties
#	shine-UI/js/pages/registration-payment-view.js
#	shine-UI/styles/components.css
2026-07-20 11:30:00 +04:00
AidarKC 8208bb0b9d Привести документацию и TODO к production-структуре 2026-07-20 11:29:03 +04:00
AidarKC daa516babe Регистрация: ожидание подтверждения Solana 2026-07-17 18:31:05 +04:00
PixelandClaude Opus 4.8 6c2a9836eb chore: вернуть server.version=1.2.298 (ошибочно откатил при мерже) + client 1.2.330
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 19:49:55 +03:00
PixelandClaude Opus 4.8 bd3e6a56ca Merge origin/main (финал регистрации для мобильного UI)
Единственный конфликт — VERSION (взято продолжение нумерации: client 1.2.329).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 19:49:22 +03:00
PixelandClaude Opus 4.8 ea2ee1711c Эмодзи/мобильный чат: убрать загрузку с GitHub, клавиатура, тихий Script error
- Пикер: анимированные webp грузились с raw.githubusercontent.com (в репо их
  нет) — у пользователей с VPN/операторскими блокировками тап по эмодзи давал
  сетевую ошибку и «моргание»/подмену картинки. Теперь всегда локальные
  статичные превью, без внешних запросов и pointer capture; мёртвая константа
  GitHub-URL удалена. Возврат анимаций — только локальным паком.
- Чат (тач): открытие эмодзи-пикера прячет клавиатуру (blur), вставка эмодзи
  не фокусирует textarea на сенсорных устройствах — поле больше не
  перекрывается клавиатурой (на десктопе фокус как раньше).
- Глобальный обработчик ошибок: кросс-ориджин «Script error.» без файла/стека
  больше не показывается алертом (лог и captureClientError остаются) —
  устраняет пугающую плашку на Xiaomi/Safari.
- VERSION: client 1.2.327.

Проверено в превью: тап по эмодзи — 0 запросов к githubusercontent, 😈
вставляется ровно как 😈, картинка кнопки стабильна; фильтр алерта: Script
error. — тихо, реальная ошибка — алерт показывается.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 19:48:22 +03:00
AidarKC 2890cdd457 Добавить экран проверки public Solana RPC 2026-07-16 20:33:48 +04:00
AidarKC e4dfb43b5e Переименовать server2 deploy-скрипт 2026-07-16 19:19:59 +04:00
AidarKC febdbc059e Доработать финал регистрации для мобильного UI 2026-07-16 19:03:54 +04:00
AidarKC 3926d561c0 Доработать финал регистрации для мобильного UI 2026-07-16 19:03:04 +04:00
PixelandClaude Opus 4.8 d6bc883520 Merge origin/main (+5: финал регистрации, автовход, порядок в deploy)
- register-view: принят вариант агента для видимости пароля — поле видно
  всегда, слова «12 слов» автоматически склеиваются в поле (проверено:
  оба режима, значение сохраняется при переключении).
- Остальное авто-слито (deploy-схема server2, AGENTS, entry-settings, state).
- VERSION: client 1.2.326, server 1.2.297.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 16:02:51 +03:00
PixelandClaude Opus 4.8 b2d20671cd Ассеты: сжать иконки бара и стекло орбов (2.4 МБ → 0.4 МБ)
Иконки нижнего бара рендерятся 27-44px, а весили 174 КБ - 1 МБ каждая
(icon_svyazi 1210x1210, 1 МБ) — из-за этого при первом заходе бар долго
оставался пустым. Ресайз до 256px (glass_overlay до 512px) + оптимизация
PNG, альфа сохранена: -84% веса, визуально на этих размерах без изменений.
VERSION: client 1.2.317.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 14:58:29 +03:00
PixelandClaude Opus 4.8 a72e2e7014 Регистрация: вернуть поле ввода пароля (инверсия видимости passwordInputRow)
Условие показа строки ввода пароля было перевёрнуто (баг из доработки
регистрации в main): поле скрывалось в обычном режиме и «показывалось»
в режиме 12 слов внутри скрытого родителя — т.е. не было видно никогда.
Проверено: обычный режим — поле видно, режим 12 слов — сетка слов.
VERSION: client 1.2.316.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 14:42:17 +03:00
PixelandClaude Opus 4.8 cf6ca1d96b UI: вернуть наш компактный вид регистрации, Enter-отправку и шапку «Связей»
По итогам сверки после мержа с origin/main:
- Регистрация: убрана кнопка «Проверить логин» (работает автопроверка через
  1.5 с после ввода + повторная проверка на «Далее»), убрана кнопка «Назад»
  (есть ← в шапке), убран блок «Первый сервер SHiNE»; компактные классы
  registration-screen/registration-form, статус логина скрыт пока пуст.
  Промокоды, «глаз» пароля и заглушка коротких логинов origin — не тронуты.
  Осиротевший импорт defaultServerHttp удалён.
- Чат: Enter снова отправляет сообщение, Ctrl+Enter — перенос строки
  (голосовой ввод и кнопка ➤ работают как раньше).
- Шапка «Связей»: возвращён наш вариант (absolute, width:auto).
- VERSION: client 1.2.315.

Проверено в превью: регистрация — автопроверка вернула «Логин свободен »
без кнопки, FAQ-ссылка работает; чат — Enter отправляет (поле очищается),
Ctrl+Enter вставляет перенос; консоль чистая.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 13:05:33 +03:00
PixelandClaude Opus 4.8 9d1e49949a Merge origin/main (+56: DM v1 E2EE, промокоды, recovery key) + локальные фичи
Слито и разрешено вручную:
- chat-view.js: база — новый DM-чат агента (ответы/реплаи, ревизии, клавиатурный
  UX, голосовой ввод); поверх встроен эмодзи-пикер (Telegram-набор): кнопка ☺,
  вставка в позицию курсора, анимированные эмодзи в пузырях (renderMessageText
  поверх displayText), остановка анимаций в cleanup.
- register-view.js: база — версия агента (промокоды, глаз пароля, TEMP-заглушка
  коротких логинов); возвращена компактная ссылка «Вопросы о регистрации»
  (FAQ-экран у агента осиротел — снова достижим).
- components.css: шапка «Связей» — вариант агента (sticky + grid minmax,
  заголовок сокращается, кнопки не обрезаются); стили эмодзи-пикера сохранены.
- Заметки Pending_Features (шапка связей, поиск каналов, локальный вход)
  переложены в новую структуру docs/Pending_Features.
- VERSION: client 1.2.314, server 1.2.292 (продолжение нумерации origin).

Проверено локально: старт → «Открыть локальный тестовый режим» → Личные →
чат: эмодзи-пикер открывается, эмодзи вставляется, PNG-ассеты грузятся;
регистрация: промокод/12 слов/FAQ-ссылка работают; консоль чистая.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:41:41 +03:00
PixelandClaude Opus 4.8 ea3b34e11e WIP: эмодзи-пикер — чекпоинт 2 (доводка перед мержем с origin)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:21:00 +03:00
AidarKC 09dc55db0e Смерджить ветку регитсрация в main 2026-07-16 12:16:10 +04:00
AidarKC 85133db483 Удалить проверенные pending-фичи 2026-07-16 12:15:22 +04:00
AidarKC 6fc7d75e17 Улучшить финал регистрации и автовход 2026-07-16 12:08:59 +04:00
AidarKC be321813fa Убрать test.shineup.me из deploy-схемы 2026-07-16 11:54:21 +04:00
AidarKC d2c0ecdf62 Исправить регистрацию и навести порядок в UI deploy 2026-07-16 11:46:42 +04:00
PixelandClaude Opus 4.8 266a74ef79 WIP: эмодзи-пикер (Telegram-набор) — чекпоинт незавершённой работы
- Эмодзи-пикер: js/components/emoji-picker.js + каталог telegram-emoji-*.js
  (smileys/people/animals_nature/food_drink/travel_places/activity/objects/
  symbols/flags) + 631 ассет в assets/emoji (7.2 МБ).
- Интеграция пикера: chat-view.js, app.js, index.html, styles/components.css.
- Дев-скрипты генерации превью + THIRD_PARTY_NOTICES.
- Работа НЕ завершена (WIP), сохранено как чекпоинт. С origin (+56) пока НЕ
  мержится — большой мерж будет после завершения, отдельным шагом.
- VERSION: client 1.2.263.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:28:45 +03:00
AidarKC 18d27c0146 Слить feature/pda-recovery-key в main 2026-07-15 19:05:13 +04:00
AidarKC 24cca1f1c6 UI: доработка регистрации и документации 2026-07-15 16:16:24 +04:00
PixelandClaude Opus 4.8 8c4997ee2e UI: адаптация шапки «Связи», кнопка поиска каналов, локальный тестовый вход
- Связи: верхняя панель не выходит за границы экрана — кнопки назад/Найти/«?»
  не обрезаются на узких устройствах; заголовок сокращается вместо сдвига кнопок.
- Каналы: emoji-иконка поиска заменена на контурную кнопку-лупу в стиле панели
  (подсказка «Найти канал»); окно поиска без изменений.
- Локальный тестовый вход: на localhost/127.0.0.1/::1 — кнопка демо-входа
  (сеанс local-tester, автономные локальные состояния ЛС/каналов/профиля, без
  сервера и пароля); на shineup.me и тестовых доменах кнопка отсутствует.
- Заметки на ручную проверку добавлены в Dev_Docs/Pending_Features.
- VERSION: client 1.2.262.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:40:16 +03:00
AidarKC 118be418b5 Обновить диагностику звонков и документацию деплоя 2026-07-14 20:07:31 +04:00
AidarKCandClaude Fable 5 2a4c2c64c9 ИТХ: вход в график закрытий по доверию, а не по депозиту
- Из TODO убран депозитный/платный вход — сознательно отвергнут.
- Зафиксирована модель: DAO ведёт список как временный уровень,
  пока не сформировано сообщество сияющих; целевое состояние —
  приём/исключение серверов через голосование сияющих.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 19:57:12 +04:00
AidarKCandClaude Fable 5 172f007e1e Дополнить дизайн ИТХ: форки, списки доверия, децентрализация
- Раздел 6: правило признания — правило чтения (дефолт rota PDA,
  наблюдатель может считать по своему списку доверия); параллельные
  группы закрытий разрешены by design.
- Новый раздел 10 «Форки пользовательских цепочек»: fork-choice
  дежурного (длиннейшая ветка + tie-break), зачекпоинченное побеждает,
  заморозка chain_frozen только при противоречии архивов двух групп,
  рестарт с login-002, байт-в-байт повтор для клиентов.
- Шаг 9.9 проверки: конфликт -> принять версию чекпоинта (один
  пользователь больше не срывает закрытие дня).
- TODO: хэш-выбор дежурного, вход по депозиту, механизм цепочки -002.
- CHANGELOG блокчейна дополнен; формат блоков и AddBlock не менялись.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 18:50:41 +04:00
AidarKC 4cb98f7ca0 Обновить UI чатов и связей и добавить документацию ИТХ 2026-07-14 13:39:01 +04:00
AidarKC af12d7b954 Перенести документацию в docs и добавить социальный граф 2026-07-13 12:58:41 +04:00
AidarKC c9cfb394d7 Добавить ответы в DM и обновить UI чатов 2026-07-10 19:27:49 +04:00
AidarKC 0d2d7b6dc5 Добавить SHiNE теги в DM и безопасный рендер превью 2026-07-10 18:57:44 +04:00
AidarKC 076b43e542 Сделать DM body opaque для сервера и улучшить fallback UI 2026-07-10 18:12:17 +04:00
AidarKC cc1f78106d Обновить backup-снимок production сервера 2026-07-10 12:32:17 +04:00
AidarKC 5a5e9c01ce Обновить UI чата и закрыть проверенные DM задачи 2026-07-10 12:26:36 +04:00
AidarKC 0fb1147eb7 Зафиксировать накопленные изменения по deploy и devnet 2026-07-10 11:57:33 +04:00
AidarKC 2b23aa7b95 Исправить UI статусы DM 2026-07-10 11:57:00 +04:00
AidarKC bb92cb6b2b Исправить восстановление DM после перелогина 2026-07-10 11:47:39 +04:00
AidarKC 0730837ad7 Изолировать страницу DEVNET пополнения от mainnet 2026-07-08 20:28:29 +04:00
AidarKC 98a2631590 Довести DM v1 до соответствия коду и документации 2026-07-08 17:28:39 +04:00
AidarKC faada9dcdb Добавить временную миграцию v11 для очистки старых DM 2026-07-07 20:26:16 +04:00
AidarKC 1b7a2a8f7c Реализовать SHiNE_DM v1 с E2EE и tombstone 2026-07-07 18:07:10 +04:00
AidarKC 9c588bd9b5 Документировать новый DM-протокол и формат (в коде пока не реализовано) 2026-07-07 01:19:04 +04:00
AidarKC 31563fe9ed Добавить исторические файлы создания реального DAO 2026-07-07 01:18:16 +04:00
AidarKC 81bef1e1cc Синхронизировать изменения проекта 2026-07-05 18:18:03 +04:00
AidarKC 0240db59ee Подготовка перед деплоем в mainnet 2026-07-01 12:23:30 +04:00
AidarKC ab4dab34aa Исправить Ed25519 promo-подпись в UI 2026-06-30 18:54:39 +04:00
AidarKC 63f13a4c29 Сократить promo-аргументы регистрации в Solana 2026-06-30 18:40:33 +04:00
AidarKC 0a416a2f5c Добавить promo-регистрацию и оффлайн генератор промокодов 2026-06-30 17:39:40 +04:00
AidarKC b0887648f7 Обновить документацию и словари 2026-06-30 11:39:31 +04:00
AidarKC 5720f1cb50 Переименовать экран предоплаченного места 2026-06-29 08:45:30 +04:00
AidarKC 9324da5cb7 Разделить покупку билета на два экрана 2026-06-28 15:11:05 +04:00
AidarKC 408b0eeb39 Закрыть проверку remote AddBlock через homeserver 2026-06-28 14:49:57 +04:00
AidarKC ed83b1f906 Переделать remote AddBlock на сборку блока на homeserver 2026-06-28 14:45:29 +04:00
AidarKC 3068c3e2b8 Добавить раздел поддержки проекта Сияние 2026-06-28 13:53:12 +04:00
AidarKC 93c6f247f7 Исправить ответный SendSignal на ESP32 2026-06-28 12:29:01 +04:00
AidarKC 05a9441493 Исправить подпись SendSignal в UI 2026-06-28 12:09:13 +04:00
AidarKC aa02e92e4d Добавить SendSignal и remote AddBlock через homeserver 2026-06-28 11:20:51 +04:00
AidarKC c397c28acb Удалить старый мусор из документации 2026-06-28 10:29:27 +04:00
AidarKC c93cc6c522 Перенести backlog в TODO 2026-06-28 09:30:59 +04:00
AidarKC 0cdcc77606 Добавить TODO с планами на будущее 2026-06-26 17:55:05 +04:00
AidarKC 87eec7e5c9 Зафиксировать успешную синхронизацию на тестовом сервере 2026-06-26 17:46:34 +04:00
AidarKC 44a1ba01f3 Починить восстановление blockchain_state при full resync 2026-06-26 17:30:10 +04:00
AidarKC d49661fa29 Исправить хэш коммита в changelog блокчейна 2026-06-26 17:06:05 +04:00
AidarKC 71fdee0cfd Вернуть crash-safe запись AddBlock через tmp_bch 2026-06-26 17:05:37 +04:00
AidarKC 1ced351ea2 Закрыть проверенные pending-фичи 2026-06-26 16:56:57 +04:00
AidarKC c048347f2e Добавить startup recovery для resync цепочек 2026-06-26 15:51:52 +04:00
AidarKC be4f76834a Добавить resync блокчейна при рассинхроне 2026-06-26 15:25:11 +04:00
AidarKC 23edad416c Добавить sync-профиль пользователя и обход Solana RPC 2026-06-25 18:46:47 +04:00
AidarKC f3e4233285 Исправить загрузку внешнего application.properties 2026-06-25 18:04:04 +04:00
AidarKC 84e0f039cb Добавить периодический sync блокчейнов каждые 12 часов 2026-06-25 17:58:07 +04:00
AidarKC 1f8b20a7d1 Добавить ListBlockchainHeads для межсерверной сверки 2026-06-25 17:52:04 +04:00
AidarKC f0e1ab3af8 Перевести тестовый контур на t.shineup.me 2026-06-25 13:44:22 +04:00
AidarKC 112ab4d5d5 Улучшить звуки входящих личных сообщений 2026-06-25 12:55:42 +04:00
AidarKC 8768e142e3 Исправить мобильный ввод в личном чате 2026-06-25 11:20:30 +04:00
AidarKC 827d2e9c3e Улучшить открытие личного чата 2026-06-25 10:55:17 +04:00
AidarKC 0f3c4a621d Улучшить UX личного чата на мобильных 2026-06-25 10:46:45 +04:00
AidarKC e60475f351 Обновить синхронизацию серверов и экран сохранения ключей 2026-06-24 20:18:40 +04:00
AidarKC 0f63f7dae6 Добавить документацию по Figma 2026-06-24 16:30:16 +04:00
AidarKC 127c561a41 Слить обновления UI каналов и кошелька 2026-06-24 15:00:02 +04:00
AidarKC f9a15ab192 Обновить список каналов и кнопку сообщения 2026-06-24 14:59:08 +04:00
AidarKC 77f5759d60 Обновить UI кошелька и регистрацию 2026-06-24 13:48:07 +04:00
AidarKC 684f3237cf Исправить подключение и подпись в браузерном кошельке 2026-06-24 10:28:54 +04:00
AidarKC 23e61cc182 ESP32 регистрация и архив тестового удаления PDA 2026-06-23 20:31:05 +04:00
AidarKC d2f45ff67a ESP32: приостановить reconnect на экранах регистрации 2026-06-23 19:19:18 +04:00
AidarKC 06e12e9103 ESP32: убрать ранние checkpoint при add homeserver 2026-06-23 19:12:58 +04:00
AidarKC 29dddeff4f ESP32: убрать временный автозапуск homeserver 2026-06-23 19:03:32 +04:00
AidarKC 017d568aea ESP32: автозапуск Add Homeserver для отладки 2026-06-23 18:56:33 +04:00
AidarKC c91b52cfd2 ESP32: checkpoint перед чтением PDA homeserver 2026-06-23 18:46:41 +04:00
AidarKC 2bd38d8d78 ESP32: диагностический checkpoint для update homeserver 2026-06-23 18:39:58 +04:00
AidarKC 7d9db68d80 ESP32: NTP для update user_pda 2026-06-23 18:32:32 +04:00
AidarKC 4b94303d67 ESP32: server login и NTP для регистрации 2026-06-23 18:11:11 +04:00
AidarKC 08628704c7 UI: добавить просмотр любого PDA 2026-06-23 17:05:57 +04:00
AidarKC f1c1132690 AuthChallenge: поддержать RecoveryKeyBlock 2026-06-23 17:04:47 +04:00
AidarKC d2426c473c ESP32: убрать кэш генерации секрета 2026-06-23 14:42:02 +04:00
AidarKC 66986b804c Вынести дефолтный сервер в конфиг UI и оживить счетчик пароля 2026-06-23 14:11:09 +04:00
AidarKC 95daa230bb Обновить серверный UI под recovery key 2026-06-23 13:44:18 +04:00
AidarKC 365b22d778 ESP32: кэш последних генераций секрета 2026-06-23 12:06:24 +04:00
AidarKC cf2b54464e Исправить update user_pda для homeserver на ESP32 2026-06-23 11:42:44 +04:00
AidarKC 4e60c1274a ESP32: ускорить и упростить secret screen 2026-06-23 11:08:19 +04:00
AidarKC 2f65e63fbe ESP32: новая derivation ключей 2026-06-23 10:51:03 +04:00
AidarKC b461431197 Смена адреса shine_users и выкладка на test2 2026-06-23 10:40:52 +04:00
AidarKC 5c92b6a734 Миграция PDA на client.key 2026-06-22 21:57:09 +04:00
AidarKC ba348dafb3 Add Wallet Standard registration for browser wallet 2026-06-22 01:35:26 +04:00
AidarKC ce2d310e8c ESP32 wallet RPC, browser wallet provider, and side panel 2026-06-22 01:30:08 +04:00
AidarKC 475db28095 main: это именно та версия, которая уже стоит на тестовом продакшен сервере
Да, звучит немного удивительно, но именно так: люди уже могут регистрироваться, писать друг другу личные сообщения, звонить и пользоваться каналами.

При этом регистрация пока идёт через тестовые Solana/devnet, так что это всё ещё этап тестирования. И в UI ещё куча всего не доделана.

Но тем не менее всё равно.

Прикольно.
2026-06-21 13:14:02 +04:00
AidarKC 823a41c027 chore idea vcs mappings cleanup 2026-06-21 13:10:51 +04:00
AidarKC 2a834f1b14 fix ui dm chatid lowercase normalization 2026-06-21 12:27:41 +04:00
AidarKC c8ffb6cf29 Настроить test2 как основной контур деплоя 2026-06-20 23:34:41 +04:00
AidarKC ecc9efd434 UI: скрыть пароль при режиме 12 слов 2026-06-20 21:33:44 +04:00
AidarKC dd35e56029 Добавить временную бесплатную загрузку аватаров в Arweave 2026-06-20 21:29:35 +04:00
AidarKC d0e7998650 UI: обновить экраны входа 2026-06-20 20:15:40 +04:00
AidarKC fec5e49304 UI: FAQ регистрации и режим пароля из 12 слов 2026-06-20 19:05:45 +04:00
AidarKC 3b12e14e71 Docs: добавить идею homeserver команд и обмена файлами 2026-06-20 17:21:47 +04:00
AidarKC 86eaf2139d UI: улучшить личные сообщения и поиск контактов 2026-06-20 17:19:32 +04:00
AidarKC 65fad993ad UI: вернуть старую вкладку Личные и починить аватары в Связях 2026-06-20 16:43:53 +04:00
AidarKC 55e6e477be merge(main): объединить esp-and-wallet и UI Pixel 2026-06-20 12:17:11 +04:00
PixelandClaude Opus 4.8 ba5efcc152 Merge: UI «Связи» (финал) + редизайн «Личные» (чистый прод) в main
- Граф «Связи»: обновлён до финальной версии поверх PR #3 «pixel-связи»
  (орбы 12/13.06, единый PNG-оверлей орбов, мягкий край, вибрация выключена).
- «Личные»: редизайн списка как формы «Связей» — фото-аватары/инициалы,
  золотая галочка подтверждения у имени, значок-цепочка связи с попапом пути
  (Ты → посредники → цель) и переходом в профиль, граница карточки по типу
  связи, шапка «Shine». Данные пока мок-плейсхолдер (реальные relations/чаты —
  отдельная задача с бэкендом).
- Чистый прод: сняты обе демо-лаборатории (граф/ЛС), demo-чат, гость-обвязка
  ЛС и demo-avatars; экран «Личные» под логином.
- Сохранена работа агента в main (DM-ревизии/редактирование, wallet/pairing, esp32).
- VERSION: client 1.2.217 (server 1.2.204 без изменений).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 23:10:59 +03:00
PixelandClaude Opus 4.8 26253564d5 chore: bump client 1.2.169
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:31:10 +03:00
PixelandClaude Opus 4.8 92791c77a9 прод: убрать ЛС-лабу (demo-чат, гость-доступ, isDemo, demo-avatars); мок→плейсхолдер
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:31:09 +03:00
PixelandClaude Opus 4.8 465792b2ab прод: убрать граф-лабу (network/lab.js, selftest.js, граф-мок в mock-data)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:31:09 +03:00
PixelandClaude Opus 4.8 de269fd828 chore: bump client 1.2.168 + .gitignore (.claude, бэкап-ассеты)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:48:28 +03:00
PixelandClaude Opus 4.8 8c91484f37 nav: вкладка «Личные» (лейбл)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:48:27 +03:00
PixelandClaude Opus 4.8 6904ac8b7c ЛС: редизайн списка (фото-аватары, галочка/значок связи у имени, попап-цепочка→профиль) + demo-чат и lab-маршрут
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:48:27 +03:00
PixelandClaude Opus 4.8 aea6bbcb0e ЛС: токены связей + резолвер визуала + семантический мок (connectedVia/login)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:48:26 +03:00
AidarKC a788d8bcf5 Обновить pairing устройств и доработать ESP32 UI 2026-06-19 20:47:56 +04:00
AidarKC cc074a941f Исправить маршрутизацию call push по sessionId 2026-06-19 19:18:16 +04:00
AidarKC 47574100f9 Исправить самообрыв звонка и обновить TURN 2026-06-19 18:25:47 +04:00
PixelandClaude Opus 4.8 7ad74942e0 Связи: отключена вибрация в графе
haptic() сделан no-op — на экране «Связи» телефон не вибрирует ни на тапах по узлам,
ни на переходах (раскрытие/погружение/всплытие/пан). Вызовы haptic(...) оставлены, тело пустое.
Версия 1.2.167.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 00:03:32 +03:00
PixelandClaude Opus 4.8 ac1cc04637 nav: неоновые PNG-иконки вкладок бара + единый вид
- toolbar.js: data-driven иконки (iconImg/glow/hero); все 5 вкладок → <img> неон-PNG;
  «Связи» помечена hero.
- components.css: единый размер (--tab-icon-size 27px), «Связи» крупнее ВИЗУАЛЬНО через
  transform: scale (без сдвига раскладки — иконки на одной линии); active/tap-состояния;
  у «Связи» убран лишний drop-shadow-ореол (светится сама PNG); глобально
  -webkit-tap-highlight-color: transparent (нет синего tap-квадрата нигде).
- assets: icon_lichnye/kanaly/svyazi/uvedomleniya/profil.png. Иконка «Уведомления»
  приведена к прозрачному фону (была без альфы) и обрезана до ~92% заполнения, как у других.
Версия 1.2.166.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 00:03:04 +03:00
aidar 722d055e2d Merge esp32-2-by-tbilisi into main
Merge ветки esp32-2-by-tbilisi в main: TrustedDeviceLogin, редактирование и удаление личных сообщений, browser wallet extension, аудит Solana smart contracts, удаление устаревшего SHiNE-promo-solana-devnet и закрытых pending features.
2026-06-18 12:21:37 +00:00
AidarKC a9a55da8e0 Удалить закрытые записи pending features 2026-06-18 16:11:36 +04:00
AidarKC f2b23ace8b Удалить устаревший SHiNE-promo-solana-devnet 2026-06-18 15:57:55 +04:00
AidarKC 1f2048e270 Перестроить структуру codex-agent-VPS для VPS-пакета 2026-06-18 15:29:09 +04:00
AidarKC b16a23243e Добавить переносимый шаблон codex-agent-VPS 2026-06-18 15:18:07 +04:00
AidarKC 653f1268a6 Проверено: DM-ревизии подтверждены, pending убран 2026-06-18 14:34:37 +04:00
AidarKC 56db6d0add TrustedDeviceLogin API и настройки входа через устройство
Что сделано:\n- публичный API сценария входа через доверенное устройство переведён на TrustedDeviceLogin\n- добавлен GetTrustedDeviceLoginSettings\n- отсутствие записи настроек на сервере теперь трактуется как enabled=true и hasPassword=false\n- ttlSeconds убран из клиентского API, TTL заявки фиксирован на сервере: 300 секунд\n- в shine-UI добавлен отдельный экран настроек входа через устройство и статус на основном экране\n- browser wallet переведён на новые TrustedDeviceLogin операции\n- в wallet добавлен выбор rootKey/deviceKey для будущего запроса подписи\n- документация API обновлена\n\nЧто ещё не проверено вручную end-to-end:\n- полный сценарий UI/plugin после этого деплоя не прогонялся руками до конца\n- сам signaling подписи в wallet всё ещё не реализован
2026-06-18 14:19:31 +04:00
AidarKC cf2152dcfc НЕ ПРОВЕРЕНО: UI редактирования и удаления личных сообщений 2026-06-18 13:04:06 +04:00
AidarKC a95bd245cf НЕ ПРОВЕРЕНО: откат DM-вложений, оставлены ревизии и удаление 2026-06-18 12:24:14 +04:00
AidarKC 92fd315505 НЕ ПРОВЕРЕНО: DM-вложения, upload файлов и ревизии личных сообщений 2026-06-18 11:46:58 +04:00
AidarKC 2225c2d173 Wallet plugin: офлайн wallet-session и выбор homeserver\n\nСделано:\n- wallet plugin сохраняет PDA-профиль и остаётся офлайн до действия;\n- добавлен каркас выбора ключа подписи и homeserver-устройства;\n- добавлен ручной refresh trusted devices через ListSessions;\n- на регистрации показан первый сервер SHiNE и его адрес;\n- обновлены pending notes для ручной проверки.\n\nЕщё не проверено / не доделано:\n- end-to-end ручная проверка plugin после этих правок не завершена;\n- signaling запроса подписи и ответ подписи ещё не реализованы;\n- локальный browser plugin нужно отдельно reload в Chrome/Opera. 2026-06-18 11:04:34 +04:00
AidarKC f8a76bcd7f Автоопределение SHiNE-сервера по логину через PDA 2026-06-16 16:32:33 +04:00
AidarKC 3efa8bb7ee Wallet-session pairing и browser plugin wallet, оплаты пока не работают 2026-06-16 16:23:08 +04:00
AidarKC 5c155ef503 UI: нормальное закрытие сессий и сортировка устройств 2026-06-16 10:36:43 +04:00
AidarKC 41d199e24a Показывать ошибки pairing-пароля в отдельном окне 2026-06-15 15:31:19 +04:00
AidarKC e1f2b54de3 Сузить диалог изменения pairing-пароля 2026-06-15 15:23:19 +04:00
AidarKC d6c5757dfa Переделать UI дополнительного pairing-пароля 2026-06-15 13:35:05 +04:00
AidarKC 9a489801c5 Доработать UX и отмену pairing по коду 2026-06-15 13:13:16 +04:00
AidarKC 9fcdcd087b Убрать QR-заглушку и очищать код после reject 2026-06-15 02:37:26 +04:00
AidarKC af1304022e Исправить синтаксис экрана pairing 2026-06-15 02:30:17 +04:00
AidarKC 7972676eb8 Исправить pairing без пароля и убрать фантомные заявки 2026-06-15 02:21:21 +04:00
AidarKC bef205aec7 Разрешить pairing без доп пароля 2026-06-15 00:54:56 +04:00
AidarKC 49fdbbf7ae Исправить переход со старта на экран выбора входа 2026-06-14 21:33:43 +04:00
AidarKC dd69a52273 Форсировать обновление UI модулей входа 2026-06-14 21:13:22 +04:00
AidarKC c681b4d684 Добавить UI pairing по коду и обновить документацию агента 2026-06-14 20:39:05 +04:00
AidarKC b166013707 Бот: починить resume-вызов Codex CLI 2026-06-14 20:30:17 +04:00
AidarKC 3e04727022 Добавить ESP pairing через доверенные сессии 2026-06-14 18:21:23 +04:00
AidarKC 5d13112b00 ESP32: уменьшить рамку wallet QR 2026-06-14 11:22:11 +04:00
AidarKC 373f88086e ESP32: подправить вертикальный ритм wallet QR 2026-06-14 11:16:49 +04:00
AidarKC 05492306c0 ESP32: смягчить SHiNE reconnect при плохом сервере 2026-06-14 11:01:47 +04:00
AidarKC 423d490939 ESP32: доработать home экран и wallet QR 2026-06-14 10:50:31 +04:00
AidarKC 7edc0ba901 ESP32: зафиксировать LVGL qrcode конфиг 2026-06-14 10:28:42 +04:00
AidarKC 0ebb71daf1 ESP32: добавить реальный wallet QR через LVGL 2026-06-14 10:27:10 +04:00
PixelandClaude Opus 4.8 b4480d89cf css: убрать закомментированный блок кластерной ауры (финально)
Решение по ауре финальное — не возвращаем. Удалён закомментированный «ТЕСТ»-блок
box-shadow по категориям; оставлено явное box-shadow:none для сияющих/фокуса.
Версия 1.2.165.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:13:17 +03:00
PixelandClaude Opus 4.8 ff584ba5d1 orb: мягкий край фото + убран фон-градиент категории из-под стекла
- растушёвка края фото радиальной маской (--feather-full 62% / --feather-edge 78%):
  фото сливается со стеклом без жёсткого ободка; параметры для подкрутки.
- убран фон-градиент категории на .node-dot (просвечивал через прозрачный центр
  стекла → читался как цветная «обводка»): селектор поднят до (0,4,0), чтобы
  перебить правила категории. Цвет категории остаётся на линиях.
- кластерная аура (box-shadow по категории) отключена.
Не тронуто: кромка PNG, свечение сияющих/фокуса/common, линии.
Версия 1.2.164.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:55:09 +03:00
AidarKC 4b15cabd4f ESP32: добавить быстрый QR-экран кошелька 2026-06-13 23:20:35 +04:00
AidarKC be4a2d135a Проект: включить ESP32-wallet в основной репозиторий 2026-06-13 23:01:57 +04:00
PixelandClaude Opus 4.8 69f0fdf120 orb: единый PNG-оверлей на все узлы + ретайр вектора
- glass_overlay_faithful.png в assets; орбы = фото (низ, круглая маска 78%) +
  стеклянный PNG (верх, бокс 119% от node-dot, контакт линий от ORB_R).
- PNG-оверлей применён ко ВСЕМ полным орбам (центр + спутники); tier-3 точки без изменений.
- ретайр мёртвого векторного стекла: удалён buildGlassOrb (+orbSeq) и CSS .fg-orb-svg,
  снят остаточный border .node-dot (синее кольцо) у PNG-хостов.
Версия 1.2.163.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:21:56 +03:00
PixelandClaude Opus 4.8 e3bebff618 anim (13.06): ускорение разлёта узлов — BLOOM_MS 900→550
Дети «выстреливают» из центра почти вдвое быстрее; easing и каскад (stagger)
прежние. Убирает ощущение тяжести/«подтупливания» при смене центра.
Версия 1.2.162.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:36:46 +03:00
PixelandClaude Opus 4.8 f19f7b0ec4 Связи (13.06): орбы по референсу, линии по категории, постоянная вселенная
Орбы:
- материал «хрусталь»: чистое лицо (виньетка 0.5→0.22), диффузный блик окна
  вместо «капли» (+мягкий blur sf), стекло прозрачнее (тело 0.38→0.3),
  полупрозрачная преломляющая кромка (blur + opacity 0.25→0.2).
- размер +11.5% (node-dot 52→58px); единый ORB_R=29 как источник радиуса.
- убран значок * у общих узлов (логика is-common цела).

Линии:
- цвет по категории на ВСЕХ рёбрах; плазма только сияющим.
- общий узел наследует сияние исходного человека (не серый).
- контакт линий ровно на кромке сферы орба (ORB_R), без зазора, все уровни.

Навигация:
- констелляция (паутина 2-3 уровней) — постоянный режим; кнопка «Вселенная»
  убрана; Семья/Друзья/Сияющие остаются фильтрами. Чистка осиротевшего CSS.

Версия 1.2.161.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:31:46 +03:00
AidarKC ca4cfd9d8d UI: выровнять online-флаг с ответом ListSessions 2026-06-13 15:50:47 +04:00
AidarKC 96d292074b API: добавить online-флаг для ListSessions 2026-06-13 15:49:34 +04:00
AidarKC 0536a018c6 ESP32: починить JSON auth для homeserver sessionType 2026-06-13 15:22:19 +04:00
AidarKC 81d1b84a7d ESP32: отправлять homeserver sessionType в SHiNE auth 2026-06-13 15:08:53 +04:00
AidarKC 61c21b245e UI: явно показать тип сеанса в списке устройств 2026-06-13 15:05:41 +04:00
AidarKC 919387f581 API сессий: добавить sessionType и clientPlatform 2026-06-13 14:15:42 +04:00
AidarKC 3b8ea70d3c ESP32: добавить диагностику подключения SHiNE и починить WS handshake 2026-06-13 13:09:32 +04:00
AidarKC 477ab3b580 ESP32: починить добавление homeserver и вернуть автопрогон 2026-06-13 12:53:40 +04:00
AidarKC a1da814030 ESP32: добавить flow обновления homeserver в user PDA 2026-06-13 09:07:49 +04:00
AidarKC 19fd5611b2 ESP32: добавить автотест регистрации и исправить signed server profile 2026-06-13 08:41:25 +04:00
AidarKC 556004a557 ESP32: исправить off-curve проверку для user PDA 2026-06-13 08:20:12 +04:00
AidarKC fba6d6bba0 ESP32: исправить derivation user_pda для Solana 2026-06-13 07:36:45 +04:00
AidarKC 04252e006b ESP32: сохранять полный текст ошибки регистрации 2026-06-13 00:24:42 +04:00
AidarKC 436e1f0c53 ESP32: добавить USB-диагностику регистрации Solana 2026-06-13 00:01:57 +04:00
AidarKC 21030b1d51 ESP32: исправить base64 сериализацию Solana транзакции 2026-06-12 23:48:38 +04:00
AidarKC b583a86ade ESP32: исправить ABI регистрации и подробные ошибки RPC 2026-06-12 23:43:42 +04:00
AidarKC 3262ec9b4a ESP32: перевести UI регистрации на английский 2026-06-12 23:35:05 +04:00
AidarKC 0c9afea67a ESP32: экран подтверждения регистрации 2026-06-12 23:02:07 +04:00
AidarKC b83543d018 ESP32: регистрация по кнопке и зазор между кнопками 2026-06-12 22:24:21 +04:00
AidarKC d4a0185507 Перенёс основной ESP32-скетч в main-device 2026-06-12 22:02:08 +04:00
AidarKCandClaude Opus 4.8 42dcf6970d homeserver: рендейм subserver→homeserver, документ деривации ключей, запрет пустого пароля
Основное (наша работа в этой сессии):
- Переименование «subserver» → «homeserver» по всему проекту: основной ESP32-скетч
  (папка shine_subserver_ui → shine_homeserver_ui, .ino, flash-скрипт, режим burn.sh
  homeserver-ui), скетч lvgl_nav_minimal_test (ключ homeserver.key:<имя>), spec-доки
  reference/*, формат PDA (терминология session_type=100 «Homeserver пользователя»),
  константа SESSION_TYPE_HOMESERVER в JS и Rust (значение 100 не менялось, формат не затронут),
  pending/future доки, AGENTS.md, DAO-док. Сохранены отдельный lvgl_subserver_touch_test и
  историческая пометка о рендейме в DERIVATION.md.
- Новый источник истины по деривации ключей: Dev_Docs/Keys/DERIVATION.md (Argon2id-секрет из
  пароля, формула Ed25519(SHA-256(base64(secret)|suffix)), суффиксы root/bch/dev/homeserver.key,
  Solana-ключ = dev.key). Уточнены роли root (главный/master) и dev (пополняемый кошелёк) в
  Dev_Docs/Keys/README.md.
- UI: убран легаси-путь пустого пароля (derivePasswordSeed и др.), deriveMasterSecretFromPassword
  бросает ошибку на пустом пароле, register-view блокирует пустой пароль; экран пополнения
  переведён на канонический device-адрес из preGeneratedKeyBundle (удалён расходящийся
  deriveWalletFromPassword).

Включены также параллельные правки Solana-аудита №3 (были в рабочем дереве, переплетены в lib.rs):
- shine_users: defense-in-depth «строгий список аккаунтов» (require!(it.next().is_none()))
  в init/update economy config и create/update user PDA, плюс описание в doc/programs/shine_users.md;
- Dev_Docs/audit/Solana-audit-3-by-Claude-12июня2026.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 21:16:12 +04:00
PixelandClaude Opus 4.8 0b4374141e Связи (test 12.06): центр-орб крупнее (FOCUS_SCALE 1.78) + шире ореол центра (glowSpread 7)
Рычаг 1: glowSpread центра 4.5→7 (мягче/шире свечение), спутники без изменений. Рычаг 2: FOCUS_SCALE 1.5→1.78 (иерархия). Версия 1.2.160.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:53:13 +03:00
PixelandClaude Opus 4.8 652ddc9d88 Связи (test 12.06): SVG-стеклянные орбы аватаров + цвет/свечение линий глубоких связей
Аватары → SVG GlassOrb (фото в стеклянной сфере, блик, rim, свечение). Линии глубоких связей (tier-2/3) — в цвете типа (друзья/семья/...), сияющие связи светятся (голубой ореол + ядро). Версия 1.2.159.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:10:06 +03:00
AidarKCandClaude Fable 5 cf6a2830c8 solana: закрыть griefing создания PDA и заморозку выплат, добавить аудит №2
shine_payments + shine_users:
- create_pda_account переведён на «создание поверх предзаполненного»
  (allocate+assign+добор ренты), чтобы подсев лампортов на детерминированный
  адрес PDA (тикет/логин) не блокировал создание — закрыт LOW из аудита №1;
  в shine_payments is_uninitialized_account перестала зависеть от баланса.

shine_payments (HIGH из аудита №2):
- запрещён recipient == inflow_vault в buy_ticket*, manager_add_ticket и
  change_ticket_recipient; добавлена защита по умолчанию в transfer_from_vault
  (require vault.key != recipient.key). Это убирает алиасинг аккаунта в
  step_payout, который навсегда замораживал очередь выплат и средства вольта.

Документация и учёт:
- doc/programs/shine_payments.md §3.4, §10.1; doc/programs/shine_users.md §3.3;
- Dev_Docs/audit: добавлен аудит №2, обе закрытые находки помечены ИСПРАВЛЕНО;
- Dev_Docs/Pending_Features: две записи на ручную e2e-проверку на devnet;
- VERSION.properties: client 1.2.161, server 1.2.150.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 04:10:31 +04:00
AidarKC 01d9553db4 merge(ui): влить PR #3 pixel-связи в main 2026-06-11 00:24:36 +04:00
AidarKC 578b526f96 Скорректировать main к базе 553a1f1 и UI из Pixel 2026-06-10 23:03:01 +04:00
AidarKC e3061b46f9 Связи: финальный вид сияющих связей
Перенести финальный плазменный рендер связей из коммита Pixel 2559f1e6 в main.
2026-06-10 19:00:22 +04:00
PixelandClaude Opus 4.8 2559f1e66b Связи: финальный вид сияющих связей — плазма (поле+трубка+ядро, screen-blend) + цвет связи у обычных
Промежуточные итерации линий схлопнуты в один коммит. Убран мёртвый фильтр fg-plasma-turb. Лаборатория (/network-view/lab) и автотесты сохранены. Версия 1.2.158.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:09:33 +03:00
AidarKC 9ca469a075 solana: усилить проверку Pyth oracle в shine_payments 2026-06-10 02:25:45 +04:00
PixelandClaude Opus 4.8 519bce6b78 Связи (pixel-aquarium, 10.06): партия 3 (лаборатория) — общие связи + доступность
Вариант 2 (всё в лаборатории, реальный путь /network-view не трогаем).

- Общие связи: среди друзей человека один помечен как «общий» (он и твой друг тоже) — золотой ободок
  + ★ (CSS .fg-node.is-common). В лаб-генерации addDeepLevels подставляет узнаваемого друга Ивана.
- Доступность: визуально скрытый (sr-only) текстовый список графа .fg-a11y (центр + связи 1-го уровня)
  для скринридеров; обновляется в updateA11y при перестроении (role=region, aria-label).

Автопроверки расширены до 19 ассертов (добавлены «общие связи ★» и sr-only список) — прогон 19/19 PASS.
Бамп client.version → 1.2.150.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:53:49 +03:00
PixelandClaude Opus 4.8 557ea96be0 Связи (pixel-aquarium, 10.06): партия 2 (UI-фишки) — поиск, хлебные крошки, бейджи, цветовые кластеры
Всё в лаборатории (вариант 2: реальный путь /network-view не трогаем).

- Поиск + телепорт: строка .fg-search; Enter → graph.findNode(имя) → камера летит к узлу
  (dive в «Вселенной», иначе перецентр).
- Хлебные крошки: .fg-breadcrumb «Иван › Нина › Ада» (движок шлёт onDiveChange(path), API getDivePath);
  клик по корню — полный сброс, по предку — навигация на его уровень.
- Бейдж числа связей: .fg-node-badge (degreeById → updateBadges; у центра — число связей 1-го уровня).
- Цветовые кластеры: мягкая аура узла по типу связи (CSS is-family/friend/business/contact).

Автопроверки расширены до 17 ассертов (добавлены поиск/крошки/бейдж) — прогон 17/17 PASS.
Фикс: TDZ breadcrumbEl (объявлен до createForceGraph, т.к. onDiveChange вызывается при монтировании).
Бамп client.version → 1.2.149.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:43:02 +03:00
PixelandClaude Opus 4.8 9a49cc67f0 Связи (pixel-aquarium, 10.06): партия 1 (полиш) + автопроверки графа
Усиления (движок-полиш) с детерминированной самопроверкой:
- Веер детей — полукругом «наружу» (DEEP_FAN, по sibIndex от направления деда→родитель): не перекрывает
  нить-крошку и родителя; равномерное распределение.
- LOD с гистерезисом (LOD_ZOOM_UP=1.6 / DOWN=1.4) — точки 3-го уровня ↔ аватарки без «мигания» у порога.
- Двойной тап по пустому фону и сильный pinch-out на минимальном зуме = быстрый выход из погружения.
- Префетч аватарок детей при наведении/нырке (prefetchChildren) — лица в кэше до раскрытия.

Автопроверки (dev-only, ТОЛЬКО при ?fgtest):
- js/pages/network/selftest.js — 14 ассертов: камера-центровка, collision (нет слипания), полукруг,
  spotlight (путь 1.0 / фон 0.25 / сброс при переключении / 100% на выходе), LOD, возврат зума.
- Движок: read-only graph.debugState() + graph.pumpForTest() (синхронно докручивает кадры до покоя,
  не зависит от троттлинга rAF в фоне). Граф как window.__fg — тоже только при ?fgtest.
- Прогон: 14/14 PASS (offset 0px, мин.дистанция детей 89px, веер ±99°, LOD 4/4).

В обычной работе тест-хелперы не активны. Реальный путь /network-view не затронут. Бамп client → 1.2.148.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:25:57 +03:00
PixelandClaude Opus 4.8 3012f0799b Связи (pixel-aquarium, 10.06): фикс физики раскрытия + камера-наезд + железный spotlight
Исправлены критические баги «аквариума» по разбору (видео):

1. Слипание узлов → адаптивный радиус орбиты (фикс collision). Дети раскладываются на кольце
   ringR = max(baseR + радиус_родителя, число_детей×13): не лезут на (увеличенный зумом) родитель
   и друг на друга. Проверено: мин. дистанция 125px, 0 наложений (было — все в одной точке).

2. Умный наезд камеры на КЛИК по любому узлу (раньше 1-й уровень раскрывался на месте). diveTo
   центрирует узел (offset ~0), zoom 1.7; узел и дети растут до единого видимого размера
   (HERO_VISUAL/baseScaleOf, DIVE_CHILD_VISUAL) — крупно и читаемо. Наведение остаётся лёгким превью.

3. Железный Spotlight (единый активный путь): diveTo гасит ВСЕ прежние pin/hover, затем раскрывает
   только путь к цели. Открыто → путь=1.0, остальное=0.25; переключение веток сбрасывает прежнюю;
   exitDive/тап по Ивану → ВСЕ узлы гарантированно 1.0 + камера отъезжает. (Проверено программно.)

Реальный путь /network-view не затронут (вся глубина под tier≥2/hasDeep). Бамп client.version → 1.2.147.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:12:27 +03:00
PixelandClaude Opus 4.8 7a8852f64b Связи (pixel-aquarium): Умный фокус (Smart Zoom / «аквариум») — погружение в узел + LOD
Новая ветка для безопасного отката (от pixel-web). Режим «Вселенная», только лаборатория.

1. Гибрид клика: 1-й уровень → раскрытие ветки НА МЕСТЕ (как раньше); 2-й уровень+ → ПОГРУЖЕНИЕ.

2. Dive (умный наезд камеры, «аквариум», без перестройки графа):
   - diveTo(node): пинит весь путь (предки до Ивана), ставит diveTargetId + diveZoom=1.7;
     камера в tick плавно ЛЕТИТ и ЗУМИТ, центрируя узел (DIVE_FLY_K), узел ВЫРАСТАЕТ (×2.1 ~ герой).
   - Глубина (contextTargetOf → depthScale/depthBlur/spotCur, лерп): Иван и боковые ветки
     УМЕНЬШАЮТСЯ (root ×0.55) + уходят в BLUR 3px + тускнеют до 0.25 → задний план «аквариума».
   - Нить-крошка: путь Иван→…→узел (divePathSet/onPath) горит ярким «световодом» — виден путь назад.
   - Всплытие: повтор клика по цели → exitDive (камера/зум плавно к корню); клик по Ивану →
     collapseAll (полный сброс + всплытие).

3. Pinch-to-Zoom + LOD 3-го уровня: при zoom≥1.55 видимые точки 3-го уровня дорисовываются как
   читаемые аватарки (лицо+имя; updateLod/setNodeLod — пере-рендер DOM на пороге), при отдалении —
   обратно в светящиеся точки. Узлам tier-3 добавлены фото-заглушки (pravatar) и имена.

Глубина — фейк-3D через масштаб + CSS-blur (GPU), без WebGL. Реальный путь /network-view не затронут:
dive только tier≥2 (в реале их нет), depthScale/Blur нейтральны по умолчанию, updateLod выходит при !hasDeep.
Бамп client.version → 1.2.146.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:32:16 +03:00
PixelandClaude Opus 4.8 f92e6c3cf1 Связи (pixel-web): фикс багов паутины — видимый 2-й уровень, удалён «прицел», toggle+spotlight
Исправлены замечания по видео (режим «Вселенная», только лаборатория):

1. «Невидимые друзья» (узлы 2-го уровня). Раньше — тусклые пустые кружки с инициалом.
   Теперь tier-2 — полноценные аватарки: фото-лицо (pravatar) + имя, DEEP2_SCALE 0.5→0.62
   (≈radius 16px), DEEP2_OPACITY 0.4→0.85; читаемый ободок и подпись (CSS).

2. Мусорный «прицел» (пунктирное кольцо у центра). Полностью удалён из движка:
   элемент .fg-reticle, updateReticle/pulseReticle и все их вызовы, CSS .fg-reticle*.
   На экране только аватарки и линии связей.

3. Логика toggle + spotlight:
   - Повторный клик по раскрытому узлу теперь СВОРАЧИВАЕТ его (isOpen = pinned || expandP>0.5
     → сброс pinned+hovered) — работает, даже если ветка была раскрыта ховером.
   - Spotlight: при закреплённой ветке остальные тускнеют до 0.25 (узлы и линии), фокус и
     закреплённая/наведённая ветка — 100%; плавно через spotCur (lerp, см. spotTargetOf).
   - Клик по центру (Иван) — collapseAll + возврат всему графу 100% яркости.

Реальный путь /network-view не затронут (deep-код под tier≥2/hasDeep). Ветка экспериментальная.
Бамп client.version → 1.2.145.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:55:10 +03:00
PixelandClaude Opus 4.8 72dc83daff Связи (pixel-web): этап 2 паутины — hover-превью + collision + zoom + камера-доводчик + синхро-пульс
Доработка режима «Интерактивная паутина» (только лаборатория, deep-режим «Вселенная»):

Взаимодействие (по запросу): наведение ≠ клик.
- Hover-превью: навёл мышь/палец на узел — его ветка ВРЕМЕННО выплывает; убрал — втягивается.
  (pointerover/out для мыши, pointerdown/up для пальца → onNodeHover → graph.setHover; флаг hovered).
- Фиксация кликом: тап/клик → graph.toggleExpand ставит pinned — ветка остаётся раскрытой и
  после ухода курсора; повторный тап снимает фиксацию. Эффект = pinned || hovered (expandTargetOf).

Этап 2 «Мегамасштаб»:
- Collision-расталкивание: раскрытая ветка усиливает отталкивание соседей 1-го уровня
  пропорционально expandP (EXPAND_REPULSION=2.4) — кластеры разъезжаются, не накладываясь.
- Свободный зум: колесо мыши (onWheel) + щипок двумя пальцами (activePointers/pinching),
  zoom 0.55–2.6 «к точке»; мир — CSS-scale, линии (SVG) пересчитываются в экранных координатах × zoom.
- Камера-доводчик: при фиксации ветки, если её веер упирается в край, камера мягко дотягивается
  (glideCameraTo → camTargetX/Y, lerp CAM_GLIDE_K в tick); любой жест отменяет доводчик.
- Синхро-пульс: сияющие/трековые «световоды» дышат толщиной/размытием 3.6с в такт ободку узла.

Реальный путь /network-view не затронут: deep-код под tier≥2/hasDeep, hover-колбэк даёт только
лаборатория. Ветка экспериментальная (отдельно от pixel-08.06/PR). Бамп client.version → 1.2.144.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:34:26 +03:00
AidarKC 5981d3f871 solana: привязать singleton pda в shine_payments 2026-06-09 23:10:20 +04:00
AidarKC fb0c5ad3f8 audit: сохранить отчёт по Solana 2026-06-09 23:09:21 +04:00
PixelandClaude Opus 4.8 04d9d588e8 Связи (pixel-web): режим «Интерактивная паутина» — раскрытие веток без смены центра
Этап 1 mind-map (только лаборатория, deep-режим «Вселенная»):
- Отмена прыжков в центр: тап по периферийному узлу больше НЕ перецентрирует — он остаётся
  на орбите, а из него раскрывается/сворачивается (toggle) его ветка дальних связей НА МЕСТЕ.
- Глобальный сброс: тап по корню (Иван) рекурсивно сворачивает все раскрытые ветки (collapseAll).
- Глубина скрыта по умолчанию; ветка плавно выплывает (expandP, ~400мс) и втягивается по повтору.
- Мерцающие звёзды 3-го уровня (CSS box-shadow/brightness, десинхрон по узлам) — «созвездие».
- Тактильный отклик navigator.vibrate(): клик при нажатии, серия импульсов на bloom-раскрытие,
  щелчок «гитарной струны» при сильном натяжении нитей свайпом.
- Движок: API toggleExpand/collapseAll; убрана press/hover-логика раскрытия (заменена тапом).

Ветка экспериментальная (отдельно от pixel-08.06/PR), бамп client.version → 1.2.143.
Ещё не сделано (следующие этапы): collision-расталкивание веток, камера-доводчик, zoom,
синхро-пульс линий к сияющим.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:04:48 +03:00
AidarKC e5fe925023 solana: защитить economy config в shine_users 2026-06-09 23:04:03 +04:00
PixelandClaude Opus 4.8 345a21a211 Связи (эксперимент pixel-web): база — микро-взаимодействия + глубина
Фиксирую накопленные черновики как точку отката перед режимом «Интерактивная паутина»:
- press/pan-bend (резиновые нити при свайпе, тактильное «вдавливание» узла);
- глубина 2-3 уровней (прототип «Вселенная», переключатель в лаборатории);
- прогрессивное раскрытие (глубина скрыта, выплывает по нажатию/наведению).

Ветка pixel-web — экспериментальная (отдельно от pixel-08.06/PR), чтобы можно было откатиться.
Бамп client.version → 1.2.142.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:51:48 +03:00
AidarKC b83e8c3979 solana: удалить неиспользуемый модуль common 2026-06-09 22:48:59 +04:00
AidarKC 0744ac3163 fix(ui): починить прямые ссылки профиля и связей 2026-06-09 22:32:02 +04:00
PixelandClaude Opus 4.8 3de992d251 Связи: вернуть лабораторию в ветку (для просмотра графа на моках)
Откат удаления из aed64e7: lab.js снова в репозитории, статический импорт в network-view.js,
строка lab.js убрана из .gitignore. Лаборатория /network-view/lab нужна для демонстрации/проверки
графа на мок-данных без бэкенда. Бамп client.version → 1.2.141.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:23:18 +03:00
PixelandClaude Opus 4.8 369ef61cab Связи: лаборатория только локально (исключена из репозитория)
- shine-UI/js/pages/network/lab.js убран из git (git rm --cached) и добавлен в .gitignore:
  на сервере лаборатория не нужна (там реальные данные), локально файл остаётся и работает.
- network-view.js: статический импорт lab.js заменён на ДИНАМИЧЕСКИЙ с фолбэком — если файла
  нет (прод/сервер), реальный экран «Связи» не ломается, а заход на /network-view/lab уводит
  на обычный экран. Локально лаборатория грузится как прежде.
- Документация фичи отражает, что lab.js — локальный (в .gitignore).
- Бамп client.version → 1.2.140.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:23:18 +03:00
PixelandClaude Opus 4.8 e6e96c4b0d Связи: чистка мёртвого кода в движке (lerp, неиспользуемые easing)
- Убран механизм lerpX/lerpY: координаты для отрисовки берутся из n.x/n.y, lerp нигде
  не читался кроме условия заморозки (lerpSettling). Удалены поля, advanceLerp(), EDGE_LERP
  и lerpSettling — граф засыпает чуть раньше (без визуальных изменений; проверено: frozen=true).
- Удалены неиспользуемые cubicBezier() и EASE_BLOOM (easing теперь делает CSS); easeOutCubic
  оставлен (нужен в stepTween для фолбэк-центрирования).
- Документация фичи актуализирована (убрана заметка про lerp как кандидата на чистку).
- Бамп client.version → 1.2.139.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:23:18 +03:00
PixelandClaude Opus 4.8 dc96033cb1 Связи: двухслойные линии-световоды, живой фон и стеклянные фильтры
- Сияющие связи — двухслойный неоновый «световод»: размытый glow (4px, blur 2px,
  opacity 0.4) + тонкий чёткий core (1.5px, #e0f7fc). Объёмное OLED-свечение,
  линия остаётся изящной. Оба слоя растут синхронно (общий dashoffset).
- Обычные линии — тоньше (1.0–1.2px) и глубокий уход в прозрачность (0.42 → 0.07),
  чтобы матовые связи не спорили с сияющими.
- Живой фон-«небула»: глубокое размытое сине-голубое облако под центром, медленная
  пульсация радиуса/яркости + переливы индиго↔ультрамарин (hue-rotate, 7с).
- Стеклянные чипы фильтров (frosted glass): rgba(255,255,255,0.03) + backdrop blur(12px)
  + граница 0.5px solid rgba(255,255,255,0.1); активный подсвечен сине-голубым.
- Бамп client.version → 1.2.138; документация фичи обновлена.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:23:17 +03:00
PixelandClaude Opus 4.8 9ee6bf4380 Связи: полировка карты связей (свечение, прорастание линий, CSS-фильтры)
- Линии: тонкие дуги Безье (градиент неон-центр → цвет роли); связь к «сияющему»
  монолитно светится статичной тенью drop-shadow (без бегущих импульсов).
- Прорастание новых линий из центра: stroke-dasharray/dashoffset синхронно с
  разлётом узла (кончик трекает аватарку); старые линии исчезают мгновенно.
- Ghost-слой: только аватарки (без линий), 1000мс — нет висящих «ошмётков».
- CSS-bloom разлёта на компоновщике (устойчив к троттлингу rAF; завершение по таймеру).
- Сияющие узлы: мягкая медленная пульсация 3.6с (многослойная box-shadow + SVG-ореол);
  тестовые фото-аватарки.
- Фильтры слоёв в лаборатории + фикс перехвата click сценой (stopPropagation на чипах);
  фейд скрываемых на месте (opacity 0 + scale 0.8, 300мс), фиксация без физики (ноль тряски).
- Бамп client.version → 1.2.137; обновлена документация фичи.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:23:17 +03:00
PixelandClaude Opus 4.8 e0f0726e68 Связи: интерактивная карта связей (force-directed graph)
Переработка экрана «Связи» в интерактивный нод-граф с премиальными переходами.

Движок (js/pages/network/force-graph.js):
- diffing-переходы: общие узлы перелетают, новые расцветают каскадом, исчезнувшие — Ghost-слой (800мс, на месте);
- мягкая радиальная пружина + отталкивание (органичная орбита), упругий влёт фокуса;
- динамическая вязкость на старте (трение 0.92→0.82, отталкивание ослаблено) — мягкий разлёт без тряски;
- жёсткая заморозка (kill-switch) при затухании — нет «треска», экономия батареи;
- линии — SVG <path> Безье (изогнутые нити), прорастание; жесты pan с инерцией;
- хард-лимит DOM-аватарок (остальное — SVG-точки).

Интеграция и UX:
- adapter.js: getUserConnectionsGraph → модель движка (сервер не трогаем, read-only);
- фильтры (Все/Семья/Друзья/Сияющие), контекстное меню (node-menu.js), нижний сниппет, профиль;
- прицел в центре, дыхание фокуса, свечение сияющих;
- лаборатория network-view/lab на мок-данных (networkGraphUsers) для тестов без бэкенда.

Документация: shine-UI/Dev_Docs/features/interactive-network-graph.md.
Бамп client.version 1.2.135 -> 1.2.136.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:23:16 +03:00
AidarKC 105a56499d merge(ui): влить pixel-08.06 в main 2026-06-09 22:20:49 +04:00
AidarKCandClaude Opus 4.8 2bd27cd73b Связи: вернуть лабораторию в ветку (для просмотра графа на моках)
Откат удаления из aed64e7: lab.js снова в репозитории, статический импорт в network-view.js,
строка lab.js убрана из .gitignore. Лаборатория /network-view/lab нужна для демонстрации/проверки
графа на мок-данных без бэкенда. Бамп client.version → 1.2.141.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:23:40 +03:00
AidarKCandClaude Opus 4.8 aed64e76a7 Связи: лаборатория только локально (исключена из репозитория)
- shine-UI/js/pages/network/lab.js убран из git (git rm --cached) и добавлен в .gitignore:
  на сервере лаборатория не нужна (там реальные данные), локально файл остаётся и работает.
- network-view.js: статический импорт lab.js заменён на ДИНАМИЧЕСКИЙ с фолбэком — если файла
  нет (прод/сервер), реальный экран «Связи» не ломается, а заход на /network-view/lab уводит
  на обычный экран. Локально лаборатория грузится как прежде.
- Документация фичи отражает, что lab.js — локальный (в .gitignore).
- Бамп client.version → 1.2.140.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:12:48 +03:00
AidarKCandClaude Opus 4.8 abfd073de8 Связи: чистка мёртвого кода в движке (lerp, неиспользуемые easing)
- Убран механизм lerpX/lerpY: координаты для отрисовки берутся из n.x/n.y, lerp нигде
  не читался кроме условия заморозки (lerpSettling). Удалены поля, advanceLerp(), EDGE_LERP
  и lerpSettling — граф засыпает чуть раньше (без визуальных изменений; проверено: frozen=true).
- Удалены неиспользуемые cubicBezier() и EASE_BLOOM (easing теперь делает CSS); easeOutCubic
  оставлен (нужен в stepTween для фолбэк-центрирования).
- Документация фичи актуализирована (убрана заметка про lerp как кандидата на чистку).
- Бамп client.version → 1.2.139.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:20:00 +03:00
AidarKCandClaude Opus 4.8 41edd1423c Связи: двухслойные линии-световоды, живой фон и стеклянные фильтры
- Сияющие связи — двухслойный неоновый «световод»: размытый glow (4px, blur 2px,
  opacity 0.4) + тонкий чёткий core (1.5px, #e0f7fc). Объёмное OLED-свечение,
  линия остаётся изящной. Оба слоя растут синхронно (общий dashoffset).
- Обычные линии — тоньше (1.0–1.2px) и глубокий уход в прозрачность (0.42 → 0.07),
  чтобы матовые связи не спорили с сияющими.
- Живой фон-«небула»: глубокое размытое сине-голубое облако под центром, медленная
  пульсация радиуса/яркости + переливы индиго↔ультрамарин (hue-rotate, 7с).
- Стеклянные чипы фильтров (frosted glass): rgba(255,255,255,0.03) + backdrop blur(12px)
  + граница 0.5px solid rgba(255,255,255,0.1); активный подсвечен сине-голубым.
- Бамп client.version → 1.2.138; документация фичи обновлена.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:01:36 +03:00
AidarKC 471fde78c1 ESP32: ужесточить touch UX и обновить инструкции 2026-06-09 18:30:12 +04:00
AidarKCandClaude Opus 4.8 3e4759a0c9 Связи: полировка карты связей (свечение, прорастание линий, CSS-фильтры)
- Линии: тонкие дуги Безье (градиент неон-центр → цвет роли); связь к «сияющему»
  монолитно светится статичной тенью drop-shadow (без бегущих импульсов).
- Прорастание новых линий из центра: stroke-dasharray/dashoffset синхронно с
  разлётом узла (кончик трекает аватарку); старые линии исчезают мгновенно.
- Ghost-слой: только аватарки (без линий), 1000мс — нет висящих «ошмётков».
- CSS-bloom разлёта на компоновщике (устойчив к троттлингу rAF; завершение по таймеру).
- Сияющие узлы: мягкая медленная пульсация 3.6с (многослойная box-shadow + SVG-ореол);
  тестовые фото-аватарки.
- Фильтры слоёв в лаборатории + фикс перехвата click сценой (stopPropagation на чипах);
  фейд скрываемых на месте (opacity 0 + scale 0.8, 300мс), фиксация без физики (ноль тряски).
- Бамп client.version → 1.2.137; обновлена документация фичи.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:06:45 +03:00
AidarKC b5276890fb ESP32: обновить подпись и выровнять индикаторы HOME 2026-06-09 17:52:33 +04:00
AidarKC 1488bc3d6d ESP32: доработать HOME экран и поток секретов 2026-06-09 17:44:56 +04:00
AidarKC f4e7210a40 ESP32: добавить NAV v8 с account и Wi-Fi reconnect 2026-06-09 16:14:24 +04:00
AidarKC e385bb6bf9 ESP32: зафиксировать промежуточный NAV v6 UI прототип 2026-06-09 15:22:45 +04:00
AidarKCandClaude Opus 4.8 f56e531384 Связи: интерактивная карта связей (force-directed graph)
Переработка экрана «Связи» в интерактивный нод-граф с премиальными переходами.

Движок (js/pages/network/force-graph.js):
- diffing-переходы: общие узлы перелетают, новые расцветают каскадом, исчезнувшие — Ghost-слой (800мс, на месте);
- мягкая радиальная пружина + отталкивание (органичная орбита), упругий влёт фокуса;
- динамическая вязкость на старте (трение 0.92→0.82, отталкивание ослаблено) — мягкий разлёт без тряски;
- жёсткая заморозка (kill-switch) при затухании — нет «треска», экономия батареи;
- линии — SVG <path> Безье (изогнутые нити), прорастание; жесты pan с инерцией;
- хард-лимит DOM-аватарок (остальное — SVG-точки).

Интеграция и UX:
- adapter.js: getUserConnectionsGraph → модель движка (сервер не трогаем, read-only);
- фильтры (Все/Семья/Друзья/Сияющие), контекстное меню (node-menu.js), нижний сниппет, профиль;
- прицел в центре, дыхание фокуса, свечение сияющих;
- лаборатория network-view/lab на мок-данных (networkGraphUsers) для тестов без бэкенда.

Документация: shine-UI/Dev_Docs/features/interactive-network-graph.md.
Бамп client.version 1.2.135 -> 1.2.136.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:43:56 +03:00
AidarKC 32606fe1c2 ESP32: зафиксировать рабочий LVGL nav prototype и тесты 2026-06-08 18:39:11 +04:00
AidarKC a8734846a0 ESP32: добавить LVGL тесты и отметить рабочий hybrid touch test 2026-06-08 16:56:00 +04:00
AidarKC ad0edf3c88 ESP32: зафиксировать тесты и нерабочий LVGL/UI вариант 2026-06-08 15:35:27 +04:00
AidarKC 66975862f7 ESP32: временно отключить PIN-вход в subserver UI 2026-06-08 12:29:47 +04:00
AidarKC b9185e761b ESP32: добавить UI сабсервера, PIN-ввод пока не работает 2026-06-08 12:25:54 +04:00
AidarKC 885cf463a7 Уточнить финальный экран devnet topup 2026-06-07 11:26:09 +04:00
AidarKC 689f35fea2 Доработать клиентский UI Solana-регистрации 2026-06-07 11:19:58 +04:00
AidarKC 6bf5d1d5ed Исправить клиентскую Solana-регистрацию после ухода от Anchor 2026-06-07 10:58:06 +04:00
AidarKC b0b8c7a445 Перевести shine_payments на новый program id и подтвердить e2e 2026-06-07 10:00:39 +04:00
AidarKC d25c19cdaa Исправить чтение state в shine_payments и описать e2e тест 2026-06-06 17:12:26 +04:00
AidarKC 89d06d317b Переписать shine_payments и обновить тестовый UI с известным багом state 2026-06-06 16:58:57 +04:00
AidarKC c5ec32f87a Обновить Telegram-бота, документацию и связанные доработки 2026-06-06 13:45:02 +04:00
AidarKC ce5c348023 Убрать v2 из economy seed shine_users 2026-06-05 11:42:21 +04:00
AidarKC 832eea5889 Переписать shine_users и shine_login_guard на чистый Rust 2026-06-04 23:05:45 +04:00
AidarKC 60049442f1 Зафиксировать все текущие изменения проекта 2026-06-04 22:27:09 +04:00
AidarKC 624557ebfd Удалить старый путь документа формата PDA 2026-06-04 22:17:33 +04:00
AidarKC 6b0379bfdc Добавить спецификации Solana программ и вынести формат PDA 2026-06-04 22:17:17 +04:00
AidarKC a9510a6d36 Обновить lazy-import Solana PDA под новый формат 2026-06-04 14:33:42 +04:00
AidarKC 59e4156bb9 Удалить obsolete server UI и подчистить ссылки 2026-06-04 14:25:59 +04:00
AidarKC de9606519a Починить native Ed25519 update_user_pda без OOM 2026-06-04 13:47:47 +04:00
AidarKC eeb115584d Добавить диагностику server PDA и баланс device (не проверено) 2026-06-03 16:12:40 +04:00
AidarKC ee3721dfa4 Исправить DEVNET topup и автоподстановку пароля 2026-06-03 15:57:49 +04:00
AidarKC 239cc231ea Снимок состояния перед фиксом DEVNET topup и автоподстановки пароля 2026-06-03 15:56:17 +04:00
AidarKC 4bd4df7b09 Добавить переход в server UI и DEVNET topup 2026-06-03 15:21:55 +04:00
AidarKC d12371b84f Перенести server UI в shine-UI и объединить PDA-модуль 2026-06-03 15:11:26 +04:00
AidarKC c97b3e3ec3 Снимок состояния до переноса серверного UI 2026-06-03 14:49:03 +04:00
AidarKC 2c2aad1355 Убраны непроверенные готовые фичи и перенесён QR-план 2026-06-03 14:20:45 +04:00
AidarKC 9949935bcc Добавить обработку длинных voice/audio в агент-боте 2026-06-03 00:18:30 +04:00
AidarKC 35fc6ebf62 Сделана компактная кнопка показа ключей в регистрации 2026-06-02 16:43:38 +04:00
AidarKC d2205648e6 Сделана компактная кнопка скрытия ключей в UI 2026-06-02 16:40:58 +04:00
AidarKC 68ed93dd24 Переведены ключи UI в base58 и обновлены deploy defaults 2026-06-02 16:34:37 +04:00
AidarKC a06b76b800 Обновлён server UI и приватные ключи переведены в base58 2026-06-02 15:52:22 +04:00
AidarKC 67f882b9bc Добавлен контур server-backup для shineup.me и регламент обновляемых бэкапов 2026-06-01 13:05:54 +04:00
AidarKC 17dc4981c6 Поправить Solana-программу регистрации пользователей
Шаг 1 — Rust (users.rs)

- Убран server_key: Pubkey из UserMutableFields и UserRecord.

- Добавлены address_format_type: u8 и address_format_version: u8 в соответствующие структуры.

- Добавлена константа BLOCK_VERSION_1: u8 = 1.

- Обновлен write_server_profile_block: версия блока = 1, убраны 32 байта server_key, добавлены 2 байта формата адреса перед server_address.

- Обновлен deserialize_record_from_pda для BLOCK_TYPE_SERVER_PROFILE: ожидается BLOCK_VERSION_1, чтение server_key убрано, добавлено чтение type/version формата адреса.

- Обновлены конструкторы UserRecord под новые поля.

- Обновлена документация формата: shine-solana/shine/doc/SHiNE-user-format-v.1.0.md.

- Синхронизированы связанные изменения UI/доков и VERSION.properties (client 1.2.109, server 1.2.101).
2026-05-31 22:25:33 +04:00
AidarKC 0179b25d12 Исправить дефолты деплоя на shineup.me
- deployServer: remoteHost по умолчанию = shineup.me.

- deployServer: путь localJar по умолчанию = build/libs/shine-server.jar.

- deployUI: REMOTE_HOST по умолчанию = player@shineup.me.

- VERSION.properties: client 1.2.107, server 1.2.100.
2026-05-31 20:36:34 +04:00
AidarKC e3c1cbf1c0 Обновить UI каналов, логаут DM и документацию
- Исправлена вкладка Каналы: стабильные режимы Все/Мои, корректные кнопки и навигация назад.

- Зафиксирована доработка по личным сообщениям: при logout очищается локальная база/кеш DM на устройстве.

- Обновлены AGENTS/CLAUDE и документация Personal_Messages.

- Обновлены версии в VERSION.properties (client 1.2.106, server 1.2.99).
2026-05-31 20:30:31 +04:00
AidarKC 5899bd2f77 Убрал long-press меню каналов и обновил deploy-проверку sudo 2026-05-31 19:30:36 +04:00
AidarKC 1b0e1cf1d4 refactor: перенос серверных модулей в папку SHiNE-server 2026-05-30 17:12:15 +04:00
AidarKC 134e877b7c chore: состояние проекта перед переносом сервера в SHiNE-server 2026-05-30 17:10:21 +04:00
AidarKC b75ac46781 Кошелёк Сияния: фактический расход с сервера, доработка UI и topup через compute budget 2026-05-30 11:53:36 +04:00
AidarKC edc94d3700 WIP: кошелек запускается, но есть ошибки 2026-05-30 11:24:59 +04:00
AidarKC b13efa92fd Добавить адаптацию голосовых ответов бота 2026-05-30 00:16:39 +04:00
AidarKC 6f796c98f7 Уточнить голосовые ответы агента 2026-05-29 23:53:59 +04:00
AidarKC 3a5856c7f0 Добавить кошелек блокчейна и озвучивание агента 2026-05-29 23:48:44 +04:00
AidarKC 775b655aac Логин guard: корректный precheck, company приоритет, hp в trademarks; подробные ошибки UI 2026-05-27 22:15:54 +04:00
AidarKC 101fd2eaa4 Solana-first регистрация: lazy-import пользователя при входе, AddUser отключен, UI ожидание 15с 2026-05-27 18:38:45 +04:00
AidarKC 6f0bb01b61 Промежуточный коммит: состояние до нормальной Solana-first регистрации 2026-05-27 18:33:26 +04:00
AidarKC b345900459 docs: убрать выполненную задачу про кнопку создания канала из Future_Features 2026-05-26 00:46:58 +03:00
AidarKC f1cfe9b6aa UI: обновлена шапка каналов, закрыты pending-задачи и обновлены версии 2026-05-26 00:30:49 +03:00
AidarKC 8941582d54 chore: зафиксированы все текущие изменения проекта 2026-05-25 23:46:54 +03:00
AidarKC 8c5de781ea API: задокументирован rawBlockB64 в GetMessageThread и обновлены версии 2026-05-25 23:43:43 +03:00
AidarKC baef264bd0 Обновить формат Solana user PDA 2026-05-24 19:41:13 +03:00
AidarKC 74df7e2645 Добавить документацию Solana PDA и ESP32-подпроект 2026-05-24 19:29:42 +03:00
AidarKC 56cd90a197 Отключить репосты и добавить Solana-модуль 2026-05-24 12:16:39 +03:00
AidarKC abdce05136 Удалить Java-реализацию агента-кодера 2026-05-24 09:30:25 +03:00
AidarKC 35565845ca Добавить канальный режим агента-кодера 2026-05-24 09:25:25 +03:00
AidarKC a83ec2c971 Обновить сервис агента-кодера 2026-05-24 09:21:50 +03:00
AidarKC 4b371e142d Документировать API и сервис агента-кодера 2026-05-24 08:04:44 +03:00
AidarKC aa2644d812 fix: считать шаг линии через lineStep перед AddBlock 2026-05-21 16:57:31 +03:00
AidarKC fd99250882 feat: добавить репосты сообщений в каналах и тредах 2026-05-21 16:16:26 +03:00
AidarKC 5344c42ceb chore: очистить Pending_Features и обновить версии 2026-05-21 15:03:28 +03:00
AidarKC 21413268f3 Добавил гостевой режим, единые shine-ссылки и пометку о нестабильности мнений 2026-05-20 16:14:59 +03:00
AidarKC aa35d87885 Добавить opinion-связи и обновить UI связей в профиле 2026-05-20 13:13:50 +03:00
AidarKC a53444b863 Перенести UI-деплой в /home/player/SHiNE/shine-ui 2026-05-19 22:10:19 +03:00
AidarKC 4b0031fb08 Исправить UI-деплой в /var/www и права rsync через sudo 2026-05-19 21:55:21 +03:00
AidarKC 62b8534769 Исправить устойчивость UI-деплоя и проверку Caddy 2026-05-19 21:50:05 +03:00
AidarKC f3262c2d64 Исправить edit/delete сообщений, упростить вкладки каналов и улучшить автоскролл DM 2026-05-19 21:00:29 +03:00
AidarKC 7986184111 chore(deploy): новый прод-контур 93.170.12.154, единые deploy task и docs 2026-05-19 16:27:09 +03:00
AidarKC 9c35567389 fix(dm): Ctrl+Enter перенос, время в списке и выравнивание карточек 2026-05-19 16:13:44 +03:00
AidarKC 8325cbec84 UI: DM список метаданных и Enter/Ctrl+Enter в чате 2026-05-19 15:50:42 +03:00
AidarKC c6d310184b UI: упростить профиль и обновить UX чатов/шапок 2026-05-19 15:34:46 +03:00
AidarKC 83892d5093 UI: поднята фиксированная шапка в канале и треде 2026-05-19 15:05:29 +03:00
AidarKC 1e1cdd9e76 UI: шапка channel owner/name и унификация карточек треда 2026-05-19 14:22:28 +03:00
AidarKC 3e62a2a01c UI: компактная тёмная плитка автора и корректный переход в профиль 2026-05-19 14:18:20 +03:00
AidarKC 90d10086d7 UI: карточка автора в канале, профиль user и назад по истории 2026-05-19 13:58:49 +03:00
AidarKC db2d9a666b UI: переход на history-router без # и короткие ссылки тредов 2026-05-19 10:15:15 +03:00
AidarKC 3a0899bcfe feat(ui): короткий роут m для тредов и восстановление заголовка канала 2026-05-19 01:05:25 +03:00
AidarKC d13c60fca1 fix(thread): открывать ответ через channel-thread-view с hash 2026-05-19 00:49:22 +03:00
AidarKC 580bd6fbeb feat(thread): переход в тред ответа и явная история сверху 2026-05-19 00:47:12 +03:00
AidarKC 49ebf1605a fix(ui-thread): корректные поля channel для GetChannelMessages 2026-05-19 00:42:00 +03:00
AidarKC f1fbb35296 fix(ui): открыть вкладку каналов по умолчанию и исправить резолв owner/channel 2026-05-19 00:41:19 +03:00
AidarKC b85643ca33 docs(blockchain): актуализировать MVP-формат и правила изменения протокола 2026-05-19 00:23:10 +03:00
AidarKC c27da63a3e chore: зафиксированы оставшиеся локальные изменения 2026-05-19 00:07:49 +03:00
AidarKC a332ddc828 UI: отключение вкладки чатов и исправление загрузки каналов 2026-05-19 00:04:34 +03:00
AidarKC ab31ccf6d8 UI: каналы 1..32, публичный type=1 и актуальный prevLine перед записью 2026-05-14 17:58:16 +03:00
AidarKC 01b38952e5 UI: исправить каналы и добавить MCP-док по чтению/дозаписи 2026-05-14 17:35:54 +03:00
AidarKC 0fdb5b245c Откат мультисессии: возвращен один активный сеанс 2026-05-14 17:11:24 +03:00
AidarKC 94263a46bd UI: мультиаккаунты профиля и улучшенный поиск каналов 2026-05-14 16:28:17 +03:00
AidarKC 56a69ab683 UI: отправка UI-ошибок, персональный публичный чат, русские pending-файлы 2026-05-14 14:16:03 +03:00
AidarKC e73e103ac4 UI: заглушки уведомлений и правило intake в AGENTS 2026-05-14 13:32:53 +03:00
AidarKC b8b33696ec Docs: добавить правило push через GITEA_TOKEN 2026-05-14 12:49:10 +03:00
AidarKC c0b0c99f53 UI: обновить thread/counters, вкладку Каналы и сценарий просмотра+подписки 2026-05-14 12:46:22 +03:00
AidarKC a2954071bd Channels/CreateChannel: выровнена версия формата на v3 + legacy fallback 2026-05-13 02:47:43 +03:00
AidarKC 76e4a6cba0 UI/Channels: вкладки по тапу + CreateChannel fallback для legacy формата 2026-05-13 02:42:57 +03:00
AidarKC b55fd1571e Auth/UI: Argon2id derivation для login/register + блок Расширенные 2026-05-13 02:10:42 +03:00
AidarKC 8de4e95c6a UI: голосовой ввод/STT, TTS через OpenAI, настройки инструментов + учёт недопроверенных фич 2026-05-13 02:01:51 +03:00
AidarKC ddeaf82bfd CreateChannel: оставить единый актуальный формат, убрать legacy v2/v3 2026-05-13 01:22:07 +03:00
AidarKC e95f65ac78 Каналы: типы 0/1/100/200, CreateChannel v3, state для chat200, новые API и деплой на prod 2026-05-13 01:17:23 +03:00
AidarKC 97d37a2eb6 12-05-2026
Перенёс репо
2026-05-12 23:26:27 +03:00
AidarKC a23d090bc1 Канал root 0 переименован в news в API и документации 2026-05-08 20:10:02 +03:00
AidarKC 4956ba7352 Ужесточение имен каналов и удаление legacy USER_PARAM для описания 2026-05-08 19:06:58 +03:00
AidarKC acdd6c928b Каналы: новый роутинг, поиск, вход-возврат, удаление просмотров и документация 2026-05-08 01:15:54 +03:00
AidarKC 6774c26ea1 Звонки: preflight сессии перед вызовом и retry; таймаут вынесен в настройки 2026-05-05 18:11:55 +03:00
AidarKC ef0cd2cb7d deployServer: проверка sudo -n только для systemctl сервиса 2026-05-02 19:06:31 +03:00
AidarKC e921b06826 Звонки: дольше показывать статус занято; deployServer проверяет sudo -n 2026-05-02 19:05:54 +03:00
AidarKC c0c29b74ab Звонки: WebPush incoming/stop, actions и TTL; обновлена логика 2026-05-02 18:25:44 +03:00
AidarKC 310863faec Checkpoint: первая рабочая версия звонков, сигналинг будет переделан 2026-05-02 18:13:22 +03:00
AidarKC b05da86197 Удалён AckIncomingMessage и обновлена документация доставки 2026-05-02 17:02:57 +03:00
AidarKC c44d755ce0 Добавлен TODO по будущим доработкам доставки почты 2026-05-02 16:52:38 +03:00
AidarKC b7e6cf7437 Почта v2: ReceiveOutcomingMessage без авторизации и атомарная вставка пары 2026-05-02 16:46:22 +03:00
AidarKC e73328461e Звонки: выбор одной callee-сессии и авто-закрытие входящего на других устройствах 2026-05-01 19:26:32 +03:00
AidarKC db93eace30 WebRTC: строгая обработка сигналов (ICE до PC в очередь, ANSWER только для outgoing) 2026-05-01 18:16:01 +03:00
AidarKC c5dfa47903 WebRTC: очередь ранних ICE, игнор дублирующего ANSWER и деплой UI 1.2.30 2026-05-01 18:08:43 +03:00
AidarKC d96985303d Звонки/UI: освобождение микрофона после звонка, детальнее failed и возврат в Настройки после принудительного обновления 2026-05-01 18:05:39 +03:00
AidarKC 27bd47dbe0 Звонки: фиксы session fallback/registry, аналитика ICE/TURN, авточек UI-версии и перенос кнопки разработчика 2026-05-01 17:42:51 +03:00
AidarKC e3377a48b3 Звонки: расширенная диагностика + экран настроек разработчика + обновление TURN-конфига 2026-05-01 16:39:03 +03:00
AidarKC a2ed41514d Деплой UI: обязательная подстановка client.version из VERSION.properties 2026-05-01 16:09:01 +03:00
AidarKC 78ee5a60fa UI: кнопка помощи при проблеме обновления клиента + инкремент версий 2026-05-01 15:36:04 +03:00
AidarKC bff403ea04 CallDeliveryReport: универсальный формат type/value и расширенные отчёты по звонкам 2026-05-01 15:09:20 +03:00
AidarKC 3061bf3d1e Добавлен SHiNE-promo-solana-devnet в основной репозиторий без вложенного git 2026-05-01 13:58:59 +03:00
AidarKC 9b03273055 Промо devnet: вечный код 0000, сообщение после пополнения и деплой-задачи 2026-05-01 13:55:29 +03:00
AidarKC bdcab5ee05 Регистрация: wallet.key работает и с пустым паролем 2026-04-27 02:20:02 +03:00
AidarKC 14cc3be620 Регистрация: проверка реального баланса wallet.key перед продолжением 2026-04-27 01:50:41 +03:00
AidarKC 50da3e868d Регистрация Solana: промо-topup URL с wallet(base58) и порог 0.01 SOL 2026-04-27 01:44:07 +03:00
AidarKC 2c68dedea2 UI: фикс шапки связей и правки профиля (работает) 2026-04-26 20:34:12 +03:00
AidarKC da12521517 UI: обновлены профиль/связи, статусы отношений и фикс верхней панели (работает) 2026-04-26 19:13:08 +03:00
AidarKC 28bbdb8b7c style(ui): уплотнить профиль и растянуть экран связей 2026-04-26 18:57:26 +03:00
AidarKC 1e8e2915f9 feat(network): поиск, help, история центра и fullscreen PWA 2026-04-26 18:50:36 +03:00
AidarKC 2350745e61 feat(profile): разделить просмотр и редактирование профиля 2026-04-26 18:31:38 +03:00
AidarKC 1fec6c7b54 feat(relations): spouse 40/41 и новый UX вкладки Связи (проверено) 2026-04-26 18:24:30 +03:00
AidarKC 3e10407afd fix(auth): вход сразу по логину, регистрацию отправлять при любом балансе (проверено) 2026-04-26 18:13:37 +03:00
AidarKC 3d5b5b2214 fix(ui): восстановить показ аватаров и корректный fallback 2026-04-26 09:31:56 +03:00
AidarKC c094af920e fix(ui): исправить наложение fallback-букв на аватарах 2026-04-26 09:24:27 +03:00
AidarKC dafdae5276 Настройки: блок Для разработчиков и ручная загрузка аватара в Arweave 2026-04-26 02:46:18 +03:00
AidarKC df7f38bd0a Аватары: убрать инициалы при наличии txId и усилить загрузку старых файлов 2026-04-26 02:39:21 +03:00
AidarKC 4c1aeeeac8 Аватары: общий компонент, кэш txId и avatar.ar в графе связей 2026-04-26 02:27:41 +03:00
AidarKC 667c5310bf Добавить аватар профиля через Arweave и мастер загрузки 2026-04-26 01:41:09 +03:00
AidarKC 126cf2f5c3 Добавить SAWD-v1 и Arweave-кошелек в UI 2026-04-26 01:19:46 +03:00
AidarKC c8fa4a01a1 Добавить версионирование схемы БД и заблокировать прод-очистку БД 2026-04-26 00:32:10 +03:00
AidarKC e764a713c4 chore(deploy): перевести UI-деплой на белый IP 194.87.0.247 2026-04-23 20:16:12 +03:00
AidarKC 93aef6e18b UI: исправить автообновление и хронологию диалогов; обновить деплой-цели 2026-04-23 18:04:14 +03:00
AidarKC f213e9aa43 feat(update): server push-команда на массовое обновление UI и формат версий 2026-04-23 16:19:00 +03:00
AidarKC 630ba30c27 chore(version): bump client/server до 1.2.1 после merge каналов 2026-04-23 15:47:48 +03:00
AidarKC b2d3061796 Merge remote-tracking branch 'origin/user/channels-ui-read-unread-views-2026-04-23' 2026-04-23 15:46:44 +03:00
AidarKC ecb018b32c feat(version): единый файл версий и отображение client/server в настройках 2026-04-23 15:37:39 +03:00
AidarKC e9f58ca004 fix(ui): чат метаданные сообщений и корректная раскладка backlog type2 2026-04-23 13:41:13 +03:00
DrygMira c0dfa6c7ab Channels UI + read/unread + unique views + style polish 2026-04-23 13:36:01 +03:00
AidarKC 78d6124f2a feat(update): проверка версии UI через Ping без периодических опросов 2026-04-22 19:57:59 +03:00
AidarKC 1a8d1c70fd feat(ui): зелёная кнопка ответа и автообновление PWA 2026-04-22 19:49:32 +03:00
AidarKC e0333a9c32 chore(docs): восстановить случайно удалённый README 2026-04-22 19:26:05 +03:00
AidarKC 9a3bc9e488 fix(call): корректный mute, статус прямое/TURN и реавторизация WS 2026-04-22 19:25:52 +03:00
AidarKC a905822515 feat(call): серверная выдача ICE/TURN и подключение в WebRTC 2026-04-22 18:11:47 +03:00
AidarKC d7c7bb3c23 fix(call): кнопки входящего и автопереподключение после обрыва 2026-04-22 17:33:57 +03:00
AidarKC 97a2bee81a feat(ui): старт с личных сообщений и бейдж непрочитанных 2026-04-22 17:23:20 +03:00
AidarKC 58bbf063ca fix(ui): обработка устаревшей сессии и корректировка индикаторов соединения 2026-04-22 17:04:16 +03:00
AidarKC 0159dd9074 feat(ui): индикатор connected в тулбаре и панель переподключения 2026-04-22 16:53:36 +03:00
AidarKC 29a07a9a8b feat(call-ui): полноценное окно звонка, статусы, звуки и тех-история вызовов 2026-04-22 15:46:45 +03:00
AidarKC c824fb5e9b fix(debug-api): починить сборку после merge и унифицировать буфер debug-логов 2026-04-22 15:03:14 +03:00
AidarKC 6a9746c17a Merge branch 'main' of https://github.com/ai5590/SHiNE-server
# Conflicts:
#	shine-UI/js/app.js
2026-04-22 14:58:28 +03:00
ai5590andGitHub af0e9e51e0 Merge pull request #17 from ai5590/codex/add-temporary-debug-api-endpoints
Добавил времеенный энд поинт для возможности легко писать и тестировать звонки. Add temporary debug HTTP API and client debug reporting; UI WS reconnect and debug call tooling
2026-04-21 20:07:45 +03:00
ai5590 5713287c84 Добавлен флаг debug.tempApi.enabled для временных debug API 2026-04-21 20:06:51 +03:00
ai5590 bd0c3dba50 Добавлен временный debug API для автотеста WebRTC и runbook 2026-04-21 19:52:25 +03:00
AidarKC e63c53a855 feat(ui): кошелек на device.key и проверки серверов (тоже пока не проверено) 2026-04-21 03:12:22 +03:00
AidarKC 8be56192cb Добавить автообновление UI и нижний статус соединения 2026-04-21 01:58:46 +03:00
AidarKC d07602b0a9 Добавить диагностику PWA/Push и endpoint тестового push 2026-04-21 01:10:56 +03:00
AidarKC 185ba5b1d3 наверное работает 2026-04-21 01:04:05 +03:00
AidarKC 2d48ae7a16 Добавить деплой без clean/тестов и доработки PWA/сессии в UI 2026-04-20 20:33:20 +03:00
AidarKC bec1d08757 Исправлен ReferenceError chatId в chat-view при отправке read-receipt 2026-04-20 12:53:46 +03:00
AidarKC cc59bd18ee WIP: новая схема сообщений и push (не проверено) 2026-04-19 20:41:58 +03:00
AidarKC f0b560ec06 Связи UI: сияние и бейдж официального
- Добавлен визуальный эффект сияния вокруг круга для аккаунтов с флагом 'Сияющий'.

- Добавлен бейдж 'ОФ' над узлом для официальных аккаунтов.
2026-04-17 23:56:11 +03:00
AidarKC ba3ee4290f Добавлено тестовое заполнение и доработки входа
- Добавлено расширенное тестовое заполнение данных через Seed_TestDataPopulation и SeedDataPopulationHelper (включая базовую схему для пользователей 1, 2, 3, их связи и профили).

- Убраны лишние проверки из тестового заполнения: сид теперь только заполняет данные.

- Исправлен WsTestClient: корректная сборка фрагментированных WS-сообщений до полного JSON.

- На странице входа по логину добавлена подсказка про основные тестовые логины 1, 2, 3 (вход без пароля).
2026-04-17 22:26:37 +03:00
AidarKC 30fcde5744 Уточнён термин close friend и улучшен UX связей
Что сделано:\n- В UI возвращён термин «Близкий друг» без пометки про «друга».\n- На графе связей добавлено меню узла с двумя действиями: «Показать информацию» и «Показать связи» (перенос узла в центр).\n- В модалке добавления связи реализован автопоиск логинов: Enter или пауза 2 секунды, до 5 подсказок, выбор кликом.\n- Добавлены стили для меню узла и списка подсказок.\n- В коде добавлены явные пояснения и alias-константы close friend (без изменения кодов 10/11 и логики):\n  CONNECTION_CLOSE_FRIEND / CONNECTION_UNCLOSE_FRIEND.\n- Обработчики чтения/записи связей переключены на alias close friend для лучшей читаемости.
2026-04-17 21:18:03 +03:00
AidarKC 4a92a7fa22 Добавлены родственные связи, расширен граф связей и улучшен локальный запуск
Что добавлено:\n- Новые типы CONNECTION для родственников: parent/child/sibling (50/51, 52/53, 54/55) в blockchain/db слоях.\n- Обновлены проверки ConnectionBody и DB-триггер connections_state для корректной записи/удаления новых связей.\n- В профиле добавлен блок "Близкие родственники" с модальным выбором типа связи и логина; добавление через AddBlock для parent/child/sibling.\n- Расширен API GetUserConnectionsGraph: out/in списки для родителей/детей/сиблингов, агрегированные списки родственников с полом, список allUsers с метками официальный/сияющий.\n- Полностью обновлен UI страницы "Связи": новое позиционирование родственников вокруг центра, отдельный цвет родственных связей, линия для взаимных связей и стрелка для односторонних, корректная геометрия линий при ресайзе.\n- Добавлена Gradle-задача startLocalWithBuild для запуска локального стека после build; сохранена отдельная startLocal без полного build.
2026-04-17 21:01:53 +03:00
AidarKC 9b188d56e9 17-04-2026
Сделал красивыми кнопки для изменения параметров на вкладке профиль
2026-04-17 17:51:03 +03:00
AidarKC c7bf8051b9 17-04-2026
Добавил вкладку пол
2026-04-17 17:39:04 +03:00
AidarKC 7591fbdace 17-04-2026
Сделал что бы все пораметры пользователя получаличь врезодним запросом ListUserParams, а не по отдельности кучей разных
2026-04-17 15:49:36 +03:00
AidarKC 5d4d451943 merge: codex/outline-call-request-workflow into main 2026-04-16 01:27:48 +03:00
AidarKC 828820b6e4 14-04-2026
Веб пуш работает. Дальше попробую звонки добавить.
2026-04-15 22:38:43 +03:00
ai5590 eaad476bf5 Add MVP call signaling API and browser call flow 2026-04-15 09:33:37 +03:00
AidarKC 0b7691bdea 14-04-2026
Веб пуш работает. Дальше попробую звонки добавить.
2026-04-15 00:50:25 +03:00
AidarKC 21fbc8ffa0 14-04-2026
Промежуточный комит версии в которой ну хоть какието тестовые уведомления приходят. Но пока ещё вебпуш не работает
2026-04-14 23:53:54 +03:00
AidarKC 24be1d0c1f merge: add-web-push into main
# Conflicts:
#	shine-UI/js/router.js
#	shine-UI/js/services/auth-service.js
#	shine-UI/js/state.js
2026-04-14 22:35:28 +03:00
AidarKC 2830f75f65 merge: drygmira/main into main 2026-04-14 22:05:18 +03:00
AidarKC c4a3ff6f68 14-04-2026
То что дела ай и то во что надо влить изменнеия
2026-04-14 21:55:57 +03:00
AidarKC cfc92beec0 14-04-2026
То что дела ай и то во что надо влить изменнеия
2026-04-14 21:51:16 +03:00
DrygMira cdfc416d02 Finalize remaining Channels updates 2026-04-14 13:49:03 +03:00
DrygMira e17a6765ec Fix channel/author subscription confirmation and follow trigger persistence 2026-04-14 13:47:59 +03:00
DrygMira 7bdb3118ae Channels UI surgical cleanup and create description save fix 2026-04-14 10:36:50 +03:00
DrygMira 126b4ba3a1 channels ux cleanup and create-flow recovery 2026-04-14 02:08:44 +03:00
DrygMira 07e57b8563 chore: merge remote main baseline 2026-04-13 23:01:27 +03:00
DrygMira a9c69e5947 feat: finalize channels fixes and runtime stability 2026-04-13 23:00:36 +03:00
DrygMiraandGitHub 1340c8e9c6 Initial commit 2026-04-13 19:51:35 +03:00
ai5590 62e55dbaec feat(dm): implement signed direct messaging with web push fallback 2026-04-12 19:34:55 +03:00
AidarKC 1ee2a1cf62 12 -04-2026
Сделал отдельную ветку для ai
2026-04-12 18:30:31 +03:00
AidarKC ad45e005f5 fix(ui): исправление мелкого бага деплоя
Исправлен скрипт deploy_shine-PWA.sh: версия сборки теперь проставляется в staged-копии и обновляется только __SHINE_BUILD_HASH__, чтобы не ломать index.html при деплое.
2026-04-07 21:29:48 +03:00
AidarKC 0c7d8fac02 07-04-2026
Сделал вкладку параметры пользователя РАБОТАЮЩЕЙ

Добавил
- Локальный запуск
- техническое задание 1 (доработать ui что бы RFYFKS работали)
2026-04-07 14:43:08 +03:00
AidarKC 0b7ad79032 ДОАВИЛ В ГРАДЛЕ ЛОКАЛЬНЫЙ ЗАПУСК 2026-04-07 14:25:49 +03:00
AidarKC e2a9caa07d Внёс изменения что бы постоянно не обновляло версию каждого JS файла и не создавало кучу шума 2026-04-07 14:01:29 +03:00
AidarKC 619d2145f9 Закомитил промежуточную почти работающую версию ... 2026-04-07 13:57:32 +03:00
AidarKC 3016d25f73 Закомитил промежуточную почти работающую версию ... 2026-04-07 13:57:09 +03:00
ai5590 4deaedf79f Trim profile param flow to AddBlock-only write path 2026-04-07 02:18:32 +03:00
AidarKC 3a412fcd51 Merge branch 'codex/add-personal-data-to-blockchain-api'
# Conflicts:
#	shine-UI/js/pages/profile-view.js
#	shine-UI/js/services/auth-service.js
#	task/2.md
2026-04-07 01:11:54 +03:00
AidarKC d9e61e7c5b добаил автозаполнение тестовых пользователей 2026-04-07 01:05:33 +03:00
ai5590 f3e4651bd5 Профиль: рабочие переключатели official/shine и подтверждение блокчейн-записи 2026-04-07 01:03:33 +03:00
AidarKC 9cbff47194 Добавил массовое тестовое заполнение через API: A1..A10 и дружеские связи
- Переименован тест в Seed_TestDataPopulation (не IT_07_*)\n- Тест создаёт пользователей A1..A10 через AddUser\n- Дружеские связи формируются через AddBlock (CONNECTION_FRIEND)\n- Добавлен контроль количества друзей (A1=5, A2=7, A3=3)\n- Тест включён в обязательный запуск всех IT и в suite\n- Обновлена TASKS-документация по тестовым логинам
2026-04-06 10:28:22 +03:00
ai5590 525627c972 Профиль: реальные UserParam данные в правой вкладке и обновление через сервер 2026-04-05 20:39:17 +03:00
AidarKC 6d8777da83 Merge remote-tracking branch 'origin/main' 2026-04-05 14:49:25 +03:00
ai5590andGitHub 8fb428ef0d Merge pull request #11 from ai5590/codex/add-personal-data-to-blockchain-api-1vsw7s
Docs: clarify `sessionId` format, recommend USER_PARAM keys for profile, and note missing direct-session RPC
2026-04-05 14:48:40 +03:00
ai5590 1bfe742278 task: добавлен русский чеклист проверки API-доков по USER_PARAM и sessionId 2026-04-05 14:48:26 +03:00
AidarKC 76b1131d95 Merge remote-tracking branch 'origin/main' 2026-04-05 14:43:11 +03:00
ai5590andGitHub 670e7e9743 Merge pull request #10 from ai5590/codex/add-personal-data-to-blockchain-api
docs: document profile user-param keys and session-target messaging gap
2026-04-05 12:40:11 +03:00
ai5590andGitHub ff911636c7 Merge pull request #8 from ai5590/codex/add-session-closing-notifications-and-pwa-support
Add PWA / FCM push support (frontend + server) with direct messages and delivery tracking
2026-04-05 12:39:49 +03:00
ai5590 61f4c1e115 docs: clarify user params profile keys and sessionId format 2026-04-05 12:39:21 +03:00
AidarKC 3ded1da707 Merge remote-tracking branch 'origin/codex/add-session-closing-notifications-and-pwa-support' 2026-04-05 12:20:33 +03:00
AidarKC c9e4b8dfbf Промежуточная версия
в которой надо дорабоать

1. Исправить ошибки и сделать что бы работала вторая слева вкладка. ТОесть АПИ для сервера я сделал (пока они возвращают весь список сообщений целиком - всем большим списком сообщений в канал - для мвп это устраивает,и по этому только три АПИ функции добавилось)

  Там какието ошибки на клиенте ( я только сгенерил код - но гдето вылетает) по UI можешь исправлять переделывать - моешь оставить калечное как есть - мне пока не важно. Важно увидить что каналы и сообщения и публичная переписка в каналах блокчейна работает

2. потестировать и сделать корректное завершение сессии (там есть глюки при завершении сесии)
2026-04-05 12:14:17 +03:00
ai5590andGitHub c08826e848 Merge pull request #9 from ai5590/codex/add-session-closing-notifications-and-pwa-support-7qeofz
Add PWA/FCM push, direct messaging and connections (client + server)
2026-04-05 12:13:17 +03:00
ai5590 bc8f4a0582 Add handoff task document for PWA, chats, and connections 2026-04-05 12:13:05 +03:00
ai5590 ad323d17a2 Add handoff task document for PWA, chats, and connections 2026-04-05 12:12:59 +03:00
ai5590 09566fdfde Add close-friend flow on network tab with server API 2026-04-05 12:12:55 +03:00
ai5590 91ed444c90 Allow first DM to any user and show real login in profile 2026-04-05 12:12:46 +03:00
ai5590 32c046233b Add WS push events, PWA/FCM scaffolding, and direct messaging MVP 2026-04-04 18:10:25 +03:00
AidarKC cf5460c5c7 Промежуточная версия
в которой надо дорабоать

1. Исправить ошибки и сделать что бы работала вторая слева вкладка. ТОесть АПИ для сервера я сделал (пока они возвращают весь список сообщений целиком - всем большим списком сообщений в канал - для мвп это устраивает,и по этому только три АПИ функции добавилось)

  Там какието ошибки на клиенте ( я только сгенерил код - но гдето вылетает) по UI можешь исправлять переделывать - моешь оставить калечное как есть - мне пока не важно. Важно увидить что каналы и сообщения и публичная переписка в каналах блокчейна работает

2. потестировать и сделать корректное завершение сессии (там есть глюки при завершении сесии)
2026-04-03 11:45:42 +03:00
ai5590andGitHub c25393e3b6 Задание которое надо доделать 2026-04-03 11:06:52 +03:00
AidarKC 8a83ac85d9 Промежуточная версия
в которой надо дорабоать

1. Исправить ошибки и сделать что бы работала вторая слева вкладка. ТОесть АПИ для сервера я сделал (пока они возвращают весь список сообщений целиком - всем большим списком сообщений в канал - для мвп это устраивает,и по этому только три АПИ функции добавилось)

  Там какието ошибки на клиенте ( я только сгенерил код - но гдето вылетает) по UI можешь исправлять переделывать - моешь оставить калечное как есть - мне пока не важно. Важно увидить что каналы и сообщения и публичная переписка в каналах блокчейна работает

2. потестировать и сделать корректное завершение сессии (там есть глюки при завершении сесии)
2026-04-03 11:04:59 +03:00
AidarKC 78e62997d1 Промежуточный комит для отдачи задания брату 2026-04-03 10:50:44 +03:00
ai5590 c0fba4af94 Add channels IT coverage, live UI loading, and runbook 2026-03-30 16:06:28 +03:00
ai5590 9723696b2c Start server-side channel read RPC handlers and simplify API spec 2026-03-30 14:32:15 +03:00
AidarKC eb5593c7be 30 03 25
Сделал адекватное отображение ключей / и при регистрации ключи спрашивают какие сохранять

(что то работает что то сложно)
2026-03-30 03:11:09 +03:00
ai5590andGitHub 089146a137 Merge pull request #5 from ai5590/codex/connect-ui-client-to-server-for-authentication-8njihj
Add AuthService, WS client and key-vault; implement session-based auth flow and update auth UI/pages
2026-03-30 02:25:59 +03:00
ai5590andGitHub d4c8201a88 Merge branch 'plus_ui' into codex/connect-ui-client-to-server-for-authentication-8njihj 2026-03-30 02:25:51 +03:00
ai5590 538ec8ec73 Auto-resume active session on app reload 2026-03-30 02:25:14 +03:00
ai5590andGitHub 52fa631733 Merge pull request #4 from ai5590/codex/connect-ui-client-to-server-for-authentication-xqvn1u
Add AuthService + crypto/key-vault + WS client and integrate real auth/session flows into UI
2026-03-30 02:16:35 +03:00
ai5590andGitHub 284e962910 Merge branch 'plus_ui' into codex/connect-ui-client-to-server-for-authentication-xqvn1u 2026-03-30 02:16:20 +03:00
ai5590 6ba7a54921 Restore registration step flow and create new session on login 2026-03-30 02:09:44 +03:00
ai5590andGitHub ecd059ced2 Merge pull request #3 from ai5590/codex/connect-ui-client-to-server-for-authentication
Implement authentication backend (AuthService, WS client, crypto, key vault) and integrate session flows in UI
2026-03-30 01:52:31 +03:00
ai5590 4f825e2a86 Derive root/device/blockchain keys from password SHA-256 2026-03-30 01:52:12 +03:00
AidarKC 1bf1c768dd 30 03 25
Удалил наверное устаревшую  документацию
2026-03-30 00:53:45 +03:00
AidarKC b33fa4aeaa 30 03 25
Добавил сайт с UI прямо сюда
2026-03-30 00:43:49 +03:00
ai5590andGitHub 889ce0d921 Merge pull request #2 from ai5590/codex/analyze-block-addition-functionality-and-create-api-docs
Документация: добавление API `AddBlock` и раздела Blockchain в Dev_Docs
2026-03-30 00:38:39 +03:00
ai5590andGitHub 1c9841b4a6 Merge branch 'main' into codex/analyze-block-addition-functionality-and-create-api-docs 2026-03-30 00:36:29 +03:00
AidarKC 99cf000f24 30 03 25
Добавил АПИ функцию которая возвращает информацию о версии сервера и о том что он работает
2026-03-30 00:34:16 +03:00
ai5590 3d780a2605 Split blockchain block-format docs by block type 2026-03-28 11:11:16 +03:00
AidarKC 1aabcf4d80 27 03 25
Доделал API функции для авторификации и работы с сессиями сервер и документ для разработчиков по

Авторификациии и серверам

Всё работает
2026-03-27 22:06:19 +03:00
AidarKC 51de9779e3 27 03 25
Доделал сервер и документ для разработчиков по

Авторификациии и серверам

Всё работает
2026-03-27 16:29:19 +03:00
AidarKC 2f9cf2bff1 27 03 25
Добавил документ для разработчиков (про сессии но не закончил) и исправил мекую ошибку с несопостовлениеминдексов 2
2026-03-27 14:59:52 +03:00
AidarKC 6d3719ba71 27 03 25
Добавил документ для разработчиков (про сессии но не закончил) и исправил мекую ошибку с несопостовлениеминдексов
2026-03-27 14:44:01 +03:00
ai5590andGitHub dabda362e6 Merge pull request #1 from ai5590/codex/create-dev_docs-folder-with-documentation
Добавил документацию от кодекса
2026-03-26 15:29:22 +03:00
ai5590 b23ecdfdf2 Add Dev_Docs with protocol, blockchain, and API design analysis 2026-03-26 15:27:59 +03:00
AidarKC 18bf5d65d7 Initial commit 2026-03-18 22:28:13 +03:00
AidarKC 37c36ffdba 19 02 25
сделал единый формат протокола в случае ошибок (Наверное сделал удобнее)
2026-02-19 18:06:03 +03:00
AidarKC c7440e2b5c 19 02 25
Лобавил пинг
2026-02-19 17:33:58 +03:00
AidarKC 6949fd8a2f 06 02 25
Сделал отдельную команду что бы всё на сервер заливать
2026-02-06 16:51:18 +03:00
AidarKC ef72719502 06 02 25
Сделал отдельную команду что бы всё на сервер заливать
2026-02-06 16:49:58 +03:00
AidarKC a647091a3f 06 02 25
Сделал отдельную команду что бы всё на сервер заливать
2026-02-06 16:47:28 +03:00
AidarKC b5706d3ed5 29 01 25
Зделал всё только на база64 без всяких урл сафе

Вроде всё работает
тест весь проходит
2026-01-29 19:29:08 +03:00
AidarKC 0b2bee0a3d 29 01 25
Удалил старые черновики
тест весь проходит
2026-01-29 18:30:55 +03:00
AidarKC 4cee326a25 28 01 25
додела запрос связи с друзьями
тест весь проходит
2026-01-29 17:33:30 +03:00
AidarKC bf4cecde05 28 01 25
додела запрос связи с друзьями
тест весь проходит
2026-01-29 17:28:50 +03:00
AidarKC 84bef3365e 28 01 25
доработки по запросу связи друг
тест пока не весь проходит
2026-01-29 17:26:54 +03:00
AidarKC 922c18db4b 28 01 25
Добавил запрос связей пользователя - друзей - для построения графа друзей

И тест добавил.

но тест пока не весь проходит
2026-01-28 22:31:20 +03:00
AidarKC 22fb35d1d4 28 01 25
Добавил запрос поиска пользователей по начаоу логина.
И тест добавил.

Все тесты проходят.
2026-01-28 21:23:01 +03:00
AidarKC ebf7c9f18e 28 01 25
Добавил запрос проверить есть ли в системе такой пользователь и получить его данные.

И тесты добавил.

Все тесты проходят
2026-01-28 20:33:06 +03:00
AidarKC 43b0efb4d3 23 01 25
Ура прошли все тесты новой версии авторификации!!

Осталось что то доделать поправить с лишними закрытиями сервака
2026-01-23 22:10:14 +03:00
AidarKC 4430615117 23 01 25
Промежуточный комит.
Ужас как устал сегодня, узнал что и запросы у меня постоянно закрывают сессию. Надо переделать
2026-01-23 21:52:45 +03:00
AidarKC e84c63c3d1 23 01 25
Сделал авторификацию новую через sessionKey

(Но пока тесты сессии падают)
2026-01-23 20:50:58 +03:00
AidarKC 580695b486 23 01 25
Сделал ещё более два поля в общем формате блоков блокчейна (перед самим блоком данных) и перед его цп

(все тесты проходят)
2026-01-23 17:49:13 +03:00
AidarKC 9f1ca37977 23 01 25
Сделал более понятный названия у интерфейса  BodyHasLine

(всё работает)
2026-01-23 13:05:29 +03:00
AidarKC c1964adb58 22 01 25
перенёс класс у вдругую папку
Вроде всё работает и тесты проходят.

И блоки добавляются все что надо для MVP
2026-01-22 02:19:25 +03:00
AidarKC ad5525d88b 22 01 25
увеличел количество тестовых запросов добавления блоков

И вроде всё работает и тесты проходят.

И блоки добавляются все что надо для MVP
2026-01-22 02:18:12 +03:00
AidarKC 98d478531b 22 01 25
добавил комент
Да вроде всё работает и тесты проходят.

И блоки добавляются все что надо для MVP
2026-01-22 02:09:28 +03:00
AidarKC a2495afa44 22 01 25
Да вроде всё работает и тесты проходят.

И блоки добавляются все что надо для MVP
2026-01-22 01:59:49 +03:00
AidarKC 3f5f94a53f 22 01 25
Да вроде всё работает и тесты проходят.

И блоки добавляются все что надо для MVP
2026-01-22 01:57:02 +03:00
AidarKC 97840a45d6 22 01 25
Счас попробую новое просто добавить от гпт
Патч работает добавление линий - ситуация сложная

тест падает
2026-01-22 01:20:51 +03:00
AidarKC 69cd33479b 15 01 25
Потч работает добавление линий - ситуация сложная

тест падает
2026-01-21 18:37:05 +03:00
AidarKC 376d42cd79 15 01 25
Доделал типы сообщений посты в линии и едиты на них.ответы на них
И ответы в другие блокчейны

(Все тесты тесты проходят)
2026-01-15 18:55:03 +03:00
AidarKC b69075cbac 15 01 25
Добавил мелких доп проверок

(Все тесты тесты проходят)
2026-01-15 15:24:25 +03:00
AidarKC bbca821dcd 15 01 25
Исправил что бы в интерфейсе BodyHasTarget не требывалось хранить в блоках  BodyHasTarget
и в блоках коннекстин не зранилась поле тоЛогин в байты блока.

(Все тесты тесты проходят)
2026-01-15 15:13:29 +03:00
AidarKC d9fe1f02b8 15 01 25
Исправил что бы в интерфейсе BodyHasTarget не требывалось хранить в блоках  BodyHasTarget
и в блоках коннекстин не зранилась поле тоЛогин в байты блока.

(Все тесты тесты проходят)
2026-01-15 15:09:45 +03:00
AidarKC 5fe41c7656 13 01 25
мелкие исправления. Убрал оставшиеся странные связи линии
2026-01-13 17:54:10 +03:00
AidarKC 9cf6fabe64 13 01 25
мелкие исправления 2
2026-01-13 17:46:23 +03:00
AidarKC cd0352f904 13 01 25
мелкие исправления
2026-01-13 17:34:30 +03:00
AidarKC fa30bd2a49 13 01 25
Перевёл блокчен на новый формат!
Все тесты проходят!!

Может в каких то деталях/мелочах ещё что то не так (не смотрел подробно), но в общем выглядит всё хорошо.

И главное работает!
2026-01-13 16:28:30 +03:00
AidarKC e9e05c1192 13 01 25
Переписал код кучи классов перешёл на новый надеюсь теперь подходящий формат блоков

и тесты переделал.

Но пока остались баги и тесты не проходят (в частности пользователи не создаются - ошибка в бд)
2026-01-13 16:18:38 +03:00
AidarKC b7025dde59 13 01 25
Запрос подписок, но это версия уже не актуальна тк дальше буду переделывать блоки под новый формат
2026-01-13 12:44:45 +03:00
AidarKC 973a632b85 09 01 25
На этом с форматом разобрались и отложили всё на праздничные выходные
2026-01-10 01:47:00 +03:00
AidarKC 9d0da4b39f 08 01 25
Список каналов возвращает - хотя сырое всё как то - но всё работает :)
2026-01-09 00:56:05 +03:00
AidarKC aba86fc687 08 01 25
ещё помелочи
2026-01-09 00:03:40 +03:00
AidarKC 4c87207129 08 01 25
Помелоги поменял
2026-01-08 23:32:17 +03:00
AidarKC 7a167b470a 08 01 25
Сделал что бы при создании пользователя передавались три ключа пользователя. И имя блокчейна сделал через "-"
2026-01-08 15:02:01 +03:00
AidarKC 1ea5390771 08 01 25
Переименовал везде loginKey в solanaKey
2026-01-08 14:44:47 +03:00
AidarKC a218f6586d 08 01 25
Навёл порядок в тестах. и доки дописал.
Всё красиво и работает!
2026-01-08 14:25:37 +03:00
AidarKC 4753b83831 08 01 25
Навёл порядок в тестах.
Всё красиво и работает!
2026-01-08 14:21:55 +03:00
AidarKC a2626dfdd0 08 01 25
Добавил счётчик сколько раз изменялось сообщение
2026-01-08 14:13:44 +03:00
AidarKC 8e19486cf5 08 01 25
Тесты почти переделал
2026-01-08 14:12:16 +03:00
AidarKC e2b89da2fa 08 01 25
Вынес константы, начал переделаывать тесты
2026-01-08 13:24:55 +03:00
AidarKC f1af2bd4d4 07 01 25
вынес константы SHiNe
2026-01-08 00:02:43 +03:00
AidarKC 1c94bb25a6 07 01 25
сделал тест и он работает на то что бы изменить тект сообщения
2026-01-07 23:50:16 +03:00
AidarKC 06c77b1c1f 07 01 25
refactor: перевели хэши на BLOB и добавили поля block_hash / block_signature / edited_by_block_global_number

и главное добавили тип блока изменение сообщение и сслку на последнее изменение в табл блокс
2026-01-07 19:58:50 +03:00
AidarKC 8bcaa192c5 05 01 25
Добавил текст описание форматов блокчейна
2026-01-07 18:16:14 +03:00
AidarKC 8fd7f4676b 05 01 25
поменял все названия таблиц и полей в таблицах на стиль только маленькие буквы и разделение через "_"  . Все тесты проходят норм
2026-01-06 01:49:26 +03:00
AidarKC 93c007b2b9 05 01 25
добавил таблицу message_state и тригеры который считает в ней актуальное количество всех лайков и ответов на сообщения! (и всё рабоатет - тесты проходят)
2026-01-06 01:11:29 +03:00
AidarKC eb922d918b 05 01 25
добавил таблицу connections_state и тригер который ведёт в ней актуальное состояние всех связей! (и всё рабоатет - тесты проходят)
2026-01-06 00:30:37 +03:00
AidarKC 7ba333bf6c 05 01 25
добавил новые типы связи - тоесть возможность добавлять убирать друга, контакт или подписку  (и тесты и всё работает)
2026-01-06 00:24:24 +03:00
AidarKC 94777c58c6 05 01 25
Документы: Описание БД и всех запросов актуализировал!
2026-01-05 17:17:39 +03:00
AidarKC a6a5089379 05 01 25
Запрос для работы с параметрами пользователя работают!! И тесты на них проходят!!
2026-01-05 16:45:37 +03:00
AidarKC eb122456ab 05 01 25
Дабавил и два запроса на получение параметров, но пока не работает
2026-01-05 16:42:32 +03:00
AidarKC 55d34e2a87 05 01 25
Дабавил и два запроса на получение параметров, но пока не проверял
2026-01-05 16:14:14 +03:00
AidarKC bfffe44c4a 05 01 25
Дабавил соранение параметра пользователя. (бд и запрос на добавление), но пока не проверял
2026-01-05 16:11:54 +03:00
AidarKC dd49c4de00 02 01 25
Сделал что бы в базу писался msgSubType и поля to (to login, toBlockchainName и т.д.)
2026-01-02 20:15:59 +03:00
AidarKC eef760d776 02 01 25
улучшил скрипт склейки файлов (что бы он сразу в буфер кидал результат)
2026-01-02 19:52:53 +03:00
AidarKC fa019bcb4f 02 01 25
ОГО доделал и тесты (теперь два пользователя добавляется и меж ними есть связи) вроде всё работает
2026-01-02 19:09:56 +03:00
AidarKC 432b574592 02 01 25
ОГО доделал и тесты (теперь два пользователя добавляется и меж ними есть связи) вроде всё работает
2026-01-02 19:09:17 +03:00
AidarKC c3d20ba338 02 01 25
Доделал тесты и названия линий сделал в константы

Дальше делать:
Описание форматов.
Запросы клиент-сервер.
Промт на клиента.

---
Потом в сервак дописать
Синхронизацию серверов.
2026-01-02 18:52:19 +03:00
AidarKC be7a3ab7a6 02 01 25
Добавил боди для параметров пользователя

Дальше делать:
Описание форматов.
Запросы клиент-сервер.
Промт на клиента.

---
Потом в сервак дописать
Синхронизацию серверов.
2026-01-02 18:44:35 +03:00
AidarKC 272d7ca1be 02 01 25
Добавил боди для связей

Дальше делать:
Описание форматов.
Запросы клиент-сервер.
Промт на клиента.

---
Потом в сервак дописать
Синхронизацию серверов.
2026-01-02 18:16:59 +03:00
AidarKC 05a4714fb1 02 01 25
Добавил боди для связей

Дальше делать:
Описание форматов.
Запросы клиент-сервер.
Промт на клиента.

---
Потом в сервак дописать
Синхронизацию серверов.
2026-01-02 17:18:13 +03:00
AidarKC 59e5df0dd3 02 01 25
Сделал тесты с ответами на сообщения

Дальше делать:
Описание форматов.
Запросы клиент-сервер.
Промт на клиента.

---
Потом в сервак дописать
Синхронизацию серверов.
2026-01-02 17:06:07 +03:00
AidarKC 771758c831 02 01 25
Переделал тест добавления блоков в новый формат ( стло удобнее)

Дальше делать:
Описание форматов.
Запросы клиент-сервер.
Промт на клиента.

---
Потом в сервак дописать
Синхронизацию серверов.
2026-01-02 16:54:13 +03:00
AidarKC ca55bfca93 02 01 25
Добавил поле subType и исправил мелкие баги (все тесты работают)

Дальше делать:
Описание форматов.
Запросы клиент-сервер.
Промт на клиента.

---
Потом в сервак дописать
Синхронизацию серверов.
2026-01-02 16:42:15 +03:00
AidarKC 71f1a6179c 31 12 25
Стабильная версия сервера  0.2
Пакует блокченй в файл и бд, проверяет форматы.

Дальше делать:
Описание форматов.
Запросы клиент-сервер.
Промт на клиента.

---
Потом в сервак дописать
Синхронизацию серверов.
2025-12-31 21:50:16 +03:00
AidarKC c13940216b 31 12 25
тест
2025-12-31 21:41:58 +03:00
AidarKC f17d077f25 31 12 25
Сделал что бы запускалось. Поправил мелкие ошибки
2025-12-31 21:10:05 +03:00
AidarKC 62ea49d1fc 30 12 25
Ура работает всё под новую таблицу. И все тесты проходят!!
2025-12-30 13:03:03 +03:00
AidarKC f653689112 30 12 25
Ура работает всё под новую таблицу 2
2025-12-30 12:55:21 +03:00
AidarKC df03f3f4ba 30 12 25
Ну типо переделал Всё под короткую таблицу солана юзерс, но теперь не надо поправить баги
2025-12-30 12:41:06 +03:00
AidarKC 34e8640e78 30 12 25
Ну типо переделал Всё под короткую таблицу солана юзерс, но теперь не надо поправить баги
2025-12-30 12:39:55 +03:00
AidarKC b6b50557a7 29 12 25
добавил toString для классов Body
2025-12-29 15:31:20 +03:00
AidarKC 08d90b6e8e 29 12 25
исправил микро баг
2025-12-29 15:10:14 +03:00
AidarKC 43a26007d6 29 12 25
Тесты работают - запускаются из специальной кнопки - возле билд.

Не совсем всё по стандарту но и это очень хорошо и удобно!
2025-12-29 15:03:42 +03:00
AidarKC 7fdc890a85 29 12 25
В общем тесты работают. Только запускать их надо из специального класса :)
2025-12-29 14:51:13 +03:00
AidarKC ae3838ccf2 29 12 25
Ура линии работают тесты прошли! Третий тест в лог выводиться!
2025-12-29 14:14:53 +03:00
AidarKC 783b5b08e3 29 12 25
Ура линии работают тесты прошли! 2
2025-12-29 14:07:20 +03:00
AidarKC 015caec01c 29 12 25
Ура линии работают тесты прошли!
2025-12-29 13:53:22 +03:00
AidarKC 526e2d9cc4 28 12 25
Всё ещё не работает проверка линий.
Переделываю тесты понял что нетак в сервере. Дальше буду исправлять сервак.
2025-12-29 13:33:26 +03:00
AidarKC 3f374f48e1 28 12 25
Всё ещё не работает проверка линий.
Переделал первые два теста! Третий (АддБлокс) ещё не работает
2025-12-29 13:16:00 +03:00
AidarKC 795341dd8d 28 12 25
Всё ещё не работает проверка линий. Залил старые тесты какие есть - каке есть
2025-12-29 12:13:12 +03:00
AidarKC c523816cdf 28 12 25
Вроде как сделал работу с линиями :) но ещё не тестил
2025-12-28 20:11:31 +03:00
AidarKC b26e09904a 28 12 25
Старая версия. Вроде там тесты доделал что бы работали
2025-12-28 19:48:23 +03:00
AidarKC 1526392ca5 28 12 25
Старая версия. Вроде там тесты доделал что бы работали
2025-12-28 18:39:13 +03:00
AidarKC 809d897da6 26 12 25
Исправил баг что пир повторном подключении по одной сессии всё будет норм работать
2025-12-26 13:12:53 +03:00
AidarKC eeb8ee9069 25 12 25
Сделал три теста на общий формат
2025-12-25 17:16:15 +03:00
AidarKC 25aa57dc5e 25 12 25
Добавил восстановление на случай застрявших темп файлов.  Оно работает!  И уведомление админа о критических ощибках, если файлы блокчейна поврежденны
2025-12-25 17:08:08 +03:00
AidarKC 6c2449f623 25 12 25
Добавил восстановление на случай застрявших темп файлов. Оно работает!
2025-12-25 16:42:40 +03:00
AidarKC d8057807a3 25 12 25
Вроде заработало!!
2025-12-25 16:10:33 +03:00
AidarKC e532401a75 25 12 25
Добавил логгер в настройки.2
2025-12-25 16:05:25 +03:00
AidarKC f8cc12560e 25 12 25
Добавил логгер в настройки.2
2025-12-25 16:00:57 +03:00
AidarKC c8ee9925a1 25 12 25
Добавил логгер в настройки.Омталось созранение стате в бд поправить
2025-12-25 14:53:08 +03:00
AidarKC d460ea2952 25 12 25
Дорабатываю добавление блоков. Промежуточный комит2.Омталось созранение стате в бд поправить
2025-12-25 14:32:58 +03:00
AidarKC e1b2c62231 25 12 25
Дорабатываю добавление блоков. Промежуточный комит
2025-12-25 14:15:26 +03:00
AidarKC bead78b372 24 12 25
Дорабатываю добавление блоков.
2025-12-24 17:29:50 +03:00
AidarKC 4e14f300f9 24 12 25
Дорабатываю добавление блоков.
Убрал лишние старые классы
2025-12-24 17:11:29 +03:00
AidarKC 4759521176 24 12 25
Дорабатываю добавление блоков.
Улучшил класс записыватель в БД.
2025-12-24 17:01:10 +03:00
AidarKC a309b6f3ef 24 12 25
Дорабатываю добавление блоков. Ура добавилось.
Объеденил в один Хэндлер и сделал атомарную запись в БД.
2025-12-24 16:42:26 +03:00
AidarKC 834cf98ef9 24 12 25
Дорабатываю добавление блоков. Ура добавилось. Осталось порядок навести и добавление файлов сделать и откат.
2025-12-24 14:55:30 +03:00
AidarKC 80ea016687 24 12 25
Дорабатываю добавление блоков. Промежуточный исправления (не работают)
2025-12-24 14:22:50 +03:00
AidarKC 5ecaf67bcb 24 12 25
Дорабатываю добавление блоков. Поставил todo что доделать
2025-12-24 14:08:40 +03:00
AidarKC 33635886e0 23 12 25
Дорабатываю добавление блоков! Вроде всё.  (но ещё не проверял и тестов нету)
2025-12-24 11:05:25 +03:00
AidarKC bba4b7fb41 23 12 25
Дорабатываю добавление блоков! Вроде всё.  (но ещё не проверял и тестов нету)
2025-12-23 16:14:25 +03:00
AidarKC 26afcb892a 23 12 25
Дорабатываю добавление блоков! Вроде всё.Осталось ещё размер уточнить что без хэш и пподписи  2
2025-12-23 16:12:36 +03:00
AidarKC 9633e3528d 23 12 25
Дорабатываю добавление блоков! Вроде всё.Осталось ещё размер уточнить что без хэш и пподписи
2025-12-23 15:58:54 +03:00
AidarKC 62e4338e88 23 12 25
Дорабатываю добавление блоков!
2025-12-23 15:48:23 +03:00
AidarKC d949895fec 23 12 25
Сессии работают пользователи добавляются. И тесты красиво выводият коменты. Старый класс теста больше не нужен
2025-12-23 14:55:18 +03:00
AidarKC b5fa05a660 23 12 25
Сессии работают пользователи добавляются. И тесты красиво выводият коменты. Старый класс теста больше не нужен
2025-12-23 14:00:08 +03:00
AidarKC c515d5287e 23 12 25
Сессии работают пользователи добавляются. Плюс сделал автоматические тесты как положенно
2025-12-23 13:51:20 +03:00
AidarKC ae63a653c8 23 12 25
Прошли тесты на создание сессии - посути всё работает (но добавление блоков пока не работает)
2025-12-23 12:59:12 +03:00
AidarKC 03b6ff3c32 22 12 25
Не работающая версия в странном состоянии
2025-12-23 11:32:26 +03:00
AidarKC 935ffecbb0 19 12 25
Переделал и запросы и тесты (но ещё не тетил)
2025-12-22 13:16:17 +03:00
AidarKC c140e3aae4 19 12 25
Переделал и запросы и тесты (но ещё не тетил)
2025-12-22 13:15:39 +03:00
AidarKC 7f92dc5f51 19 12 25
Ещё поправил мелкие детали в библиотеке работы с БД
2025-12-22 12:47:17 +03:00
AidarKC 627321d4ae 19 12 25
Передел библиотеку работы с БД под только login и bchName
2025-12-19 14:08:05 +03:00
AidarKC 0c49cae055 19 12 25
Минисально переименовал классы (убрал лишнее _new)
2025-12-19 13:56:18 +03:00
AidarKC 3cafd29ee5 19 12 25
В тот день Вроде дописал добавление но так и не заработало
2025-12-19 11:06:25 +03:00
AidarKC 6c4d8cd51b 19 12 25
Добавил таблицу для хранения блоков
2025-12-18 17:24:22 +03:00
AidarKC d6d2bfeb73 19 12 25
Все ДАО получили перезагруженный метод для того что бы вызываться с передачей соединения и без передачи соединия
2025-12-18 15:58:43 +03:00
AidarKC 1b1da19d3d 17 12 25
Заработало
2025-12-18 15:37:28 +03:00
AidarKC 4fb6b10a97 17 12 25
Заработало блок добавляется в файл и в статус блокчена в БД!!
Но ещё надо сделать таблицу с записями :)    2
2025-12-17 19:17:10 +03:00
AidarKC e9c11d6b75 17 12 25
Заработало блок добавляется в файл и в статус блокчена в БД!!
Но ещё надо сделать таблицу с записями :)
2025-12-17 18:35:34 +03:00
AidarKC 2037ebaa8b 17 12 25
Ещё промежуточный комит верии - не работает :)   4
2025-12-17 18:17:21 +03:00
AidarKC 45a862b11f 17 12 25
Ещё промежуточный комит верии - не работает :)   3
2025-12-17 17:21:10 +03:00
AidarKC 29c6e5a0f6 17 12 25
Ещё промежуточный комит верии - не работает :)   2
2025-12-17 17:15:52 +03:00
AidarKC aa2caf1f10 17 12 25
Ещё промежуточный комит верии - не работает :)
2025-12-17 15:57:05 +03:00
AidarKC 8188b91f86 17 12 25
Промежуточный комит верии которая может быть работает :)
2025-12-17 13:32:54 +03:00
AidarKC eb37b43de4 17 12 25
Промежуточный комит верии которая может быть работает :)
2025-12-17 13:32:06 +03:00
AidarKC eaf1affb27 17 12 25
Промежуточная версия
2025-12-17 13:06:08 +03:00
AidarKC ab44cc5282 16 12 25
Промежуточная версия и ТУДУ на чём остановился
2025-12-16 17:56:36 +03:00
AidarKC 19c4fd6cd1 11 12 25
Вроде как то заработали
2025-12-11 17:52:13 +03:00
AidarKC 096246542d 11 12 25
Добавил Вывсти список активных сессий!!
2025-12-11 17:19:17 +03:00
AidarKC a6be7b75aa 11 12 25
Добавил Закрытие сессии
2025-12-11 16:56:53 +03:00
AidarKC 80ffba545a 11 12 25
Добавил поле authNonce(Вместо SessionPWD) при запросе авторизации
2025-12-11 16:22:12 +03:00
AidarKC dbf1f22bac 11 12 25
Сделал закрытие сесси сервером если пароль не верный
2025-12-11 16:03:46 +03:00
AidarKC 7f91c60d26 10 12 25
Переименовал классы в один стиль
2025-12-11 15:29:10 +03:00
AidarKC 7072882b0b 10 12 25
Вроде всё доделал. Авторификацию. (осталось закрытие сессии и получение списка активных сессии)
2025-12-10 16:18:32 +03:00
AidarKC 00fc9e3926 10 12 25
промежуточный не рабочий комит
2025-12-10 16:15:36 +03:00
AidarKC 95ec6ba037 10 12 25
доработал получение инфы о клиенте из соединения
2025-12-10 15:28:29 +03:00
AidarKC 87da6efbfb 10 12 25
Добавил таблицы для геолокации
2025-12-10 13:54:15 +03:00
AidarKC 2ab1bbc02c 10 12 25
Всё работает. Плюс чть улучшил тест работы геолокации
2025-12-10 13:20:24 +03:00
AidarKC 47c53c1a14 10 12 25
Попереименовывал классы и запросы для авторификации.
Вроде хоршо и наверное всё работает :) пока не тестил
2025-12-10 12:58:16 +03:00
AidarKC 888bb1595f 09 12 25
Авторификация работает и тест авторификации проходит.

(создание пользователя, два этапа создания сессии и рефреш сессии)
2025-12-09 20:04:18 +03:00
AidarKC 2ed4f6d666 09 12 25
В черновую переделал авторификацию
2025-12-09 19:12:37 +03:00
AidarKC 2b5fa16824 09 12 25
Исправил что бы во входящих запросах тоже был  payload
2025-12-09 18:34:17 +03:00
AidarKC 199769cac0 04 12 25 Версия авторификации где сервер выдовал сессион Ид 2025-12-05 17:36:02 +03:00
AidarKC c9bfa2d01a 04 12 25 Версия авторификации где сервер выдовал сессион Ид 2025-12-05 17:35:58 +03:00
AidarKC fc748a744c 04 12 25 2025-12-05 10:56:34 +03:00
AidarKC 5d8dd86c96 обновляю сетевые хэндлеры 2025-12-04 13:34:04 +03:00
1087 changed files with 228938 additions and 3382 deletions
+74 -1
View File
@@ -1,4 +1,11 @@
## папки с данными создавайемыми при работе сервера
data/
logs/
logs
.understand-anything/
.gradle
.gradle-home/
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
@@ -6,6 +13,7 @@ build/
.kotlin
### IntelliJ IDEA ###
.idea/
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
@@ -40,4 +48,69 @@ bin/
.vscode/
### Mac OS ###
.DS_Store
.DS_Store
# временный debug token
.debug-token
# Локальные артефакты и секреты Solana-модуля
shine-solana/.git/
shine-solana/.git-local-backup/
shine-solana/.idea/
shine-solana/shine/.idea/
shine-solana/shine/.gradle/
shine-solana/shine/.anchor/
shine-solana/shine/.yarn/
shine-solana/shine/.vendor/
shine-solana/shine/node_modules/
shine-solana/shine/target/
shine-solana/shine/test-ledger/
shine-solana/shine/old_vers/
shine-solana/shine/program-keypair.json
shine-solana/shine/keys/
shine-solana/shine/validator.log
shine-solana/shine/doc/КОШЕЛЬКИ_DEVNET_ТЕСТ.md
shine-solana/shine/scripts/del/
shine-solana/shine/scripts/**/keypairs/
shine-solana/shine/scripts/**/runs/
shine-solana/shine/scripts/**/*.env
shine-solana/shine/scripts/**/TEMP_*.md
# Локальные артефакты и внешние материалы ESP32-подпроекта
ESP32/esp32-config-tool/
ESP32/**/.git/
ESP32/**/.idea/
ESP32-wallet/.idea/
ESP32/**/.arduino-build/
ESP32/**/official-demo/
!ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/
ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/**
!ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/examples/
ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/examples/**
!ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/examples/Arduino-v3.3.5/
ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/examples/Arduino-v3.3.5/**
!ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/examples/Arduino-v3.3.5/libraries/
ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/examples/Arduino-v3.3.5/libraries/**
!ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/examples/Arduino-v3.3.5/libraries/lv_conf.h
ESP32/**/original-firmware/*.bin
ESP32/**/original-firmware/*.bin.sha256
ESP32/**/*.elf
ESP32/**/*.map
ESP32/**/*.merged.bin
ESP32/**/*.uf2
ESP32/**/*.o
ESP32/**/*.d
ESP32/**/*.a
# Полные серверные бэкапы (тяжёлые архивы, не коммитим)
deploy/backup/archive/**
!deploy/backup/archive/.gitkeep
SHiNE-bundle-*.zip
# Локальная дев-обвязка AI-агентов (сессии, планы, настройки) — не коммитим
.agents/
.codex/
.claude/
# Рабочие бэкапы/превью-ассеты UI — не для репозитория
*.bak.png
shine-UI/assets/navbar_preview.png
-10
View File
@@ -1,10 +0,0 @@
<component name="ArtifactManager">
<artifact type="jar" build-on-make="true" name="server:jar">
<output-path>$PROJECT_DIR$/out/artifacts/server_jar</output-path>
<root id="archive" name="server.jar">
<element id="directory" name="META-INF">
<element id="file-copy" path="$PROJECT_DIR$/META-INF/MANIFEST.MF" />
</element>
</root>
</artifact>
</component>
+147
View File
@@ -0,0 +1,147 @@
# AGENTS
## Язык проекта
- По умолчанию использовать русский язык во всех пользовательских текстах и технических пояснениях.
- Пояснения к коммитам, PR и merge-запросам писать на русском языке.
- Комментарии в коде, встроенные справки, документацию и инструкции писать по возможности на русском языке.
## Примечание
- Если внешний инструмент/интеграция требует английский формат, допускается английский, но рядом желательно дать краткое пояснение на русском.
## Структура проекта (кратко)
- Серверный код SHiNE находится в папке `SHiNE-server/`.
- Код клиентского UI SHiNE находится в папке `shine-UI/`.
- Веб-панель администратора сервера (управление Solana PDA сервера) находится в `shine-UI/`:
- точка входа `shine-UI/server-ui.html`;
- остальные файлы серверного UI — в `shine-UI/server-ui/`.
- Локальный Telegram-бот агента-кодера живёт рядом с репозиторием продукта, обычно в `../SHiNE-agent-bot-coder/`, и не входит в публичный код основного приложения.
- Solana/Anchor-модуль находится в папке `shine-solana/shine/` и ведётся отдельно от основного server/UI деплоя.
## Сервис агента-кодера
- Локальный Telegram-бот-сервис агента-кодера находится вне этого git-репозитория, обычно в `../SHiNE-agent-bot-coder/`.
- Сервис принимает сообщения из Telegram, ведёт историю диалога, ставит задачи в очередь и вызывает Codex CLI для обработки запросов по проекту.
- Рабочая папка Codex для сервиса должна указывать на этот продуктовый репозиторий: `CODEX_WORKDIR=/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-product`.
- Автоматически читаемые инструкции для Codex внутри сервиса держать в `../SHiNE-agent-bot-coder/AGENTS.md`.
- Подробные служебные правила Telegram-обработчика, его очередь, история, systemd-запуск и особенности ответов описывать в `../SHiNE-agent-bot-coder/AGENT.md`.
- Если в сообщениях пользователя встречается «агент MD» или похожая формулировка про файл инструкций Codex, считать, что имеется в виду автоматически читаемый `AGENTS.md`.
## ESP32 UI homeserver
- Для UI-скетча устройства `ESP32-S3-Touch-AMOLED-2.16` документ-спецификация и Arduino-скетч должны всегда оставаться синхронными.
- Актуальный документ по экранной логике, состояниям, кнопкам, полям, статусам и ограничениям UI считать источником истины для скетча.
- При изменении документа обязательно в том же наборе изменений приводить в соответствие скетч.
- При изменении скетча обязательно в том же наборе изменений обновлять документ, если поменялись экраны, тексты, переходы, статусы, кнопки, поля или поведение.
- Для нового ESP32 UI-прототипа homeserver использовать русский язык как основной и отдельно следить, чтобы текст реально отображался на устройстве, а не только логически присутствовал в коде.
## Solana-модуль
- В проекте есть отдельный Solana/Anchor-модуль в папке `shine-solana/shine/`.
- Модуль логически связан с SHiNE, но не должен автоматически подключаться к сборке или деплою основного сервера без отдельного решения.
- В Solana-модуле действуют локальные инструкции `shine-solana/shine/AGENTS.md`; при изменениях внутри модуля сначала читать их.
- В git добавлять исходники, lock-файлы, настройки проекта и документацию Solana-модуля, но не добавлять локальные ключи, `.git`, `.idea`, `.gradle`, `target`, `node_modules`, `test-ledger`, логи, временные run-отчёты и `.env`-конфиги.
- Для Solana deploy/push использовать правила из локального `shine-solana/shine/AGENTS.md`; не смешивать deploy Solana-модуля со скриптами основного server/UI deploy из `deploy/scripts/`.
- Для регистрации пользователей в Solana (программа `shine_users`) единая актуальная инструкция по деплою/инициализации, адресам программ, и куда их прописывать в UI/сервере находится в:
- `docs/Инициализация_Solana_регистрации/README.md`
- Этот файл считать основной справкой (single source of truth) по деплою и первичной инициализации Solana-регистрации в текущем проекте.
- Актуальная архитектурная справка по устройству Solana-программ, PDA-счетам, ролям DAO и движению средств находится в:
- `docs/Solana_Architecture/README.md`
- Документ формата пользовательской PDA-записи `shine_users` находится в:
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`
- Актуальная документация по серверному модулю синхронизации Solana users находится в:
- `docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md`
- При любом изменении логики серверной синхронизации `shine_users`, её таблиц PostgreSQL, checkpoint-механизма, startup/lifecycle или deploy-настроек обязательно обновлять:
- `docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md`
- `deploy/SOLANA_USERS_SYNC_SERVER_SETUP.md`
## Документация блокчейна
- Актуальная документация по форматам блокчейна находится в `docs/Blockchain/README.md`.
- Это точка входа (оглавление), рядом расположены детальные файлы по форматам, типам каналов и командным сообщениям.
- При любом изменении кода, связанного с блокчейном (формат блока, типы каналов, правила чтения/записи, команды), обязательно обновлять соответствующие документы в `docs/Blockchain/`.
- Дополнительно обязательно вести `docs/Blockchain/CHANGELOG.md`: дописывать изменения построчно с указанием даты/времени и хэша коммита, после которого внесено изменение.
- Перед любым изменением формата блокчейна обязательно заранее предупреждать пользователя, что формат будет изменён.
- Изменять формат блокчейна можно только после явного подтверждения пользователя (без подтверждения формат не менять).
- Добавление любых данных в блокчейн выполнять только через операцию `AddBlock`.
- Перед каждым `AddBlock` обязательно проверять/актуализировать текущее состояние вершины блокчейна (`last global number/hash`) и использовать его при формировании блока.
## Документация личных сообщений (DM)
- Актуальная документация по логике личных сообщений находится в `docs/Personal_Messages/Протокол_DM_v1.md`.
- Точный байтовый формат DM находится в `docs/Personal_Messages/Формат_DM_v1.md`.
- При любом изменении кода, связанного с личными сообщениями (формат подписанного DM-блока, типы DM-сообщений, правила доставки/ACK/read-receipt, роутинг по сессиям, UI-логика чатов), обязательно обновлять оба документа:
- `docs/Personal_Messages/Протокол_DM_v1.md`
- `docs/Personal_Messages/Формат_DM_v1.md`
- Логика личных сообщений в коде должна всегда соответствовать этим документам.
- Документ по личным сообщениям обязан поддерживаться в актуальном состоянии.
## Документация API сервера
- Актуальная документация по публичному JSON/WebSocket API сервера находится в `docs/API/`.
- При любом изменении серверного API/эндпоинтов/операций `op` обязательно обновлять соответствующие документы в `docs/API/`.
- Перед изменением самого серверного API обязательно явно предупредить пользователя, какие операции, поля запросов/ответов или коды ошибок будут изменены, и запросить отдельное подтверждение.
- Без явного подтверждения пользователя формат серверного API не менять; допускается только приведение документации в соответствие уже существующему коду.
- Если добавляется новая операция `op`, нужно обновить общий список операций в `docs/API/09_Operations_Index.md` или создать его, если файла ещё нет.
## Документация Figma
- Актуальная документация по переносу экранов SHiNE в Figma и обратному переносу из Figma в код находится в `docs/Figma/`.
- Точка входа: `docs/Figma/README.md`.
- Подробный рабочий регламент: `docs/Figma/TRANSFER_UI_SCREENS.md`.
- Для экранов регистрации, входа и других чувствительных UI-flow по умолчанию переносить экраны в Figma по одному, а не пачкой, если пользователь отдельно не подтвердил иной способ.
## Версионирование
- Все правила по коммитам, merge в `main`, `git push` и обновлению `VERSION.properties` находятся в `COMMIT_AND_VERSION_RULES.md`.
- Этот файл считать единым источником истины по правилам версионирования и коммитов для данного репозитория.
## Deploy
- Все документы, инструкции, backup-правила и скрипты деплоя хранить в папке `deploy/`.
- Подробные правила для агента по деплою находятся в `deploy/AGENTS.md`; перед любым deploy читать этот файл.
- Production-серверы SHiNE: `shineup.me` и `server2.shineup.me`.
- Тестовые/devnet серверы SHiNE: `t1.shineup.me`, `t2.shineup.me`, `t3.shineup.me`, `t4.shineup.me`.
- В deploy-документах и скриптах использовать домены, а не IP.
- По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке.
- Любые изменения и любой деплой на production (`shineup.me` и `server2.shineup.me`) выполнять только после отдельного явного подтверждения пользователя.
- Перед production deploy обязательно проверить/обновить бэкап в `deploy/backup/archive/`.
- Если пользователь пишет просто `задеплой` без уточнения production/test, уточнить целевой контур; не выбирать production автоматически.
- Deploy выполнять shell-скриптами из `deploy/scripts/`; Gradle deploy-задачи не использовать.
- Для локального запуска использовать `./gradlew startLocal` (или `startLocalWithBuild`).
- Сначала предлагать локальную проверку, а деплой на сервер выполнять по запросу пользователя.
- Для временной бесплатной загрузки аватаров в Arweave секретный JWK нельзя хранить в git и нельзя прописывать в репозиторный `application.properties`.
- Для продовой настройки тестового Arweave-кошелька JWK-файл нужно хранить только на сервере, например: `/home/player/SHiNE/secrets/test-free-avatar-wallet.json`.
- Для этой временной фичи на проде должны быть заданы параметры `test.freeAvatar.walletJwkPath` и `test.freeAvatar.walletAddress` через серверный override-конфиг/секреты на хосте.
- После изменения продовых значений `test.freeAvatar.*` нужно заново выполнить серверный деплой или перезапуск сервера, чтобы настройки были перечитаны приложением.
- При таких изменениях в git допускается коммитить только документацию и код чтения настроек, но не сам JWK, не содержимое секрета и не реальные приватные ключи.
## Логи звонков (установка соединения)
- Специальный поток диагностики установки звонков идёт через `CallDeliveryReport` (клиент → сервер).
- На проде специальный файл для звонков:
- `/home/player/SHiNE/shine-server/logs/call-delivery-events.log`
- Общий серверный лог (и ротации) на проде:
- `/home/player/SHiNE/shine-server/logs/app.log`
- `/home/player/SHiNE/shine-server/logs/app.YYYY-MM-DD.log`
- Для анализа причин недозвона в первую очередь фильтровать записи по ключам:
- `CallDeliveryReport`
- `call_connected`
- `outgoing_failed`
- `incoming_failed`
- `call_busy`
- `call_declined`
- `unknown_error`
- В этих записях искать поля `reason`, `failureStage`, `pcConnectionState`, `pcIceConnectionState`, `routeLabel`, `configuredTurnHosts*`, `reachableTurnHosts*`.
## Будущие фичи / TODO
- Папка для задач, сознательно отложенных на будущее: `TODO/`.
- Точка входа по планам: `TODO/README.md`.
- Внутри планы разделены по горизонтам: `near/`, `medium/`, `far/` и тематическим подпапкам.
- Если пользователь спрашивает, какие есть планы или что можно продолжить, сначала читать `TODO/README.md`, затем при необходимости конкретные файлы из подпапок.
- Файлы из этой папки не считать активными задачами и не начинать реализацию без явной просьбы пользователя.
- Старую папку `docs/Future_Features/` считать выведенной из использования и больше не использовать для новых записей.
- Если часть кода временно отключена или закомментирована, либо удалена как временная заглушка, в TODO-файле подробно описывать:
- какие файлы и участки отключены;
- что осталось в коде как заготовка;
- какие документы нужно обновить при возврате;
- с какого сценария продолжать разработку;
- из какого коммита брать последнюю полную реализацию.
## Коммуникация по новым задачам (обязательно)
- При получении нового задания сначала кратко пересказать задачу своими словами.
- До начала реализации задать недостающие уточняющие вопросы (если они есть).
- Если есть уместные идеи/улучшения — кратко предложить их; если полезных идей нет, ничего дополнительно не предлагать.
- Добавлять краткую оценку фичи (насколько это полезно/удачно по мнению исполнителя).
- После этого обязательно запросить подтверждение от пользователя, что задача понята верно, и только после подтверждения переходить к реализации.
- Если вопросов нет, явно написать в формате: «Я всё понял, начинаю делать?» и ждать подтверждения.
- Без подтверждения пользователя реализацию не начинать.
+18
View File
@@ -0,0 +1,18 @@
# Runbook для агента: тест сетевого соединения
## Быстрый цикл
1. Убедись, что сервер запущен.
2. Скажи пользователю: «Запусти двух клиентов и напиши “продолжай”».
3. По команде «продолжай»:
- вызови `GET /debug/ws/clients`,
- выбери 2 активные сессии (предпочтительно разных логинов),
- вызови `POST /debug/ws/connect`,
- получи `runId`.
4. Читай `GET /debug/ws/logs?limit=200&runId=<runId>` и сообщай прогресс.
5. Если неуспех — покажи ошибки, предложи перезапуск 2 клиентов и повтори.
## Формат взаимодействия с пользователем
- Старт: «Сервер готов. Запусти двух клиентов и скажи “продолжай”.»
- После старта run: «Тест запущен, runId=..., проверяю логи.»
- Успех: «Соединение установлено, вижу connected.»
- Неуспех: «Соединение не поднялось, причины: ... Предлагаю перезапустить клиентов и повторить.»
+11
View File
@@ -0,0 +1,11 @@
@AGENTS.md
@AGENT_DEBUG_RUNBOOK.md
## Обязательно читать при работе с UI
@shine-UI/AGENTS.md
## Справка по подпроектам
- При работе с локальным агентом-кодером — читать внешние файлы `../SHiNE-agent-bot-coder/AGENTS.md` и `../SHiNE-agent-bot-coder/AGENT.md`.
- При работе внутри `shine-solana/shine/` — читать `shine-solana/shine/AGENTS.md`.
- При работе внутри `shine-UI/server-ui/` — читать `shine-UI/AGENTS.md`.
- При работе внутри `SHiNE-server/` — читать `SHiNE-server/AGENTS.md`.
+47
View File
@@ -0,0 +1,47 @@
# Правила коммитов, merge и версионирования
Этот файл является единым источником правил для:
- коммитов;
- merge в `main`;
- изменения версий в `VERSION.properties`;
- `git push` из этого репозитория.
## Язык
- Пояснения к коммитам, PR и merge-запросам писать на русском языке.
## Где хранится версия
- Единый файл версий проекта: `VERSION.properties` в корне репозитория.
- Основные поля:
- `client.version` — версия клиентского UI.
- `server.version` — версия серверной части.
## Базовое правило для обычных коммитов
- Перед каждым новым коммитом обязательно обновлять версии в `VERSION.properties`.
- Если менялся только UI, увеличивать только `client.version`.
- Если менялся только сервер, увеличивать только `server.version`.
- Если менялись и UI, и сервер, увеличивать обе версии.
- Для обычных коммитов вне `main` использовать стандартный patch-инкремент: `+1` к последнему числовому сегменту.
- Пример: `1.2.346``1.2.347`.
## Правило для `main`
- Ветка `main` предназначена только для стабильных версий.
- По умолчанию в `main` нужно не коммитить напрямую, а мержить готовые изменения из рабочей ветки.
- Если пользователь просит сделать прямой коммит в `main`, нужно отдельно и явно предупредить, что это обход обычного стабильного процесса, и обязательно переспросить подтверждение.
## Версионирование при merge или прямом коммите в `main`
- Для попадания изменений в `main` действует отдельная схема инкремента.
- Нужно увеличивать вторую цифру версии и обнулять третью.
- Пример: `1.2.346``1.3.0`.
- Если в наборе изменений менялся только UI, обновлять только `client.version`.
- Если в наборе изменений менялся только сервер, обновлять только `server.version`.
- Если соответствующая часть не менялась, её версию не трогать.
## Правила для git commit и git push
- Обычные коммиты делать стандартным `git commit`; токен для локального коммита не нужен и не используется.
- Для операций `git push` при необходимости использовать токен из переменной окружения `$GITEA_TOKEN`.
+28
View File
@@ -0,0 +1,28 @@
# AGENTS for ESP32
## Язык UI
- Для ESP32-скетчей и экранного UI использовать английский язык.
- Русский текст на экране ESP32 пока не поддерживается корректно: шрифтовой путь для кириллицы не считается рабочим.
- Если меняется UI-скетч, все пользовательские строки на экране должны оставаться английскими, пока ограничение не снято отдельной задачей.
## Синхронизация со спецификацией
- При изменении экранов, кнопок, переходов, статусов или текстов обязательно обновлять соответствующую спецификацию в `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/`.
## Сборка ESP32
- Основной способ проверки и прошивки скетчей для `ESP32-S3-Touch-AMOLED-2.16` - `main-device/burn.sh`.
- Не собирать эти скетчи напрямую через `arduino-cli compile` без `burn.sh`, потому что скрипт добавляет нужные локальные библиотеки и конфиги из `official-demo/examples/Arduino-v3.3.5/libraries`.
- Если сборка падает по `lv_conf.h` или `TouchDrvCSTXXX.hpp`, сначала проверять именно `burn.sh` и его `--library` пути, а не считать, что файл пропал из репозитория.
## Диагностика ESP32
- Последнюю сохранённую ошибку или диагностическую запись читать с устройства через USB serial monitor на `115200`.
- Базовая команда:
`arduino-cli monitor -p /dev/ttyACM0 --config baudrate=115200`
- После подключения отправлять одну из команд:
`last_error`, `last_diag` или `reg_diag`
- Для очистки сохранённой диагностики использовать:
`clear_error` или `clear_diag`
- При падениях в ветках регистрации и обновления PDA сначала читать именно `last_error`: запись хранится в NVS и может пережить перезагрузку устройства.
+119
View File
@@ -0,0 +1,119 @@
# ESP32-S3-Touch-AMOLED-2.16 Codex Guide
Этот файл переносится в другие проекты как готовая инструкция для Codex по этой плате.
## 1) Что это за плата
- Модель: `Waveshare ESP32-S3-Touch-AMOLED-2.16`
- MCU: `ESP32-S3` (flash 16MB, PSRAM 8MB)
- Экран: AMOLED, физически 480x480, углы скруглены (часть крайних пикселей может быть невидима)
- Touch: CST92xx
- IMU: QMI8658
- Аудио:
- DAC/вывод (динамик): ES8311
- ADC/вход (микрофоны): ES7210
## 2) Что уже установлено в этой среде
- Ubuntu
- `arduino-cli 1.4.0`
- `esp32:esp32` core `3.3.5`
- `esptool` из `~/.arduino15/packages/esp32/tools/esptool_py/5.1.0/esptool`
- USB порт платы: обычно `/dev/ttyACM0`
Проверка:
```bash
arduino-cli version
arduino-cli core list
arduino-cli board list
ls -l /dev/ttyACM0
```
## 3) Структура подпроекта (эталон)
- `official-demo/` — официальный repo Waveshare (примеры+библиотеки)
- `original-firmware/` — backup/restore заводской прошивки
- `main-device/` — прошивки и `burn.sh`
- `reference/` — заметки и ссылки
## 4) Бэкап перед любыми экспериментами
```bash
cd ESP32-S3-Touch-AMOLED-2.16/original-firmware
./backup_factory.sh
```
Ожидаемый результат:
- `factory-full-16mb.bin`
- `factory-full-16mb.bin.sha256`
Восстановление:
```bash
./restore_factory_backup.sh
```
## 5) Деплой (прошивка) — стандарт
Главный скрипт:
```bash
cd ESP32-S3-Touch-AMOLED-2.16/main-device
./burn.sh <mode>
```
Режимы:
- `hello` — базовый экран
- `widgets` — экран+touch+IMU (официальный пример)
- `audio` — тест аудио тракта
- `simple` — кастомный интеграционный тест (экран, touch, запись/воспроизведение, VU, tilt)
## 6) Как писать код под эту плату (важно)
1. **Экран**
- Рабочее разрешение использовать `480x480`.
- Не рисовать критичный текст/кнопки впритык к краю; держать safe margin (`~20px+`) из-за скругленных углов.
- Не делать полный `fillScreen` в каждом loop: только частичные обновления (`fillRect`/локальные перерисовки), иначе мерцание.
2. **Touch**
- Настройка CST:
- `setMaxCoordinates(480, 480)`
- `setSwapXY(true)`
- `setMirrorXY(true, false)`
- Обрабатывать touch по IRQ + `getPoint`.
- После смещения UI обязательно пересчитывать hitbox кнопок.
3. **Аудио**
- Для динамика инициализировать `ES8311`.
- Для микрофона обязательно инициализировать `ES7210`; без этого запись может быть пустой.
- Для отладки записи показывать VU/peak на экране во время `RECORD`.
- Для быстрой проверки тракта всегда держать кнопку `BEEP` (тон), чтобы отделить проблему динамика от проблемы микрофона.
4. **IMU**
- QMI8658 обновлять с ограниченной частотой (например 80–150 мс для UI-строки), чтобы не шуметь перерисовками.
5. **Стабильность UI**
- Статика: рисуется один раз в setup.
- Динамика: отдельная зона, перерисовывать только по изменению данных.
## 7) Рекомендуемый workflow для Codex
1. Проверить порт и инструменты.
2. Если новая плата/первый запуск — сделать backup flash.
3. Собрать и залить `simple`.
4. Пройти ручной чек:
- экран отображает текст без обрезки,
- touch срабатывает по кнопкам,
- `BEEP` слышно,
- VU двигается во время записи,
- `PLAY` воспроизводит записанное,
- `Tilt` меняется при повороте.
5. Только после этого усложнять приложение.
## 8) Ссылки
- Product page: https://www.waveshare.com/product/arduino/boards-kits/esp32-s3/esp32-s3-touch-amoled-2.16.htm
- Docs: https://docs.waveshare.com/ESP32-S3-Touch-AMOLED-2.16
- Arduino setup: https://docs.waveshare.com/ESP32-S3-Touch-AMOLED-2.16/Development-Environment-Setup-Arduino
- Official examples: https://github.com/waveshareteam/ESP32-S3-Touch-AMOLED-2.16
@@ -0,0 +1,119 @@
# ESP32-S3-Touch-AMOLED-2.16 Codex Guide
Этот файл переносится в другие проекты как готовая инструкция для Codex по этой плате.
## 1) Что это за плата
- Модель: `Waveshare ESP32-S3-Touch-AMOLED-2.16`
- MCU: `ESP32-S3` (flash 16MB, PSRAM 8MB)
- Экран: AMOLED, физически 480x480, углы скруглены (часть крайних пикселей может быть невидима)
- Touch: CST92xx
- IMU: QMI8658
- Аудио:
- DAC/вывод (динамик): ES8311
- ADC/вход (микрофоны): ES7210
## 2) Что уже установлено в этой среде
- Ubuntu
- `arduino-cli 1.4.0`
- `esp32:esp32` core `3.3.5`
- `esptool` из `~/.arduino15/packages/esp32/tools/esptool_py/5.1.0/esptool`
- USB порт платы: обычно `/dev/ttyACM0`
Проверка:
```bash
arduino-cli version
arduino-cli core list
arduino-cli board list
ls -l /dev/ttyACM0
```
## 3) Структура подпроекта (эталон)
- `official-demo/` — официальный repo Waveshare (примеры+библиотеки)
- `original-firmware/` — backup/restore заводской прошивки
- `main-device/` — прошивки и `burn.sh`
- `reference/` — заметки и ссылки
## 4) Бэкап перед любыми экспериментами
```bash
cd ESP32-S3-Touch-AMOLED-2.16/original-firmware
./backup_factory.sh
```
Ожидаемый результат:
- `factory-full-16mb.bin`
- `factory-full-16mb.bin.sha256`
Восстановление:
```bash
./restore_factory_backup.sh
```
## 5) Деплой (прошивка) — стандарт
Главный скрипт:
```bash
cd ESP32-S3-Touch-AMOLED-2.16/main-device
./burn.sh <mode>
```
Режимы:
- `hello` — базовый экран
- `widgets` — экран+touch+IMU (официальный пример)
- `audio` — тест аудио тракта
- `simple` — кастомный интеграционный тест (экран, touch, запись/воспроизведение, VU, tilt)
## 6) Как писать код под эту плату (важно)
1. **Экран**
- Рабочее разрешение использовать `480x480`.
- Не рисовать критичный текст/кнопки впритык к краю; держать safe margin (`~20px+`) из-за скругленных углов.
- Не делать полный `fillScreen` в каждом loop: только частичные обновления (`fillRect`/локальные перерисовки), иначе мерцание.
2. **Touch**
- Настройка CST:
- `setMaxCoordinates(480, 480)`
- `setSwapXY(true)`
- `setMirrorXY(true, false)`
- Обрабатывать touch по IRQ + `getPoint`.
- После смещения UI обязательно пересчитывать hitbox кнопок.
3. **Аудио**
- Для динамика инициализировать `ES8311`.
- Для микрофона обязательно инициализировать `ES7210`; без этого запись может быть пустой.
- Для отладки записи показывать VU/peak на экране во время `RECORD`.
- Для быстрой проверки тракта всегда держать кнопку `BEEP` (тон), чтобы отделить проблему динамика от проблемы микрофона.
4. **IMU**
- QMI8658 обновлять с ограниченной частотой (например 80–150 мс для UI-строки), чтобы не шуметь перерисовками.
5. **Стабильность UI**
- Статика: рисуется один раз в setup.
- Динамика: отдельная зона, перерисовывать только по изменению данных.
## 7) Рекомендуемый workflow для Codex
1. Проверить порт и инструменты.
2. Если новая плата/первый запуск — сделать backup flash.
3. Собрать и залить `simple`.
4. Пройти ручной чек:
- экран отображает текст без обрезки,
- touch срабатывает по кнопкам,
- `BEEP` слышно,
- VU двигается во время записи,
- `PLAY` воспроизводит записанное,
- `Tilt` меняется при повороте.
5. Только после этого усложнять приложение.
## 8) Ссылки
- Product page: https://www.waveshare.com/product/arduino/boards-kits/esp32-s3/esp32-s3-touch-amoled-2.16.htm
- Docs: https://docs.waveshare.com/ESP32-S3-Touch-AMOLED-2.16
- Arduino setup: https://docs.waveshare.com/ESP32-S3-Touch-AMOLED-2.16/Development-Environment-Setup-Arduino
- Official examples: https://github.com/waveshareteam/ESP32-S3-Touch-AMOLED-2.16
@@ -0,0 +1,28 @@
# ESP32-S3-Touch-AMOLED-2.16
Подпроект для Waveshare `ESP32-S3-Touch-AMOLED-2.16`.
Структура:
- `official-demo/` — официальный репозиторий примеров Waveshare
- `original-firmware/` — резервная копия заводской прошивки
- `main-device/` — скрипты быстрой проверки устройства и основной скетч `shine_homeserver_main/`
- `reference/` — локальные заметки по документации и железу
- `main-device/shine_homeserver_main/` — основной рабочий скетч ESP32-проекта `SHiNE`
Примечание по git:
- `official-demo/` держать как локальный внешний checkout из `https://github.com/waveshareteam/ESP32-S3-Touch-AMOLED-2.16`, в основной git его не добавлять.
- `original-firmware/*.bin` — локальный дамп конкретной платы, в git не добавлять.
- `.arduino-build/` и готовые `.bin/.elf/.map` — сборочные артефакты, в git не добавлять.
Быстрый старт:
1. Сделать backup текущей прошивки:
- `cd original-firmware && ./backup_factory.sh`
2. Залить тест экрана/тача:
- `cd ../main-device && ./burn.sh widgets`
3. Залить тест динамика:
- `cd ../main-device && ./burn.sh audio`
4. Залить основной UI:
- `cd ../main-device && ./burn.sh shine-homeserver-main`
@@ -0,0 +1,52 @@
# Main Device
Основной скетч homeserver и старые тестовые скетчи для быстрой проверки платы.
`burn.sh` теперь:
- сам пытается найти USB-порт ESP32;
- сначала делает быструю инкрементальную сборку;
- если быстрая сборка не удалась, автоматически повторяет полную `clean`-сборку.
Для режимов `widgets`, `audio` и `hello` рядом должен лежать локальный checkout `official-demo/` из официального репозитория Waveshare. В основной git он не добавляется, потому что это большой внешний набор примеров, библиотек, прошивок и артефактов.
Режимы:
- `widgets` — экран + touch + IMU (пример `05_LVGL_Widgets`)
- `audio` — динамик/аудио-кодек (пример `07_ES8311`)
- `hello` — базовый тест экрана (пример `01_HelloWorld`)
- `simple` — простой кастомный тест: экран + touch + запись/проигрывание + наклон (IMU)
- `argon2` — генерация masterSecret через Argon2id с SD-картой как памятью (тест скорости)
- `homeserver-ui` — совместимый алиас, указывает на `shine_homeserver_main/`
- `shine-homeserver-main` — основной скетч проекта `SHiNE` для ESP32, текущая рабочая версия UI
- `shine-homeserver-ui-main` — старое имя основного скетча, оставлено как совместимый алиас
- `legacy-homeserver-ui` — старый UI-прототип `shine_homeserver_ui/`, оставлен как тестовый и не является основным
- `text-test` — диагностический экран рендера текста: default font, U8g2 ASCII, U8g2 кириллица, кнопки с подписями
- `gfx-text-test` — тот же тест рендера текста, но уже внутри новой папки `test_sketches/`
- `gfx-layout-test` — тест геометрии и нижних рядов кнопок
- `lvgl-basic-test` — минимальный экран на `LVGL` с текстом и кнопками
- `lvgl-interaction-test` — экран на `LVGL` с большим числом кнопок и сообщением о нажатой кнопке
- `lvgl-touch-debug-test` — точечная диагностика touch: сырые координаты, маркер точки и большая тест-кнопка `LVGL`
- `lvgl-official-based-test` — наш минимальный экран, но на максимально близкой к официальному `LVGL_Widgets` инициализации
- `lvgl-subserver-touch-test` — старый гибридный тест: `LVGL`-интерфейс, но display/touch init и raw touch-read взяты из старого `shine_homeserver_ui`; подтверждено на устройстве, touch работает, зелёных линий по краям нет
- `lvgl-russian-font-test` — тест кастомного `LVGL`-шрифта с кириллицей: русские кнопки, длинные подписи и статусы
- `lvgl-nav-minimal-test` — старое имя основного скетча, теперь ведёт на `shine_homeserver_main/` для совместимости
Запуск:
- `./burn.sh widgets`
- `./burn.sh audio`
- `./burn.sh hello`
- `./burn.sh simple`
- `./burn.sh homeserver-ui`
- `./burn.sh shine-homeserver-main`
- `./burn.sh shine-homeserver-ui-main`
- `./burn.sh legacy-homeserver-ui`
- `./burn.sh text-test`
- `./burn.sh gfx-text-test`
- `./burn.sh gfx-layout-test`
- `./burn.sh lvgl-basic-test`
- `./burn.sh lvgl-interaction-test`
- `./burn.sh lvgl-touch-debug-test`
- `./burn.sh lvgl-official-based-test`
- `./burn.sh lvgl-subserver-touch-test`
- `./burn.sh lvgl-russian-font-test`
- `./burn.sh lvgl-nav-minimal-test`
- `./flash_shine_homeserver_main.sh` - автоматически находит USB-порт и заливает `shine_homeserver_main`
@@ -0,0 +1,10 @@
#pragma once
#include <stdint.h>
#include <stddef.h>
// BLAKE2b state
struct B2State {
uint64_t h[8], t[2], f[2];
uint8_t buf[128];
size_t buflen, outlen;
};
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BOARD_DIR="$(cd "${ROOT_DIR}/.." && pwd)"
DEMO_BASE="${BOARD_DIR}/official-demo/examples/Arduino-v3.3.5"
MODE="${1:-widgets}"
PORT="${PORT:-}"
FQBN="${FQBN:-esp32:esp32:esp32s3:USBMode=hwcdc,CDCOnBoot=cdc,UploadSpeed=921600,CPUFreq=240,FlashMode=dio,FlashSize=16M,PartitionScheme=app3M_fat9M_16MB,PSRAM=opi}"
BUILD_DIR="${BUILD_DIR:-${ROOT_DIR}/.arduino-build/build-${MODE}}"
OUT_DIR="${OUT_DIR:-${ROOT_DIR}/.arduino-build/out-${MODE}}"
detect_port() {
local detected
detected="$(arduino-cli board list 2>/dev/null | awk '/\/dev\/tty(ACM|USB)/ {print $1; exit}')"
if [[ -n "${detected}" ]]; then
echo "${detected}"
return 0
fi
for candidate in /dev/ttyACM* /dev/ttyUSB*; do
if [[ -e "${candidate}" ]]; then
echo "${candidate}"
return 0
fi
done
return 1
}
case "${MODE}" in
hello) SKETCH_DIR="${DEMO_BASE}/examples/01_HelloWorld" ;;
widgets) SKETCH_DIR="${DEMO_BASE}/examples/05_LVGL_Widgets" ;;
audio) SKETCH_DIR="${DEMO_BASE}/examples/07_ES8311" ;;
simple) SKETCH_DIR="${ROOT_DIR}/simple_av_test" ;;
argon2) SKETCH_DIR="${ROOT_DIR}/argon2_sd_test" ;;
homeserver-ui) SKETCH_DIR="${ROOT_DIR}/shine_homeserver_main" ;;
shine-homeserver-main) SKETCH_DIR="${ROOT_DIR}/shine_homeserver_main" ;;
shine-homeserver-ui-main) SKETCH_DIR="${ROOT_DIR}/shine_homeserver_main" ;;
legacy-homeserver-ui) SKETCH_DIR="${ROOT_DIR}/shine_homeserver_ui" ;;
text-test) SKETCH_DIR="${ROOT_DIR}/text_render_test" ;;
gfx-text-test) SKETCH_DIR="${ROOT_DIR}/test_sketches/gfx_text_render_test" ;;
gfx-layout-test) SKETCH_DIR="${ROOT_DIR}/test_sketches/gfx_button_layout_test" ;;
lvgl-basic-test) SKETCH_DIR="${ROOT_DIR}/test_sketches/lvgl_basic_test" ;;
lvgl-interaction-test) SKETCH_DIR="${ROOT_DIR}/test_sketches/lvgl_interaction_test" ;;
lvgl-touch-debug-test) SKETCH_DIR="${ROOT_DIR}/test_sketches/lvgl_touch_debug_test" ;;
lvgl-official-based-test) SKETCH_DIR="${ROOT_DIR}/test_sketches/lvgl_official_based_test" ;;
lvgl-subserver-touch-test) SKETCH_DIR="${ROOT_DIR}/test_sketches/lvgl_subserver_touch_test" ;;
lvgl-russian-font-test) SKETCH_DIR="${ROOT_DIR}/test_sketches/lvgl_russian_font_test" ;;
lvgl-nav-minimal-test) SKETCH_DIR="${ROOT_DIR}/shine_homeserver_main" ;;
*)
echo "Unknown mode: ${MODE}" >&2
echo "Use one of: hello, widgets, audio, simple, argon2, homeserver-ui, shine-homeserver-main, shine-homeserver-ui-main, legacy-homeserver-ui, text-test, gfx-text-test, gfx-layout-test, lvgl-basic-test, lvgl-interaction-test, lvgl-touch-debug-test, lvgl-official-based-test, lvgl-subserver-touch-test, lvgl-russian-font-test" >&2
exit 2
;;
esac
if [[ -z "${PORT}" ]]; then
if ! PORT="$(detect_port)"; then
echo "Failed to auto-detect ESP32 port. Set PORT=/dev/ttyACM0 ./burn.sh ${MODE}" >&2
exit 3
fi
fi
echo "== Mode: ${MODE}"
echo "== Sketch: ${SKETCH_DIR}"
echo "== Port: ${PORT}"
echo "== FQBN: ${FQBN}"
mkdir -p "${BUILD_DIR}" "${OUT_DIR}"
compile_args=(
--fqbn "${FQBN}"
--build-path "${BUILD_DIR}"
--output-dir "${OUT_DIR}"
--library "${DEMO_BASE}/libraries/GFX_Library_for_Arduino"
--library "${DEMO_BASE}/libraries/SensorLib"
--library "${DEMO_BASE}/libraries/XPowersLib"
--library "${DEMO_BASE}/libraries/lvgl"
--library "${DEMO_BASE}/libraries/Mylibrary"
"${SKETCH_DIR}"
)
echo "== Compile: fast incremental build"
if ! arduino-cli compile "${compile_args[@]}"; then
echo "== Compile: fast build failed, retrying clean build"
arduino-cli compile --clean "${compile_args[@]}"
fi
arduino-cli upload \
-p "${PORT}" \
--fqbn "${FQBN}" \
--input-dir "${OUT_DIR}" \
"${SKETCH_DIR}"
echo
echo "== Done."
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
detect_port_from_arduino_cli() {
local line
while IFS= read -r line; do
[[ -z "${line}" ]] && continue
[[ "${line}" == Port* ]] && continue
if [[ "${line}" == /dev/* ]]; then
awk '{print $1}' <<<"${line}"
return 0
fi
done < <(arduino-cli board list 2>/dev/null || true)
return 1
}
detect_port_from_dev() {
local candidates=()
local path
for path in /dev/ttyACM* /dev/ttyUSB*; do
[[ -e "${path}" ]] || continue
candidates+=("${path}")
done
if [[ "${#candidates[@]}" -eq 1 ]]; then
printf '%s\n' "${candidates[0]}"
return 0
fi
return 1
}
PORT="${PORT:-}"
if [[ -z "${PORT}" ]]; then
PORT="$(detect_port_from_arduino_cli || true)"
fi
if [[ -z "${PORT}" ]]; then
PORT="$(detect_port_from_dev || true)"
fi
if [[ -z "${PORT}" ]]; then
echo "Не удалось автоматически найти USB-порт ESP32." >&2
echo "Подключите плату и проверьте 'arduino-cli board list'." >&2
echo "Либо укажите порт вручную: PORT=/dev/ttyACM0 ./flash_shine_homeserver_main.sh" >&2
exit 1
fi
echo "== Найден порт: ${PORT}"
PORT="${PORT}" "${ROOT_DIR}/burn.sh" shine-homeserver-main
@@ -0,0 +1,14 @@
# SHiNE Homeserver UI Main
Это основной рабочий скетч ESP32-проекта `SHiNE`.
Текущая каноническая точка запуска:
- `./burn.sh shine-homeserver-main`
- `./burn.sh homeserver-ui`
Историческое имя этого скетча:
- `lvgl-nav-minimal-test`
Прежние тестовые варианты для этой платы остаются в `main-device/test_sketches/` и должны восприниматься как старые диагностические сборки, а не как основной UI.
@@ -0,0 +1,591 @@
#include "shine_secret_generation.h"
#include <SD_MMC.h>
#include <mbedtls/sha256.h>
#include <mbedtls/base64.h>
#include <string.h>
#include <stdint.h>
#include <driver/gpio.h>
#define PIN_SD_CLK 2
#define PIN_SD_CMD 1
#define PIN_SD_D0 3
#define SD_MEM_FILE "/argon2.bin"
#define A2_T 2u
#define A2_M 65536u
#define A2_P 1u
#define A2_DKLEN 32u
#define A2_SYNC 4u
#define A2_SEG (A2_M / A2_SYNC)
#define A2_VERSION 0x13u
#define A2_TYPE 2u
#define A2_BLKSZ 1024u
#define TOTAL_FILLS (A2_T * A2_M - 2u)
struct B2State {
uint64_t h[8], t[2], f[2];
uint8_t buf[128];
size_t buflen, outlen;
};
static bool gSdReady = false;
static bool gRunning = false;
static bool gDone = false;
static bool gError = false;
static char gMessage[96] = {};
static uint8_t gSecret[32] = {};
static char gSecretB58[64] = {};
static uint32_t gDoneBlocks = 0;
static uint32_t gStartMs = 0;
static uint32_t gCurPass = 0;
static uint32_t gCurBlock = 2;
static bool gInitDone = false;
static File gSdFile;
static uint8_t *gBufPrev = nullptr;
static uint8_t *gBufRef = nullptr;
static uint8_t *gBufOut = nullptr;
static uint8_t *gBufZero = nullptr;
static uint8_t *gBufAddr = nullptr;
static uint32_t *gJ1Seg = nullptr;
static uint8_t gH0[64] = {};
static B2State gB2S;
#define ROTR64(x,n) (((x)>>(n))|((x)<<(64-(n))))
static const uint64_t B2IV[8] = {
0x6A09E667F3BCC908ULL,0xBB67AE8584CAA73BULL,
0x3C6EF372FE94F82BULL,0xA54FF53A5F1D36F1ULL,
0x510E527FADE682D1ULL,0x9B05688C2B3E6C1FULL,
0x1F83D9ABFB41BD6BULL,0x5BE0CD19137E2179ULL
};
static const uint8_t B2SIGMA[12][16] = {
{0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15},
{14,10,4,8,9,15,13,6,1,12,0,2,11,7,5,3},
{11,8,12,0,5,2,15,13,10,14,3,6,7,1,9,4},
{7,9,3,1,13,12,11,14,2,6,5,10,4,0,15,8},
{9,0,5,7,2,4,10,15,14,1,11,12,6,8,3,13},
{2,12,6,10,0,11,8,3,4,13,7,5,15,14,1,9},
{12,5,1,15,14,13,4,10,0,7,6,3,9,2,8,11},
{13,11,7,14,12,1,3,9,5,0,15,4,8,6,2,10},
{6,15,14,9,11,3,0,8,12,2,13,7,1,4,10,5},
{10,2,8,4,7,6,1,5,15,11,9,14,3,12,13,0},
{0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15},
{14,10,4,8,9,15,13,6,1,12,0,2,11,7,5,3}
};
static const char B58_ALPHA[] = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
static void setMessage(const char *message) {
snprintf(gMessage, sizeof(gMessage), "%s", message ? message : "");
}
static void sha256calc(const uint8_t *in, size_t len, uint8_t *out32);
static void finishSecretFromBytes(const uint8_t secret32[32], const char *message) {
memcpy(gSecret, secret32, 32);
shineSecretBase58Encode(gSecret, 32, gSecretB58, sizeof(gSecretB58));
gDone = true;
gRunning = false;
gError = false;
gInitDone = false;
gDoneBlocks = TOTAL_FILLS;
setMessage(message ? message : "Secret generated");
}
static void b2_compress(B2State *S, const uint8_t *blk) {
uint64_t m[16], v[16];
for (int i = 0; i < 16; i++) m[i] = ((const uint64_t *)blk)[i];
for (int i = 0; i < 8; i++) v[i] = S->h[i];
v[8]=B2IV[0];v[9]=B2IV[1];v[10]=B2IV[2];v[11]=B2IV[3];
v[12]=B2IV[4]^S->t[0];v[13]=B2IV[5]^S->t[1];
v[14]=B2IV[6]^S->f[0];v[15]=B2IV[7]^S->f[1];
#define BG(r,i,a,b,c,d) \
v[a]+=v[b]+m[B2SIGMA[r][2*(i)]]; v[d]=ROTR64(v[d]^v[a],32); \
v[c]+=v[d]; v[b]=ROTR64(v[b]^v[c],24); \
v[a]+=v[b]+m[B2SIGMA[r][2*(i)+1]]; v[d]=ROTR64(v[d]^v[a],16); \
v[c]+=v[d]; v[b]=ROTR64(v[b]^v[c],63)
for (int r = 0; r < 12; r++) {
BG(r,0,0,4,8,12);BG(r,1,1,5,9,13);BG(r,2,2,6,10,14);BG(r,3,3,7,11,15);
BG(r,4,0,5,10,15);BG(r,5,1,6,11,12);BG(r,6,2,7,8,13);BG(r,7,3,4,9,14);
}
#undef BG
for (int i = 0; i < 8; i++) S->h[i] ^= v[i] ^ v[i + 8];
}
static void b2_init(B2State *S, size_t outlen) {
memset(S, 0, sizeof(*S));
memcpy(S->h, B2IV, 64);
S->h[0] ^= 0x01010000ULL | outlen;
S->outlen = outlen;
}
static void b2_update(B2State *S, const uint8_t *in, size_t inlen) {
while (inlen > 0) {
size_t fill = 128 - S->buflen;
size_t use = inlen < fill ? inlen : fill;
memcpy(S->buf + S->buflen, in, use);
S->buflen += use;
in += use;
inlen -= use;
if (S->buflen == 128 && inlen > 0) {
S->t[0] += 128;
if (S->t[0] < 128) S->t[1]++;
b2_compress(S, S->buf);
S->buflen = 0;
}
}
}
static void b2_final(B2State *S, uint8_t *digest) {
S->f[0] = ~0ULL;
S->t[0] += S->buflen;
if (S->t[0] < S->buflen) S->t[1]++;
memset(S->buf + S->buflen, 0, 128 - S->buflen);
b2_compress(S, S->buf);
uint8_t tmp[64];
for (int i = 0; i < 8; i++) {
uint64_t w = S->h[i];
for (int j = 0; j < 8; j++) tmp[i * 8 + j] = (uint8_t)(w >> (j * 8));
}
memcpy(digest, tmp, S->outlen);
}
static void b2hash(const uint8_t *in, size_t inlen, uint8_t *digest, size_t outlen) {
b2_init(&gB2S, outlen);
b2_update(&gB2S, in, inlen);
b2_final(&gB2S, digest);
}
static void b2long(const uint8_t *in, size_t inlen, uint8_t *digest, uint32_t outlen) {
uint8_t lenle[4] = {(uint8_t)outlen, (uint8_t)(outlen >> 8), (uint8_t)(outlen >> 16), (uint8_t)(outlen >> 24)};
if (outlen <= 64) {
b2_init(&gB2S, outlen);
b2_update(&gB2S, lenle, 4);
b2_update(&gB2S, in, inlen);
b2_final(&gB2S, digest);
return;
}
uint8_t tmp[64];
b2_init(&gB2S, 64);
b2_update(&gB2S, lenle, 4);
b2_update(&gB2S, in, inlen);
b2_final(&gB2S, tmp);
memcpy(digest, tmp, 32);
digest += 32;
uint32_t rem = outlen - 32;
while (rem > 64) {
b2hash(tmp, 64, tmp, 64);
memcpy(digest, tmp, 32);
digest += 32;
rem -= 32;
}
b2hash(tmp, 64, tmp, rem);
memcpy(digest, tmp, rem);
}
#define A2G(a,b,c,d) do{ \
(a)+=(b)+2*((a)&0xFFFFFFFFULL)*((b)&0xFFFFFFFFULL); \
(d)=ROTR64((d)^(a),32); \
(c)+=(d)+2*((c)&0xFFFFFFFFULL)*((d)&0xFFFFFFFFULL); \
(b)=ROTR64((b)^(c),24); \
(a)+=(b)+2*((a)&0xFFFFFFFFULL)*((b)&0xFFFFFFFFULL); \
(d)=ROTR64((d)^(a),16); \
(c)+=(d)+2*((c)&0xFFFFFFFFULL)*((d)&0xFFFFFFFFULL); \
(b)=ROTR64((b)^(c),63); \
}while(0)
#define A2P(v) do{ \
A2G((v)[0],(v)[4],(v)[8],(v)[12]);A2G((v)[1],(v)[5],(v)[9],(v)[13]); \
A2G((v)[2],(v)[6],(v)[10],(v)[14]);A2G((v)[3],(v)[7],(v)[11],(v)[15]); \
A2G((v)[0],(v)[5],(v)[10],(v)[15]);A2G((v)[1],(v)[6],(v)[11],(v)[12]); \
A2G((v)[2],(v)[7],(v)[8],(v)[13]);A2G((v)[3],(v)[4],(v)[9],(v)[14]); \
}while(0)
static void fillBlock(uint32_t prevIdx, uint32_t refIdx, uint32_t outIdx, bool xorMode) {
gSdFile.seek((uint64_t)prevIdx * A2_BLKSZ); gSdFile.read(gBufPrev, A2_BLKSZ);
gSdFile.seek((uint64_t)refIdx * A2_BLKSZ); gSdFile.read(gBufRef, A2_BLKSZ);
uint64_t *R = (uint64_t *)gBufOut, *P = (uint64_t *)gBufPrev, *F = (uint64_t *)gBufRef;
for (int i = 0; i < 128; i++) R[i] = P[i] ^ F[i];
uint64_t *Q = (uint64_t *)gBufRef; memcpy(Q, R, A2_BLKSZ);
for (int j = 0; j < 8; j++) A2P(&Q[16 * j]);
uint64_t row[16];
for (int i = 0; i < 8; i++) {
for (int k = 0; k < 8; k++) { row[2 * k] = Q[2 * i + 16 * k]; row[2 * k + 1] = Q[2 * i + 16 * k + 1]; }
A2P(row);
for (int k = 0; k < 8; k++) { Q[2 * i + 16 * k] = row[2 * k]; Q[2 * i + 16 * k + 1] = row[2 * k + 1]; }
}
for (int i = 0; i < 128; i++) R[i] = Q[i] ^ R[i];
if (xorMode) {
gSdFile.seek((uint64_t)outIdx * A2_BLKSZ); gSdFile.read(gBufPrev, A2_BLKSZ);
uint64_t *O = (uint64_t *)gBufPrev; for (int i = 0; i < 128; i++) R[i] ^= O[i];
}
gSdFile.seek((uint64_t)outIdx * A2_BLKSZ); gSdFile.write(gBufOut, A2_BLKSZ);
}
static void generateAddresses(uint32_t pass, uint32_t slice) {
memset(gBufZero, 0, A2_BLKSZ);
uint8_t inputBlk[A2_BLKSZ]; memset(inputBlk, 0, A2_BLKSZ);
uint64_t *iv = (uint64_t *)inputBlk;
iv[0]=pass;iv[1]=0;iv[2]=slice;iv[3]=A2_M;iv[4]=A2_T;iv[5]=A2_TYPE;
uint32_t count = 0;
for (uint32_t b = 0; b < A2_SEG; b += 128) {
iv[6] = ++count;
{
uint64_t *R=(uint64_t*)gBufAddr,*Z=(uint64_t*)gBufZero,*I=iv;
for(int i=0;i<128;i++)R[i]=Z[i]^I[i];
uint64_t *Q=R,row[16];
for(int j=0;j<8;j++)A2P(&Q[16*j]);
for(int i=0;i<8;i++){
for(int k=0;k<8;k++){row[2*k]=Q[2*i+16*k];row[2*k+1]=Q[2*i+16*k+1];}
A2P(row);
for(int k=0;k<8;k++){Q[2*i+16*k]=row[2*k];Q[2*i+16*k+1]=row[2*k+1];}
}
for(int i=0;i<128;i++)R[i]=Q[i]^(Z[i]^I[i]);
}
{
uint64_t *R=(uint64_t*)gBufPrev,*Z=(uint64_t*)gBufZero,*A=(uint64_t*)gBufAddr;
for(int i=0;i<128;i++)R[i]=Z[i]^A[i];
uint64_t *Q=R,row[16];
for(int j=0;j<8;j++)A2P(&Q[16*j]);
for(int i=0;i<8;i++){
for(int k=0;k<8;k++){row[2*k]=Q[2*i+16*k];row[2*k+1]=Q[2*i+16*k+1];}
A2P(row);
for(int k=0;k<8;k++){Q[2*i+16*k]=row[2*k];Q[2*i+16*k+1]=row[2*k+1];}
}
for(int i=0;i<128;i++)R[i]=Q[i]^(Z[i]^A[i]);
memcpy(gBufAddr,gBufPrev,A2_BLKSZ);
}
uint64_t *addr=(uint64_t*)gBufAddr;
uint32_t cnt=(b+128<=A2_SEG)?128:(A2_SEG-b);
for(uint32_t j=0;j<cnt;j++)gJ1Seg[b+j]=(uint32_t)(addr[j]&0xFFFFFFFFULL);
}
}
static uint32_t indexAlpha(uint32_t pass, uint32_t slice, uint32_t posInSlice, uint32_t J1) {
uint32_t refAreaSize;
if (pass == 0) {
if (slice == 0) {
uint32_t pos = posInSlice;
refAreaSize = (pos < 2) ? 0 : pos - 1;
} else {
refAreaSize = slice * A2_SEG + posInSlice - 1;
}
} else {
refAreaSize = A2_M - A2_SEG + posInSlice - 1;
}
if (refAreaSize == 0) refAreaSize = 1;
uint64_t relPos = (uint64_t)J1 * J1 >> 32;
relPos = refAreaSize - 1 - ((uint64_t)refAreaSize * relPos >> 32);
uint32_t startPos = 0;
if (pass != 0 && slice != A2_SYNC - 1) startPos = (slice + 1) * A2_SEG;
return (startPos + relPos) % A2_M;
}
static void sha256calc(const uint8_t *in, size_t len, uint8_t *out32) {
mbedtls_sha256_context ctx;
mbedtls_sha256_init(&ctx);
mbedtls_sha256_starts(&ctx, 0);
mbedtls_sha256_update(&ctx, in, len);
mbedtls_sha256_finish(&ctx, out32);
mbedtls_sha256_free(&ctx);
}
static void trimInPlace(char *text) {
if (!text) return;
size_t len = strlen(text);
size_t start = 0;
while (start < len && (text[start] == ' ' || text[start] == '\t' || text[start] == '\n' || text[start] == '\r')) start++;
size_t end = len;
while (end > start && (text[end - 1] == ' ' || text[end - 1] == '\t' || text[end - 1] == '\n' || text[end - 1] == '\r')) end--;
if (start > 0 && end > start) memmove(text, text + start, end - start);
if (end <= start) { text[0] = '\0'; return; }
text[end - start] = '\0';
}
static void lowercaseAsciiInPlace(char *text) {
if (!text) return;
for (int i = 0; text[i]; i++) if (text[i] >= 'A' && text[i] <= 'Z') text[i] += 32;
}
static void normalizeLoginInPlace(char *text) {
trimInPlace(text);
lowercaseAsciiInPlace(text);
}
static void bytesToBase64Std(const uint8_t *data, size_t len, char *out, size_t outSz) {
size_t b64len = 0;
if (outSz == 0) return;
if (mbedtls_base64_encode((uint8_t*)out, outSz, &b64len, data, len) != 0) {
out[0] = '\0';
return;
}
if (b64len >= outSz) b64len = outSz - 1;
out[b64len] = '\0';
}
static void deriveLegacyMasterSecret(const char *password, uint8_t *out32) {
uint8_t baseHash[32];
sha256calc((const uint8_t*)password, strlen(password), baseHash);
char baseB64[64];
bytesToBase64Std(baseHash, 32, baseB64, sizeof(baseB64));
char material[96];
snprintf(material, sizeof(material), "%smaster.secret", baseB64);
sha256calc((const uint8_t*)material, strlen(material), out32);
}
void shineSecretBase58Encode(const uint8_t *data, size_t len, char *out, size_t outSz) {
int zeros = 0;
while (zeros < (int)len && data[zeros] == 0) zeros++;
uint8_t tmp[128] = {};
int tmpLen = 0;
for (int i = zeros; i < (int)len; i++) {
int carry = data[i];
for (int j = 0; j < tmpLen; j++) { carry += 256 * tmp[j]; tmp[j] = carry % 58; carry /= 58; }
while (carry) { tmp[tmpLen++] = carry % 58; carry /= 58; }
}
int n = 0;
for (int i = 0; i < zeros && n < (int)outSz - 1; i++) out[n++] = '1';
for (int i = tmpLen - 1; i >= 0 && n < (int)outSz - 1; i--) out[n++] = B58_ALPHA[(int)tmp[i]];
out[n] = '\0';
}
bool shineSecretBase58Decode(const char *input, uint8_t *out, size_t *outLen, size_t maxOutLen, String &error) {
error = "";
if (!input || !*input) {
error = "secret too short";
return false;
}
size_t inLen = strlen(input);
uint8_t tmp[256] = {};
size_t tmpLen = 0;
size_t zeros = 0;
while (zeros < inLen && input[zeros] == '1') zeros++;
for (size_t i = zeros; i < inLen; ++i) {
const char *pos = strchr(B58_ALPHA, input[i]);
if (!pos) {
error = "invalid base58";
return false;
}
int carry = (int)(pos - B58_ALPHA);
for (size_t j = 0; j < tmpLen; ++j) {
carry += 58 * tmp[j];
tmp[j] = carry & 0xFF;
carry >>= 8;
}
while (carry > 0) {
tmp[tmpLen++] = carry & 0xFF;
carry >>= 8;
if (tmpLen >= sizeof(tmp)) {
error = "secret too long";
return false;
}
}
}
size_t decodedLen = zeros + tmpLen;
if (decodedLen > maxOutLen) {
error = "secret too long";
return false;
}
memset(out, 0, maxOutLen);
size_t offset = 0;
for (size_t i = 0; i < zeros; ++i) out[offset++] = 0;
for (size_t i = 0; i < tmpLen; ++i) out[offset + i] = tmp[tmpLen - 1 - i];
*outLen = decodedLen;
return true;
}
static void argon2Init(const char *login, const char *password) {
char saltSrc[256];
snprintf(saltSrc, sizeof(saltSrc), "shine-auth-v2|login=%s|suffix=master.secret", login);
uint8_t saltHash[32]; sha256calc((uint8_t*)saltSrc, strlen(saltSrc), saltHash);
uint8_t salt[16]; memcpy(salt, saltHash, 16);
char passBytes[128];
snprintf(passBytes, sizeof(passBytes), "%s\n%s", login, password);
uint32_t passLen = strlen(passBytes), saltLen = 16;
b2_init(&gB2S, 64);
uint8_t le4[4];
#define B2LE32(val) do{uint32_t _v=(val);le4[0]=_v;le4[1]=_v>>8;le4[2]=_v>>16;le4[3]=_v>>24;b2_update(&gB2S,le4,4);}while(0)
B2LE32(A2_P);B2LE32(A2_DKLEN);B2LE32(A2_M);B2LE32(A2_T);
B2LE32(A2_VERSION);B2LE32(A2_TYPE);
B2LE32(passLen);b2_update(&gB2S,(const uint8_t*)passBytes,passLen);
B2LE32(saltLen);b2_update(&gB2S,salt,saltLen);
B2LE32(0);B2LE32(0);
#undef B2LE32
b2_final(&gB2S, gH0);
uint8_t input[72]; memcpy(input, gH0, 64);
for (uint32_t i = 0; i < 2; i++) {
input[64]=i;input[65]=0;input[66]=0;input[67]=0;
input[68]=0;input[69]=0;input[70]=0;input[71]=0;
b2long(input, 72, gBufOut, A2_BLKSZ);
gSdFile.seek((uint64_t)i * A2_BLKSZ); gSdFile.write(gBufOut, A2_BLKSZ);
}
gCurPass = 0;
gCurBlock = 2;
gDoneBlocks = 0;
gStartMs = millis();
gInitDone = true;
generateAddresses(0, 0);
}
bool shineSecretInitSd(String &error) {
error = "";
if (gSdReady) return true;
gpio_reset_pin(GPIO_NUM_2);
pinMode(PIN_SD_CMD, INPUT_PULLUP);
pinMode(PIN_SD_D0, INPUT_PULLUP);
delay(20);
SD_MMC.setPins(PIN_SD_CLK, PIN_SD_CMD, PIN_SD_D0);
gSdReady = SD_MMC.begin("/sdcard", true);
if (!gSdReady) {
error = "SD card error";
return false;
}
if (!gBufPrev) {
gBufPrev=(uint8_t*)ps_malloc(A2_BLKSZ);
gBufRef=(uint8_t*)ps_malloc(A2_BLKSZ);
gBufOut=(uint8_t*)ps_malloc(A2_BLKSZ);
gBufZero=(uint8_t*)ps_calloc(1, A2_BLKSZ);
gBufAddr=(uint8_t*)ps_malloc(A2_BLKSZ);
gJ1Seg=(uint32_t*)ps_malloc(A2_SEG * sizeof(uint32_t));
}
if (!gBufPrev || !gBufRef || !gBufOut || !gBufZero || !gBufAddr || !gJ1Seg) {
error = "PSRAM alloc failed";
return false;
}
return true;
}
bool shineSecretStart(const char *normalizedLogin, const char *password, String &error) {
error = "";
if (!normalizedLogin || !*normalizedLogin) {
error = "login not set";
return false;
}
char loginBuf[64];
snprintf(loginBuf, sizeof(loginBuf), "%s", normalizedLogin);
normalizeLoginInPlace(loginBuf);
if (!loginBuf[0]) {
error = "login not set";
return false;
}
if (!shineSecretInitSd(error)) return false;
if (gSdFile) gSdFile.close();
SD_MMC.remove(SD_MEM_FILE);
gSdFile = SD_MMC.open(SD_MEM_FILE, "w+");
if (!gSdFile) {
error = "SD open failed";
return false;
}
memset(gSecret, 0, sizeof(gSecret));
gSecretB58[0] = '\0';
gDone = false;
gError = false;
gRunning = true;
gInitDone = false;
setMessage("Generating secret...");
if (!password || !password[0]) {
deriveLegacyMasterSecret(password ? password : "", gSecret);
shineSecretBase58Encode(gSecret, 32, gSecretB58, sizeof(gSecretB58));
gDone = true;
gRunning = false;
setMessage("Secret generated");
gSdFile.close();
SD_MMC.remove(SD_MEM_FILE);
return true;
}
argon2Init(loginBuf, password);
return true;
}
bool shineSecretStep(uint32_t blocksPerTick) {
if (!gRunning || !gInitDone) return gDone;
bool done = false;
for (uint32_t i = 0; i < blocksPerTick && !done; i++) {
if (gCurPass >= A2_T) {
done = true;
break;
}
uint32_t blk = gCurBlock;
uint32_t slice = blk / A2_SEG;
uint32_t posInSlice = blk - slice * A2_SEG;
bool xorMode = (gCurPass > 0);
uint32_t J1;
bool useArgon2i = (gCurPass == 0 && slice < 2);
if (useArgon2i) {
J1 = gJ1Seg[posInSlice];
} else {
uint32_t prevIdx = (blk == 0) ? A2_M - 1 : blk - 1;
gSdFile.seek((uint64_t)prevIdx * A2_BLKSZ);
gSdFile.read(gBufRef, 8);
J1 = ((uint32_t*)gBufRef)[0];
}
uint32_t prevIdx = (blk == 0) ? A2_M - 1 : blk - 1;
uint32_t refIdx = indexAlpha(gCurPass, slice, posInSlice, J1);
fillBlock(prevIdx, refIdx, blk, xorMode);
gDoneBlocks++;
gCurBlock++;
if (gCurBlock >= A2_M) {
gCurBlock = 0;
gCurPass++;
} else {
uint32_t newSlice = gCurBlock / A2_SEG;
if (newSlice != slice && gCurPass == 0 && newSlice < 2) generateAddresses(gCurPass, newSlice);
}
done = (gCurPass >= A2_T);
}
if (done) {
gSdFile.seek((uint64_t)(A2_M - 1) * A2_BLKSZ);
gSdFile.read(gBufOut, A2_BLKSZ);
b2long(gBufOut, A2_BLKSZ, gSecret, A2_DKLEN);
shineSecretBase58Encode(gSecret, 32, gSecretB58, sizeof(gSecretB58));
gDone = true;
gRunning = false;
setMessage("Secret generated");
gSdFile.close();
SD_MMC.remove(SD_MEM_FILE);
}
return gDone;
}
void shineSecretAbort() {
gRunning = false;
gDone = false;
gError = false;
gInitDone = false;
gDoneBlocks = 0;
if (gSdFile) gSdFile.close();
if (gSdReady) SD_MMC.remove(SD_MEM_FILE);
setMessage("Generation cancelled");
}
ShineSecretGenerationStatus shineSecretStatus() {
ShineSecretGenerationStatus status = {};
status.running = gRunning;
status.done = gDone;
status.error = gError;
status.doneBlocks = gDoneBlocks;
status.totalBlocks = TOTAL_FILLS;
status.elapsedSec = gStartMs == 0 ? 0 : (millis() - gStartMs) / 1000;
snprintf(status.message, sizeof(status.message), "%s", gMessage);
snprintf(status.secretBase58, sizeof(status.secretBase58), "%s", gSecretB58);
return status;
}
const uint8_t *shineSecretBytes() {
return gSecret;
}
const char *shineSecretBase58() {
return gSecretB58;
}
@@ -0,0 +1,26 @@
#pragma once
#include <Arduino.h>
#include <stdint.h>
#include <stddef.h>
struct ShineSecretGenerationStatus {
bool running;
bool done;
bool error;
uint32_t doneBlocks;
uint32_t totalBlocks;
uint32_t elapsedSec;
char message[96];
char secretBase58[64];
};
bool shineSecretInitSd(String &error);
bool shineSecretStart(const char *normalizedLogin, const char *password, String &error);
bool shineSecretStep(uint32_t blocksPerTick = 4);
void shineSecretAbort();
ShineSecretGenerationStatus shineSecretStatus();
const uint8_t *shineSecretBytes();
const char *shineSecretBase58();
void shineSecretBase58Encode(const uint8_t *data, size_t len, char *out, size_t outSz);
bool shineSecretBase58Decode(const char *input, uint8_t *out, size_t *outLen, size_t maxOutLen, String &error);
@@ -0,0 +1,6 @@
# SHiNE Homeserver UI Legacy
Это старый тестовый вариант UI для ESP32-платы `Waveshare ESP32-S3-Touch-AMOLED-2.16`.
Не использовать как основной скетч проекта.
Основной рабочий скетч сейчас лежит в `../shine_homeserver_main/`.
@@ -0,0 +1 @@
#include "../../official-demo/examples/Arduino-v3.3.5/libraries/lvgl/src/extra/libs/qrcode/qrcodegen.c"
@@ -0,0 +1,136 @@
/*
* ESPRESSIF MIT License
*
* Copyright (c) 2018 <ESPRESSIF SYSTEMS (SHANGHAI) PTE LTD>
*
* Permission is hereby granted for use on all ESPRESSIF SYSTEMS products, in which case,
* it is free of charge, to any person obtaining a copy of this software and associated
* documentation files (the "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished
* to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*
*/
#ifndef _AUDIO_HAL_H_
#define _AUDIO_HAL_H_
#define AUDIO_HAL_VOL_DEFAULT 60
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief Select media hal codec mode
*/
typedef enum {
AUDIO_HAL_CODEC_MODE_ENCODE = 1, /*!< select adc */
AUDIO_HAL_CODEC_MODE_DECODE, /*!< select dac */
AUDIO_HAL_CODEC_MODE_BOTH, /*!< select both adc and dac */
AUDIO_HAL_CODEC_MODE_LINE_IN, /*!< set adc channel */
} audio_hal_codec_mode_t;
/**
* @brief Select adc channel for input mic signal
*/
typedef enum {
AUDIO_HAL_ADC_INPUT_LINE1 = 0x00, /*!< mic input to adc channel 1 */
AUDIO_HAL_ADC_INPUT_LINE2, /*!< mic input to adc channel 2 */
AUDIO_HAL_ADC_INPUT_ALL, /*!< mic input to both channels of adc */
AUDIO_HAL_ADC_INPUT_DIFFERENCE, /*!< mic input to adc difference channel */
} audio_hal_adc_input_t;
/**
* @brief Select channel for dac output
*/
typedef enum {
AUDIO_HAL_DAC_OUTPUT_LINE1 = 0x00, /*!< dac output signal to channel 1 */
AUDIO_HAL_DAC_OUTPUT_LINE2, /*!< dac output signal to channel 2 */
AUDIO_HAL_DAC_OUTPUT_ALL, /*!< dac output signal to both channels */
} audio_hal_dac_output_t;
/**
* @brief Select operating mode i.e. start or stop for audio codec chip
*/
typedef enum {
AUDIO_HAL_CTRL_STOP = 0x00, /*!< set stop mode */
AUDIO_HAL_CTRL_START = 0x01, /*!< set start mode */
} audio_hal_ctrl_t;
/**
* @brief Select I2S interface operating mode i.e. master or slave for audio codec chip
*/
typedef enum {
AUDIO_HAL_MODE_SLAVE = 0x00, /*!< set slave mode */
AUDIO_HAL_MODE_MASTER = 0x01, /*!< set master mode */
} audio_hal_iface_mode_t;
/**
* @brief Select I2S interface samples per second
*/
typedef enum {
AUDIO_HAL_08K_SAMPLES, /*!< set to 8k samples per second */
AUDIO_HAL_11K_SAMPLES, /*!< set to 11.025k samples per second */
AUDIO_HAL_16K_SAMPLES, /*!< set to 16k samples in per second */
AUDIO_HAL_22K_SAMPLES, /*!< set to 22.050k samples per second */
AUDIO_HAL_24K_SAMPLES, /*!< set to 24k samples in per second */
AUDIO_HAL_32K_SAMPLES, /*!< set to 32k samples in per second */
AUDIO_HAL_44K_SAMPLES, /*!< set to 44.1k samples per second */
AUDIO_HAL_48K_SAMPLES, /*!< set to 48k samples per second */
} audio_hal_iface_samples_t;
/**
* @brief Select I2S interface number of bits per sample
*/
typedef enum {
AUDIO_HAL_BIT_LENGTH_16BITS = 1, /*!< set 16 bits per sample */
AUDIO_HAL_BIT_LENGTH_24BITS, /*!< set 24 bits per sample */
AUDIO_HAL_BIT_LENGTH_32BITS, /*!< set 32 bits per sample */
} audio_hal_iface_bits_t;
/**
* @brief Select I2S interface format for audio codec chip
*/
typedef enum {
AUDIO_HAL_I2S_NORMAL = 0, /*!< set normal I2S format */
AUDIO_HAL_I2S_LEFT, /*!< set all left format */
AUDIO_HAL_I2S_RIGHT, /*!< set all right format */
AUDIO_HAL_I2S_DSP, /*!< set dsp/pcm format */
} audio_hal_iface_format_t;
/**
* @brief I2s interface configuration for audio codec chip
*/
typedef struct {
audio_hal_iface_mode_t mode; /*!< audio codec chip mode */
audio_hal_iface_format_t fmt; /*!< I2S interface format */
audio_hal_iface_samples_t samples; /*!< I2S interface samples per second */
audio_hal_iface_bits_t bits; /*!< i2s interface number of bits per sample */
} audio_hal_codec_i2s_iface_t;
/**
* @brief Configure media hal for initialization of audio codec chip
*/
typedef struct {
audio_hal_adc_input_t adc_input; /*!< set adc channel */
audio_hal_dac_output_t dac_output; /*!< set dac channel */
audio_hal_codec_mode_t codec_mode; /*!< select codec mode: adc, dac or both */
audio_hal_codec_i2s_iface_t i2s_iface; /*!< set I2S interface configuration */
} audio_hal_codec_config_t;
#ifdef __cplusplus
}
#endif
#endif //__AUDIO_HAL_H__
@@ -0,0 +1,549 @@
/*
* ESPRESSIF MIT License
*
* Copyright (c) 2021 <ESPRESSIF SYSTEMS (SHANGHAI) CO., LTD>
*
* Permission is hereby granted for use on all ESPRESSIF SYSTEMS products, in which case,
* it is free of charge, to any person obtaining a copy of this software and associated
* documentation files (the "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished
* to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*
*/
#ifdef ESP32
#include <Wire.h>
#include <string.h>
#include "esp_log.h"
#include "es7210.h"
#define I2S_DSP_MODE_A 0
#define MCLK_DIV_FRE 256
#define ES7210_MCLK_SOURCE FROM_CLOCK_DOUBLE_PIN /* In master mode, 0 : MCLK from pad 1 : MCLK from clock doubler */
#define FROM_PAD_PIN 0
#define FROM_CLOCK_DOUBLE_PIN 1
static TwoWire *es7210wire;
static es7210_gain_value_t gain;
/*
* Clock coefficient structer
*/
struct _coeff_div_es7210 {
uint32_t mclk; /* mclk frequency */
uint32_t lrck; /* lrck */
uint8_t ss_ds;
uint8_t adc_div; /* adcclk divider */
uint8_t dll; /* dll_bypass */
uint8_t doubler; /* doubler enable */
uint8_t osr; /* adc osr */
uint8_t mclk_src; /* select mclk source */
uint32_t lrck_h; /* The high 4 bits of lrck */
uint32_t lrck_l; /* The low 8 bits of lrck */
};
static const char *TAG = "ES7210";
static es7210_input_mics_t mic_select = (es7210_input_mics_t)(ES7210_INPUT_MIC1 | ES7210_INPUT_MIC2 | ES7210_INPUT_MIC3 | ES7210_INPUT_MIC4);
/* Codec hifi mclk clock divider coefficients
* MEMBER REG
* mclk: 0x03
* lrck: standard
* ss_ds: --
* adc_div: 0x02
* dll: 0x06
* doubler: 0x02
* osr: 0x07
* mclk_src: 0x03
* lrckh: 0x04
* lrckl: 0x05
*/
static const struct _coeff_div_es7210 coeff_div[] = {
//mclk lrck ss_ds adc_div dll doubler osr mclk_src lrckh lrckl
/* 8k */
{12288000, 8000 , 0x00, 0x03, 0x01, 0x00, 0x20, 0x00, 0x06, 0x00},
{16384000, 8000 , 0x00, 0x04, 0x01, 0x00, 0x20, 0x00, 0x08, 0x00},
{19200000, 8000 , 0x00, 0x1e, 0x00, 0x01, 0x28, 0x00, 0x09, 0x60},
{4096000, 8000 , 0x00, 0x01, 0x01, 0x00, 0x20, 0x00, 0x02, 0x00},
/* 11.025k */
{11289600, 11025, 0x00, 0x02, 0x01, 0x00, 0x20, 0x00, 0x01, 0x00},
/* 12k */
{12288000, 12000, 0x00, 0x02, 0x01, 0x00, 0x20, 0x00, 0x04, 0x00},
{19200000, 12000, 0x00, 0x14, 0x00, 0x01, 0x28, 0x00, 0x06, 0x40},
/* 16k */
{4096000, 16000, 0x00, 0x01, 0x01, 0x01, 0x20, 0x00, 0x01, 0x00},
{19200000, 16000, 0x00, 0x0a, 0x00, 0x00, 0x1e, 0x00, 0x04, 0x80},
{16384000, 16000, 0x00, 0x02, 0x01, 0x00, 0x20, 0x00, 0x04, 0x00},
{12288000, 16000, 0x00, 0x03, 0x01, 0x01, 0x20, 0x00, 0x03, 0x00},
/* 22.05k */
{11289600, 22050, 0x00, 0x01, 0x01, 0x00, 0x20, 0x00, 0x02, 0x00},
/* 24k */
{12288000, 24000, 0x00, 0x01, 0x01, 0x00, 0x20, 0x00, 0x02, 0x00},
{19200000, 24000, 0x00, 0x0a, 0x00, 0x01, 0x28, 0x00, 0x03, 0x20},
/* 32k */
{12288000, 32000, 0x00, 0x03, 0x00, 0x00, 0x20, 0x00, 0x01, 0x80},
{16384000, 32000, 0x00, 0x01, 0x01, 0x00, 0x20, 0x00, 0x02, 0x00},
{19200000, 32000, 0x00, 0x05, 0x00, 0x00, 0x1e, 0x00, 0x02, 0x58},
/* 44.1k */
{11289600, 44100, 0x00, 0x01, 0x01, 0x01, 0x20, 0x00, 0x01, 0x00},
/* 48k */
{12288000, 48000, 0x00, 0x01, 0x01, 0x01, 0x20, 0x00, 0x01, 0x00},
{19200000, 48000, 0x00, 0x05, 0x00, 0x01, 0x28, 0x00, 0x01, 0x90},
/* 64k */
{16384000, 64000, 0x01, 0x01, 0x01, 0x00, 0x20, 0x00, 0x01, 0x00},
{19200000, 64000, 0x00, 0x05, 0x00, 0x01, 0x1e, 0x00, 0x01, 0x2c},
/* 88.2k */
{11289600, 88200, 0x01, 0x01, 0x01, 0x01, 0x20, 0x00, 0x00, 0x80},
/* 96k */
{12288000, 96000, 0x01, 0x01, 0x01, 0x01, 0x20, 0x00, 0x00, 0x80},
{19200000, 96000, 0x01, 0x05, 0x00, 0x01, 0x28, 0x00, 0x00, 0xc8},
};
static esp_err_t es7210_write_reg(uint8_t reg_addr, uint8_t data)
{
es7210wire->beginTransmission(ES7210_ADDR);
es7210wire->write(reg_addr);
es7210wire->write(data);
return es7210wire->endTransmission();
}
static esp_err_t es7210_update_reg_bit(uint8_t reg_addr, uint8_t update_bits, uint8_t data)
{
uint8_t regv;
regv = es7210_read_reg(reg_addr);
regv = (regv & (~update_bits)) | (update_bits & data);
return es7210_write_reg(reg_addr, regv);
}
static int get_coeff(uint32_t mclk, uint32_t lrck)
{
for (int i = 0; i < (sizeof(coeff_div) / sizeof(coeff_div[0])); i++) {
if (coeff_div[i].lrck == lrck && coeff_div[i].mclk == mclk)
return i;
}
return -1;
}
int8_t get_es7210_mclk_src(void)
{
return ES7210_MCLK_SOURCE;
}
int es7210_read_reg(uint8_t reg_addr)
{
uint8_t data;
es7210wire->beginTransmission(ES7210_ADDR);
es7210wire->write(reg_addr);
es7210wire->endTransmission(false);
es7210wire->requestFrom(ES7210_ADDR, (size_t)1);
data = es7210wire->read();
return (int)data;
}
esp_err_t es7210_config_sample(audio_hal_iface_samples_t sample)
{
uint8_t regv;
int coeff;
int sample_fre = 0;
int mclk_fre = 0;
esp_err_t ret = ESP_OK;
switch (sample) {
case AUDIO_HAL_08K_SAMPLES:
sample_fre = 8000;
break;
case AUDIO_HAL_11K_SAMPLES:
sample_fre = 11025;
break;
case AUDIO_HAL_16K_SAMPLES:
sample_fre = 16000;
break;
case AUDIO_HAL_22K_SAMPLES:
sample_fre = 22050;
break;
case AUDIO_HAL_24K_SAMPLES:
sample_fre = 24000;
break;
case AUDIO_HAL_32K_SAMPLES:
sample_fre = 32000;
break;
case AUDIO_HAL_44K_SAMPLES:
sample_fre = 44100;
break;
case AUDIO_HAL_48K_SAMPLES:
sample_fre = 48000;
break;
default:
ESP_LOGE(TAG, "Unable to configure sample rate %dHz", sample_fre);
break;
}
mclk_fre = sample_fre * MCLK_DIV_FRE;
coeff = get_coeff(mclk_fre, sample_fre);
if (coeff < 0) {
ESP_LOGE(TAG, "Unable to configure sample rate %dHz with %dHz MCLK", sample_fre, mclk_fre);
return ESP_FAIL;
}
/* Set clock parammeters */
if (coeff >= 0) {
/* Set adc_div & doubler & dll */
regv = es7210_read_reg(ES7210_MAINCLK_REG02) & 0x00;
regv |= coeff_div[coeff].adc_div;
regv |= coeff_div[coeff].doubler << 6;
regv |= coeff_div[coeff].dll << 7;
ret |= es7210_write_reg(ES7210_MAINCLK_REG02, regv);
/* Set osr */
regv = coeff_div[coeff].osr;
ret |= es7210_write_reg(ES7210_OSR_REG07, regv);
/* Set lrck */
regv = coeff_div[coeff].lrck_h;
ret |= es7210_write_reg(ES7210_LRCK_DIVH_REG04, regv);
regv = coeff_div[coeff].lrck_l;
ret |= es7210_write_reg(ES7210_LRCK_DIVL_REG05, regv);
}
return ret;
}
esp_err_t es7210_mic_select(es7210_input_mics_t mic)
{
esp_err_t ret = ESP_OK;
mic_select = mic;
if (mic_select & (ES7210_INPUT_MIC1 | ES7210_INPUT_MIC2 | ES7210_INPUT_MIC3 | ES7210_INPUT_MIC4)) {
for (int i = 0; i < 4; i++) {
ret |= es7210_update_reg_bit(ES7210_MIC1_GAIN_REG43 + i, 0x10, 0x00);
}
ret |= es7210_write_reg(ES7210_MIC12_POWER_REG4B, 0xff);
ret |= es7210_write_reg(ES7210_MIC34_POWER_REG4C, 0xff);
if (mic_select & ES7210_INPUT_MIC1) {
ESP_LOGI(TAG, "Enable ES7210_INPUT_MIC1");
ret |= es7210_update_reg_bit(ES7210_CLOCK_OFF_REG01, 0x0b, 0x00);
ret |= es7210_write_reg(ES7210_MIC12_POWER_REG4B, 0x00);
ret |= es7210_update_reg_bit(ES7210_MIC1_GAIN_REG43, 0x10, 0x10);
}
if (mic_select & ES7210_INPUT_MIC2) {
ESP_LOGI(TAG, "Enable ES7210_INPUT_MIC2");
ret |= es7210_update_reg_bit(ES7210_CLOCK_OFF_REG01, 0x0b, 0x00);
ret |= es7210_write_reg(ES7210_MIC12_POWER_REG4B, 0x00);
ret |= es7210_update_reg_bit(ES7210_MIC2_GAIN_REG44, 0x10, 0x10);
}
if (mic_select & ES7210_INPUT_MIC3) {
ESP_LOGI(TAG, "Enable ES7210_INPUT_MIC3");
ret |= es7210_update_reg_bit(ES7210_CLOCK_OFF_REG01, 0x15, 0x00);
ret |= es7210_write_reg(ES7210_MIC34_POWER_REG4C, 0x00);
ret |= es7210_update_reg_bit(ES7210_MIC3_GAIN_REG45, 0x10, 0x10);
}
if (mic_select & ES7210_INPUT_MIC4) {
ESP_LOGI(TAG, "Enable ES7210_INPUT_MIC4");
ret |= es7210_update_reg_bit(ES7210_CLOCK_OFF_REG01, 0x15, 0x00);
ret |= es7210_write_reg(ES7210_MIC34_POWER_REG4C, 0x00);
ret |= es7210_update_reg_bit(ES7210_MIC4_GAIN_REG46, 0x10, 0x10);
}
} else {
ESP_LOGE(TAG, "Microphone selection error");
return ESP_FAIL;
}
return ret;
}
esp_err_t es7210_adc_init(TwoWire *tw, audio_hal_codec_config_t *codec_cfg)
{
esp_err_t ret = ESP_OK;
es7210wire = tw;
ret |= es7210_write_reg(ES7210_RESET_REG00, 0xff);
ret |= es7210_write_reg(ES7210_RESET_REG00, 0x41);
ret |= es7210_write_reg(ES7210_CLOCK_OFF_REG01, 0x1f);
ret |= es7210_write_reg(ES7210_TIME_CONTROL0_REG09, 0x30); /* Set chip state cycle */
ret |= es7210_write_reg(ES7210_TIME_CONTROL1_REG0A, 0x30); /* Set power on state cycle */
// ret |= es7210_write_reg(ES7210_ADC12_HPF2_REG23, 0x2a); /* Quick setup */
// ret |= es7210_write_reg(ES7210_ADC12_HPF1_REG22, 0x0a);
// ret |= es7210_write_reg(ES7210_ADC34_HPF2_REG20, 0x0a);
// ret |= es7210_write_reg(ES7210_ADC34_HPF1_REG21, 0x2a);
/* Set master/slave audio interface */
audio_hal_codec_i2s_iface_t *i2s_cfg = & (codec_cfg->i2s_iface);
switch (i2s_cfg->mode) {
case AUDIO_HAL_MODE_MASTER: /* MASTER MODE */
ESP_LOGI(TAG, "ES7210 in Master mode");
// ret |= es7210_update_reg_bit(ES7210_MODE_CONFIG_REG08, 0x01, 0x01);
ret |= es7210_write_reg(ES7210_MODE_CONFIG_REG08, 0x20);
/* Select clock source for internal mclk */
switch (get_es7210_mclk_src()) {
case FROM_PAD_PIN:
ret |= es7210_update_reg_bit(ES7210_MASTER_CLK_REG03, 0x80, 0x00);
break;
case FROM_CLOCK_DOUBLE_PIN:
ret |= es7210_update_reg_bit(ES7210_MASTER_CLK_REG03, 0x80, 0x80);
break;
default:
ret |= es7210_update_reg_bit(ES7210_MASTER_CLK_REG03, 0x80, 0x00);
break;
}
break;
case AUDIO_HAL_MODE_SLAVE: /* SLAVE MODE */
ESP_LOGI(TAG, "ES7210 in Slave mode");
break;
default:
break;
}
ret |= es7210_write_reg(ES7210_ANALOG_REG40, 0xC3); /* Select power off analog, vdda = 3.3V, close vx20ff, VMID select 5KΩ start */
ret |= es7210_write_reg(ES7210_MIC12_BIAS_REG41, 0x70); /* Select 2.87v */
ret |= es7210_write_reg(ES7210_MIC34_BIAS_REG42, 0x70); /* Select 2.87v */
ret |= es7210_write_reg(ES7210_OSR_REG07, 0x20);
ret |= es7210_write_reg(ES7210_MAINCLK_REG02, 0xc1); /* Set the frequency division coefficient and use dll except clock doubler, and need to set 0xc1 to clear the state */
ret |= es7210_config_sample(i2s_cfg->samples);
ret |= es7210_mic_select(mic_select);
ret |= es7210_adc_set_gain_all(GAIN_0DB);
return ESP_OK;
}
esp_err_t es7210_adc_deinit()
{
return ESP_OK;
}
esp_err_t es7210_config_fmt(audio_hal_iface_format_t fmt)
{
esp_err_t ret = ESP_OK;
uint8_t adc_iface = 0;
adc_iface = es7210_read_reg(ES7210_SDP_INTERFACE1_REG11);
adc_iface &= 0xfc;
switch (fmt) {
case AUDIO_HAL_I2S_NORMAL:
ESP_LOGD(TAG, "ES7210 in I2S Format");
adc_iface |= 0x00;
break;
case AUDIO_HAL_I2S_LEFT:
case AUDIO_HAL_I2S_RIGHT:
ESP_LOGD(TAG, "ES7210 in LJ Format");
adc_iface |= 0x01;
break;
case AUDIO_HAL_I2S_DSP:
if (I2S_DSP_MODE_A) {
ESP_LOGD(TAG, "ES7210 in DSP-A Format");
adc_iface |= 0x03;
} else {
ESP_LOGD(TAG, "ES7210 in DSP-B Format");
adc_iface |= 0x13;
}
break;
default:
adc_iface &= 0xfc;
break;
}
ret |= es7210_write_reg(ES7210_SDP_INTERFACE1_REG11, adc_iface);
/* Force ADC1/2 output to SDOUT1 and ADC3/4 output to SDOUT2 */
ret |= es7210_write_reg(ES7210_SDP_INTERFACE2_REG12, 0x00);
return ret;
}
esp_err_t es7210_set_bits(audio_hal_iface_bits_t bits)
{
esp_err_t ret = ESP_OK;
uint8_t adc_iface = 0;
adc_iface = es7210_read_reg(ES7210_SDP_INTERFACE1_REG11);
adc_iface &= 0x1f;
switch (bits) {
case AUDIO_HAL_BIT_LENGTH_16BITS:
adc_iface |= 0x60;
break;
case AUDIO_HAL_BIT_LENGTH_24BITS:
adc_iface |= 0x00;
break;
case AUDIO_HAL_BIT_LENGTH_32BITS:
adc_iface |= 0x80;
break;
default:
adc_iface |= 0x60;
break;
}
ret |= es7210_write_reg(ES7210_SDP_INTERFACE1_REG11, adc_iface);
return ret;
}
esp_err_t es7210_adc_config_i2s(audio_hal_codec_mode_t mode, audio_hal_codec_i2s_iface_t *iface)
{
esp_err_t ret = ESP_OK;
ret |= es7210_set_bits(iface->bits);
ret |= es7210_config_fmt(iface->fmt);
ret |= es7210_config_sample(iface->samples);
return ret;
}
esp_err_t es7210_start(uint8_t clock_reg_value)
{
esp_err_t ret = ESP_OK;
ret |= es7210_write_reg(ES7210_CLOCK_OFF_REG01, clock_reg_value);
ret |= es7210_write_reg(ES7210_POWER_DOWN_REG06, 0x00);
// ret |= es7210_write_reg(ES7210_ANALOG_REG40, 0x40);
ret |= es7210_write_reg(ES7210_MIC1_POWER_REG47, 0x00);
ret |= es7210_write_reg(ES7210_MIC2_POWER_REG48, 0x00);
ret |= es7210_write_reg(ES7210_MIC3_POWER_REG49, 0x00);
ret |= es7210_write_reg(ES7210_MIC4_POWER_REG4A, 0x00);
ret |= es7210_mic_select(mic_select);
return ret;
}
esp_err_t es7210_stop(void)
{
esp_err_t ret = ESP_OK;
ret |= es7210_write_reg(ES7210_MIC1_POWER_REG47, 0xff);
ret |= es7210_write_reg(ES7210_MIC2_POWER_REG48, 0xff);
ret |= es7210_write_reg(ES7210_MIC3_POWER_REG49, 0xff);
ret |= es7210_write_reg(ES7210_MIC4_POWER_REG4A, 0xff);
ret |= es7210_write_reg(ES7210_MIC12_POWER_REG4B,0xff);
ret |= es7210_write_reg(ES7210_MIC34_POWER_REG4C, 0xff);
// ret |= es7210_write_reg(ES7210_ANALOG_REG40, 0xc0);
ret |= es7210_write_reg(ES7210_CLOCK_OFF_REG01, 0x7f);
ret |= es7210_write_reg(ES7210_POWER_DOWN_REG06, 0x07);
return ret;
}
esp_err_t es7210_adc_ctrl_state(audio_hal_codec_mode_t mode, audio_hal_ctrl_t ctrl_state)
{
static uint8_t regv;
esp_err_t ret = ESP_OK;
// ESP_LOGW(TAG, "ES7210 only supports ADC mode");
ret = es7210_read_reg(ES7210_CLOCK_OFF_REG01);
if ((ret != 0x7f) && (ret != 0xff)) {
regv = es7210_read_reg(ES7210_CLOCK_OFF_REG01);
}
if (ctrl_state == AUDIO_HAL_CTRL_START) {
ESP_LOGI(TAG, "The ES7210_CLOCK_OFF_REG01 value before stop is %x",regv);
ret |= es7210_start(regv);
} else {
ESP_LOGW(TAG, "The codec is about to stop");
regv = es7210_read_reg(ES7210_CLOCK_OFF_REG01);
ret |= es7210_stop();
}
return ESP_OK;
}
esp_err_t es7210_adc_set_gain(es7210_input_mics_t mic_mask, es7210_gain_value_t gain)
{
esp_err_t ret_val = ESP_OK;
if (gain < GAIN_0DB) {
gain = GAIN_0DB;
}
if (gain > GAIN_37_5DB) {
gain = GAIN_37_5DB;
}
if (mic_mask & ES7210_INPUT_MIC1) {
ret_val |= es7210_update_reg_bit(ES7210_MIC1_GAIN_REG43, 0x0f, gain);
}
if (mic_mask & ES7210_INPUT_MIC2) {
ret_val |= es7210_update_reg_bit(ES7210_MIC2_GAIN_REG44, 0x0f, gain);
}
if (mic_mask & ES7210_INPUT_MIC3) {
ret_val |= es7210_update_reg_bit(ES7210_MIC3_GAIN_REG45, 0x0f, gain);
}
if (mic_mask & ES7210_INPUT_MIC4) {
ret_val |= es7210_update_reg_bit(ES7210_MIC4_GAIN_REG46, 0x0f, gain);
}
return ret_val;
}
esp_err_t es7210_adc_set_gain_all(es7210_gain_value_t gain)
{
esp_err_t ret = ESP_OK;
uint32_t max_gain_vaule = 14;
if (gain < 0) {
gain = (es7210_gain_value_t) 0;
} else if (gain > max_gain_vaule) {
gain = (es7210_gain_value_t) max_gain_vaule;
}
ESP_LOGD(TAG, "SET: gain:%d", gain);
if (mic_select & ES7210_INPUT_MIC1) {
ret |= es7210_update_reg_bit(ES7210_MIC1_GAIN_REG43, 0x0f, gain);
}
if (mic_select & ES7210_INPUT_MIC2) {
ret |= es7210_update_reg_bit(ES7210_MIC2_GAIN_REG44, 0x0f, gain);
}
if (mic_select & ES7210_INPUT_MIC3) {
ret |= es7210_update_reg_bit(ES7210_MIC3_GAIN_REG45, 0x0f, gain);
}
if (mic_select & ES7210_INPUT_MIC4) {
ret |= es7210_update_reg_bit(ES7210_MIC4_GAIN_REG46, 0x0f, gain);
}
return ret;
}
esp_err_t es7210_adc_get_gain(es7210_input_mics_t mic_mask, es7210_gain_value_t *gain)
{
int regv = 0;
uint8_t gain_value;
if (mic_mask & ES7210_INPUT_MIC1) {
regv = es7210_read_reg(ES7210_MIC1_GAIN_REG43);
} else if (mic_mask & ES7210_INPUT_MIC2) {
regv = es7210_read_reg(ES7210_MIC2_GAIN_REG44);
} else if (mic_mask & ES7210_INPUT_MIC3) {
regv = es7210_read_reg(ES7210_MIC3_GAIN_REG45);
} else if (mic_mask & ES7210_INPUT_MIC4) {
regv = es7210_read_reg(ES7210_MIC4_GAIN_REG46);
} else {
ESP_LOGE(TAG, "No MIC selected");
return ESP_FAIL;
}
if (regv == ESP_FAIL) {
return regv;
}
gain_value = (regv & 0x0f); /* Retain the last four bits for gain */
*gain = (es7210_gain_value_t) gain_value;
ESP_LOGI(TAG, "GET: gain_value:%d", gain_value);
return ESP_OK;
}
esp_err_t es7210_adc_set_volume(int volume)
{
esp_err_t ret = ESP_OK;
ESP_LOGD(TAG, "ADC can adjust gain");
return ret;
}
esp_err_t es7210_set_mute(bool enable)
{
ESP_LOGD(TAG, "ES7210 SetMute :%d", enable);
return ESP_OK;
}
void es7210_read_all(void)
{
for (int i = 0; i <= 0x4E; i++) {
uint8_t reg = es7210_read_reg(i);
ets_printf("REG:%02x, %02x\n", reg, i);
}
}
#endif
@@ -0,0 +1,260 @@
/*
* ESPRESSIF MIT License
*
* Copyright (c) 2021 <ESPRESSIF SYSTEMS (SHANGHAI) CO., LTD>
*
* Permission is hereby granted for use on all ESPRESSIF SYSTEMS products, in which case,
* it is free of charge, to any person obtaining a copy of this software and associated
* documentation files (the "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished
* to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*
*/
#ifndef _ES7210_H
#define _ES7210_H
#include "audio_hal.h"
#include <Wire.h>
typedef enum {
ES7210_AD1_AD0_00 = 0x40,
ES7210_AD1_AD0_01 = 0x41,
ES7210_AD1_AD0_10 = 0x42,
ES7210_AD1_AD0_11 = 0x43,
} es7210_address_t;
/* ES7210 address*/
#define ES7210_ADDR ES7210_AD1_AD0_00
#ifdef __cplusplus
extern "C" {
#endif
#define ES7210_RESET_REG00 0x00 /* Reset control */
#define ES7210_CLOCK_OFF_REG01 0x01 /* Used to turn off the ADC clock */
#define ES7210_MAINCLK_REG02 0x02 /* Set ADC clock frequency division */
#define ES7210_MASTER_CLK_REG03 0x03 /* MCLK source $ SCLK division */
#define ES7210_LRCK_DIVH_REG04 0x04 /* lrck_divh */
#define ES7210_LRCK_DIVL_REG05 0x05 /* lrck_divl */
#define ES7210_POWER_DOWN_REG06 0x06 /* power down */
#define ES7210_OSR_REG07 0x07
#define ES7210_MODE_CONFIG_REG08 0x08 /* Set master/slave & channels */
#define ES7210_TIME_CONTROL0_REG09 0x09 /* Set Chip intial state period*/
#define ES7210_TIME_CONTROL1_REG0A 0x0A /* Set Power up state period */
#define ES7210_SDP_INTERFACE1_REG11 0x11 /* Set sample & fmt */
#define ES7210_SDP_INTERFACE2_REG12 0x12 /* Pins state */
#define ES7210_ADC_AUTOMUTE_REG13 0x13 /* Set mute */
#define ES7210_ADC34_MUTERANGE_REG14 0x14 /* Set mute range */
#define ES7210_ADC34_HPF2_REG20 0x20 /* HPF */
#define ES7210_ADC34_HPF1_REG21 0x21
#define ES7210_ADC12_HPF1_REG22 0x22
#define ES7210_ADC12_HPF2_REG23 0x23
#define ES7210_ANALOG_REG40 0x40 /* ANALOG Power */
#define ES7210_MIC12_BIAS_REG41 0x41
#define ES7210_MIC34_BIAS_REG42 0x42
#define ES7210_MIC1_GAIN_REG43 0x43
#define ES7210_MIC2_GAIN_REG44 0x44
#define ES7210_MIC3_GAIN_REG45 0x45
#define ES7210_MIC4_GAIN_REG46 0x46
#define ES7210_MIC1_POWER_REG47 0x47
#define ES7210_MIC2_POWER_REG48 0x48
#define ES7210_MIC3_POWER_REG49 0x49
#define ES7210_MIC4_POWER_REG4A 0x4A
#define ES7210_MIC12_POWER_REG4B 0x4B /* MICBias & ADC & PGA Power */
#define ES7210_MIC34_POWER_REG4C 0x4C
typedef enum {
ES7210_INPUT_MIC1 = 0x01,
ES7210_INPUT_MIC2 = 0x02,
ES7210_INPUT_MIC3 = 0x04,
ES7210_INPUT_MIC4 = 0x08
} es7210_input_mics_t;
typedef enum gain_value{
GAIN_0DB = 0,
GAIN_3DB,
GAIN_6DB,
GAIN_9DB,
GAIN_12DB,
GAIN_15DB,
GAIN_18DB,
GAIN_21DB,
GAIN_24DB,
GAIN_27DB,
GAIN_30DB,
GAIN_33DB,
GAIN_34_5DB,
GAIN_36DB,
GAIN_37_5DB,
} es7210_gain_value_t;
/*
* @brief Initialize ES7210 ADC chip
*
* @param[in] codec_cfg: configuration of ES7210
*
* @return
* - ESP_OK
* - ESP_FAIL
*/
esp_err_t es7210_adc_init(TwoWire *tw, audio_hal_codec_config_t *codec_cfg);
/**
* @brief Deinitialize ES7210 ADC chip
*
* @return
* - ESP_OK
* - ESP_FAIL
*/
esp_err_t es7210_adc_deinit();
/**
* @brief Configure ES7210 ADC mode and I2S interface
*
* @param[in] mode: codec mode
* @param[in] iface: I2S config
*
* @return
* - ESP_FAIL Parameter error
* - ESP_OK Success
*/
esp_err_t es7210_adc_config_i2s(audio_hal_codec_mode_t mode, audio_hal_codec_i2s_iface_t *iface);
/**
* @brief Control ES7210 ADC chip
*
* @param[in] mode: codec mode
* @param[in] ctrl_state: start or stop progress
*
* @return
* - ESP_FAIL Parameter error
* - ESP_OK Success
*/
esp_err_t es7210_adc_ctrl_state(audio_hal_codec_mode_t mode, audio_hal_ctrl_t ctrl_state);
/**
* @brief Set gain of given mask
*
* @param[in] mic_mask Mask of MIC channel
*
* @param[in] gain: gain
*
* gain : value
* GAIN_0DB : 1
* GAIN_3DB : 2
* GAIN_6DB : 3
* ·
* ·
* ·
* GAIN_30DB : 10
* GAIN_33DB : 11
* GAIN_34_5DB : 12
* GAIN_36DB : 13
* GAIN_37_5DB : 14
*
* @return
* - ESP_OK
* - ESP_FAIL
*/
esp_err_t es7210_adc_set_gain(es7210_input_mics_t mic_mask, es7210_gain_value_t gain);
/**
* @brief Set gain (Note: the enabled microphone sets the same gain)
*
* @param[in] gain: gain
*
* gain : value
* GAIN_0DB : 1
* GAIN_3DB : 2
* GAIN_6DB : 3
* ·
* ·
* ·
* GAIN_30DB : 10
* GAIN_33DB : 11
* GAIN_34_5DB : 12
* GAIN_36DB : 13
* GAIN_37_5DB : 14
*
* @return
* - ESP_OK
* - ESP_FAIL
*/
esp_err_t es7210_adc_set_gain_all(es7210_gain_value_t gain);
/**
* @brief Get MIC gain
*
* @param mic_mask Selected MIC
* @param gain Pointer to `es7210_gain_value_t`
* @return
* - ESP_OK
* - ESP_FAIL
*/
esp_err_t es7210_adc_get_gain(es7210_input_mics_t mic_mask, es7210_gain_value_t *gain);
/**
* @brief Set volume
*
* @param[in] volume: volume
*
* @return
* - ESP_OK
*/
esp_err_t es7210_adc_set_volume(int volume);
/**
* @brief Set ES7210 ADC mute status
*
* @return
* - ESP_FAIL
* - ESP_OK
*/
esp_err_t es7210_set_mute(bool enable);
/**
* @brief Select ES7210 mic
*
* @param[in] mic: mics
*
* @return
* - ESP_FAIL
* - ESP_OK
*/
esp_err_t es7210_mic_select(es7210_input_mics_t mic);
/**
* @brief Read regs of ES7210
*
* @param[in] reg_addr: reg_addr
*
* @return
* - ESP_FAIL
* - ESP_OK
*/
int es7210_read_reg(uint8_t reg_addr);
/**
* @brief Read all regs of ES7210
*/
void es7210_read_all(void);
#ifdef __cplusplus
}
#endif
#endif /* _ES7210_H_ */
@@ -0,0 +1,443 @@
/*
* SPDX-FileCopyrightText: 2015-2022 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <string.h>
#include "es8311.h"
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_err.h"
#include "esp_check.h"
#include "es8311_reg.h"
#include "esp32-hal-i2c.h"
typedef struct {
unsigned int port;
uint16_t dev_addr;
} es8311_dev_t;
/*
* Clock coefficient structure
*/
struct _coeff_div {
uint32_t mclk; /* mclk frequency */
uint32_t rate; /* sample rate */
uint8_t pre_div; /* the pre divider with range from 1 to 8 */
uint8_t pre_multi; /* the pre multiplier with 0: 1x, 1: 2x, 2: 4x, 3: 8x selection */
uint8_t adc_div; /* adcclk divider */
uint8_t dac_div; /* dacclk divider */
uint8_t fs_mode; /* double speed or single speed, =0, ss, =1, ds */
uint8_t lrck_h; /* adclrck divider and daclrck divider */
uint8_t lrck_l;
uint8_t bclk_div; /* sclk divider */
uint8_t adc_osr; /* adc osr */
uint8_t dac_osr; /* dac osr */
};
/* codec hifi mclk clock divider coefficients */
static const struct _coeff_div coeff_div[] = {
/*!<mclk rate pre_div mult adc_div dac_div fs_mode lrch lrcl bckdiv osr */
/* 8k */
{12288000, 8000, 0x06, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{18432000, 8000, 0x03, 0x01, 0x03, 0x03, 0x00, 0x05, 0xff, 0x18, 0x10, 0x10},
{16384000, 8000, 0x08, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{8192000, 8000, 0x04, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{6144000, 8000, 0x03, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{4096000, 8000, 0x02, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{3072000, 8000, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{2048000, 8000, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{1536000, 8000, 0x03, 0x02, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{1024000, 8000, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
/* 11.025k */
{11289600, 11025, 0x04, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{5644800, 11025, 0x02, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{2822400, 11025, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{1411200, 11025, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
/* 12k */
{12288000, 12000, 0x04, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{6144000, 12000, 0x02, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{3072000, 12000, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{1536000, 12000, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
/* 16k */
{12288000, 16000, 0x03, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{18432000, 16000, 0x03, 0x01, 0x03, 0x03, 0x00, 0x02, 0xff, 0x0c, 0x10, 0x10},
{16384000, 16000, 0x04, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{8192000, 16000, 0x02, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{6144000, 16000, 0x03, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{4096000, 16000, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{3072000, 16000, 0x03, 0x02, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{2048000, 16000, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{1536000, 16000, 0x03, 0x03, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{1024000, 16000, 0x01, 0x02, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
/* 22.05k */
{11289600, 22050, 0x02, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{5644800, 22050, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{2822400, 22050, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{1411200, 22050, 0x01, 0x02, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{705600, 22050, 0x01, 0x03, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
/* 24k */
{12288000, 24000, 0x02, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{18432000, 24000, 0x03, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{6144000, 24000, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{3072000, 24000, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{1536000, 24000, 0x01, 0x02, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
/* 32k */
{12288000, 32000, 0x03, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{18432000, 32000, 0x03, 0x02, 0x03, 0x03, 0x00, 0x02, 0xff, 0x0c, 0x10, 0x10},
{16384000, 32000, 0x02, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{8192000, 32000, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{6144000, 32000, 0x03, 0x02, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{4096000, 32000, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{3072000, 32000, 0x03, 0x03, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{2048000, 32000, 0x01, 0x02, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{1536000, 32000, 0x03, 0x03, 0x01, 0x01, 0x01, 0x00, 0x7f, 0x02, 0x10, 0x10},
{1024000, 32000, 0x01, 0x03, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
/* 44.1k */
{11289600, 44100, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{5644800, 44100, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{2822400, 44100, 0x01, 0x02, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{1411200, 44100, 0x01, 0x03, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
/* 48k */
{12288000, 48000, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{18432000, 48000, 0x03, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{6144000, 48000, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{3072000, 48000, 0x01, 0x02, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{1536000, 48000, 0x01, 0x03, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
/* 64k */
{12288000, 64000, 0x03, 0x02, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{18432000, 64000, 0x03, 0x02, 0x03, 0x03, 0x01, 0x01, 0x7f, 0x06, 0x10, 0x10},
{16384000, 64000, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{8192000, 64000, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{6144000, 64000, 0x01, 0x02, 0x03, 0x03, 0x01, 0x01, 0x7f, 0x06, 0x10, 0x10},
{4096000, 64000, 0x01, 0x02, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{3072000, 64000, 0x01, 0x03, 0x03, 0x03, 0x01, 0x01, 0x7f, 0x06, 0x10, 0x10},
{2048000, 64000, 0x01, 0x03, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{1536000, 64000, 0x01, 0x03, 0x01, 0x01, 0x01, 0x00, 0xbf, 0x03, 0x18, 0x18},
{1024000, 64000, 0x01, 0x03, 0x01, 0x01, 0x01, 0x00, 0x7f, 0x02, 0x10, 0x10},
/* 88.2k */
{11289600, 88200, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{5644800, 88200, 0x01, 0x02, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{2822400, 88200, 0x01, 0x03, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{1411200, 88200, 0x01, 0x03, 0x01, 0x01, 0x01, 0x00, 0x7f, 0x02, 0x10, 0x10},
/* 96k */
{12288000, 96000, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{18432000, 96000, 0x03, 0x02, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{6144000, 96000, 0x01, 0x02, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{3072000, 96000, 0x01, 0x03, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{1536000, 96000, 0x01, 0x03, 0x01, 0x01, 0x01, 0x00, 0x7f, 0x02, 0x10, 0x10},
};
static const char *TAG = "ES8311";
static inline esp_err_t es8311_write_reg(es8311_handle_t dev, uint8_t reg_addr, uint8_t data)
{
es8311_dev_t *es = (es8311_dev_t *) dev;
const uint8_t write_buf[2] = {reg_addr, data};
return i2cWrite(es->port, es->dev_addr, write_buf, sizeof(write_buf), 1000);
}
static inline esp_err_t es8311_read_reg(es8311_handle_t dev, uint8_t reg_addr, uint8_t *reg_value)
{
es8311_dev_t *es = (es8311_dev_t *) dev;
size_t readCount = 0;
return i2cWriteReadNonStop(es->port, es->dev_addr, &reg_addr, 1, reg_value, 1, 1000, &readCount);
}
/*
* look for the coefficient in coeff_div[] table
*/
static int get_coeff(uint32_t mclk, uint32_t rate)
{
for (int i = 0; i < (sizeof(coeff_div) / sizeof(coeff_div[0])); i++) {
if (coeff_div[i].rate == rate && coeff_div[i].mclk == mclk) {
return i;
}
}
return -1;
}
esp_err_t es8311_sample_frequency_config(es8311_handle_t dev, int mclk_frequency, int sample_frequency)
{
uint8_t regv;
/* Get clock coefficients from coefficient table */
int coeff = get_coeff(mclk_frequency, sample_frequency);
if (coeff < 0) {
ESP_LOGE(TAG, "Unable to configure sample rate %dHz with %dHz MCLK", sample_frequency, mclk_frequency);
return ESP_ERR_INVALID_ARG;
}
const struct _coeff_div *const selected_coeff = &coeff_div[coeff];
/* register 0x02 */
ESP_RETURN_ON_ERROR(es8311_read_reg(dev, ES8311_CLK_MANAGER_REG02, &regv), TAG, "I2C read/write error");
regv &= 0x07;
regv |= (selected_coeff->pre_div - 1) << 5;
regv |= selected_coeff->pre_multi << 3;
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_CLK_MANAGER_REG02, regv), TAG, "I2C read/write error");
/* register 0x03 */
const uint8_t reg03 = (selected_coeff->fs_mode << 6) | selected_coeff->adc_osr;
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_CLK_MANAGER_REG03, reg03), TAG, "I2C read/write error");
/* register 0x04 */
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_CLK_MANAGER_REG04, selected_coeff->dac_osr), TAG, "I2C read/write error");
/* register 0x05 */
const uint8_t reg05 = ((selected_coeff->adc_div - 1) << 4) | (selected_coeff->dac_div - 1);
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_CLK_MANAGER_REG05, reg05), TAG, "I2C read/write error");
/* register 0x06 */
ESP_RETURN_ON_ERROR(es8311_read_reg(dev, ES8311_CLK_MANAGER_REG06, &regv), TAG, "I2C read/write error");
regv &= 0xE0;
if (selected_coeff->bclk_div < 19) {
regv |= (selected_coeff->bclk_div - 1) << 0;
} else {
regv |= (selected_coeff->bclk_div) << 0;
}
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_CLK_MANAGER_REG06, regv), TAG, "I2C read/write error");
/* register 0x07 */
ESP_RETURN_ON_ERROR(es8311_read_reg(dev, ES8311_CLK_MANAGER_REG07, &regv), TAG, "I2C read/write error");
regv &= 0xC0;
regv |= selected_coeff->lrck_h << 0;
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_CLK_MANAGER_REG07, regv), TAG, "I2C read/write error");
/* register 0x08 */
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_CLK_MANAGER_REG08, selected_coeff->lrck_l), TAG, "I2C read/write error");
return ESP_OK;
}
static esp_err_t es8311_clock_config(es8311_handle_t dev, const es8311_clock_config_t *const clk_cfg, es8311_resolution_t res)
{
uint8_t reg06;
uint8_t reg01 = 0x3F; // Enable all clocks
int mclk_hz;
/* Select clock source for internal MCLK and determine its frequency */
if (clk_cfg->mclk_from_mclk_pin) {
mclk_hz = clk_cfg->mclk_frequency;
} else {
mclk_hz = clk_cfg->sample_frequency * (int)res * 2;
reg01 |= BIT(7); // Select BCLK (a.k.a. SCK) pin
}
if (clk_cfg->mclk_inverted) {
reg01 |= BIT(6); // Invert MCLK pin
}
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_CLK_MANAGER_REG01, reg01), TAG, "I2C read/write error");
ESP_RETURN_ON_ERROR(es8311_read_reg(dev, ES8311_CLK_MANAGER_REG06, &reg06), TAG, "I2C read/write error");
if (clk_cfg->sclk_inverted) {
reg06 |= BIT(5);
} else {
reg06 &= ~BIT(5);
}
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_CLK_MANAGER_REG06, reg06), TAG, "I2C read/write error");
/* Configure clock dividers */
return es8311_sample_frequency_config(dev, mclk_hz, clk_cfg->sample_frequency);
}
static esp_err_t es8311_resolution_config(const es8311_resolution_t res, uint8_t *reg)
{
switch (res) {
case ES8311_RESOLUTION_16:
*reg |= (3 << 2);
break;
case ES8311_RESOLUTION_18:
*reg |= (2 << 2);
break;
case ES8311_RESOLUTION_20:
*reg |= (1 << 2);
break;
case ES8311_RESOLUTION_24:
*reg |= (0 << 2);
break;
case ES8311_RESOLUTION_32:
*reg |= (4 << 2);
break;
default:
return ESP_ERR_INVALID_ARG;
}
return ESP_OK;
}
static esp_err_t es8311_fmt_config(es8311_handle_t dev, const es8311_resolution_t res_in, const es8311_resolution_t res_out)
{
uint8_t reg09 = 0; // SDP In
uint8_t reg0a = 0; // SDP Out
ESP_LOGI(TAG, "ES8311 in Slave mode and I2S format");
uint8_t reg00;
ESP_RETURN_ON_ERROR(es8311_read_reg(dev, ES8311_RESET_REG00, &reg00), TAG, "I2C read/write error");
reg00 &= 0xBF;
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_RESET_REG00, reg00), TAG, "I2C read/write error"); // Slave serial port - default
/* Setup SDP In and Out resolution */
es8311_resolution_config(res_in, &reg09);
es8311_resolution_config(res_out, &reg0a);
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_SDPIN_REG09, reg09), TAG, "I2C read/write error");
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_SDPOUT_REG0A, reg0a), TAG, "I2C read/write error");
return ESP_OK;
}
esp_err_t es8311_microphone_config(es8311_handle_t dev, bool digital_mic)
{
uint8_t reg14 = 0x1A; // enable analog MIC and max PGA gain
/* PDM digital microphone enable or disable */
if (digital_mic) {
reg14 |= BIT(6);
}
es8311_write_reg(dev, ES8311_ADC_REG17, 0xC8); // Set ADC gain @todo move this to ADC config section
return es8311_write_reg(dev, ES8311_SYSTEM_REG14, reg14);
}
esp_err_t es8311_init(es8311_handle_t dev, const es8311_clock_config_t *const clk_cfg, const es8311_resolution_t res_in, const es8311_resolution_t res_out)
{
ESP_RETURN_ON_FALSE(
(clk_cfg->sample_frequency >= 8000) && (clk_cfg->sample_frequency <= 96000),
ESP_ERR_INVALID_ARG, TAG, "ES8311 init needs frequency in interval [8000; 96000] Hz"
);
if (!clk_cfg->mclk_from_mclk_pin) {
ESP_RETURN_ON_FALSE(res_out == res_in, ESP_ERR_INVALID_ARG, TAG, "Resolution IN/OUT must be equal if MCLK is taken from SCK pin");
}
/* Reset ES8311 to its default */
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_RESET_REG00, 0x1F), TAG, "I2C read/write error");
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_RESET_REG00, 0x00), TAG, "I2C read/write error");
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_RESET_REG00, 0x80), TAG, "I2C read/write error"); // Power-on command
/* Setup clock: source, polarity and clock dividers */
ESP_RETURN_ON_ERROR(es8311_clock_config(dev, clk_cfg, res_out), TAG, "");
/* Setup audio format (fmt): master/slave, resolution, I2S */
ESP_RETURN_ON_ERROR(es8311_fmt_config(dev, res_in, res_out), TAG, "");
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_SYSTEM_REG0D, 0x01), TAG, "I2C read/write error"); // Power up analog circuitry - NOT default
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_SYSTEM_REG0E, 0x02), TAG, "I2C read/write error"); // Enable analog PGA, enable ADC modulator - NOT default
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_SYSTEM_REG12, 0x00), TAG, "I2C read/write error"); // power-up DAC - NOT default
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_SYSTEM_REG13, 0x10), TAG, "I2C read/write error"); // Enable output to HP drive - NOT default
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_ADC_REG1C, 0x6A), TAG, "I2C read/write error"); // ADC Equalizer bypass, cancel DC offset in digital domain
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_DAC_REG37, 0x08), TAG, "I2C read/write error"); // Bypass DAC equalizer - NOT default
return ESP_OK;
}
void es8311_delete(es8311_handle_t dev)
{
free(dev);
}
esp_err_t es8311_voice_volume_set(es8311_handle_t dev, int volume, int *volume_set)
{
if (volume < 0) {
volume = 0;
} else if (volume > 100) {
volume = 100;
}
int reg32;
if (volume == 0) {
reg32 = 0;
} else {
reg32 = ((volume) * 256 / 100) - 1;
}
// provide user with real volume set
if (volume_set != NULL) {
*volume_set = volume;
}
return es8311_write_reg(dev, ES8311_DAC_REG32, reg32);
}
esp_err_t es8311_voice_volume_get(es8311_handle_t dev, int *volume)
{
uint8_t reg32;
ESP_RETURN_ON_ERROR(es8311_read_reg(dev, ES8311_DAC_REG32, &reg32), TAG, "I2C read/write error");
if (reg32 == 0) {
*volume = 0;
} else {
*volume = ((reg32 * 100) / 256) + 1;
}
return ESP_OK;
}
esp_err_t es8311_voice_mute(es8311_handle_t dev, bool mute)
{
uint8_t reg31;
ESP_RETURN_ON_ERROR(es8311_read_reg(dev, ES8311_DAC_REG31, &reg31), TAG, "I2C read/write error");
if (mute) {
reg31 |= BIT(6) | BIT(5);
} else {
reg31 &= ~(BIT(6) | BIT(5));
}
return es8311_write_reg(dev, ES8311_DAC_REG31, reg31);
}
esp_err_t es8311_microphone_gain_set(es8311_handle_t dev, es8311_mic_gain_t gain_db)
{
return es8311_write_reg(dev, ES8311_ADC_REG16, gain_db); // ADC gain scale up
}
esp_err_t es8311_voice_fade(es8311_handle_t dev, const es8311_fade_t fade)
{
uint8_t reg37;
ESP_RETURN_ON_ERROR(es8311_read_reg(dev, ES8311_DAC_REG37, &reg37), TAG, "I2C read/write error");
reg37 &= 0x0F;
reg37 |= (fade << 4);
return es8311_write_reg(dev, ES8311_DAC_REG37, reg37);
}
esp_err_t es8311_microphone_fade(es8311_handle_t dev, const es8311_fade_t fade)
{
uint8_t reg15;
ESP_RETURN_ON_ERROR(es8311_read_reg(dev, ES8311_ADC_REG15, &reg15), TAG, "I2C read/write error");
reg15 &= 0x0F;
reg15 |= (fade << 4);
return es8311_write_reg(dev, ES8311_ADC_REG15, reg15);
}
void es8311_register_dump(es8311_handle_t dev)
{
for (int reg = 0; reg < 0x4A; reg++) {
uint8_t value;
ESP_ERROR_CHECK(es8311_read_reg(dev, reg, &value));
printf("REG:%02x: %02x", reg, value);
}
}
es8311_handle_t es8311_create(const unsigned int port, const uint16_t dev_addr)
{
es8311_dev_t *sensor = (es8311_dev_t *) calloc(1, sizeof(es8311_dev_t));
sensor->port = port;
sensor->dev_addr = dev_addr;
return (es8311_handle_t) sensor;
}
@@ -0,0 +1,227 @@
/*
* SPDX-FileCopyrightText: 2015-2022 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @file
* @brief ES8311 driver
*/
#pragma once
#include "esp_types.h"
#include "esp_err.h"
/* ES8311 address: CE pin low - 0x18, CE pin high - 0x19 */
#define ES8311_ADDRRES_0 0x18u // Leaving this here for backward compatibility
#define ES8311_ADDRESS_0 0x18u
#define ES8311_ADDRESS_1 0x19u
#ifdef __cplusplus
extern "C" {
#endif
typedef void *es8311_handle_t;
typedef enum {
ES8311_MIC_GAIN_MIN = -1,
ES8311_MIC_GAIN_0DB,
ES8311_MIC_GAIN_6DB,
ES8311_MIC_GAIN_12DB,
ES8311_MIC_GAIN_18DB,
ES8311_MIC_GAIN_24DB,
ES8311_MIC_GAIN_30DB,
ES8311_MIC_GAIN_36DB,
ES8311_MIC_GAIN_42DB,
ES8311_MIC_GAIN_MAX
} es8311_mic_gain_t;
typedef enum {
ES8311_FADE_OFF = 0,
ES8311_FADE_4LRCK, // 4LRCK means ramp 0.25dB/4LRCK
ES8311_FADE_8LRCK,
ES8311_FADE_16LRCK,
ES8311_FADE_32LRCK,
ES8311_FADE_64LRCK,
ES8311_FADE_128LRCK,
ES8311_FADE_256LRCK,
ES8311_FADE_512LRCK,
ES8311_FADE_1024LRCK,
ES8311_FADE_2048LRCK,
ES8311_FADE_4096LRCK,
ES8311_FADE_8192LRCK,
ES8311_FADE_16384LRCK,
ES8311_FADE_32768LRCK,
ES8311_FADE_65536LRCK
} es8311_fade_t;
typedef enum es8311_resolution_t {
ES8311_RESOLUTION_16 = 16,
ES8311_RESOLUTION_18 = 18,
ES8311_RESOLUTION_20 = 20,
ES8311_RESOLUTION_24 = 24,
ES8311_RESOLUTION_32 = 32
} es8311_resolution_t;
typedef struct es8311_clock_config_t {
bool mclk_inverted;
bool sclk_inverted;
bool mclk_from_mclk_pin; // true: from MCLK pin (pin no. 2), false: from SCLK pin (pin no. 6)
int mclk_frequency; // This parameter is ignored if MCLK is taken from SCLK pin
int sample_frequency; // in Hz
} es8311_clock_config_t;
/**
* @brief Initialize ES8311
*
* There are two ways of providing Master Clock (MCLK) signal to ES8311 in Slave Mode:
* 1. From MCLK pin:
* For flexible scenarios. A clock signal from I2S master is routed to MCLK pin.
* Its frequency must be defined in clk_cfg->mclk_frequency parameter.
* 2. From SCLK pin:
* For simpler scenarios. ES8311 takes its clock from SCK pin. MCLK pin does not have to be connected.
* In this case, res_in must equal res_out; clk_cfg->mclk_frequency parameter is ignored
* and MCLK is calculated as MCLK = clk_cfg->sample_frequency * res_out * 2.
* Not all sampling frequencies are supported in this mode.
*
* @param dev ES8311 handle
* @param[in] clk_cfg Clock configuration
* @param[in] res_in Input serial port resolution
* @param[in] res_out Output serial port resolution
* @return
* - ESP_OK success
* - ESP_ERR_INVALID_ARG Sample frequency or resolution invalid
* - Else fail
*/
esp_err_t es8311_init(es8311_handle_t dev, const es8311_clock_config_t *const clk_cfg, const es8311_resolution_t res_in,
const es8311_resolution_t res_out);
/**
* @brief Set output volume
*
* Volume paramter out of <0, 100> interval will be truncated.
*
* @param dev ES8311 handle
* @param[in] volume Set volume (0 ~ 100)
* @param[out] volume_set Volume that was set. Same as volume, unless volume is outside of <0, 100> interval.
* This parameter can be set to NULL, if user does not need this information.
*
* @return
* - ESP_OK success
* - Else fail
*/
esp_err_t es8311_voice_volume_set(es8311_handle_t dev, int volume, int *volume_set);
/**
* @brief Get output volume
*
* @param dev ES8311 handle
* @param[out] volume get volume (0 ~ 100)
*
* @return
* - ESP_OK success
* - Else fail
*/
esp_err_t es8311_voice_volume_get(es8311_handle_t dev, int *volume);
/**
* @brief Print out ES8311 register content
*
* @param dev ES8311 handle
*/
void es8311_register_dump(es8311_handle_t dev);
/**
* @brief Mute ES8311 output
*
* @param dev ES8311 handle
* @param[in] enable true: mute, false: don't mute
* @return
* - ESP_OK success
* - Else fail
*/
esp_err_t es8311_voice_mute(es8311_handle_t dev, bool enable);
/**
* @brief Set Microphone gain
*
* @param dev ES8311 handle
* @param[in] gain_db Microphone gain
* @return
* - ESP_OK success
* - Else fail
*/
esp_err_t es8311_microphone_gain_set(es8311_handle_t dev, es8311_mic_gain_t gain_db);
/**
* @brief Configure microphone
*
* @param dev ES8311 handle
* @param[in] digital_mic Set to true for digital microphone
* @return
* - ESP_OK success
* - Else fail
*/
esp_err_t es8311_microphone_config(es8311_handle_t dev, bool digital_mic);
/**
* @brief Configure sampling frequency
*
* @note This function is called by es8311_init().
* Call this function explicitly only if you want to change sample frequency during runtime.
* @param dev ES8311 handle
* @param[in] mclk_frequency MCLK frequency in [Hz] (MCLK or SCLK pin, depending on bit register01[7])
* @param[in] sample_frequency Required sample frequency in [Hz], e.g. 44100, 22050...
* @return
* - ESP_OK success
* - ESP_ERR_INVALID_ARG cannot set clock dividers for given MCLK and sampling frequency
* - Else I2C read/write error
*/
esp_err_t es8311_sample_frequency_config(es8311_handle_t dev, int mclk_frequency, int sample_frequency);
/**
* @brief Configure fade in/out for ADC: voice
*
* @param dev ES8311 handle
* @param[in] fade Fade ramp rate
* @return
* - ESP_OK success
* - Else I2C read/write error
*/
esp_err_t es8311_voice_fade(es8311_handle_t dev, const es8311_fade_t fade);
/**
* @brief Configure fade in/out for DAC: microphone
*
* @param dev ES8311 handle
* @param[in] fade Fade ramp rate
* @return
* - ESP_OK success
* - Else I2C read/write error
*/
esp_err_t es8311_microphone_fade(es8311_handle_t dev, const es8311_fade_t fade);
/**
* @brief Create ES8311 object and return its handle
*
* @param[in] port I2C port number
* @param[in] dev_addr I2C device address of ES8311
*
* @return
* - NULL Fail
* - Others Success
*/
es8311_handle_t es8311_create(const unsigned int port, const uint16_t dev_addr);
/**
* @brief Delete ES8311 object
*
* @param dev ES8311 handle
*/
void es8311_delete(es8311_handle_t dev);
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,76 @@
/*
* SPDX-FileCopyrightText: 2015-2021 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
/*
* ES8311_REGISTER NAME_REG_REGISTER ADDRESS
*/
#define ES8311_RESET_REG00 0x00 /*reset digital,csm,clock manager etc.*/
/*
* Clock Scheme Register definition
*/
#define ES8311_CLK_MANAGER_REG01 0x01 /* select clk src for mclk, enable clock for codec */
#define ES8311_CLK_MANAGER_REG02 0x02 /* clk divider and clk multiplier */
#define ES8311_CLK_MANAGER_REG03 0x03 /* adc fsmode and osr */
#define ES8311_CLK_MANAGER_REG04 0x04 /* dac osr */
#define ES8311_CLK_MANAGER_REG05 0x05 /* clk divier for adc and dac */
#define ES8311_CLK_MANAGER_REG06 0x06 /* bclk inverter and divider */
#define ES8311_CLK_MANAGER_REG07 0x07 /* tri-state, lrck divider */
#define ES8311_CLK_MANAGER_REG08 0x08 /* lrck divider */
/*
* SDP
*/
#define ES8311_SDPIN_REG09 0x09 /* dac serial digital port */
#define ES8311_SDPOUT_REG0A 0x0A /* adc serial digital port */
/*
* SYSTEM
*/
#define ES8311_SYSTEM_REG0B 0x0B /* system */
#define ES8311_SYSTEM_REG0C 0x0C /* system */
#define ES8311_SYSTEM_REG0D 0x0D /* system, power up/down */
#define ES8311_SYSTEM_REG0E 0x0E /* system, power up/down */
#define ES8311_SYSTEM_REG0F 0x0F /* system, low power */
#define ES8311_SYSTEM_REG10 0x10 /* system */
#define ES8311_SYSTEM_REG11 0x11 /* system */
#define ES8311_SYSTEM_REG12 0x12 /* system, Enable DAC */
#define ES8311_SYSTEM_REG13 0x13 /* system */
#define ES8311_SYSTEM_REG14 0x14 /* system, select DMIC, select analog pga gain */
/*
* ADC
*/
#define ES8311_ADC_REG15 0x15 /* ADC, adc ramp rate, dmic sense */
#define ES8311_ADC_REG16 0x16 /* ADC */
#define ES8311_ADC_REG17 0x17 /* ADC, volume */
#define ES8311_ADC_REG18 0x18 /* ADC, alc enable and winsize */
#define ES8311_ADC_REG19 0x19 /* ADC, alc maxlevel */
#define ES8311_ADC_REG1A 0x1A /* ADC, alc automute */
#define ES8311_ADC_REG1B 0x1B /* ADC, alc automute, adc hpf s1 */
#define ES8311_ADC_REG1C 0x1C /* ADC, equalizer, hpf s2 */
/*
* DAC
*/
#define ES8311_DAC_REG31 0x31 /* DAC, mute */
#define ES8311_DAC_REG32 0x32 /* DAC, volume */
#define ES8311_DAC_REG33 0x33 /* DAC, offset */
#define ES8311_DAC_REG34 0x34 /* DAC, drc enable, drc winsize */
#define ES8311_DAC_REG35 0x35 /* DAC, drc maxlevel, minilevel */
#define ES8311_DAC_REG37 0x37 /* DAC, ramprate */
/*
*GPIO
*/
#define ES8311_GPIO_REG44 0x44 /* GPIO, dac2adc for test */
#define ES8311_GP_REG45 0x45 /* GP CONTROL */
/*
* CHIP
*/
#define ES8311_CHD1_REGFD 0xFD /* CHIP ID1 */
#define ES8311_CHD2_REGFE 0xFE /* CHIP ID2 */
#define ES8311_CHVER_REGFF 0xFF /* VERSION */
#define ES8311_CHD1_REGFD 0xFD /* CHIP ID1 */
#define ES8311_MAX_REGISTER 0xFF
@@ -0,0 +1,272 @@
#include <Arduino.h>
#include <Wire.h>
#include <math.h>
#include "ESP_I2S.h"
#include "esp_check.h"
#include "Arduino_GFX_Library.h"
#include "pin_config.h"
#include "TouchDrvCSTXXX.hpp"
#include "SensorQMI8658.hpp"
#include "es8311.h"
#include "es7210.h"
static const int DISP_W = 480;
static const int DISP_H = 480;
static const int SAFE = 20;
static const uint32_t SAMPLE_RATE = 16000;
static const size_t REC_SECONDS = 2;
static const size_t AUDIO_BYTES = SAMPLE_RATE * REC_SECONDS * 2 * 2;
static const int I2C_NUM = 0;
I2SClass i2s;
TouchDrvCST92xx touch;
SensorQMI8658 qmi;
IMUdata acc;
Arduino_DataBus *bus = new Arduino_ESP32QSPI(LCD_CS, LCD_SCLK, LCD_SDIO0, LCD_SDIO1, LCD_SDIO2, LCD_SDIO3);
Arduino_CO5300 *gfx = new Arduino_CO5300(bus, LCD_RESET, 0, DISP_W, DISP_H, 0, 0, 0, 0);
uint8_t *audioBuf = nullptr;
size_t audioLen = 0;
bool touchIrq = false;
String statusLine = "Ready";
String tiltLine = "Tilt: FLAT";
String audioLine = "Audio: empty";
uint32_t lastTiltMs = 0;
bool micReady = false;
void IRAM_ATTR onTouchInt() { touchIrq = true; }
esp_err_t codecInit() {
es8311_handle_t esHandle = es8311_create(I2C_NUM, ES8311_ADDRRES_0);
ESP_RETURN_ON_FALSE(esHandle, ESP_FAIL, "AV", "es8311 create failed");
const es8311_clock_config_t clk = {
.mclk_inverted = false,
.sclk_inverted = false,
.mclk_from_mclk_pin = true,
.mclk_frequency = SAMPLE_RATE * 256,
.sample_frequency = SAMPLE_RATE,
};
ESP_ERROR_CHECK(es8311_init(esHandle, &clk, ES8311_RESOLUTION_16, ES8311_RESOLUTION_16));
ESP_RETURN_ON_ERROR(es8311_sample_frequency_config(esHandle, clk.mclk_frequency, clk.sample_frequency), "AV", "clock cfg failed");
ESP_RETURN_ON_ERROR(es8311_microphone_config(esHandle, false), "AV", "mic cfg failed");
ESP_RETURN_ON_ERROR(es8311_voice_volume_set(esHandle, 90, NULL), "AV", "volume failed");
ESP_RETURN_ON_ERROR(es8311_microphone_gain_set(esHandle, (es8311_mic_gain_t)6), "AV", "mic gain failed");
return ESP_OK;
}
esp_err_t micInit() {
audio_hal_codec_config_t cfg = {
.adc_input = AUDIO_HAL_ADC_INPUT_ALL,
.codec_mode = AUDIO_HAL_CODEC_MODE_ENCODE,
.i2s_iface = {
.mode = AUDIO_HAL_MODE_SLAVE,
.fmt = AUDIO_HAL_I2S_NORMAL,
.samples = AUDIO_HAL_16K_SAMPLES,
.bits = AUDIO_HAL_BIT_LENGTH_16BITS,
},
};
esp_err_t ret = ESP_OK;
ret |= es7210_adc_init(&Wire, &cfg);
ret |= es7210_adc_config_i2s(cfg.codec_mode, &cfg.i2s_iface);
ret |= es7210_adc_set_gain(
(es7210_input_mics_t)(ES7210_INPUT_MIC1 | ES7210_INPUT_MIC2),
(es7210_gain_value_t)GAIN_24DB);
ret |= es7210_adc_set_gain(
(es7210_input_mics_t)(ES7210_INPUT_MIC3 | ES7210_INPUT_MIC4),
(es7210_gain_value_t)GAIN_24DB);
ret |= es7210_adc_ctrl_state(cfg.codec_mode, AUDIO_HAL_CTRL_START);
return ret;
}
void drawStaticUi() {
gfx->fillScreen(RGB565_BLACK);
gfx->setTextSize(2);
gfx->setTextColor(RGB565_WHITE);
gfx->setCursor(SAFE + 16, SAFE + 10);
gfx->println("Simple AV + Tilt Test");
gfx->drawRect(24, 76, 132, 64, RGB565_GREEN);
gfx->setCursor(44, 100);
gfx->println("RECORD");
gfx->drawRect(174, 76, 132, 64, RGB565_YELLOW);
gfx->setCursor(212, 100);
gfx->println("PLAY");
gfx->drawRect(324, 76, 132, 64, RGB565_CYAN);
gfx->setCursor(360, 100);
gfx->println("BEEP");
}
void drawDynamic() {
gfx->fillRect(20, 166, 440, 140, RGB565_BLACK);
gfx->setTextSize(2);
gfx->setTextColor(RGB565_WHITE);
gfx->setCursor(28, 178);
gfx->println(statusLine);
gfx->setTextColor(RGB565_CYAN);
gfx->setCursor(28, 212);
gfx->println(tiltLine);
gfx->setTextColor(RGB565_ORANGE);
gfx->setCursor(28, 246);
gfx->println(audioLine);
gfx->drawRect(28, 278, 300, 20, RGB565_DARKGREY);
}
void updateTilt() {
if (!qmi.getDataReady()) return;
if (!qmi.getAccelerometer(acc.x, acc.y, acc.z)) return;
String next = "Tilt: FLAT";
if (acc.x > 0.8f) next = "Tilt: RIGHT";
else if (acc.x < -0.8f) next = "Tilt: LEFT";
else if (acc.y > 0.8f) next = "Tilt: UP";
else if (acc.y < -0.8f) next = "Tilt: DOWN";
if (next != tiltLine) {
tiltLine = next;
drawDynamic();
}
}
void playBeep() {
static int16_t tone[16000];
for (int i = 0; i < 8000; ++i) {
float s = sinf(2.0f * 3.1415926f * 440.0f * ((float)i / SAMPLE_RATE));
int16_t v = (int16_t)(s * 6000.0f);
tone[i * 2] = v;
tone[i * 2 + 1] = v;
}
statusLine = "Playing test beep...";
drawDynamic();
i2s.write((uint8_t *)tone, 8000 * 2 * sizeof(int16_t));
statusLine = "Beep done";
drawDynamic();
}
void recordAudio() {
if (!audioBuf || !micReady) {
statusLine = "No audio buffer";
drawDynamic();
return;
}
statusLine = "Recording 2 sec...";
drawDynamic();
size_t total = 0;
uint16_t peakAll = 0;
while (total < AUDIO_BYTES) {
size_t chunk = min((size_t)4096, AUDIO_BYTES - total);
size_t got = i2s.readBytes((char *)(audioBuf + total), chunk);
if (!got) break;
total += got;
int16_t *s = (int16_t *)(audioBuf + total - got);
size_t n = got / sizeof(int16_t);
uint16_t peak = 0;
for (size_t i = 0; i < n; i++) {
uint16_t a = (uint16_t)abs((int)s[i]);
if (a > peak) peak = a;
}
if (peak > peakAll) peakAll = peak;
int bar = map((int)peak, 0, 20000, 0, 296);
if (bar < 0) bar = 0;
if (bar > 296) bar = 296;
gfx->fillRect(30, 280, 296, 16, RGB565_BLACK);
uint16_t c = (peak > 12000) ? RGB565_RED : ((peak > 6000) ? RGB565_YELLOW : RGB565_GREEN);
gfx->fillRect(30, 280, bar, 16, c);
}
audioLen = total;
audioLine = (total > 0) ? "Audio: recorded" : "Audio: empty";
if (total > 0) {
statusLine = String("Record done, peak=") + String((int)peakAll);
} else {
statusLine = "Record failed";
}
drawDynamic();
}
void playAudio() {
if (!audioLen) {
statusLine = "No recording. Try BEEP.";
drawDynamic();
return;
}
statusLine = "Playing recording...";
drawDynamic();
size_t wrote = i2s.write(audioBuf, audioLen);
statusLine = (wrote > 0) ? "Play done" : "Play failed";
drawDynamic();
}
void handleTouch() {
if (!touchIrq) return;
touchIrq = false;
int16_t x[1];
int16_t y[1];
uint8_t touched = touch.getPoint(x, y, 1);
if (!touched) return;
int px = x[0];
int py = y[0];
if (py < 76 || py > 140) return;
if (px >= 24 && px <= 156) recordAudio();
else if (px >= 174 && px <= 306) playAudio();
else if (px >= 324 && px <= 456) playBeep();
}
void setup() {
Serial.begin(115200);
Wire.begin(IIC_SDA, IIC_SCL);
pinMode(PA, OUTPUT);
digitalWrite(PA, HIGH);
if (!gfx->begin()) Serial.println("gfx begin failed");
bus->writeC8D8(0x36, 0xA0);
gfx->setBrightness(200);
pinMode(TP_RST, OUTPUT);
digitalWrite(TP_RST, LOW);
delay(30);
digitalWrite(TP_RST, HIGH);
delay(50);
touch.setPins(TP_RST, TP_INT);
touch.begin(Wire, 0x5A, IIC_SDA, IIC_SCL);
touch.setMaxCoordinates(480, 480);
touch.setSwapXY(true);
touch.setMirrorXY(true, false);
attachInterrupt(TP_INT, onTouchInt, FALLING);
if (qmi.begin(Wire, QMI8658_L_SLAVE_ADDRESS, IIC_SDA, IIC_SCL)) {
qmi.configAccelerometer(SensorQMI8658::ACC_RANGE_4G, SensorQMI8658::ACC_ODR_1000Hz, SensorQMI8658::LPF_MODE_0);
qmi.enableAccelerometer();
} else {
tiltLine = "Tilt: IMU not found";
}
i2s.setPins(9, 45, 8, 10, 42);
if (!i2s.begin(I2S_MODE_STD, SAMPLE_RATE, I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO, I2S_STD_SLOT_BOTH)) {
statusLine = "I2S init failed";
} else if (codecInit() != ESP_OK) {
statusLine = "ES8311 init failed";
} else if (micInit() != ESP_OK) {
statusLine = "ES7210 init failed";
} else {
micReady = true;
}
audioBuf = (uint8_t *)ps_malloc(AUDIO_BYTES);
if (!audioBuf) statusLine = "No PSRAM buffer";
drawStaticUi();
drawDynamic();
}
void loop() {
handleTouch();
if (millis() - lastTiltMs > 120) {
lastTiltMs = millis();
updateTilt();
}
delay(5);
}
@@ -0,0 +1,31 @@
# Test Sketches
Набор старых отдельных диагностических скетчей для `Waveshare ESP32-S3-Touch-AMOLED-2.16`.
Скетчи в этой папке нужны для быстрой проверки конкретных гипотез и не являются основным UI проекта.
Основной скетч сейчас лежит в `main-device/shine_homeserver_main/`.
## Список
- `gfx_text_render_test/` - проверка рендера текста через `Arduino_GFX` и сравнение с `U8g2`
- `gfx_button_layout_test/` - проверка геометрии кнопок, особенно нижних рядов и широких кнопок
- `lvgl_basic_test/` - минимальный тест `LVGL` с заголовком, текстом и кнопками
- `lvgl_interaction_test/` - расширенный тест `LVGL` с 9 кнопками, touch-вводом и статусом нажатия
- `lvgl_touch_debug_test/` - диагностика touch: сырые координаты, точка касания и одна большая кнопка `LVGL`
- `lvgl_official_based_test/` - минимальный наш экран поверх максимально близкой к официальному `05_LVGL_Widgets` инициализации
- `lvgl_subserver_touch_test/` - старый гибридный тест: `LVGL`-экран с инициализацией дисплея и чтением touch из старого `shine_homeserver_ui`; подтверждён на реальном устройстве
- `lvgl_russian_font_test/` - тест кастомного кириллического `LVGL`-шрифта с русскими кнопками, длинными строками и рабочим touch
- `lvgl_nav_minimal_test/` - старое тестовое имя, этот скетч перенесён в `shine_homeserver_main/` и теперь является основным
## Запуск
Использовать через `burn.sh`:
- `./burn.sh gfx-text-test`
- `./burn.sh gfx-layout-test`
- `./burn.sh lvgl-basic-test`
- `./burn.sh lvgl-interaction-test`
- `./burn.sh lvgl-touch-debug-test`
- `./burn.sh lvgl-official-based-test`
- `./burn.sh lvgl-subserver-touch-test`
- `./burn.sh lvgl-russian-font-test`
@@ -0,0 +1,93 @@
#include <Arduino.h>
#include <Wire.h>
#include <Arduino_GFX_Library.h>
#define PIN_LCD_CS 12
#define PIN_LCD_SCLK 38
#define PIN_LCD_D0 4
#define PIN_LCD_D1 5
#define PIN_LCD_D2 6
#define PIN_LCD_D3 7
#define PIN_LCD_RST 2
#define PIN_I2C_SDA 15
#define PIN_I2C_SCL 14
#define DISP_W 480
#define DISP_H 480
#define C_BG 0x0841u
#define C_PANEL 0x1082u
#define C_CARD 0x18C3u
#define C_BORDER 0x39C7u
#define C_TEXT 0xFFFFu
#define C_OK 0x3666u
#define C_WARN 0xECA0u
#define C_BUTTON 0x2145u
#define C_BUTTON2 0x3186u
Arduino_DataBus *gBus = new Arduino_ESP32QSPI(
PIN_LCD_CS, PIN_LCD_SCLK, PIN_LCD_D0, PIN_LCD_D1, PIN_LCD_D2, PIN_LCD_D3);
Arduino_CO5300 *gfx = new Arduino_CO5300(
gBus, PIN_LCD_RST, 0, DISP_W, DISP_H, 0, 0, 0, 0);
static void drawPanel(int x, int y, int w, int h, uint16_t fill, uint16_t border, int radius = 10) {
gfx->fillRoundRect(x, y, w, h, radius, fill);
gfx->drawRoundRect(x, y, w, h, radius, border);
}
static void drawText(int x, int y, const char *text, uint16_t color, uint8_t size = 2) {
gfx->setFont();
gfx->setTextSize(size);
gfx->setTextColor(color);
gfx->setCursor(x, y);
gfx->print(text);
}
static void drawButton(int x, int y, int w, int h, uint16_t fill, const char *label, uint8_t size = 2) {
drawPanel(x, y, w, h, fill, C_BORDER, 12);
gfx->setFont();
gfx->setTextSize(size);
gfx->setTextColor(C_TEXT);
int textW = strlen(label) * 6 * size;
int textX = x + (w - textW) / 2;
if (textX < x + 8) {
textX = x + 8;
}
int textY = y + (h + 8 * size) / 2 - 2;
gfx->setCursor(textX, textY);
gfx->print(label);
}
static void drawScreen() {
gfx->fillScreen(C_BG);
drawPanel(12, 12, 456, 456, C_PANEL, C_BORDER, 16);
drawText(24, 42, "BUTTON LAYOUT TEST", C_TEXT, 2);
drawText(24, 70, "Check bottom and wide buttons", C_WARN, 1);
drawButton(20, 110, 136, 52, C_BUTTON, "STATUS");
drawButton(172, 110, 136, 52, C_BUTTON, "CONNECT");
drawButton(324, 110, 136, 52, C_BUTTON, "ACCOUNT");
drawButton(20, 180, 136, 52, C_BUTTON, "WALLET");
drawButton(172, 180, 136, 52, C_BUTTON, "REQUESTS");
drawButton(324, 180, 136, 52, C_BUTTON, "SETTINGS");
drawButton(20, 270, 212, 48, C_BUTTON2, "BACK");
drawButton(248, 270, 212, 48, C_OK, "REFRESH");
drawButton(20, 338, 440, 40, C_BUTTON2, "FULL WIDTH BUTTON", 2);
drawButton(20, 390, 440, 56, C_OK, "REGISTER DEVICE", 2);
}
void setup() {
Serial.begin(115200);
Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL);
gfx->begin();
gBus->writeC8D8(0x36, 0xA0);
gfx->setBrightness(220);
drawScreen();
}
void loop() {
delay(1000);
}
@@ -0,0 +1,104 @@
#include <Arduino.h>
#include <Wire.h>
#include <Arduino_GFX_Library.h>
#include <U8g2lib.h>
#define PIN_LCD_CS 12
#define PIN_LCD_SCLK 38
#define PIN_LCD_D0 4
#define PIN_LCD_D1 5
#define PIN_LCD_D2 6
#define PIN_LCD_D3 7
#define PIN_LCD_RST 2
#define PIN_I2C_SDA 15
#define PIN_I2C_SCL 14
#define DISP_W 480
#define DISP_H 480
#define C_BG 0x0841u
#define C_PANEL 0x1082u
#define C_CARD 0x18C3u
#define C_BORDER 0x39C7u
#define C_TEXT 0xFFFFu
#define C_MUTE 0xBDF7u
#define C_OK 0x3666u
#define C_WARN 0xECA0u
#define C_BUTTON 0x2145u
#define C_BUTTON2 0x3186u
#define FONT_BODY u8g2_font_9x15_t_cyrillic
#define FONT_SMALL u8g2_font_6x13_t_cyrillic
Arduino_DataBus *gBus = new Arduino_ESP32QSPI(
PIN_LCD_CS, PIN_LCD_SCLK, PIN_LCD_D0, PIN_LCD_D1, PIN_LCD_D2, PIN_LCD_D3);
Arduino_CO5300 *gfx = new Arduino_CO5300(
gBus, PIN_LCD_RST, 0, DISP_W, DISP_H, 0, 0, 0, 0);
static void drawPanel(int x, int y, int w, int h, uint16_t fill, uint16_t border, int radius = 10) {
gfx->fillRoundRect(x, y, w, h, radius, fill);
gfx->drawRoundRect(x, y, w, h, radius, border);
}
static void drawDefaultText(int x, int y, const char *text, uint16_t color, uint8_t size = 1) {
gfx->setFont();
gfx->setTextSize(size);
gfx->setTextColor(color);
gfx->setCursor(x, y);
gfx->print(text);
}
static void drawU8Text(int x, int y, const String &text, uint16_t color, const uint8_t *font) {
gfx->setTextSize(1);
gfx->setFont(font);
gfx->setTextColor(color);
gfx->setCursor(x, y);
gfx->print(text);
}
static void drawButton(int x, int y, int w, int h, uint16_t fill, const String &label, const uint8_t *font, bool useDefaultFont = false) {
drawPanel(x, y, w, h, fill, C_BORDER, 12);
if (useDefaultFont) {
drawDefaultText(x + 14, y + 30, label.c_str(), C_TEXT, 2);
return;
}
drawU8Text(x + 14, y + 32, label, C_TEXT, font);
}
static void drawScreen() {
gfx->fillScreen(C_BG);
drawPanel(12, 12, 456, 456, C_PANEL, C_BORDER, 16);
drawDefaultText(28, 42, "TEXT TEST 123", C_TEXT, 2);
drawU8Text(28, 72, "Default ASCII above", C_MUTE, (const uint8_t *)FONT_SMALL);
drawPanel(24, 90, 432, 72, C_CARD, C_BORDER, 12);
drawDefaultText(38, 118, "A: Default font ABC123", C_TEXT, 2);
drawDefaultText(38, 146, "Visible? then base path OK", C_WARN, 1);
drawPanel(24, 174, 432, 84, C_CARD, C_BORDER, 12);
drawU8Text(38, 204, "B: U8g2 ASCII abc123", C_TEXT, (const uint8_t *)FONT_BODY);
drawU8Text(38, 230, "C: Русский текст 123", C_OK, (const uint8_t *)FONT_BODY);
drawPanel(24, 270, 432, 84, C_CARD, C_BORDER, 12);
drawU8Text(38, 298, "D: Мелкий шрифт кнопок", C_TEXT, (const uint8_t *)FONT_SMALL);
drawU8Text(38, 320, "Если это видно, FONT_SMALL жив", C_MUTE, (const uint8_t *)FONT_SMALL);
drawButton(24, 368, 128, 72, C_BUTTON, "BTN 1", nullptr, true);
drawButton(176, 368, 128, 72, C_OK, "abc123", (const uint8_t *)FONT_BODY);
drawButton(328, 368, 128, 72, C_BUTTON2, "Русский", (const uint8_t *)FONT_BODY);
}
void setup() {
Serial.begin(115200);
Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL);
gfx->begin();
gBus->writeC8D8(0x36, 0xA0);
gfx->setBrightness(220);
gfx->setUTF8Print(true);
drawScreen();
}
void loop() {
delay(1000);
}
@@ -0,0 +1,125 @@
#include <Arduino.h>
#include <Wire.h>
#include <lvgl.h>
#include <Arduino_GFX_Library.h>
#include "pin_config.h"
#define EXAMPLE_LVGL_TICK_PERIOD_MS 2
static lv_disp_draw_buf_t gDrawBuf;
static lv_color_t *gBuf1 = nullptr;
static lv_color_t *gBuf2 = nullptr;
Arduino_DataBus *gBus = new Arduino_ESP32QSPI(
LCD_CS, LCD_SCLK, LCD_SDIO0, LCD_SDIO1, LCD_SDIO2, LCD_SDIO3);
Arduino_CO5300 *gfx = new Arduino_CO5300(
gBus, LCD_RESET, 0, LCD_WIDTH, LCD_HEIGHT, 0, 0, 0, 0);
static void lvglRounderCb(lv_disp_drv_t *dispDrv, lv_area_t *area) {
LV_UNUSED(dispDrv);
if (area->x1 % 2 != 0) area->x1--;
if (area->y1 % 2 != 0) area->y1--;
if (area->x2 % 2 == 0) area->x2++;
if (area->y2 % 2 == 0) area->y2++;
}
static void lvglFlushCb(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *colorP) {
uint32_t w = area->x2 - area->x1 + 1;
uint32_t h = area->y2 - area->y1 + 1;
#if (LV_COLOR_16_SWAP != 0)
gfx->draw16bitBeRGBBitmap(area->x1, area->y1, (uint16_t *)&colorP->full, w, h);
#else
gfx->draw16bitRGBBitmap(area->x1, area->y1, (uint16_t *)&colorP->full, w, h);
#endif
lv_disp_flush_ready(disp);
}
static void lvglTick(void *arg) {
LV_UNUSED(arg);
lv_tick_inc(EXAMPLE_LVGL_TICK_PERIOD_MS);
}
static void createUi() {
lv_obj_set_style_bg_color(lv_scr_act(), lv_color_hex(0x0B1320), 0);
lv_obj_set_style_bg_opa(lv_scr_act(), LV_OPA_COVER, 0);
lv_obj_t *title = lv_label_create(lv_scr_act());
lv_label_set_text(title, "LVGL BASIC TEST");
lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_style_text_font(title, &lv_font_montserrat_22, 0);
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 18);
lv_obj_t *subtitle = lv_label_create(lv_scr_act());
lv_label_set_text(subtitle, "If this text is visible, LVGL path works.");
lv_obj_set_width(subtitle, 420);
lv_label_set_long_mode(subtitle, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_color(subtitle, lv_color_hex(0xD5DCE5), 0);
lv_obj_set_style_text_font(subtitle, &lv_font_montserrat_16, 0);
lv_obj_align(subtitle, LV_ALIGN_TOP_MID, 0, 58);
const char *labels[3] = {"Button One", "Second Button", "Bottom Action"};
const lv_color_t colors[3] = {
lv_color_hex(0x2B4C7E),
lv_color_hex(0x2E8B57),
lv_color_hex(0xB45F06),
};
const lv_coord_t ys[3] = {150, 236, 340};
const lv_coord_t hs[3] = {58, 58, 64};
for (int i = 0; i < 3; i++) {
lv_obj_t *btn = lv_btn_create(lv_scr_act());
lv_obj_set_size(btn, 360, hs[i]);
lv_obj_align(btn, LV_ALIGN_TOP_MID, 0, ys[i]);
lv_obj_set_style_radius(btn, 14, 0);
lv_obj_set_style_bg_color(btn, colors[i], 0);
lv_obj_set_style_border_width(btn, 2, 0);
lv_obj_set_style_border_color(btn, lv_color_hex(0x7F96B0), 0);
lv_obj_t *label = lv_label_create(btn);
lv_label_set_text(label, labels[i]);
lv_obj_set_style_text_font(label, &lv_font_montserrat_20, 0);
lv_obj_set_style_text_color(label, lv_color_hex(0xFFFFFF), 0);
lv_obj_center(label);
}
}
void setup() {
Serial.begin(115200);
Wire.begin(IIC_SDA, IIC_SCL);
gfx->begin();
gBus->writeC8D8(0x36, 0xA0);
gfx->setBrightness(220);
lv_init();
uint32_t screenWidth = gfx->width();
uint32_t screenHeight = gfx->height();
gBuf1 = (lv_color_t *)heap_caps_malloc(screenWidth * screenHeight / 4 * sizeof(lv_color_t), MALLOC_CAP_DMA);
gBuf2 = (lv_color_t *)heap_caps_malloc(screenWidth * screenHeight / 4 * sizeof(lv_color_t), MALLOC_CAP_DMA);
lv_disp_draw_buf_init(&gDrawBuf, gBuf1, gBuf2, screenWidth * screenHeight / 4);
static lv_disp_drv_t dispDrv;
lv_disp_drv_init(&dispDrv);
dispDrv.hor_res = screenWidth;
dispDrv.ver_res = screenHeight;
dispDrv.flush_cb = lvglFlushCb;
dispDrv.rounder_cb = lvglRounderCb;
dispDrv.draw_buf = &gDrawBuf;
lv_disp_drv_register(&dispDrv);
const esp_timer_create_args_t lvglTickTimerArgs = {
.callback = &lvglTick,
.name = "lvgl_tick"};
esp_timer_handle_t lvglTickTimer = nullptr;
esp_timer_create(&lvglTickTimerArgs, &lvglTickTimer);
esp_timer_start_periodic(lvglTickTimer, EXAMPLE_LVGL_TICK_PERIOD_MS * 1000);
createUi();
Serial.println("LVGL basic test ready");
}
void loop() {
lv_timer_handler();
delay(5);
}
@@ -0,0 +1,214 @@
#include <Arduino.h>
#include <Wire.h>
#include <lvgl.h>
#include <Arduino_GFX_Library.h>
#include "pin_config.h"
#include "TouchDrvCSTXXX.hpp"
#define EXAMPLE_LVGL_TICK_PERIOD_MS 2
static lv_disp_draw_buf_t gDrawBuf;
static lv_color_t *gBuf1 = nullptr;
static lv_color_t *gBuf2 = nullptr;
static lv_obj_t *gStatusLabel = nullptr;
static uint32_t gPressCount = 0;
static TouchDrvCST92xx gTouch;
static int16_t gTouchX[5];
static int16_t gTouchY[5];
Arduino_DataBus *gBus = new Arduino_ESP32QSPI(
LCD_CS, LCD_SCLK, LCD_SDIO0, LCD_SDIO1, LCD_SDIO2, LCD_SDIO3);
Arduino_CO5300 *gfx = new Arduino_CO5300(
gBus, LCD_RESET, 0, LCD_WIDTH, LCD_HEIGHT, 0, 0, 0, 0);
static void lvglFlushCb(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *colorP) {
uint32_t w = area->x2 - area->x1 + 1;
uint32_t h = area->y2 - area->y1 + 1;
#if (LV_COLOR_16_SWAP != 0)
gfx->draw16bitBeRGBBitmap(area->x1, area->y1, (uint16_t *)&colorP->full, w, h);
#else
gfx->draw16bitRGBBitmap(area->x1, area->y1, (uint16_t *)&colorP->full, w, h);
#endif
lv_disp_flush_ready(disp);
}
static void lvglTick(void *arg) {
LV_UNUSED(arg);
lv_tick_inc(EXAMPLE_LVGL_TICK_PERIOD_MS);
}
static void lvglTouchRead(lv_indev_drv_t *indevDrv, lv_indev_data_t *data) {
LV_UNUSED(indevDrv);
uint8_t touched = gTouch.getPoint(gTouchX, gTouchY, gTouch.getSupportTouchPoint());
if (touched > 0) {
data->state = LV_INDEV_STATE_PR;
data->point.x = gTouchX[0];
data->point.y = gTouchY[0];
return;
}
data->state = LV_INDEV_STATE_REL;
}
static void buttonEventCb(lv_event_t *event) {
if (lv_event_get_code(event) != LV_EVENT_CLICKED) {
return;
}
lv_obj_t *btn = lv_event_get_target(event);
const char *name = (const char *)lv_event_get_user_data(event);
gPressCount++;
lv_obj_set_style_outline_width(btn, 4, 0);
lv_obj_set_style_outline_color(btn, lv_color_hex(0xFFF3B0), 0);
lv_obj_set_style_outline_pad(btn, 2, 0);
String status = "Pressed: ";
status += name;
status += " (#";
status += String(gPressCount);
status += ")";
lv_label_set_text(gStatusLabel, status.c_str());
Serial.println(status);
}
static void createButton(lv_obj_t *parent, const char *labelText, lv_coord_t col, lv_coord_t row, uint32_t colorHex) {
static const lv_coord_t kStartX = 22;
static const lv_coord_t kStartY = 122;
static const lv_coord_t kButtonW = 134;
static const lv_coord_t kButtonH = 64;
static const lv_coord_t kGapX = 18;
static const lv_coord_t kGapY = 16;
lv_obj_t *btn = lv_btn_create(parent);
lv_obj_set_size(btn, kButtonW, kButtonH);
lv_obj_set_pos(btn, kStartX + col * (kButtonW + kGapX), kStartY + row * (kButtonH + kGapY));
lv_obj_set_style_radius(btn, 16, 0);
lv_obj_set_style_bg_color(btn, lv_color_hex(colorHex), 0);
lv_obj_set_style_border_width(btn, 2, 0);
lv_obj_set_style_border_color(btn, lv_color_hex(0x7F96B0), 0);
lv_obj_set_style_shadow_width(btn, 0, 0);
lv_obj_add_event_cb(btn, buttonEventCb, LV_EVENT_CLICKED, (void *)labelText);
lv_obj_t *label = lv_label_create(btn);
lv_label_set_text(label, labelText);
lv_obj_set_style_text_font(label, &lv_font_montserrat_18, 0);
lv_obj_set_style_text_color(label, lv_color_hex(0xFFFFFF), 0);
lv_obj_center(label);
}
static void createUi() {
lv_obj_t *screen = lv_scr_act();
lv_obj_set_style_bg_color(screen, lv_color_hex(0x0B1320), 0);
lv_obj_set_style_bg_opa(screen, LV_OPA_COVER, 0);
lv_obj_t *title = lv_label_create(screen);
lv_label_set_text(title, "LVGL INTERACTION TEST");
lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_style_text_font(title, &lv_font_montserrat_22, 0);
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 14);
lv_obj_t *subtitle = lv_label_create(screen);
lv_label_set_text(subtitle, "Tap buttons. Bottom label must change on every press.");
lv_obj_set_width(subtitle, 420);
lv_label_set_long_mode(subtitle, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_color(subtitle, lv_color_hex(0xD5DCE5), 0);
lv_obj_set_style_text_font(subtitle, &lv_font_montserrat_14, 0);
lv_obj_align(subtitle, LV_ALIGN_TOP_MID, 0, 48);
createButton(screen, "Status", 0, 0, 0x355C7D);
createButton(screen, "Connect", 1, 0, 0x2A9D8F);
createButton(screen, "Wallet", 2, 0, 0x457B9D);
createButton(screen, "Requests", 0, 1, 0xE76F51);
createButton(screen, "Settings", 1, 1, 0x8D5A97);
createButton(screen, "Register", 2, 1, 0x6A994E);
createButton(screen, "Approve", 0, 2, 0xBC6C25);
createButton(screen, "Reject", 1, 2, 0xB56576);
createButton(screen, "Back", 2, 2, 0x6C757D);
lv_obj_t *statusPanel = lv_obj_create(screen);
lv_obj_set_size(statusPanel, 436, 56);
lv_obj_align(statusPanel, LV_ALIGN_BOTTOM_MID, 0, -16);
lv_obj_set_style_radius(statusPanel, 14, 0);
lv_obj_set_style_bg_color(statusPanel, lv_color_hex(0x162033), 0);
lv_obj_set_style_border_width(statusPanel, 2, 0);
lv_obj_set_style_border_color(statusPanel, lv_color_hex(0x415A77), 0);
lv_obj_set_style_pad_all(statusPanel, 10, 0);
gStatusLabel = lv_label_create(statusPanel);
lv_label_set_text(gStatusLabel, "Pressed: none");
lv_obj_set_style_text_font(gStatusLabel, &lv_font_montserrat_18, 0);
lv_obj_set_style_text_color(gStatusLabel, lv_color_hex(0xFFFFFF), 0);
lv_obj_center(gStatusLabel);
}
void setup() {
Serial.begin(115200);
Wire.begin(IIC_SDA, IIC_SCL);
pinMode(TP_RST, OUTPUT);
pinMode(TP_INT, INPUT);
digitalWrite(TP_RST, LOW);
delay(30);
digitalWrite(TP_RST, HIGH);
delay(50);
delay(1000);
gTouch.setPins(TP_RST, TP_INT);
bool touchOk = gTouch.begin(Wire, 0x5A, IIC_SDA, IIC_SCL);
if (!touchOk) {
Serial.println("Touch init failed");
} else {
Serial.print("Touch model: ");
Serial.println(gTouch.getModelName());
gTouch.sleep();
gTouch.reset();
gTouch.setMaxCoordinates(480, 480);
gTouch.setSwapXY(true);
gTouch.setMirrorXY(true, false);
}
gfx->begin();
gBus->writeC8D8(0x36, 0xA0);
gfx->setBrightness(220);
gfx->fillScreen(0x0000);
lv_init();
uint32_t screenWidth = gfx->width();
uint32_t screenHeight = gfx->height();
gBuf1 = (lv_color_t *)heap_caps_malloc(screenWidth * screenHeight / 4 * sizeof(lv_color_t), MALLOC_CAP_DMA);
gBuf2 = (lv_color_t *)heap_caps_malloc(screenWidth * screenHeight / 4 * sizeof(lv_color_t), MALLOC_CAP_DMA);
lv_disp_draw_buf_init(&gDrawBuf, gBuf1, gBuf2, screenWidth * screenHeight / 4);
static lv_disp_drv_t dispDrv;
lv_disp_drv_init(&dispDrv);
dispDrv.hor_res = screenWidth;
dispDrv.ver_res = screenHeight;
dispDrv.flush_cb = lvglFlushCb;
dispDrv.draw_buf = &gDrawBuf;
lv_disp_drv_register(&dispDrv);
static lv_indev_drv_t indevDrv;
lv_indev_drv_init(&indevDrv);
indevDrv.type = LV_INDEV_TYPE_POINTER;
indevDrv.read_cb = lvglTouchRead;
lv_indev_drv_register(&indevDrv);
const esp_timer_create_args_t lvglTickTimerArgs = {
.callback = &lvglTick,
.name = "lvgl_tick"};
esp_timer_handle_t lvglTickTimer = nullptr;
esp_timer_create(&lvglTickTimerArgs, &lvglTickTimer);
esp_timer_start_periodic(lvglTickTimer, EXAMPLE_LVGL_TICK_PERIOD_MS * 1000);
createUi();
Serial.println("LVGL interaction test ready");
}
void loop() {
lv_timer_handler();
delay(5);
}
@@ -0,0 +1,252 @@
#include <lvgl.h>
#include "Arduino_GFX_Library.h"
#include "pin_config.h"
#include "TouchDrvCSTXXX.hpp"
#include "lv_conf.h"
#include "HWCDC.h"
#include <Wire.h>
HWCDC USBSerial;
#define EXAMPLE_LVGL_TICK_PERIOD_MS 2
static lv_disp_draw_buf_t draw_buf;
static lv_color_t *buf1 = nullptr;
static lv_color_t *buf2 = nullptr;
static lv_obj_t *status_label = nullptr;
static uint32_t click_count = 0;
Arduino_DataBus *bus = new Arduino_ESP32QSPI(
LCD_CS, LCD_SCLK, LCD_SDIO0, LCD_SDIO1, LCD_SDIO2, LCD_SDIO3);
Arduino_CO5300 *gfx = new Arduino_CO5300(
bus, LCD_RESET, 0, LCD_WIDTH, LCD_HEIGHT, 0, 0, 0, 0);
TouchDrvCST92xx touch;
int16_t touch_x[5], touch_y[5];
bool isPressed = false;
uint32_t screenWidth = 0;
uint32_t screenHeight = 0;
#if LV_USE_LOG != 0
void my_print(const char *buf) {
USBSerial.printf(buf);
USBSerial.flush();
}
#endif
static void example_lvgl_rounder_cb(struct _lv_disp_drv_t *disp_drv, lv_area_t *area) {
LV_UNUSED(disp_drv);
if (area->x1 % 2 != 0) area->x1--;
if (area->y1 % 2 != 0) area->y1--;
if (area->x2 % 2 == 0) area->x2++;
if (area->y2 % 2 == 0) area->y2++;
}
static void my_disp_flush(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *color_p) {
uint32_t w = (area->x2 - area->x1 + 1);
uint32_t h = (area->y2 - area->y1 + 1);
#if (LV_COLOR_16_SWAP != 0)
gfx->draw16bitBeRGBBitmap(area->x1, area->y1, (uint16_t *)&color_p->full, w, h);
#else
gfx->draw16bitRGBBitmap(area->x1, area->y1, (uint16_t *)&color_p->full, w, h);
#endif
lv_disp_flush_ready(disp);
}
static void example_increase_lvgl_tick(void *arg) {
LV_UNUSED(arg);
lv_tick_inc(EXAMPLE_LVGL_TICK_PERIOD_MS);
}
static void my_touchpad_read(lv_indev_drv_t *indev_driver, lv_indev_data_t *data) {
LV_UNUSED(indev_driver);
if (isPressed) {
uint8_t touched = touch.getPoint(touch_x, touch_y, touch.getSupportTouchPoint());
if (touched) {
isPressed = false;
data->state = LV_INDEV_STATE_PR;
data->point.x = touch_x[0];
data->point.y = touch_y[0];
USBSerial.print("Touch x=");
USBSerial.print(touch_x[0]);
USBSerial.print(" y=");
USBSerial.println(touch_y[0]);
} else {
data->state = LV_INDEV_STATE_REL;
}
} else {
data->state = LV_INDEV_STATE_REL;
}
}
static void button_event_cb(lv_event_t *event) {
if (lv_event_get_code(event) != LV_EVENT_CLICKED) {
return;
}
const char *name = (const char *)lv_event_get_user_data(event);
click_count++;
String text = "Pressed: ";
text += name;
text += " (#";
text += String(click_count);
text += ")";
lv_label_set_text(status_label, text.c_str());
USBSerial.println(text);
}
static lv_obj_t *make_button(lv_obj_t *parent, const char *label_text, lv_coord_t x, lv_coord_t y, lv_coord_t w, lv_coord_t h, lv_color_t bg) {
lv_obj_t *btn = lv_btn_create(parent);
lv_obj_set_size(btn, w, h);
lv_obj_set_pos(btn, x, y);
lv_obj_set_style_radius(btn, 16, 0);
lv_obj_set_style_bg_color(btn, bg, 0);
lv_obj_set_style_border_width(btn, 2, 0);
lv_obj_set_style_border_color(btn, lv_color_hex(0x8AA4BF), 0);
lv_obj_set_style_shadow_width(btn, 0, 0);
lv_obj_add_event_cb(btn, button_event_cb, LV_EVENT_CLICKED, (void *)label_text);
lv_obj_t *label = lv_label_create(btn);
lv_label_set_text(label, label_text);
lv_obj_set_style_text_font(label, &lv_font_montserrat_18, 0);
lv_obj_set_style_text_color(label, lv_color_hex(0xFFFFFF), 0);
lv_obj_center(label);
return btn;
}
static void create_ui() {
lv_obj_t *screen = lv_scr_act();
lv_obj_set_style_bg_color(screen, lv_color_hex(0x0B1320), 0);
lv_obj_set_style_bg_opa(screen, LV_OPA_COVER, 0);
lv_obj_t *title = lv_label_create(screen);
lv_label_set_text(title, "LVGL OFFICIAL BASED TEST");
lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0);
lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), 0);
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 14);
lv_obj_t *hint = lv_label_create(screen);
lv_label_set_text(hint, "Based on official LVGL_Widgets init. Tap buttons and watch the status.");
lv_obj_set_width(hint, 430);
lv_label_set_long_mode(hint, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(hint, lv_color_hex(0xD4DDE7), 0);
lv_obj_align(hint, LV_ALIGN_TOP_MID, 0, 44);
make_button(screen, "Status", 22, 120, 136, 60, lv_color_hex(0x355C7D));
make_button(screen, "Connect", 174, 120, 136, 60, lv_color_hex(0x2A9D8F));
make_button(screen, "Wallet", 326, 120, 132, 60, lv_color_hex(0x457B9D));
make_button(screen, "Requests", 22, 196, 136, 60, lv_color_hex(0xE76F51));
make_button(screen, "Settings",174, 196, 136, 60, lv_color_hex(0x8D5A97));
make_button(screen, "Register",326, 196, 132, 60, lv_color_hex(0x6A994E));
make_button(screen, "Approve", 22, 272, 136, 60, lv_color_hex(0xBC6C25));
make_button(screen, "Reject", 174, 272, 136, 60, lv_color_hex(0xB56576));
make_button(screen, "Back", 326, 272, 132, 60, lv_color_hex(0x6C757D));
lv_obj_t *bottom_btn = make_button(screen, "Bottom Action", 22, 350, 436, 64, lv_color_hex(0x1D6F42));
lv_obj_set_style_text_font(lv_obj_get_child(bottom_btn, 0), &lv_font_montserrat_20, 0);
lv_obj_t *status_panel = lv_obj_create(screen);
lv_obj_set_size(status_panel, 436, 42);
lv_obj_set_pos(status_panel, 22, 426);
lv_obj_set_style_radius(status_panel, 12, 0);
lv_obj_set_style_bg_color(status_panel, lv_color_hex(0x17263A), 0);
lv_obj_set_style_border_width(status_panel, 2, 0);
lv_obj_set_style_border_color(status_panel, lv_color_hex(0x496582), 0);
lv_obj_set_style_pad_all(status_panel, 6, 0);
status_label = lv_label_create(status_panel);
lv_label_set_text(status_label, "Pressed: none");
lv_obj_set_style_text_font(status_label, &lv_font_montserrat_16, 0);
lv_obj_set_style_text_color(status_label, lv_color_hex(0xFFFFFF), 0);
lv_obj_center(status_label);
}
void setup() {
USBSerial.begin(115200);
Wire.begin(IIC_SDA, IIC_SCL);
digitalWrite(TP_RST, LOW);
delay(30);
digitalWrite(TP_RST, HIGH);
delay(50);
delay(1000);
Wire.begin(IIC_SDA, IIC_SCL);
touch.setPins(TP_RST, TP_INT);
bool result = touch.begin(Wire, 0x5A, IIC_SDA, IIC_SCL);
if (result == false) {
USBSerial.println("touch is not online...");
while (1) delay(1000);
}
USBSerial.print("Touch model: ");
USBSerial.println(touch.getModelName());
touch.sleep();
touch.reset();
touch.setMaxCoordinates(480, 480);
touch.setSwapXY(true);
touch.setMirrorXY(true, false);
attachInterrupt(
TP_INT, []() {
isPressed = true;
},
FALLING);
gfx->begin();
gfx->setBrightness(200);
bus->writeC8D8(0x36, 0xA0);
screenWidth = gfx->width();
screenHeight = gfx->height();
lv_init();
buf1 = (lv_color_t *)heap_caps_malloc(screenWidth * screenHeight / 4 * sizeof(lv_color_t), MALLOC_CAP_DMA);
buf2 = (lv_color_t *)heap_caps_malloc(screenWidth * screenHeight / 4 * sizeof(lv_color_t), MALLOC_CAP_DMA);
#if LV_USE_LOG != 0
lv_log_register_print_cb(my_print);
#endif
lv_disp_draw_buf_init(&draw_buf, buf1, buf2, screenWidth * screenHeight / 4);
static lv_disp_drv_t disp_drv;
lv_disp_drv_init(&disp_drv);
disp_drv.hor_res = screenWidth;
disp_drv.ver_res = screenHeight;
disp_drv.flush_cb = my_disp_flush;
disp_drv.rounder_cb = example_lvgl_rounder_cb;
disp_drv.draw_buf = &draw_buf;
disp_drv.sw_rotate = 1;
lv_disp_drv_register(&disp_drv);
static lv_indev_drv_t indev_drv;
lv_indev_drv_init(&indev_drv);
indev_drv.type = LV_INDEV_TYPE_POINTER;
indev_drv.read_cb = my_touchpad_read;
lv_indev_drv_register(&indev_drv);
const esp_timer_create_args_t lvgl_tick_timer_args = {
.callback = &example_increase_lvgl_tick,
.name = "lvgl_tick"
};
esp_timer_handle_t lvgl_tick_timer = NULL;
esp_timer_create(&lvgl_tick_timer_args, &lvgl_tick_timer);
esp_timer_start_periodic(lvgl_tick_timer, EXAMPLE_LVGL_TICK_PERIOD_MS * 1000);
create_ui();
USBSerial.println("LVGL official based test ready");
}
void loop() {
lv_timer_handler();
delay(5);
}
@@ -0,0 +1,4 @@
#pragma once
#include <lvgl.h>
LV_FONT_DECLARE(lv_font_ru_18);
LV_FONT_DECLARE(lv_font_ru_24);
@@ -0,0 +1,264 @@
#include <Arduino.h>
#include <Wire.h>
#include <lvgl.h>
#include <Arduino_GFX_Library.h>
#include <TouchDrvCSTXXX.hpp>
#include "lv_font_ru.h"
#define PIN_LCD_CS 12
#define PIN_LCD_SCLK 38
#define PIN_LCD_D0 4
#define PIN_LCD_D1 5
#define PIN_LCD_D2 6
#define PIN_LCD_D3 7
#define PIN_LCD_RST 2
#define PIN_I2C_SDA 15
#define PIN_I2C_SCL 14
#define PIN_TP_INT 11
#define DISP_W 480
#define DISP_H 480
#define LVGL_TICK_MS 2
#define TEST_VERSION "v1-ru-font"
static lv_disp_draw_buf_t gDrawBuf;
static lv_color_t *gBuf1 = nullptr;
static lv_color_t *gBuf2 = nullptr;
static lv_obj_t *gStatusLabel = nullptr;
static lv_obj_t *gTouchLabel = nullptr;
static uint32_t gClickCount = 0;
static bool gTouchActive = false;
static int16_t gLastX = -1;
static int16_t gLastY = -1;
Arduino_DataBus *gBus = new Arduino_ESP32QSPI(
PIN_LCD_CS, PIN_LCD_SCLK, PIN_LCD_D0, PIN_LCD_D1, PIN_LCD_D2, PIN_LCD_D3);
Arduino_CO5300 *gfx = new Arduino_CO5300(
gBus, PIN_LCD_RST, 0, DISP_W, DISP_H, 0, 0, 0, 0);
TouchDrvCST92xx gTouch;
static void lvglFlushCb(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *colorP) {
uint32_t w = area->x2 - area->x1 + 1;
uint32_t h = area->y2 - area->y1 + 1;
#if (LV_COLOR_16_SWAP != 0)
gfx->draw16bitBeRGBBitmap(area->x1, area->y1, (uint16_t *)&colorP->full, w, h);
#else
gfx->draw16bitRGBBitmap(area->x1, area->y1, (uint16_t *)&colorP->full, w, h);
#endif
lv_disp_flush_ready(disp);
}
static void lvglTick(void *arg) {
LV_UNUSED(arg);
lv_tick_inc(LVGL_TICK_MS);
}
static void updateTouchLabel() {
if (!gTouchLabel) {
return;
}
char text[96];
if (gTouchActive) {
snprintf(text, sizeof(text), "Касание: x=%d y=%d", gLastX, gLastY);
} else {
snprintf(text, sizeof(text), "Касание: нет x=%d y=%d", gLastX, gLastY);
}
lv_label_set_text(gTouchLabel, text);
}
static void lvglTouchRead(lv_indev_drv_t *indevDrv, lv_indev_data_t *data) {
LV_UNUSED(indevDrv);
int16_t x = 0;
int16_t y = 0;
bool touching = gTouch.getPoint(&x, &y, 1) > 0;
if (touching) {
gTouchActive = true;
gLastX = x;
gLastY = y;
data->state = LV_INDEV_STATE_PR;
data->point.x = x;
data->point.y = y;
} else {
gTouchActive = false;
data->state = LV_INDEV_STATE_REL;
data->point.x = gLastX >= 0 ? gLastX : 0;
data->point.y = gLastY >= 0 ? gLastY : 0;
}
updateTouchLabel();
}
static void buttonEventCb(lv_event_t *event) {
if (lv_event_get_code(event) != LV_EVENT_CLICKED) {
return;
}
const char *name = (const char *)lv_event_get_user_data(event);
gClickCount++;
char text[128];
snprintf(text, sizeof(text), "Нажато: %s (%lu)", name, (unsigned long)gClickCount);
lv_label_set_text(gStatusLabel, text);
Serial.println(text);
}
static lv_obj_t *makeButton(lv_obj_t *parent,
const char *labelText,
lv_coord_t x,
lv_coord_t y,
lv_coord_t w,
lv_coord_t h,
uint32_t bgColor) {
lv_obj_t *btn = lv_btn_create(parent);
lv_obj_set_size(btn, w, h);
lv_obj_set_pos(btn, x, y);
lv_obj_set_style_radius(btn, 16, 0);
lv_obj_set_style_bg_color(btn, lv_color_hex(bgColor), 0);
lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(btn, 2, 0);
lv_obj_set_style_border_color(btn, lv_color_hex(0x6F8BA4), 0);
lv_obj_set_style_shadow_width(btn, 0, 0);
lv_obj_set_style_pad_all(btn, 4, 0);
lv_obj_add_event_cb(btn, buttonEventCb, LV_EVENT_CLICKED, (void *)labelText);
lv_obj_t *label = lv_label_create(btn);
lv_label_set_text(label, labelText);
lv_obj_set_width(label, w - 12);
lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_color(label, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_style_text_font(label, &lv_font_ru_18, 0);
lv_obj_center(label);
return btn;
}
static void createUi() {
lv_obj_t *screen = lv_scr_act();
lv_obj_set_style_bg_color(screen, lv_color_hex(0x08131E), 0);
lv_obj_set_style_bg_opa(screen, LV_OPA_COVER, 0);
lv_obj_set_style_pad_all(screen, 0, 0);
lv_obj_set_scrollbar_mode(screen, LV_SCROLLBAR_MODE_OFF);
lv_obj_t *title = lv_label_create(screen);
lv_label_set_text(title, "Русский тест LVGL");
lv_obj_set_style_text_font(title, &lv_font_ru_24, 0);
lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), 0);
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 10);
lv_obj_t *version = lv_label_create(screen);
lv_label_set_text(version, "Версия: " TEST_VERSION);
lv_obj_set_style_text_font(version, &lv_font_ru_18, 0);
lv_obj_set_style_text_color(version, lv_color_hex(0xD8E0EA), 0);
lv_obj_align(version, LV_ALIGN_TOP_MID, 0, 42);
lv_obj_t *hint = lv_label_create(screen);
lv_label_set_text(hint, "Проверка кириллицы, длинных слов, кнопок и нажатий.");
lv_obj_set_width(hint, 436);
lv_label_set_long_mode(hint, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_font(hint, &lv_font_ru_18, 0);
lv_obj_set_style_text_color(hint, lv_color_hex(0xD8E0EA), 0);
lv_obj_align(hint, LV_ALIGN_TOP_MID, 0, 72);
makeButton(screen, "Статус", 22, 126, 136, 62, 0x355C7D);
makeButton(screen, "Подключение", 172, 126, 136, 62, 0x2A9D8F);
makeButton(screen, "Кошелёк", 322, 126, 136, 62, 0x457B9D);
makeButton(screen, "Запросы", 22, 204, 136, 62, 0xE76F51);
makeButton(screen, "Настройки", 172, 204, 136, 62, 0x8D5A97);
makeButton(screen, "Регистрация", 322, 204, 136, 62, 0x6A994E);
makeButton(screen, "Разрешить", 22, 282, 136, 62, 0xBC6C25);
makeButton(screen, "Отклонить", 172, 282, 136, 62, 0xB56576);
makeButton(screen, "Назад", 322, 282, 136, 62, 0x6C757D);
makeButton(screen, "Проверка переноса русского текста", 22, 360, 436, 64, 0x1D6F42);
lv_obj_t *statusPanel = lv_obj_create(screen);
lv_obj_set_size(statusPanel, 436, 24);
lv_obj_set_pos(statusPanel, 22, 428);
lv_obj_set_style_radius(statusPanel, 10, 0);
lv_obj_set_style_bg_color(statusPanel, lv_color_hex(0x162435), 0);
lv_obj_set_style_bg_opa(statusPanel, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(statusPanel, 1, 0);
lv_obj_set_style_border_color(statusPanel, lv_color_hex(0x4D6986), 0);
lv_obj_set_style_pad_all(statusPanel, 2, 0);
gStatusLabel = lv_label_create(statusPanel);
lv_label_set_text(gStatusLabel, "Нажато: ничего");
lv_obj_set_style_text_font(gStatusLabel, &lv_font_ru_18, 0);
lv_obj_set_style_text_color(gStatusLabel, lv_color_hex(0xFFFFFF), 0);
lv_obj_center(gStatusLabel);
lv_obj_t *touchPanel = lv_obj_create(screen);
lv_obj_set_size(touchPanel, 436, 24);
lv_obj_set_pos(touchPanel, 22, 454);
lv_obj_set_style_radius(touchPanel, 10, 0);
lv_obj_set_style_bg_color(touchPanel, lv_color_hex(0x162435), 0);
lv_obj_set_style_bg_opa(touchPanel, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(touchPanel, 1, 0);
lv_obj_set_style_border_color(touchPanel, lv_color_hex(0x4D6986), 0);
lv_obj_set_style_pad_all(touchPanel, 2, 0);
gTouchLabel = lv_label_create(touchPanel);
lv_label_set_text(gTouchLabel, "Касание: нет x=-1 y=-1");
lv_obj_set_style_text_font(gTouchLabel, &lv_font_ru_18, 0);
lv_obj_set_style_text_color(gTouchLabel, lv_color_hex(0xD8E0EA), 0);
lv_obj_center(gTouchLabel);
}
void setup() {
Serial.begin(115200);
Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL);
gfx->begin();
gBus->writeC8D8(0x36, 0xA0);
gfx->setBrightness(220);
gfx->fillScreen(0x0000);
gTouch.setPins(PIN_TP_INT, -1);
gTouch.begin(Wire, CST92XX_SLAVE_ADDRESS, PIN_I2C_SDA, PIN_I2C_SCL);
gTouch.setMaxCoordinates(DISP_W, DISP_H);
gTouch.setSwapXY(true);
gTouch.setMirrorXY(true, false);
lv_init();
uint32_t screenWidth = gfx->width();
uint32_t screenHeight = gfx->height();
uint32_t bufPixels = screenWidth * 40;
gBuf1 = (lv_color_t *)heap_caps_malloc(bufPixels * sizeof(lv_color_t), MALLOC_CAP_DMA);
gBuf2 = (lv_color_t *)heap_caps_malloc(bufPixels * sizeof(lv_color_t), MALLOC_CAP_DMA);
lv_disp_draw_buf_init(&gDrawBuf, gBuf1, gBuf2, bufPixels);
static lv_disp_drv_t dispDrv;
lv_disp_drv_init(&dispDrv);
dispDrv.hor_res = screenWidth;
dispDrv.ver_res = screenHeight;
dispDrv.flush_cb = lvglFlushCb;
dispDrv.draw_buf = &gDrawBuf;
lv_disp_drv_register(&dispDrv);
static lv_indev_drv_t indevDrv;
lv_indev_drv_init(&indevDrv);
indevDrv.type = LV_INDEV_TYPE_POINTER;
indevDrv.read_cb = lvglTouchRead;
lv_indev_drv_register(&indevDrv);
const esp_timer_create_args_t tickArgs = {
.callback = &lvglTick,
.name = "lvgl_tick"};
esp_timer_handle_t tickTimer = nullptr;
esp_timer_create(&tickArgs, &tickTimer);
esp_timer_start_periodic(tickTimer, LVGL_TICK_MS * 1000);
createUi();
Serial.println("Русский LVGL font test ready: " TEST_VERSION);
}
void loop() {
lv_timer_handler();
delay(5);
}
@@ -0,0 +1,256 @@
#include <Arduino.h>
#include <Wire.h>
#include <lvgl.h>
#include <Arduino_GFX_Library.h>
#include <TouchDrvCSTXXX.hpp>
// Подтверждено на устройстве: LVGL-рендер работает вместе с touch-путём из shine_homeserver_ui.
#define PIN_LCD_CS 12
#define PIN_LCD_SCLK 38
#define PIN_LCD_D0 4
#define PIN_LCD_D1 5
#define PIN_LCD_D2 6
#define PIN_LCD_D3 7
#define PIN_LCD_RST 2
#define PIN_I2C_SDA 15
#define PIN_I2C_SCL 14
#define PIN_TP_INT 11
#define DISP_W 480
#define DISP_H 480
#define LVGL_TICK_MS 2
#define TEST_VERSION "v1-subtouch"
static lv_disp_draw_buf_t gDrawBuf;
static lv_color_t *gBuf1 = nullptr;
static lv_color_t *gBuf2 = nullptr;
static lv_obj_t *gStatusLabel = nullptr;
static lv_obj_t *gTouchLabel = nullptr;
static lv_obj_t *gVersionLabel = nullptr;
static uint32_t gClickCount = 0;
static bool gTouchActive = false;
static int16_t gLastX = -1;
static int16_t gLastY = -1;
Arduino_DataBus *gBus = new Arduino_ESP32QSPI(
PIN_LCD_CS, PIN_LCD_SCLK, PIN_LCD_D0, PIN_LCD_D1, PIN_LCD_D2, PIN_LCD_D3);
Arduino_CO5300 *gfx = new Arduino_CO5300(
gBus, PIN_LCD_RST, 0, DISP_W, DISP_H, 0, 0, 0, 0);
TouchDrvCST92xx gTouch;
static void lvglFlushCb(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *colorP) {
uint32_t w = area->x2 - area->x1 + 1;
uint32_t h = area->y2 - area->y1 + 1;
#if (LV_COLOR_16_SWAP != 0)
gfx->draw16bitBeRGBBitmap(area->x1, area->y1, (uint16_t *)&colorP->full, w, h);
#else
gfx->draw16bitRGBBitmap(area->x1, area->y1, (uint16_t *)&colorP->full, w, h);
#endif
lv_disp_flush_ready(disp);
}
static void lvglTick(void *arg) {
LV_UNUSED(arg);
lv_tick_inc(LVGL_TICK_MS);
}
static void updateTouchLabel() {
if (!gTouchLabel) {
return;
}
char text[96];
if (gTouchActive) {
snprintf(text, sizeof(text), "Touch: pressed x=%d y=%d", gLastX, gLastY);
} else {
snprintf(text, sizeof(text), "Touch: released x=%d y=%d", gLastX, gLastY);
}
lv_label_set_text(gTouchLabel, text);
}
static void lvglTouchRead(lv_indev_drv_t *indevDrv, lv_indev_data_t *data) {
LV_UNUSED(indevDrv);
int16_t x = 0;
int16_t y = 0;
bool touching = gTouch.getPoint(&x, &y, 1) > 0;
if (touching) {
gTouchActive = true;
gLastX = x;
gLastY = y;
data->state = LV_INDEV_STATE_PR;
data->point.x = x;
data->point.y = y;
} else {
gTouchActive = false;
data->state = LV_INDEV_STATE_REL;
data->point.x = gLastX >= 0 ? gLastX : 0;
data->point.y = gLastY >= 0 ? gLastY : 0;
}
updateTouchLabel();
}
static void buttonEventCb(lv_event_t *event) {
if (lv_event_get_code(event) != LV_EVENT_CLICKED) {
return;
}
const char *name = (const char *)lv_event_get_user_data(event);
gClickCount++;
char text[96];
snprintf(text, sizeof(text), "Pressed: %s (#%lu)", name, (unsigned long)gClickCount);
lv_label_set_text(gStatusLabel, text);
Serial.println(text);
}
static lv_obj_t *makeButton(lv_obj_t *parent,
const char *labelText,
lv_coord_t x,
lv_coord_t y,
lv_coord_t w,
lv_coord_t h,
uint32_t bgColor) {
lv_obj_t *btn = lv_btn_create(parent);
lv_obj_set_size(btn, w, h);
lv_obj_set_pos(btn, x, y);
lv_obj_set_style_radius(btn, 16, 0);
lv_obj_set_style_bg_color(btn, lv_color_hex(bgColor), 0);
lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(btn, 2, 0);
lv_obj_set_style_border_color(btn, lv_color_hex(0x6F8BA4), 0);
lv_obj_set_style_shadow_width(btn, 0, 0);
lv_obj_add_event_cb(btn, buttonEventCb, LV_EVENT_CLICKED, (void *)labelText);
lv_obj_t *label = lv_label_create(btn);
lv_label_set_text(label, labelText);
lv_obj_set_style_text_color(label, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_style_text_font(label, &lv_font_montserrat_18, 0);
lv_obj_center(label);
return btn;
}
static void createUi() {
lv_obj_t *screen = lv_scr_act();
lv_obj_set_style_bg_color(screen, lv_color_hex(0x07111C), 0);
lv_obj_set_style_bg_opa(screen, LV_OPA_COVER, 0);
lv_obj_set_style_pad_all(screen, 0, 0);
lv_obj_set_scrollbar_mode(screen, LV_SCROLLBAR_MODE_OFF);
gVersionLabel = lv_label_create(screen);
lv_label_set_text(gVersionLabel, "LVGL + subserver touch " TEST_VERSION);
lv_obj_set_style_text_font(gVersionLabel, &lv_font_montserrat_18, 0);
lv_obj_set_style_text_color(gVersionLabel, lv_color_hex(0xFFFFFF), 0);
lv_obj_align(gVersionLabel, LV_ALIGN_TOP_MID, 0, 12);
lv_obj_t *subtitle = lv_label_create(screen);
lv_label_set_text(subtitle, "Touch path comes from shine_homeserver_ui. Tap buttons and watch status.");
lv_obj_set_width(subtitle, 436);
lv_label_set_long_mode(subtitle, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_font(subtitle, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(subtitle, lv_color_hex(0xD6DEE7), 0);
lv_obj_align(subtitle, LV_ALIGN_TOP_MID, 0, 42);
makeButton(screen, "Status", 22, 110, 136, 62, 0x355C7D);
makeButton(screen, "Connect", 172, 110, 136, 62, 0x2A9D8F);
makeButton(screen, "Wallet", 322, 110, 136, 62, 0x457B9D);
makeButton(screen, "Requests", 22, 188, 136, 62, 0xE76F51);
makeButton(screen, "Settings", 172, 188, 136, 62, 0x8D5A97);
makeButton(screen, "Register", 322, 188, 136, 62, 0x6A994E);
makeButton(screen, "Approve", 22, 266, 136, 62, 0xBC6C25);
makeButton(screen, "Reject", 172, 266, 136, 62, 0xB56576);
makeButton(screen, "Back", 322, 266, 136, 62, 0x6C757D);
makeButton(screen, "Bottom Action", 22, 346, 436, 68, 0x1D6F42);
lv_obj_t *statusPanel = lv_obj_create(screen);
lv_obj_set_size(statusPanel, 436, 26);
lv_obj_set_pos(statusPanel, 22, 420);
lv_obj_set_style_radius(statusPanel, 10, 0);
lv_obj_set_style_bg_color(statusPanel, lv_color_hex(0x162435), 0);
lv_obj_set_style_bg_opa(statusPanel, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(statusPanel, 1, 0);
lv_obj_set_style_border_color(statusPanel, lv_color_hex(0x4D6986), 0);
lv_obj_set_style_pad_all(statusPanel, 2, 0);
gStatusLabel = lv_label_create(statusPanel);
lv_label_set_text(gStatusLabel, "Pressed: none");
lv_obj_set_style_text_font(gStatusLabel, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(gStatusLabel, lv_color_hex(0xFFFFFF), 0);
lv_obj_center(gStatusLabel);
lv_obj_t *touchPanel = lv_obj_create(screen);
lv_obj_set_size(touchPanel, 436, 26);
lv_obj_set_pos(touchPanel, 22, 450);
lv_obj_set_style_radius(touchPanel, 10, 0);
lv_obj_set_style_bg_color(touchPanel, lv_color_hex(0x162435), 0);
lv_obj_set_style_bg_opa(touchPanel, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(touchPanel, 1, 0);
lv_obj_set_style_border_color(touchPanel, lv_color_hex(0x4D6986), 0);
lv_obj_set_style_pad_all(touchPanel, 2, 0);
gTouchLabel = lv_label_create(touchPanel);
lv_label_set_text(gTouchLabel, "Touch: released x=-1 y=-1");
lv_obj_set_style_text_font(gTouchLabel, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(gTouchLabel, lv_color_hex(0xD6DEE7), 0);
lv_obj_center(gTouchLabel);
}
void setup() {
Serial.begin(115200);
Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL);
gfx->begin();
gBus->writeC8D8(0x36, 0xA0);
gfx->setBrightness(220);
gfx->fillScreen(0x0000);
gTouch.setPins(PIN_TP_INT, -1);
gTouch.begin(Wire, CST92XX_SLAVE_ADDRESS, PIN_I2C_SDA, PIN_I2C_SCL);
gTouch.setMaxCoordinates(DISP_W, DISP_H);
gTouch.setSwapXY(true);
gTouch.setMirrorXY(true, false);
lv_init();
uint32_t screenWidth = gfx->width();
uint32_t screenHeight = gfx->height();
uint32_t bufPixels = screenWidth * 40;
gBuf1 = (lv_color_t *)heap_caps_malloc(bufPixels * sizeof(lv_color_t), MALLOC_CAP_DMA);
gBuf2 = (lv_color_t *)heap_caps_malloc(bufPixels * sizeof(lv_color_t), MALLOC_CAP_DMA);
lv_disp_draw_buf_init(&gDrawBuf, gBuf1, gBuf2, bufPixels);
static lv_disp_drv_t dispDrv;
lv_disp_drv_init(&dispDrv);
dispDrv.hor_res = screenWidth;
dispDrv.ver_res = screenHeight;
dispDrv.flush_cb = lvglFlushCb;
dispDrv.draw_buf = &gDrawBuf;
lv_disp_drv_register(&dispDrv);
static lv_indev_drv_t indevDrv;
lv_indev_drv_init(&indevDrv);
indevDrv.type = LV_INDEV_TYPE_POINTER;
indevDrv.read_cb = lvglTouchRead;
lv_indev_drv_register(&indevDrv);
const esp_timer_create_args_t tickArgs = {
.callback = &lvglTick,
.name = "lvgl_tick"};
esp_timer_handle_t tickTimer = nullptr;
esp_timer_create(&tickArgs, &tickTimer);
esp_timer_start_periodic(tickTimer, LVGL_TICK_MS * 1000);
createUi();
Serial.println("LVGL + subserver touch test ready: " TEST_VERSION);
}
void loop() {
lv_timer_handler();
delay(5);
}
@@ -0,0 +1,218 @@
#include <Arduino.h>
#include <Wire.h>
#include <lvgl.h>
#include <Arduino_GFX_Library.h>
#include "pin_config.h"
#include "TouchDrvCSTXXX.hpp"
#define EXAMPLE_LVGL_TICK_PERIOD_MS 2
static lv_disp_draw_buf_t gDrawBuf;
static lv_color_t *gBuf1 = nullptr;
static lv_color_t *gBuf2 = nullptr;
static lv_obj_t *gRawLabel = nullptr;
static lv_obj_t *gEventLabel = nullptr;
static lv_obj_t *gMarker = nullptr;
static TouchDrvCST92xx gTouch;
static int16_t gTouchX[5];
static int16_t gTouchY[5];
static bool gTouchPressed = false;
static uint32_t gRawTouchCount = 0;
static uint32_t gLvglButtonCount = 0;
Arduino_DataBus *gBus = new Arduino_ESP32QSPI(
LCD_CS, LCD_SCLK, LCD_SDIO0, LCD_SDIO1, LCD_SDIO2, LCD_SDIO3);
Arduino_CO5300 *gfx = new Arduino_CO5300(
gBus, LCD_RESET, 0, LCD_WIDTH, LCD_HEIGHT, 0, 0, 0, 0);
static void lvglFlushCb(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *colorP) {
uint32_t w = area->x2 - area->x1 + 1;
uint32_t h = area->y2 - area->y1 + 1;
#if (LV_COLOR_16_SWAP != 0)
gfx->draw16bitBeRGBBitmap(area->x1, area->y1, (uint16_t *)&colorP->full, w, h);
#else
gfx->draw16bitRGBBitmap(area->x1, area->y1, (uint16_t *)&colorP->full, w, h);
#endif
lv_disp_flush_ready(disp);
}
static void lvglTick(void *arg) {
LV_UNUSED(arg);
lv_tick_inc(EXAMPLE_LVGL_TICK_PERIOD_MS);
}
static void lvglTouchRead(lv_indev_drv_t *indevDrv, lv_indev_data_t *data) {
LV_UNUSED(indevDrv);
uint8_t touched = gTouch.getPoint(gTouchX, gTouchY, gTouch.getSupportTouchPoint());
if (touched > 0) {
data->state = LV_INDEV_STATE_PR;
data->point.x = gTouchX[0];
data->point.y = gTouchY[0];
return;
}
data->state = LV_INDEV_STATE_REL;
}
static void bigButtonEventCb(lv_event_t *event) {
if (lv_event_get_code(event) != LV_EVENT_CLICKED) {
return;
}
gLvglButtonCount++;
String status = "LVGL button clicked: ";
status += String(gLvglButtonCount);
lv_label_set_text(gEventLabel, status.c_str());
Serial.println(status);
}
static void updateRawTouchState() {
uint8_t touched = gTouch.getPoint(gTouchX, gTouchY, gTouch.getSupportTouchPoint());
if (touched > 0) {
gTouchPressed = true;
gRawTouchCount++;
String line1 = "RAW pressed";
String line2 = "x=" + String(gTouchX[0]) + " y=" + String(gTouchY[0]) + " n=" + String(gRawTouchCount);
String text = line1 + "\n" + line2;
lv_label_set_text(gRawLabel, text.c_str());
int markerX = gTouchX[0] - 8;
int markerY = gTouchY[0] - 8;
if (markerX < 0) markerX = 0;
if (markerY < 0) markerY = 0;
lv_obj_set_pos(gMarker, markerX, markerY);
lv_obj_clear_flag(gMarker, LV_OBJ_FLAG_HIDDEN);
return;
}
if (gTouchPressed) {
gTouchPressed = false;
lv_label_set_text(gRawLabel, "RAW released\nTouch ended");
lv_obj_add_flag(gMarker, LV_OBJ_FLAG_HIDDEN);
}
}
static void createUi() {
lv_obj_t *screen = lv_scr_act();
lv_obj_set_style_bg_color(screen, lv_color_hex(0x000000), 0);
lv_obj_set_style_bg_opa(screen, LV_OPA_COVER, 0);
lv_obj_t *title = lv_label_create(screen);
lv_label_set_text(title, "LVGL TOUCH DEBUG");
lv_obj_set_style_text_font(title, &lv_font_montserrat_22, 0);
lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), 0);
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 12);
gRawLabel = lv_label_create(screen);
lv_label_set_text(gRawLabel, "RAW released\nTouch not seen yet");
lv_obj_set_style_text_font(gRawLabel, &lv_font_montserrat_18, 0);
lv_obj_set_style_text_color(gRawLabel, lv_color_hex(0x9EE493), 0);
lv_obj_align(gRawLabel, LV_ALIGN_TOP_LEFT, 16, 56);
gEventLabel = lv_label_create(screen);
lv_label_set_text(gEventLabel, "LVGL button clicked: 0");
lv_obj_set_style_text_font(gEventLabel, &lv_font_montserrat_18, 0);
lv_obj_set_style_text_color(gEventLabel, lv_color_hex(0xFFD166), 0);
lv_obj_align(gEventLabel, LV_ALIGN_TOP_LEFT, 16, 118);
lv_obj_t *btn = lv_btn_create(screen);
lv_obj_set_size(btn, 360, 110);
lv_obj_align(btn, LV_ALIGN_CENTER, 0, 36);
lv_obj_set_style_radius(btn, 18, 0);
lv_obj_set_style_bg_color(btn, lv_color_hex(0x2A9D8F), 0);
lv_obj_set_style_border_width(btn, 3, 0);
lv_obj_set_style_border_color(btn, lv_color_hex(0xBDE0FE), 0);
lv_obj_add_event_cb(btn, bigButtonEventCb, LV_EVENT_CLICKED, nullptr);
lv_obj_t *btnLabel = lv_label_create(btn);
lv_label_set_text(btnLabel, "Tap Here");
lv_obj_set_style_text_font(btnLabel, &lv_font_montserrat_24, 0);
lv_obj_set_style_text_color(btnLabel, lv_color_hex(0xFFFFFF), 0);
lv_obj_center(btnLabel);
lv_obj_t *hint = lv_label_create(screen);
lv_label_set_text(hint, "Need both: RAW coords change and LVGL button click.");
lv_obj_set_width(hint, 430);
lv_label_set_long_mode(hint, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_font(hint, &lv_font_montserrat_16, 0);
lv_obj_set_style_text_color(hint, lv_color_hex(0xD9D9D9), 0);
lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -34);
gMarker = lv_obj_create(screen);
lv_obj_set_size(gMarker, 16, 16);
lv_obj_set_style_radius(gMarker, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(gMarker, lv_color_hex(0xFF4D6D), 0);
lv_obj_set_style_border_width(gMarker, 2, 0);
lv_obj_set_style_border_color(gMarker, lv_color_hex(0xFFFFFF), 0);
lv_obj_add_flag(gMarker, LV_OBJ_FLAG_HIDDEN);
}
void setup() {
Serial.begin(115200);
Wire.begin(IIC_SDA, IIC_SCL);
pinMode(TP_RST, OUTPUT);
pinMode(TP_INT, INPUT);
digitalWrite(TP_RST, LOW);
delay(30);
digitalWrite(TP_RST, HIGH);
delay(50);
delay(1000);
gTouch.setPins(TP_RST, TP_INT);
bool touchOk = gTouch.begin(Wire, 0x5A, IIC_SDA, IIC_SCL);
if (!touchOk) {
Serial.println("Touch init failed");
} else {
Serial.print("Touch model: ");
Serial.println(gTouch.getModelName());
gTouch.sleep();
gTouch.reset();
gTouch.setMaxCoordinates(480, 480);
gTouch.setSwapXY(true);
gTouch.setMirrorXY(true, false);
}
gfx->begin();
gBus->writeC8D8(0x36, 0xA0);
gfx->setBrightness(220);
gfx->fillScreen(0x0000);
lv_init();
uint32_t screenWidth = gfx->width();
uint32_t screenHeight = gfx->height();
gBuf1 = (lv_color_t *)heap_caps_malloc(screenWidth * screenHeight / 4 * sizeof(lv_color_t), MALLOC_CAP_DMA);
gBuf2 = (lv_color_t *)heap_caps_malloc(screenWidth * screenHeight / 4 * sizeof(lv_color_t), MALLOC_CAP_DMA);
lv_disp_draw_buf_init(&gDrawBuf, gBuf1, gBuf2, screenWidth * screenHeight / 4);
static lv_disp_drv_t dispDrv;
lv_disp_drv_init(&dispDrv);
dispDrv.hor_res = screenWidth;
dispDrv.ver_res = screenHeight;
dispDrv.flush_cb = lvglFlushCb;
dispDrv.draw_buf = &gDrawBuf;
lv_disp_drv_register(&dispDrv);
static lv_indev_drv_t indevDrv;
lv_indev_drv_init(&indevDrv);
indevDrv.type = LV_INDEV_TYPE_POINTER;
indevDrv.read_cb = lvglTouchRead;
lv_indev_drv_register(&indevDrv);
const esp_timer_create_args_t lvglTickTimerArgs = {
.callback = &lvglTick,
.name = "lvgl_tick"};
esp_timer_handle_t lvglTickTimer = nullptr;
esp_timer_create(&lvglTickTimerArgs, &lvglTickTimer);
esp_timer_start_periodic(lvglTickTimer, EXAMPLE_LVGL_TICK_PERIOD_MS * 1000);
createUi();
Serial.println("LVGL touch debug test ready");
}
void loop() {
updateRawTouchState();
lv_timer_handler();
delay(5);
}
@@ -0,0 +1,115 @@
#include <Arduino.h>
#include <Wire.h>
#include <Arduino_GFX_Library.h>
#include <U8g2lib.h>
#define PIN_LCD_CS 12
#define PIN_LCD_SCLK 38
#define PIN_LCD_D0 4
#define PIN_LCD_D1 5
#define PIN_LCD_D2 6
#define PIN_LCD_D3 7
#define PIN_LCD_RST 2
#define PIN_I2C_SDA 15
#define PIN_I2C_SCL 14
#define DISP_W 480
#define DISP_H 480
#define C_BG 0x0841u
#define C_PANEL 0x1082u
#define C_CARD 0x18C3u
#define C_BORDER 0x39C7u
#define C_TEXT 0xFFFFu
#define C_MUTE 0xBDF7u
#define C_OK 0x3666u
#define C_WARN 0xECA0u
#define C_BAD 0xD145u
#define C_BUTTON 0x2145u
#define C_BUTTON2 0x3186u
#define FONT_HEAD u8g2_font_10x20_t_cyrillic
#define FONT_BODY u8g2_font_9x15_t_cyrillic
#define FONT_SMALL u8g2_font_6x13_t_cyrillic
Arduino_DataBus *gBus = new Arduino_ESP32QSPI(
PIN_LCD_CS, PIN_LCD_SCLK, PIN_LCD_D0, PIN_LCD_D1, PIN_LCD_D2, PIN_LCD_D3);
Arduino_CO5300 *gfx = new Arduino_CO5300(
gBus, PIN_LCD_RST, 0, DISP_W, DISP_H, 0, 0, 0, 0);
static void drawPanel(int x, int y, int w, int h, uint16_t fill, uint16_t border, int radius = 10) {
gfx->fillRoundRect(x, y, w, h, radius, fill);
gfx->drawRoundRect(x, y, w, h, radius, border);
}
static void drawDefaultText(int x, int y, const char *text, uint16_t color, uint8_t size = 1) {
gfx->setFont();
gfx->setTextSize(size);
gfx->setTextColor(color);
gfx->setCursor(x, y);
gfx->print(text);
}
static void drawU8Text(int x, int y, const String &text, uint16_t color, const uint8_t *font) {
gfx->setTextSize(1);
gfx->setFont(font);
gfx->setTextColor(color);
gfx->setCursor(x, y);
gfx->print(text);
}
static void drawButton(int x, int y, int w, int h, uint16_t fill, const String &label, const uint8_t *font, bool useDefaultFont = false) {
drawPanel(x, y, w, h, fill, C_BORDER, 12);
if (useDefaultFont) {
drawDefaultText(x + 14, y + 30, label.c_str(), C_TEXT, 2);
return;
}
drawU8Text(x + 14, y + 32, label, C_TEXT, font);
}
static void drawScreen() {
gfx->fillScreen(C_BG);
drawPanel(12, 12, 456, 456, C_PANEL, C_BORDER, 16);
drawDefaultText(28, 42, "TEXT TEST 123", C_TEXT, 2);
drawU8Text(28, 72, "Default ASCII above", C_MUTE, (const uint8_t *)FONT_SMALL);
drawPanel(24, 90, 432, 72, C_CARD, C_BORDER, 12);
drawDefaultText(38, 118, "A: Default font ABC123", C_TEXT, 2);
drawDefaultText(38, 146, "Visible? then base path OK", C_WARN, 1);
drawPanel(24, 174, 432, 84, C_CARD, C_BORDER, 12);
drawU8Text(38, 204, "B: U8g2 ASCII abc123", C_TEXT, (const uint8_t *)FONT_BODY);
drawU8Text(38, 230, "C: Русский текст 123", C_OK, (const uint8_t *)FONT_BODY);
drawPanel(24, 270, 432, 84, C_CARD, C_BORDER, 12);
drawU8Text(38, 298, "D: Мелкий шрифт кнопок", C_TEXT, (const uint8_t *)FONT_SMALL);
drawU8Text(38, 320, "Если это видно, FONT_SMALL жив", C_MUTE, (const uint8_t *)FONT_SMALL);
drawButton(24, 368, 128, 72, C_BUTTON, "BTN 1", nullptr, true);
drawButton(176, 368, 128, 72, C_OK, "abc123", (const uint8_t *)FONT_BODY);
drawButton(328, 368, 128, 72, C_BUTTON2, "Русский", (const uint8_t *)FONT_BODY);
}
void setup() {
Serial.begin(115200);
Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL);
gfx->begin();
gBus->writeC8D8(0x36, 0xA0);
gfx->setBrightness(220);
gfx->setUTF8Print(true);
drawScreen();
Serial.println();
Serial.println("=== text_render_test ===");
Serial.println("A: default Arduino_GFX font");
Serial.println("B: U8g2 ASCII");
Serial.println("C: U8g2 Cyrillic");
Serial.println("Buttons: default / ASCII / Cyrillic");
}
void loop() {
delay(1000);
}
@@ -0,0 +1,784 @@
/**
* @file lv_conf.h
* Configuration file for v8.4.0
*/
/*
* Copy this file as `lv_conf.h`
* 1. simply next to the `lvgl` folder
* 2. or any other places and
* - define `LV_CONF_INCLUDE_SIMPLE`
* - add the path as include path
*/
/* clang-format off */
#if 1 /*Set it to "1" to enable content*/
#ifndef LV_CONF_H
#define LV_CONF_H
#include <stdint.h>
/*====================
COLOR SETTINGS
*====================*/
/*Color depth: 1 (1 byte per pixel), 8 (RGB332), 16 (RGB565), 32 (ARGB8888)*/
#define LV_COLOR_DEPTH 16
/*Swap the 2 bytes of RGB565 color. Useful if the display has an 8-bit interface (e.g. SPI)*/
#define LV_COLOR_16_SWAP 0
/*Enable features to draw on transparent background.
*It's required if opa, and transform_* style properties are used.
*Can be also used if the UI is above another layer, e.g. an OSD menu or video player.*/
#define LV_COLOR_SCREEN_TRANSP 0
/* Adjust color mix functions rounding. GPUs might calculate color mix (blending) differently.
* 0: round down, 64: round up from x.75, 128: round up from half, 192: round up from x.25, 254: round up */
#define LV_COLOR_MIX_ROUND_OFS 0
/*Images pixels with this color will not be drawn if they are chroma keyed)*/
#define LV_COLOR_CHROMA_KEY lv_color_hex(0x00ff00) /*pure green*/
/*=========================
MEMORY SETTINGS
*=========================*/
/*1: use custom malloc/free, 0: use the built-in `lv_mem_alloc()` and `lv_mem_free()`*/
#define LV_MEM_CUSTOM 0
#if LV_MEM_CUSTOM == 0
/*Size of the memory available for `lv_mem_alloc()` in bytes (>= 2kB)*/
#define LV_MEM_SIZE (48U * 1024U) /*[bytes]*/
/*Set an address for the memory pool instead of allocating it as a normal array. Can be in external SRAM too.*/
#define LV_MEM_ADR 0 /*0: unused*/
/*Instead of an address give a memory allocator that will be called to get a memory pool for LVGL. E.g. my_malloc*/
#if LV_MEM_ADR == 0
#undef LV_MEM_POOL_INCLUDE
#undef LV_MEM_POOL_ALLOC
#endif
#else /*LV_MEM_CUSTOM*/
#define LV_MEM_CUSTOM_INCLUDE <stdlib.h> /*Header for the dynamic memory function*/
#define LV_MEM_CUSTOM_ALLOC malloc
#define LV_MEM_CUSTOM_FREE free
#define LV_MEM_CUSTOM_REALLOC realloc
#endif /*LV_MEM_CUSTOM*/
/*Number of the intermediate memory buffer used during rendering and other internal processing mechanisms.
*You will see an error log message if there wasn't enough buffers. */
#define LV_MEM_BUF_MAX_NUM 16
/*Use the standard `memcpy` and `memset` instead of LVGL's own functions. (Might or might not be faster).*/
#define LV_MEMCPY_MEMSET_STD 0
/*====================
HAL SETTINGS
*====================*/
/*Default display refresh period. LVG will redraw changed areas with this period time*/
#define LV_DISP_DEF_REFR_PERIOD 10 /*[ms]*/
/*Input device read period in milliseconds*/
#define LV_INDEV_DEF_READ_PERIOD 10 /*[ms]*/
/*Use a custom tick source that tells the elapsed time in milliseconds.
*It removes the need to manually update the tick with `lv_tick_inc()`)*/
#define LV_TICK_CUSTOM 0
#if LV_TICK_CUSTOM
#define LV_TICK_CUSTOM_INCLUDE "Arduino.h" /*Header for the system time function*/
#define LV_TICK_CUSTOM_SYS_TIME_EXPR (millis()) /*Expression evaluating to current system time in ms*/
/*If using lvgl as ESP32 component*/
// #define LV_TICK_CUSTOM_INCLUDE "esp_timer.h"
// #define LV_TICK_CUSTOM_SYS_TIME_EXPR ((esp_timer_get_time() / 1000LL))
#endif /*LV_TICK_CUSTOM*/
/*Default Dot Per Inch. Used to initialize default sizes such as widgets sized, style paddings.
*(Not so important, you can adjust it to modify default sizes and spaces)*/
#define LV_DPI_DEF 130 /*[px/inch]*/
/*=======================
* FEATURE CONFIGURATION
*=======================*/
/*-------------
* Drawing
*-----------*/
/*Enable complex draw engine.
*Required to draw shadow, gradient, rounded corners, circles, arc, skew lines, image transformations or any masks*/
#define LV_DRAW_COMPLEX 1
#if LV_DRAW_COMPLEX != 0
/*Allow buffering some shadow calculation.
*LV_SHADOW_CACHE_SIZE is the max. shadow size to buffer, where shadow size is `shadow_width + radius`
*Caching has LV_SHADOW_CACHE_SIZE^2 RAM cost*/
#define LV_SHADOW_CACHE_SIZE 0
/* Set number of maximally cached circle data.
* The circumference of 1/4 circle are saved for anti-aliasing
* radius * 4 bytes are used per circle (the most often used radiuses are saved)
* 0: to disable caching */
#define LV_CIRCLE_CACHE_SIZE 4
#endif /*LV_DRAW_COMPLEX*/
/**
* "Simple layers" are used when a widget has `style_opa < 255` to buffer the widget into a layer
* and blend it as an image with the given opacity.
* Note that `bg_opa`, `text_opa` etc don't require buffering into layer)
* The widget can be buffered in smaller chunks to avoid using large buffers.
*
* - LV_LAYER_SIMPLE_BUF_SIZE: [bytes] the optimal target buffer size. LVGL will try to allocate it
* - LV_LAYER_SIMPLE_FALLBACK_BUF_SIZE: [bytes] used if `LV_LAYER_SIMPLE_BUF_SIZE` couldn't be allocated.
*
* Both buffer sizes are in bytes.
* "Transformed layers" (where transform_angle/zoom properties are used) use larger buffers
* and can't be drawn in chunks. So these settings affects only widgets with opacity.
*/
#define LV_LAYER_SIMPLE_BUF_SIZE (24 * 1024)
#define LV_LAYER_SIMPLE_FALLBACK_BUF_SIZE (3 * 1024)
/*Default image cache size. Image caching keeps the images opened.
*If only the built-in image formats are used there is no real advantage of caching. (I.e. if no new image decoder is added)
*With complex image decoders (e.g. PNG or JPG) caching can save the continuous open/decode of images.
*However the opened images might consume additional RAM.
*0: to disable caching*/
#define LV_IMG_CACHE_DEF_SIZE 0
/*Number of stops allowed per gradient. Increase this to allow more stops.
*This adds (sizeof(lv_color_t) + 1) bytes per additional stop*/
#define LV_GRADIENT_MAX_STOPS 2
/*Default gradient buffer size.
*When LVGL calculates the gradient "maps" it can save them into a cache to avoid calculating them again.
*LV_GRAD_CACHE_DEF_SIZE sets the size of this cache in bytes.
*If the cache is too small the map will be allocated only while it's required for the drawing.
*0 mean no caching.*/
#define LV_GRAD_CACHE_DEF_SIZE 0
/*Allow dithering the gradients (to achieve visual smooth color gradients on limited color depth display)
*LV_DITHER_GRADIENT implies allocating one or two more lines of the object's rendering surface
*The increase in memory consumption is (32 bits * object width) plus 24 bits * object width if using error diffusion */
#define LV_DITHER_GRADIENT 0
#if LV_DITHER_GRADIENT
/*Add support for error diffusion dithering.
*Error diffusion dithering gets a much better visual result, but implies more CPU consumption and memory when drawing.
*The increase in memory consumption is (24 bits * object's width)*/
#define LV_DITHER_ERROR_DIFFUSION 0
#endif
/*Maximum buffer size to allocate for rotation.
*Only used if software rotation is enabled in the display driver.*/
#define LV_DISP_ROT_MAX_BUF (10*1024)
/*-------------
* GPU
*-----------*/
/*Use Arm's 2D acceleration library Arm-2D */
#define LV_USE_GPU_ARM2D 0
/*Use STM32's DMA2D (aka Chrom Art) GPU*/
#define LV_USE_GPU_STM32_DMA2D 0
#if LV_USE_GPU_STM32_DMA2D
/*Must be defined to include path of CMSIS header of target processor
e.g. "stm32f7xx.h" or "stm32f4xx.h"*/
#define LV_GPU_DMA2D_CMSIS_INCLUDE
#endif
/*Enable RA6M3 G2D GPU*/
#define LV_USE_GPU_RA6M3_G2D 0
#if LV_USE_GPU_RA6M3_G2D
/*include path of target processor
e.g. "hal_data.h"*/
#define LV_GPU_RA6M3_G2D_INCLUDE "hal_data.h"
#endif
/*Use SWM341's DMA2D GPU*/
#define LV_USE_GPU_SWM341_DMA2D 0
#if LV_USE_GPU_SWM341_DMA2D
#define LV_GPU_SWM341_DMA2D_INCLUDE "SWM341.h"
#endif
/*Use NXP's PXP GPU iMX RTxxx platforms*/
#define LV_USE_GPU_NXP_PXP 0
#if LV_USE_GPU_NXP_PXP
/*1: Add default bare metal and FreeRTOS interrupt handling routines for PXP (lv_gpu_nxp_pxp_osa.c)
* and call lv_gpu_nxp_pxp_init() automatically during lv_init(). Note that symbol SDK_OS_FREE_RTOS
* has to be defined in order to use FreeRTOS OSA, otherwise bare-metal implementation is selected.
*0: lv_gpu_nxp_pxp_init() has to be called manually before lv_init()
*/
#define LV_USE_GPU_NXP_PXP_AUTO_INIT 0
#endif
/*Use NXP's VG-Lite GPU iMX RTxxx platforms*/
#define LV_USE_GPU_NXP_VG_LITE 0
/*Use SDL renderer API*/
#define LV_USE_GPU_SDL 0
#if LV_USE_GPU_SDL
#define LV_GPU_SDL_INCLUDE_PATH <SDL2/SDL.h>
/*Texture cache size, 8MB by default*/
#define LV_GPU_SDL_LRU_SIZE (1024 * 1024 * 8)
/*Custom blend mode for mask drawing, disable if you need to link with older SDL2 lib*/
#define LV_GPU_SDL_CUSTOM_BLEND_MODE (SDL_VERSION_ATLEAST(2, 0, 6))
#endif
/*-------------
* Logging
*-----------*/
/*Enable the log module*/
#define LV_USE_LOG 0
#if LV_USE_LOG
/*How important log should be added:
*LV_LOG_LEVEL_TRACE A lot of logs to give detailed information
*LV_LOG_LEVEL_INFO Log important events
*LV_LOG_LEVEL_WARN Log if something unwanted happened but didn't cause a problem
*LV_LOG_LEVEL_ERROR Only critical issue, when the system may fail
*LV_LOG_LEVEL_USER Only logs added by the user
*LV_LOG_LEVEL_NONE Do not log anything*/
#define LV_LOG_LEVEL LV_LOG_LEVEL_WARN
/*1: Print the log with 'printf';
*0: User need to register a callback with `lv_log_register_print_cb()`*/
#define LV_LOG_PRINTF 0
/*Enable/disable LV_LOG_TRACE in modules that produces a huge number of logs*/
#define LV_LOG_TRACE_MEM 1
#define LV_LOG_TRACE_TIMER 1
#define LV_LOG_TRACE_INDEV 1
#define LV_LOG_TRACE_DISP_REFR 1
#define LV_LOG_TRACE_EVENT 1
#define LV_LOG_TRACE_OBJ_CREATE 1
#define LV_LOG_TRACE_LAYOUT 1
#define LV_LOG_TRACE_ANIM 1
#endif /*LV_USE_LOG*/
/*-------------
* Asserts
*-----------*/
/*Enable asserts if an operation is failed or an invalid data is found.
*If LV_USE_LOG is enabled an error message will be printed on failure*/
#define LV_USE_ASSERT_NULL 1 /*Check if the parameter is NULL. (Very fast, recommended)*/
#define LV_USE_ASSERT_MALLOC 1 /*Checks is the memory is successfully allocated or no. (Very fast, recommended)*/
#define LV_USE_ASSERT_STYLE 0 /*Check if the styles are properly initialized. (Very fast, recommended)*/
#define LV_USE_ASSERT_MEM_INTEGRITY 0 /*Check the integrity of `lv_mem` after critical operations. (Slow)*/
#define LV_USE_ASSERT_OBJ 0 /*Check the object's type and existence (e.g. not deleted). (Slow)*/
/*Add a custom handler when assert happens e.g. to restart the MCU*/
#define LV_ASSERT_HANDLER_INCLUDE <stdint.h>
#define LV_ASSERT_HANDLER while(1); /*Halt by default*/
/*-------------
* Others
*-----------*/
/*1: Show CPU usage and FPS count*/
#define LV_USE_PERF_MONITOR 0
#if LV_USE_PERF_MONITOR
#define LV_USE_PERF_MONITOR_POS LV_ALIGN_BOTTOM_RIGHT
#endif
/*1: Show the used memory and the memory fragmentation
* Requires LV_MEM_CUSTOM = 0*/
#define LV_USE_MEM_MONITOR 0
#if LV_USE_MEM_MONITOR
#define LV_USE_MEM_MONITOR_POS LV_ALIGN_BOTTOM_LEFT
#endif
/*1: Draw random colored rectangles over the redrawn areas*/
#define LV_USE_REFR_DEBUG 0
/*Change the built in (v)snprintf functions*/
#define LV_SPRINTF_CUSTOM 0
#if LV_SPRINTF_CUSTOM
#define LV_SPRINTF_INCLUDE <stdio.h>
#define lv_snprintf snprintf
#define lv_vsnprintf vsnprintf
#else /*LV_SPRINTF_CUSTOM*/
#define LV_SPRINTF_USE_FLOAT 0
#endif /*LV_SPRINTF_CUSTOM*/
#define LV_USE_USER_DATA 1
/*Garbage Collector settings
*Used if lvgl is bound to higher level language and the memory is managed by that language*/
#define LV_ENABLE_GC 0
#if LV_ENABLE_GC != 0
#define LV_GC_INCLUDE "gc.h" /*Include Garbage Collector related things*/
#endif /*LV_ENABLE_GC*/
/*=====================
* COMPILER SETTINGS
*====================*/
/*For big endian systems set to 1*/
#define LV_BIG_ENDIAN_SYSTEM 0
/*Define a custom attribute to `lv_tick_inc` function*/
#define LV_ATTRIBUTE_TICK_INC
/*Define a custom attribute to `lv_timer_handler` function*/
#define LV_ATTRIBUTE_TIMER_HANDLER
/*Define a custom attribute to `lv_disp_flush_ready` function*/
#define LV_ATTRIBUTE_FLUSH_READY
/*Required alignment size for buffers*/
#define LV_ATTRIBUTE_MEM_ALIGN_SIZE 1
/*Will be added where memories needs to be aligned (with -Os data might not be aligned to boundary by default).
* E.g. __attribute__((aligned(4)))*/
#define LV_ATTRIBUTE_MEM_ALIGN
/*Attribute to mark large constant arrays for example font's bitmaps*/
#define LV_ATTRIBUTE_LARGE_CONST
/*Compiler prefix for a big array declaration in RAM*/
#define LV_ATTRIBUTE_LARGE_RAM_ARRAY
/*Place performance critical functions into a faster memory (e.g RAM)*/
#define LV_ATTRIBUTE_FAST_MEM
/*Prefix variables that are used in GPU accelerated operations, often these need to be placed in RAM sections that are DMA accessible*/
#define LV_ATTRIBUTE_DMA
/*Export integer constant to binding. This macro is used with constants in the form of LV_<CONST> that
*should also appear on LVGL binding API such as Micropython.*/
#define LV_EXPORT_CONST_INT(int_value) struct _silence_gcc_warning /*The default value just prevents GCC warning*/
/*Extend the default -32k..32k coordinate range to -4M..4M by using int32_t for coordinates instead of int16_t*/
#define LV_USE_LARGE_COORD 0
/*==================
* FONT USAGE
*===================*/
/*Montserrat fonts with ASCII range and some symbols using bpp = 4
*https://fonts.google.com/specimen/Montserrat*/
#define LV_FONT_MONTSERRAT_8 1
#define LV_FONT_MONTSERRAT_10 1
#define LV_FONT_MONTSERRAT_12 1
#define LV_FONT_MONTSERRAT_14 1
#define LV_FONT_MONTSERRAT_16 1
#define LV_FONT_MONTSERRAT_18 1
#define LV_FONT_MONTSERRAT_20 1
#define LV_FONT_MONTSERRAT_22 1
#define LV_FONT_MONTSERRAT_24 1
#define LV_FONT_MONTSERRAT_26 1
#define LV_FONT_MONTSERRAT_28 1
#define LV_FONT_MONTSERRAT_30 1
#define LV_FONT_MONTSERRAT_32 1
#define LV_FONT_MONTSERRAT_34 1
#define LV_FONT_MONTSERRAT_36 1
#define LV_FONT_MONTSERRAT_38 1
#define LV_FONT_MONTSERRAT_40 1
#define LV_FONT_MONTSERRAT_42 1
#define LV_FONT_MONTSERRAT_44 1
#define LV_FONT_MONTSERRAT_46 1
#define LV_FONT_MONTSERRAT_48 1
/*Demonstrate special features*/
#define LV_FONT_MONTSERRAT_12_SUBPX 0
#define LV_FONT_MONTSERRAT_28_COMPRESSED 0 /*bpp = 3*/
#define LV_FONT_DEJAVU_16_PERSIAN_HEBREW 0 /*Hebrew, Arabic, Persian letters and all their forms*/
#define LV_FONT_SIMSUN_16_CJK 0 /*1000 most common CJK radicals*/
/*Pixel perfect monospace fonts*/
#define LV_FONT_UNSCII_8 0
#define LV_FONT_UNSCII_16 0
/*Optionally declare custom fonts here.
*You can use these fonts as default font too and they will be available globally.
*E.g. #define LV_FONT_CUSTOM_DECLARE LV_FONT_DECLARE(my_font_1) LV_FONT_DECLARE(my_font_2)*/
#define LV_FONT_CUSTOM_DECLARE
/*Always set a default font*/
#define LV_FONT_DEFAULT &lv_font_montserrat_14
/*Enable handling large font and/or fonts with a lot of characters.
*The limit depends on the font size, font face and bpp.
*Compiler error will be triggered if a font needs it.*/
#define LV_FONT_FMT_TXT_LARGE 0
/*Enables/disables support for compressed fonts.*/
#define LV_USE_FONT_COMPRESSED 0
/*Enable subpixel rendering*/
#define LV_USE_FONT_SUBPX 0
#if LV_USE_FONT_SUBPX
/*Set the pixel order of the display. Physical order of RGB channels. Doesn't matter with "normal" fonts.*/
#define LV_FONT_SUBPX_BGR 0 /*0: RGB; 1:BGR order*/
#endif
/*Enable drawing placeholders when glyph dsc is not found*/
#define LV_USE_FONT_PLACEHOLDER 1
/*=================
* TEXT SETTINGS
*=================*/
/**
* Select a character encoding for strings.
* Your IDE or editor should have the same character encoding
* - LV_TXT_ENC_UTF8
* - LV_TXT_ENC_ASCII
*/
#define LV_TXT_ENC LV_TXT_ENC_UTF8
/*Can break (wrap) texts on these chars*/
#define LV_TXT_BREAK_CHARS " ,.;:-_"
/*If a word is at least this long, will break wherever "prettiest"
*To disable, set to a value <= 0*/
#define LV_TXT_LINE_BREAK_LONG_LEN 0
/*Minimum number of characters in a long word to put on a line before a break.
*Depends on LV_TXT_LINE_BREAK_LONG_LEN.*/
#define LV_TXT_LINE_BREAK_LONG_PRE_MIN_LEN 3
/*Minimum number of characters in a long word to put on a line after a break.
*Depends on LV_TXT_LINE_BREAK_LONG_LEN.*/
#define LV_TXT_LINE_BREAK_LONG_POST_MIN_LEN 3
/*The control character to use for signalling text recoloring.*/
#define LV_TXT_COLOR_CMD "#"
/*Support bidirectional texts. Allows mixing Left-to-Right and Right-to-Left texts.
*The direction will be processed according to the Unicode Bidirectional Algorithm:
*https://www.w3.org/International/articles/inline-bidi-markup/uba-basics*/
#define LV_USE_BIDI 0
#if LV_USE_BIDI
/*Set the default direction. Supported values:
*`LV_BASE_DIR_LTR` Left-to-Right
*`LV_BASE_DIR_RTL` Right-to-Left
*`LV_BASE_DIR_AUTO` detect texts base direction*/
#define LV_BIDI_BASE_DIR_DEF LV_BASE_DIR_AUTO
#endif
/*Enable Arabic/Persian processing
*In these languages characters should be replaced with an other form based on their position in the text*/
#define LV_USE_ARABIC_PERSIAN_CHARS 0
/*==================
* WIDGET USAGE
*================*/
/*Documentation of the widgets: https://docs.lvgl.io/latest/en/html/widgets/index.html*/
#define LV_USE_ARC 1
#define LV_USE_BAR 1
#define LV_USE_BTN 1
#define LV_USE_BTNMATRIX 1
#define LV_USE_CANVAS 1
#define LV_USE_CHECKBOX 1
#define LV_USE_DROPDOWN 1 /*Requires: lv_label*/
#define LV_USE_IMG 1 /*Requires: lv_label*/
#define LV_USE_LABEL 1
#if LV_USE_LABEL
#define LV_LABEL_TEXT_SELECTION 1 /*Enable selecting text of the label*/
#define LV_LABEL_LONG_TXT_HINT 1 /*Store some extra info in labels to speed up drawing of very long texts*/
#endif
#define LV_USE_LINE 1
#define LV_USE_ROLLER 1 /*Requires: lv_label*/
#if LV_USE_ROLLER
#define LV_ROLLER_INF_PAGES 7 /*Number of extra "pages" when the roller is infinite*/
#endif
#define LV_USE_SLIDER 1 /*Requires: lv_bar*/
#define LV_USE_SWITCH 1
#define LV_USE_TEXTAREA 1 /*Requires: lv_label*/
#if LV_USE_TEXTAREA != 0
#define LV_TEXTAREA_DEF_PWD_SHOW_TIME 1500 /*ms*/
#endif
#define LV_USE_TABLE 1
/*==================
* EXTRA COMPONENTS
*==================*/
/*-----------
* Widgets
*----------*/
#define LV_USE_ANIMIMG 1
#define LV_USE_CALENDAR 1
#if LV_USE_CALENDAR
#define LV_CALENDAR_WEEK_STARTS_MONDAY 0
#if LV_CALENDAR_WEEK_STARTS_MONDAY
#define LV_CALENDAR_DEFAULT_DAY_NAMES {"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"}
#else
#define LV_CALENDAR_DEFAULT_DAY_NAMES {"Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"}
#endif
#define LV_CALENDAR_DEFAULT_MONTH_NAMES {"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"}
#define LV_USE_CALENDAR_HEADER_ARROW 1
#define LV_USE_CALENDAR_HEADER_DROPDOWN 1
#endif /*LV_USE_CALENDAR*/
#define LV_USE_CHART 1
#define LV_USE_COLORWHEEL 1
#define LV_USE_IMGBTN 1
#define LV_USE_KEYBOARD 1
#define LV_USE_LED 1
#define LV_USE_LIST 1
#define LV_USE_MENU 1
#define LV_USE_METER 1
#define LV_USE_MSGBOX 1
#define LV_USE_SPAN 1
#if LV_USE_SPAN
/*A line text can contain maximum num of span descriptor */
#define LV_SPAN_SNIPPET_STACK_SIZE 64
#endif
#define LV_USE_SPINBOX 1
#define LV_USE_SPINNER 1
#define LV_USE_TABVIEW 1
#define LV_USE_TILEVIEW 1
#define LV_USE_WIN 1
/*-----------
* Themes
*----------*/
/*A simple, impressive and very complete theme*/
#define LV_USE_THEME_DEFAULT 1
#if LV_USE_THEME_DEFAULT
/*0: Light mode; 1: Dark mode*/
#define LV_THEME_DEFAULT_DARK 0
/*1: Enable grow on press*/
#define LV_THEME_DEFAULT_GROW 1
/*Default transition time in [ms]*/
#define LV_THEME_DEFAULT_TRANSITION_TIME 80
#endif /*LV_USE_THEME_DEFAULT*/
/*A very simple theme that is a good starting point for a custom theme*/
#define LV_USE_THEME_BASIC 1
/*A theme designed for monochrome displays*/
#define LV_USE_THEME_MONO 1
/*-----------
* Layouts
*----------*/
/*A layout similar to Flexbox in CSS.*/
#define LV_USE_FLEX 1
/*A layout similar to Grid in CSS.*/
#define LV_USE_GRID 1
/*---------------------
* 3rd party libraries
*--------------------*/
/*File system interfaces for common APIs */
/*API for fopen, fread, etc*/
#define LV_USE_FS_STDIO 0
#if LV_USE_FS_STDIO
#define LV_FS_STDIO_LETTER '\0' /*Set an upper cased letter on which the drive will accessible (e.g. 'A')*/
#define LV_FS_STDIO_PATH "" /*Set the working directory. File/directory paths will be appended to it.*/
#define LV_FS_STDIO_CACHE_SIZE 0 /*>0 to cache this number of bytes in lv_fs_read()*/
#endif
/*API for open, read, etc*/
#define LV_USE_FS_POSIX 0
#if LV_USE_FS_POSIX
#define LV_FS_POSIX_LETTER '\0' /*Set an upper cased letter on which the drive will accessible (e.g. 'A')*/
#define LV_FS_POSIX_PATH "" /*Set the working directory. File/directory paths will be appended to it.*/
#define LV_FS_POSIX_CACHE_SIZE 0 /*>0 to cache this number of bytes in lv_fs_read()*/
#endif
/*API for CreateFile, ReadFile, etc*/
#define LV_USE_FS_WIN32 0
#if LV_USE_FS_WIN32
#define LV_FS_WIN32_LETTER '\0' /*Set an upper cased letter on which the drive will accessible (e.g. 'A')*/
#define LV_FS_WIN32_PATH "" /*Set the working directory. File/directory paths will be appended to it.*/
#define LV_FS_WIN32_CACHE_SIZE 0 /*>0 to cache this number of bytes in lv_fs_read()*/
#endif
/*API for FATFS (needs to be added separately). Uses f_open, f_read, etc*/
#define LV_USE_FS_FATFS 0
#if LV_USE_FS_FATFS
#define LV_FS_FATFS_LETTER '\0' /*Set an upper cased letter on which the drive will accessible (e.g. 'A')*/
#define LV_FS_FATFS_CACHE_SIZE 0 /*>0 to cache this number of bytes in lv_fs_read()*/
#endif
/*API for LittleFS (library needs to be added separately). Uses lfs_file_open, lfs_file_read, etc*/
#define LV_USE_FS_LITTLEFS 0
#if LV_USE_FS_LITTLEFS
#define LV_FS_LITTLEFS_LETTER '\0' /*Set an upper cased letter on which the drive will accessible (e.g. 'A')*/
#define LV_FS_LITTLEFS_CACHE_SIZE 0 /*>0 to cache this number of bytes in lv_fs_read()*/
#endif
/*PNG decoder library*/
#define LV_USE_PNG 0
/*BMP decoder library*/
#define LV_USE_BMP 0
/* JPG + split JPG decoder library.
* Split JPG is a custom format optimized for embedded systems. */
#define LV_USE_SJPG 0
/*GIF decoder library*/
#define LV_USE_GIF 0
/*QR code library*/
#define LV_USE_QRCODE 1
/*FreeType library*/
#define LV_USE_FREETYPE 0
#if LV_USE_FREETYPE
/*Memory used by FreeType to cache characters [bytes] (-1: no caching)*/
#define LV_FREETYPE_CACHE_SIZE (16 * 1024)
#if LV_FREETYPE_CACHE_SIZE >= 0
/* 1: bitmap cache use the sbit cache, 0:bitmap cache use the image cache. */
/* sbit cache:it is much more memory efficient for small bitmaps(font size < 256) */
/* if font size >= 256, must be configured as image cache */
#define LV_FREETYPE_SBIT_CACHE 0
/* Maximum number of opened FT_Face/FT_Size objects managed by this cache instance. */
/* (0:use system defaults) */
#define LV_FREETYPE_CACHE_FT_FACES 0
#define LV_FREETYPE_CACHE_FT_SIZES 0
#endif
#endif
/*Tiny TTF library*/
#define LV_USE_TINY_TTF 0
#if LV_USE_TINY_TTF
/*Load TTF data from files*/
#define LV_TINY_TTF_FILE_SUPPORT 0
#endif
/*Rlottie library*/
#define LV_USE_RLOTTIE 0
/*FFmpeg library for image decoding and playing videos
*Supports all major image formats so do not enable other image decoder with it*/
#define LV_USE_FFMPEG 0
#if LV_USE_FFMPEG
/*Dump input information to stderr*/
#define LV_FFMPEG_DUMP_FORMAT 0
#endif
/*-----------
* Others
*----------*/
/*1: Enable API to take snapshot for object*/
#define LV_USE_SNAPSHOT 0
/*1: Enable Monkey test*/
#define LV_USE_MONKEY 0
/*1: Enable grid navigation*/
#define LV_USE_GRIDNAV 0
/*1: Enable lv_obj fragment*/
#define LV_USE_FRAGMENT 0
/*1: Support using images as font in label or span widgets */
#define LV_USE_IMGFONT 0
/*1: Enable a published subscriber based messaging system */
#define LV_USE_MSG 0
/*1: Enable Pinyin input method*/
/*Requires: lv_keyboard*/
#define LV_USE_IME_PINYIN 0
#if LV_USE_IME_PINYIN
/*1: Use default thesaurus*/
/*If you do not use the default thesaurus, be sure to use `lv_ime_pinyin` after setting the thesauruss*/
#define LV_IME_PINYIN_USE_DEFAULT_DICT 1
/*Set the maximum number of candidate panels that can be displayed*/
/*This needs to be adjusted according to the size of the screen*/
#define LV_IME_PINYIN_CAND_TEXT_NUM 6
/*Use 9 key input(k9)*/
#define LV_IME_PINYIN_USE_K9_MODE 1
#if LV_IME_PINYIN_USE_K9_MODE == 1
#define LV_IME_PINYIN_K9_CAND_TEXT_NUM 3
#endif // LV_IME_PINYIN_USE_K9_MODE
#endif
/*==================
* EXAMPLES
*==================*/
/*Enable the examples to be built with the library*/
#define LV_BUILD_EXAMPLES 1
/*===================
* DEMO USAGE
====================*/
/*Show some widget. It might be required to increase `LV_MEM_SIZE` */
#define LV_USE_DEMO_WIDGETS 1
#if LV_USE_DEMO_WIDGETS
#define LV_DEMO_WIDGETS_SLIDESHOW 1
#endif
/*Demonstrate the usage of encoder and keyboard*/
#define LV_USE_DEMO_KEYPAD_AND_ENCODER 0
/*Benchmark your system*/
#define LV_USE_DEMO_BENCHMARK 1
#if LV_USE_DEMO_BENCHMARK
/*Use RGB565A8 images with 16 bit color depth instead of ARGB8565*/
#define LV_DEMO_BENCHMARK_RGB565A8 0
#endif
/*Stress test for LVGL*/
#define LV_USE_DEMO_STRESS 1
/*Music player demo*/
#define LV_USE_DEMO_MUSIC 1
#if LV_USE_DEMO_MUSIC
#define LV_DEMO_MUSIC_SQUARE 0
#define LV_DEMO_MUSIC_LANDSCAPE 0
#define LV_DEMO_MUSIC_ROUND 0
#define LV_DEMO_MUSIC_LARGE 0
#define LV_DEMO_MUSIC_AUTO_PLAY 0
#endif
/*--END OF LV_CONF_H--*/
#endif /*LV_CONF_H*/
#endif /*End of "Content enable"*/
@@ -0,0 +1,11 @@
# Factory Firmware Backup
Здесь хранится полный дамп флеша текущего состояния платы.
Файлы:
- `factory-full-16mb.bin` — полный дамп флеша `0x000000..0xFFFFFF`
- `factory-full-16mb.bin.sha256` — контрольная сумма
Скрипты:
- `backup_factory.sh` — снять резервную копию
- `restore_factory_backup.sh` — восстановить резервную копию на плату
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PORT="${PORT:-/dev/ttyACM0}"
ESPTOOL="${ESPTOOL:-$HOME/.arduino15/packages/esp32/tools/esptool_py/5.1.0/esptool}"
BAUD="${BAUD:-921600}"
OUT_BIN="${ROOT_DIR}/factory-full-16mb.bin"
echo "== Port: ${PORT}"
echo "== Output: ${OUT_BIN}"
echo "== Esptool: ${ESPTOOL}"
echo "== Baud: ${BAUD}"
"${ESPTOOL}" --port "${PORT}" --baud "${BAUD}" read-flash 0 0x1000000 "${OUT_BIN}"
sha256sum "${OUT_BIN}" | tee "${OUT_BIN}.sha256"
echo
echo "== Backup done."
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PORT="${PORT:-/dev/ttyACM0}"
ESPTOOL="${ESPTOOL:-$HOME/.arduino15/packages/esp32/tools/esptool_py/5.1.0/esptool}"
IN_BIN="${IN_BIN:-${ROOT_DIR}/factory-full-16mb.bin}"
if [[ ! -f "${IN_BIN}" ]]; then
echo "Backup file not found: ${IN_BIN}" >&2
exit 1
fi
echo "== Port: ${PORT}"
echo "== Input: ${IN_BIN}"
echo "== Esptool: ${ESPTOOL}"
"${ESPTOOL}" --port "${PORT}" --baud 921600 write-flash 0 "${IN_BIN}"
echo
echo "== Restore done."
@@ -0,0 +1,17 @@
# Reference Notes
Модель: Waveshare `ESP32-S3-Touch-AMOLED-2.16` (SKU 33969/33970)
Полезные страницы:
- Product: https://www.waveshare.com/product/arduino/boards-kits/esp32-s3/esp32-s3-touch-amoled-2.16.htm
- Docs home: https://docs.waveshare.com/ESP32-S3-Touch-AMOLED-2.16
- Arduino setup: https://docs.waveshare.com/ESP32-S3-Touch-AMOLED-2.16/Development-Environment-Setup-Arduino
- Official examples: https://github.com/waveshareteam/ESP32-S3-Touch-AMOLED-2.16
Ключевое по железу (по документации):
- ESP32-S3R8, 16MB flash, 8MB PSRAM
- AMOLED 2.16" 480x480
- Capacitive touch (CST9220)
- IMU QMI8658
- Audio codec ES8311 (playback) и ES7210 (microphones)
- TF slot (SD), сейчас без карты
@@ -0,0 +1,353 @@
# SHiNE ESP32 Homeserver UI Nav Minimal Spec
Легаси-спецификация старого навигационного теста для `Waveshare ESP32-S3-Touch-AMOLED-2.16`.
Актуальный основной скетч теперь находится в `../main-device/shine_homeserver_main/`; этот документ оставлен только как историческая справка по старому тестовому UI.
## Цель
Этот прототип проверяет базовую механику экранов, крупных кнопок, свайпов, первичную настройку Wi-Fi и настройку серверных адресов через общий экран редактирования текста.
На этом этапе отсутствуют:
- логин/пароль учётной записи SHiNE;
- PIN;
- кошелёк;
- QR;
- регистрация;
- PDA update/create транзакции;
- входящие запросы.
## Экраны
Прототип содержит 10 экранов:
- `HOME`
- `SETTINGS_MENU`
- `WIFI_SCREEN`
- `SERVER_SCREEN`
- `ACCOUNT_SCREEN`
- `ACCOUNT_HOMESERVER_SCREEN`
- `ACCOUNT_SECRET_SCREEN`
- `SECRET_SHOW_SCREEN`
- `SECRET_GENERATE_*`
- `TEXT_EDIT_SCREEN`
- `REGISTER_ACCOUNT_PLACEHOLDER`
## HOME
Показывает:
- сверху слева значение homeserver или `homeserver not set`;
- ниже значение логина или `login not set`;
- справа от строки логина индикатор статуса Solana-аккаунта:
- зелёный — все ключи совпадают;
- красный — есть mismatch;
- белый контур — пользователь не найден в Solana PDA;
- рядом с индикатором краткий текст ошибки, если статус не зелёный;
- третьей строкой `secret not set`, если секрет ещё не помечен как установленный;
- сверху справа один ряд индикаторов:
- процент батареи;
- иконка батареи;
- индикатор Wi-Fi уровня сигнала;
- по центру крупный текст `STATUS`;
- одна строка Wi-Fi вида `Wi-Fi (<ssid>) connected/disconnected`;
- кнопка баланса вида `Balance: <value SOL>` или `Balance: failed to load`, по нажатию выполняет повторный запрос;
- строка `SHiNE: <server> connected/account not configured/unavailable`;
- при отсутствии пользователя в Solana PDA слева снизу появляется кнопка `REGISTER ACCOUNT`;
- снизу кнопку `SETTINGS`, уменьшенную примерно до половины ширины экрана и сдвинутую к правому краю.
- внизу на тёмной полосе подпись `SHiNE homeserver (v.0.18)`.
Строка Wi-Fi на `HOME`:
- `Wi-Fi (not configured) not configured`
- `Wi-Fi (<saved_ssid>) disconnected`
- `Wi-Fi (<current_ssid>) connected`
Переходы:
- кнопка `REGISTER ACCOUNT` -> `REGISTER_ACCOUNT_PLACEHOLDER`, только если пользователь не найден;
- кнопка `SETTINGS` -> `SETTINGS_MENU`;
- свайп влево -> `SETTINGS_MENU`.
Примечание:
- поведение `REGISTER ACCOUNT -> REGISTER_ACCOUNT_PLACEHOLDER` относится к старой тестовой версии и не является актуальным для основного скетча.
Фоновая логика:
- пока открыт `HOME`, экран сам обновляется примерно раз в секунду;
- при наличии `login + secret + homeserver` и Wi-Fi устройство читает Solana `user_pda` напрямую через RPC;
- сравниваются `root key`, `blockchain key`, `client key` и `homeserver` session-запись типа `100`;
- для строки `SHiNE:` устройство держит отдельную WebSocket-сессию с сервером SHiNE:
- авторизация через `AuthChallenge/CreateAuthSession` или `SessionChallenge/SessionLogin`;
- session key = публичный `homeserver key`;
- подтверждение создания сессии подписывается `client key`;
- heartbeat выполняется `Ping` раз в минуту.
## SETTINGS_MENU
Показывает вертикальное меню из 3 пунктов:
- `Wi-Fi`
- `Server`
- `Account`
Одновременно видны только 2 пункта.
Начальное состояние:
- `Wi-Fi`
- `Server`
После свайпа вверх:
- `Server`
- `Account`
После свайпа вниз:
- `Wi-Fi`
- `Server`
Обе видимые карточки меню имеют одинаковый цвет.
Переходы:
- нажатие `Wi-Fi` -> `WIFI_SCREEN`;
- нажатие `Server` -> `SERVER_SCREEN`;
- нажатие `Account` -> `ACCOUNT_SCREEN`;
- свайп вправо -> `HOME`.
## WIFI_SCREEN
Экран содержит 2 внутренних режима.
### 1. Overview
Показывает:
- текущий Wi-Fi статус;
- сохранённый SSID и число известных сетей;
- статусное сообщение;
- кнопку `SELECT NETWORK`;
- кнопку `CLEAR SAVED WI-FI`;
- кнопку `BACK`.
### 2. Scan Results
После `SELECT NETWORK` выполняется скан доступных Wi-Fi сетей.
Показывает:
- заголовок `SELECT NETWORK`;
- количество или результат сканирования;
- список найденных SSID как крупные кнопки;
- кнопку `SCAN AGAIN`;
- кнопку `BACK`.
Нажатие на SSID открывает `TEXT_EDIT_SCREEN` для ввода пароля.
Поведение:
- если для выбранного SSID пароль уже был сохранён раньше, он сразу подставляется в поле ввода;
- если пароль меняют для другого SSID, старые сохранённые пароли других сетей не теряются;
- после успешного подключения выбранная сеть становится текущей `wifi_ssid/wifi_pass`;
- одновременно хранится до `8` известных сетей `SSID -> password`;
- `CLEAR SAVED WI-FI` очищает все сохранённые сети и текущую сеть.
Переходы:
- свайп вправо из любого режима `WIFI_SCREEN` -> `SETTINGS_MENU`
- кнопка `BACK` -> `SETTINGS_MENU`
## SERVER_SCREEN
Показывает:
- статусное сообщение;
- текущий `Solana RPC` адрес;
- кнопку `SOLANA RPC`;
- текущий `SHiNE server login` или уже резолвленный адрес;
- кнопку `SHiNE SERVER LOGIN`, если обычный `user PDA` ещё не зарегистрирован.
Значения по умолчанию:
- Solana RPC: `https://api.mainnet-beta.solana.com`
- SHiNE server login: `shineupme`
Нажатие на любую из двух кнопок открывает `TEXT_EDIT_SCREEN`.
Переходы:
- свайп вправо -> `SETTINGS_MENU`
## ACCOUNT_SCREEN
Показывает:
- заголовок `ACCOUNT`;
- статусное сообщение;
- кнопку `Login (<value|not set>)`;
- кнопку `Homeserver (<value|not set>)`;
- кнопку `Secret (<*****|not set>)`.
Переходы:
- свайп вправо -> `SETTINGS_MENU`
- `Login` -> `TEXT_EDIT_SCREEN`
- `Homeserver` -> `ACCOUNT_HOMESERVER_SCREEN`
- `Secret` -> `ACCOUNT_SECRET_SCREEN`
## ACCOUNT_HOMESERVER_SCREEN
Показывает:
- текущий `homeserver`;
- рекомендацию оставить `homeserver1`, если устройство одно;
- кнопку `USE HOMESERVER1`;
- кнопку `EDIT MANUALLY`;
- кнопку `BACK`.
Переходы:
- `USE HOMESERVER1` -> сохраняет `homeserver1` и возвращает в `ACCOUNT_SCREEN`
- `EDIT MANUALLY` -> `TEXT_EDIT_SCREEN`
- свайп вправо -> `ACCOUNT_SCREEN`
## ACCOUNT_SECRET_SCREEN
Показывает:
- кнопку `SHOW SECRET` или серую `SECRET NOT SET`, если секрета ещё нет;
- кнопку `ENTER SECRET MANUALLY (NOT RECOMMENDED)`;
- кнопку `GENERATE SECRET`.
Переходы:
- свайп вправо -> `ACCOUNT_SCREEN`
- `SHOW SECRET` -> экран просмотра секрета
- `ENTER SECRET MANUALLY (NOT RECOMMENDED)` -> `TEXT_EDIT_SCREEN`
- `GENERATE SECRET` -> экран подтверждения генерации
## SECRET_SHOW_SCREEN
Показывает:
- заголовок `SECRET`;
- вертикально прокручиваемый список ключей;
- `Secret (base58)`;
- `Root key (base58)`;
- `Root key priv (base58)`;
- `Blockchain key (base58)`;
- `Blockchain key priv (base58)`;
- `Client key (base58)`;
- `Client key priv (base58)`;
- `Homeserver key (base58)`;
- `Homeserver key priv (base58)`;
- для каждого поля показывается формула derivation;
- значения ключей показываются полными строками увеличенным шрифтом;
- кнопку `BACK`.
## TEXT_EDIT_SCREEN
Общий экран редактирования строковых значений.
Используется для:
- пароля Wi-Fi;
- Solana RPC;
- SHiNE server login.
Показывает:
- заголовок;
- поле ввода, уже заполненное старым значением;
- новые символы всегда добавляются только в конец текста;
- визуальный курсор не показывается;
- пароль Wi-Fi показывается открыто, без маскировки точками;
- кнопки `SAVE`, `CANCEL`, `DEL`, `CLR`;
- большую экранную клавиатуру.
## REGISTER_ACCOUNT_PLACEHOLDER
Временный экран-заглушка регистрации.
Показывает:
- заголовок `REGISTER ACCOUNT`;
- сообщение, что регистрационный flow пока не реализован;
- кнопку `BACK`.
## Клавиатура
Клавиатура единая для всех текстовых вводов.
Особенности:
- занимает нижнюю часть экрана;
- разбита на 2 страницы;
- переключение страниц выполняется свайпом влево/вправо внутри `TEXT_EDIT_SCREEN`;
- страница 1 содержит в основном буквы и базовые URL-символы;
- страница 2 содержит цифры и дополнительные URL/символьные кнопки;
- на символьных страницах должны быть доступны как минимум `!` и отдельная кнопка `SPACE`;
- переключение `ABC/123` и `SHIFT` не должно очищать уже введённый текст;
- переключение страниц клавиатуры свайпом тоже не должно очищать уже введённый текст, включая цифры, символы и уже набранные заглавные буквы;
- есть специальные действия `DEL` и `CLR`.
## Хранение Wi-Fi
Используется `Preferences` (NVS памяти ESP32):
- `wifi_ssid`
- `wifi_pass`
- `wifi_known_good`
- до `8` сохранённых пар `SSID -> password`
При старте устройства, если сохранён SSID, выполняется попытка подключения к текущей сохранённой сети.
Если пользователь уже вводил пароль для сети раньше:
- при повторном выборе этого SSID старый пароль сразу подставляется в editor screen;
- сохранение пароля для другой сети не удаляет уже сохранённые пароли остальных сетей.
Если сеть раньше уже была успешно подключена и помечена как валидная:
- после потери связи устройство автоматически пытается переподключиться;
- первые повторные попытки идут раз в `10` секунд;
- если связи нет долго, интервал увеличивается до `30` секунд.
- пока открыт `HOME`, экран сам обновляется примерно раз в секунду и подтягивает актуальные:
- статус Wi-Fi;
- SSID;
- уровень сигнала;
- процент батареи.
## Хранение серверов
Используется `Preferences` (NVS памяти ESP32):
- `solana_rpc`
- `shine_server_login`
## Хранение аккаунта
Используется `Preferences` (NVS памяти ESP32):
- `login`
- `homeserver`
- `secret_set`
## Детали клавиатуры
- клавиатура занимает примерно `2/3`-`3/4` высоты экрана;
- сверху остаются только заголовок и поле ввода;
- буквы занимают 3 ряда;
- половина букв находится на левой странице, половина на правой;
- на правой странице кнопки тоже стоят в ровных колонках, без сдвига рядов вправо;
- отдельный режим `symbols` тоже разделён на 2 страницы;
- в режиме `symbols` на правой странице есть отдельная кнопка `SPACE` и отдельная кнопка `!`;
- 3 основных ряда клавиш и нижний служебный ряд увеличены по высоте;
- клавиши дополнительно увеличены по высоте по сравнению с предыдущим промежуточным вариантом;
- четвёртый ряд содержит:
- переключение `ABC/123`
- `SHIFT`
- `DEL`
- `SAVE`
- `CANCEL`
- ниже рамки клавиатурного блока остаётся отдельная тёмная полоса с версией `SHiNE homeserver (v.0.18)`.
## Жесты
Поддерживаются направления:
- `SWIPE_LEFT`
- `SWIPE_RIGHT`
- `SWIPE_UP`
- `SWIPE_DOWN`
Маршруты:
- `HOME`: свайп влево -> `SETTINGS_MENU`
- `SETTINGS_MENU`: свайп вправо -> `HOME`
- `SETTINGS_MENU`: свайп вверх/вниз -> прокрутка списка
- `WIFI_SCREEN`: свайп вправо -> `SETTINGS_MENU`
- `SERVER_SCREEN`: свайп вправо -> `SETTINGS_MENU`
- `ACCOUNT_SCREEN`: свайп вправо -> `SETTINGS_MENU`
- `ACCOUNT_HOMESERVER_SCREEN`: свайп вправо -> `ACCOUNT_SCREEN`
- `ACCOUNT_SECRET_SCREEN`: свайп вправо -> `ACCOUNT_SCREEN`
- `TEXT_EDIT_SCREEN`: свайп влево/вправо -> переключение страниц клавиатуры
- переключение страниц клавиатуры срабатывает только если свайп начался в зоне самой клавиатуры, а не по всему экрану редактора
## Особенность обработки жестов
- если палец ушёл из зоны обычного тапа и жест определяется как свайп, случайное попадание на кнопку по пути не должно превращать свайп в нажатие;
- приоритет у свайпа, а не у `click`.
- одно непрерывное касание может вызвать не более одного действия кнопки;
- скольжение пальцем по экранной клавиатуре не должно вызывать цепочку из нескольких нажатий;
- периодический refresh на `HOME` не должен срабатывать во время активного касания.
## Язык
Пока интерфейс отображается в ASCII/English, чтобы использовать подтверждённый рабочий шрифтовой путь на устройстве.
@@ -0,0 +1,681 @@
# SHiNE ESP32 Homeserver UI Spec
## Назначение
Этот документ описывает актуальный UI-прототип homeserver `SHiNE` для платы `Waveshare ESP32-S3-Touch-AMOLED-2.16`.
Актуальный основной Arduino-скетч лежит в `../main-device/shine_homeserver_main/`.
Документ является источником истины для Arduino-скетча:
- если меняется этот документ, должен меняться и скетч;
- если меняется скетч, должен обновляться и этот документ;
- экраны, кнопки, поля, статусы, переходы и тексты не должны расходиться.
## Текущий объём реализации
Текущая реализация является интерактивным прототипом экрана устройства, пригодным для ручной проверки на железе.
Что уже входит в прототип:
- локальный UI на тач-экране;
- хранение настроек и секретов во внутренней памяти `ESP32` через `NVS`;
- экранный UI устройства работает на английском языке, потому что кириллица на этом шрифтовом пути пока не поддерживается стабильно;
- экран пополнения с реальным `solana:` URI и рисованием QR-кода через `LVGL`;
- реальное подключение к `Wi-Fi` по сохранённым `SSID/паролю`;
- реальная проверка доступности `API`, `RPC` и `WS`-адресов;
- реальное чтение баланса кошелька из `Solana RPC`;
- проверка обязательных условий перед регистрацией;
- живая on-chain регистрация серверного `user_pda` в `shine_users` по нажатию кнопки на главном экране;
- живой экран заявок на подключение новых устройств через доверенную homeserver-сессию;
- PIN-блокировка (в текущей временной сборке вход по PIN отключён, устройство открывает `HOME` сразу после старта);
- базовые настройки, статус и главный экран;
- сохранение `PDA` и `tx signature` после успешной регистрации.
- создание и возобновление серверной сессии `SHiNE` через WebSocket с `sessionType = 100` и `clientPlatform = "ESP32"`.
Что пока считается именно прототипом, а не финальной интеграцией:
- пока реализован только сценарий заявок на подключение устройств через доверенную сессию;
- другие типы входящих запросов на подпись и произвольные approval-flow на этом устройстве ещё не подключены.
## Основная идея устройства
Устройство работает как отдельный homeserver:
- хранит секрет на самом устройстве;
- позволяет ввести логин, секрет и имя homeserver;
- показывает адрес кошелька устройства;
- позволяет пополнить баланс перед регистрацией;
- после выполнения условий даёт зарегистрировать устройство как homeserver;
- после авторизации в SHiNE может подтверждать заявки на подключение новых устройств пользователя.
`SD`-карта не нужна для постоянного хранения секрета в этом прототипе.
Основное сохранение идёт во внутреннюю flash-память через `NVS`.
## Данные, которые хранятся на устройстве
Прототип хранит:
- `PIN`;
- `Wi-Fi SSID`;
- `Wi-Fi password`;
- `login`;
- `session/homeserver name`;
- `master secret`;
- `wallet address`;
- `user pda address`;
- `registration signature`;
- `balance`;
- `server login` для первичной привязки;
- `resolved server api url` / `rpc url` / `ws url` после чтения PDA сервера;
- флаги:
`wifiReady`, `serversReady`, `secretReady`, `registered`, `online`.
Для первой регистрации обычного `user PDA` устройство берёт `createdAtMs` из NTP прямо перед отправкой транзакции в Solana. При последующих обновлениях `user PDA` устройство так же берёт актуальный `updatedAtMs` из NTP перед отправкой update-транзакции. Дальше в `user PDA` сохраняется `accessServers`, где по умолчанию лежит `shineupme`.
## Правило серверной сессии SHiNE
При подключении к серверу `SHiNE` устройство должно авторизовываться как homeserver-сеанс:
- `sessionType = 100`
- `clientPlatform = "ESP32"`
- `clientInfo = "ESP32 homeserver:<homeserverName>"`
Это относится и к `CreateAuthSession`, и к `SessionLogin`.
## Правила готовности к регистрации
Кнопка регистрации доступна только если одновременно выполнены условия:
1. настроен и подтверждён `Wi-Fi`;
2. задан и подтверждён `SHiNE server login`;
3. задан логин;
4. сгенерирован или введён секрет;
5. баланс кошелька не меньше `0.20 SOL`;
6. устройство ещё не зарегистрировано.
Если хотя бы одно условие не выполнено, главный экран показывает, чего именно не хватает.
## Экранная модель
В прототипе используются следующие экраны:
1. `LOCK`
2. `HOME`
3. `STATUS`
4. `CONNECTION`
5. `WIFI_EDIT`
6. `SERVERS`
7. `ACCOUNT`
8. `WALLET`
9. `WALLET_QR`
10. `WALLET_SIGN_REQUEST`
11. `REQUESTS`
12. `REQUEST_DETAIL`
13. `SETTINGS`
14. `PIN_EDIT`
15. `TEXT_INPUT`
16. `CONFIRM`
17. `REGISTER_ACCOUNT_CONFIRM`
18. `REGISTER_ACCOUNT_RESULT`
## Общие правила интерфейса
- Верхняя строка всегда показывает краткий статус устройства:
`PIN`, `Wi-Fi`, `сервер`, `регистрация`.
- В правом верхнем углу рядом с батареей:
- сначала процент заряда;
- затем, если устройство реально заряжается, маленькая иконка молнии;
- затем контур батареи;
- затем индикатор `Wi-Fi`.
- Основной язык прототипа: английский.
- Для вывода текста в текущей временной сборке используется стандартный шрифт `Arduino_GFX`.
- Русские строки в UI устройства пока не использовать.
- Кнопки крупные, с тач-ориентированным размером.
- Опасные действия подтверждаются отдельным диалогом.
- После изменения данных конфигурация сразу сохраняется в `NVS`.
## Экран LOCK
Назначение:
- экран блокировки предусмотрен в UI, но в текущей временной сборке после запуска пропускается;
- вход по PIN временно отключён для отладки остальных экранов.
Отображается:
- заголовок `SHiNE Device`;
- статус `Устройство заблокировано`;
- поле ввода PIN в виде маски;
- кнопки цифровой клавиатуры;
- кнопки `Стереть` и `Открыть`.
Поведение:
- если PIN введён верно, открывается `HOME`;
- если PIN неверный, показывается ошибка `Неверный PIN`;
- в текущей временной сборке этот экран не показывается автоматически при старте.
## Экран HOME
Это основной экран устройства.
Показывает:
- крупный статус регистрации;
- имя логина;
- имя homeserver;
- строку `Wi-Fi: <SSID> connected|disconnected`;
- строку `SHiNE: <server address> connected|unavailable`;
- короткий статус баланса.
Особенности верхнего блока:
- зелёный/контурный статусный кружок аккаунта расположен слева от строки логина;
- блок `STATUS` поднят выше относительно предыдущей версии;
- если состояние хорошее, слово `connected` в строках `Wi-Fi` и `SHiNE` показывается зелёным.
В зоне кошелька:
- вместо старых двух кнопок `баланс` и `QR` показывается одна широкая кнопка;
- текст кнопки: `Wallet: <selected wallet name>`;
- доступные имена:
- `ClientKey`
- `RootKey`
- либо сохранённое имя `custom`-кошелька;
- после старта устройства баланс активного выбранного кошелька пытается загрузиться автоматически, если уже есть секрет и `Wi-Fi`;
- нажатие на эту кнопку открывает экран `WALLET`.
Нижние кнопки:
- `Статус`
- `Подключение`
- `Аккаунт`
- `Wallet`
- `Запросы`
- `Настройки`
Дополнительная большая кнопка:
- `REGISTER ACCOUNT`
- либо жёлтая `ADD HOMESERVER`
- либо жёлтая `FIX HOMESERVER PASSWORD`
Если регистрация уже сделана:
- если пользователь создан, но в `PDA` ещё нет сессии текущего homeserver, показывается жёлтая кнопка `ADD HOMESERVER`;
- если в `PDA` есть homeserver с тем же именем, но с другим ключом, показывается жёлтая кнопка `FIX HOMESERVER PASSWORD`;
- если и пользователь, и homeserver-сессия уже корректны, вместо призыва к регистрации показывается статус `Homeserver активен`.
- две нижние кнопки внизу экрана не прилегают вплотную друг к другу, между ними есть небольшой зазор.
## Экран REGISTER_ACCOUNT_CONFIRM
Показывает предварительную проверку перед отправкой транзакции регистрации.
Отображается:
- повторный `login`;
- сообщение о том, что логин свободен или уже занят;
- баланс кошелька с проверкой порога `0.020 SOL`;
- имя homeserver;
- пометка, если используется стандартное значение `homeserver1`;
- отдельное предупреждение, если `Wi-Fi` не подключён.
Кнопки:
- `ЗАРЕГИСТРИРОВАТЬ В СИЯНИИ`
- `BACK`
Поведение:
- если `Wi-Fi` не подключён, на экране прямо показывается уведомление об этом и кнопка регистрации недоступна;
- если `login` уже занят в `shine_users`, регистрация недоступна;
- если баланс меньше `0.020 SOL`, на экране показывается соответствующая ошибка;
- если все проверки пройдены, кнопка регистрации активна и запускает on-chain регистрацию.
## Экран REGISTER_ACCOUNT_RESULT
Показывает результат отправки регистрации.
Отображается:
- текст успеха или ошибки;
- подробное сообщение;
- при успехе краткий `user_pda`;
- при успехе краткий `tx signature`.
Кнопки:
- `BACK HOME`
- `ACCOUNT`
Поведение:
- после успешной регистрации данные `user_pda` и `tx signature` сохраняются в `NVS`;
- при ошибке на экране показывается причина отказа;
- если ошибку вернул `sendTransaction`, экран старается показать не только общий текст, но и детали `RPC`/preflight/simulate-логов.
## Экран HOMESERVER_PDA_CONFIRM
Показывает, что именно не так с homeserver-секцией уже существующей пользовательской `PDA`.
Отображается:
- причина (`homeserver` отсутствует в `PDA` или ключ не совпадает с локальным секретом);
- `login`;
- имя `homeserver`;
- короткое пояснение, что именно будет сделано.
Кнопки:
- `ADD HOMESERVER` или `FIX HOMESERVER PASSWORD`
- `BACK`
Поведение:
- если `Wi-Fi` не подключён, действие недоступно;
- экран не создаёт нового пользователя, а запускает `update_user_pda`;
- при `ADD HOMESERVER` в блок `sessions` добавляется запись `session_type=100`;
- при `FIX HOMESERVER PASSWORD` обновляется публичный ключ уже существующей записи `homeserver`.
## Экран HOMESERVER_PDA_RESULT
Показывает результат обновления `sessions` в пользовательской `PDA`.
Отображается:
- успех или ошибка;
- короткое сообщение;
- при успехе краткий `tx signature`.
Кнопки:
- `BACK HOME`
- `ACCOUNT`
Поведение:
- при ошибке текст ошибки сохраняется в ту же USB/NVS-диагностику, что и регистрация;
- после успешного обновления выполняется повторная проверка `PDA`, и основной экран должен перейти в состояние `ok`.
## Экран STATUS
Показывает сводку:
- логин;
- homeserver;
- есть ли секрет;
- зарегистрировано ли устройство;
- подключён ли Wi-Fi;
- доступны ли серверы;
- хватает ли баланса;
- находится ли устройство онлайн;
- краткий отпечаток `PDA` или `tx`.
Кнопки:
- `Назад`
- `Обновить статус`
`Обновить статус` в прототипе не делает сеть, а просто перерисовывает текущие вычисленные состояния.
## Экран CONNECTION
Показывает:
- `Wi-Fi`: готов / не готов;
- `Серверы`: готовы / не готовы;
- `Онлайн`: да / нет.
Кнопки:
- `Wi-Fi`
- `Серверы`
- `Подключить`
- `Отключить`
- `Назад`
Поведение:
- `Подключить` переводит устройство в `online=true`, если `Wi-Fi` реально подключён и серверы реально проверены;
- `Отключить` переводит устройство в `online=false`.
## Экран WIFI_EDIT
Поля:
- `SSID`
- `Пароль`
Кнопки:
- `Изменить SSID`
- `Изменить пароль`
- `Проверить`
- `Сбросить`
- `Назад`
Поведение:
- `Проверить` делает реальную попытку подключения к `Wi-Fi`;
- `Сбросить` очищает обе строки и ставит `wifiReady=false`.
## Экран SERVERS
Поля:
- `API URL`
- `RPC URL`
- `WS URL`
Кнопки:
- `Изменить API`
- `Изменить RPC`
- `Изменить WS`
- `Проверить`
- `Тестовые`
- `Назад`
Поведение:
- `Тестовые` подставляет дефолтные тестовые значения;
- `Проверить` делает реальные сетевые проверки:
- `API URL` должен отвечать по `HTTP/HTTPS`;
- `RPC URL` должен отвечать на `Solana JSON-RPC`;
- `WS URL` должен принимать `TCP/TLS`-соединение.
## Экран ACCOUNT
Показывает:
- логин;
- имя homeserver;
- статус секрета;
- короткий отпечаток секрета;
- статус регистрации;
- короткий отпечаток `PDA` или `tx`.
Кнопки:
- `Изменить логин`
- `Секрет`
- `Имя homeserver`
- `Сгенерировать`
- `Очистить`
- `Назад`
Поведение:
- `Сгенерировать` создаёт новый `master secret` и пересчитывает из него `device`-кошелёк;
- `Очистить` удаляет секрет, адрес кошелька, `PDA`, `tx`, регистрацию и онлайн-статус;
- логин приводится к нижнему регистру и trim.
- после успешной регистрации на экране сохраняются и отображаются краткие отпечатки `PDA` и `TX`.
## Экран WALLET
Показывает:
- кнопку `Wallet: <selected wallet name>`;
- строку текущего статуса/баланса активного кошелька;
- сокращённый публичный ключ активного кошелька.
Кнопки:
- `Wallet: <selected wallet name>`
- `SHOW BALANCE`
- `SHOW WALLET QR`
Поведение:
- верхняя кнопка открывает экран выбора кошелька `WALLET_SELECT`;
- `Показать баланс кошелька` читает реальный баланс именно активного выбранного кошелька из `Solana RPC`;
- `Показать QR-код кошелька` открывает экран `WALLET_QR`;
- этот экран не меняет логику Solana-регистрации пользователя: on-chain регистрация и проверки `PDA` по-прежнему завязаны на `client key`.
## Экран WALLET_SELECT
Показывает:
- строку `Current: <selected wallet name>`;
- три кнопки выбора:
- `ClientKey`
- `RootKey`
- `Custom` или `Custom: <имя>`;
- у текущего выбора видна галочка.
Поведение:
- `ClientKey` активирует кошелёк, выведенный из suffix `client.key`;
- `RootKey` активирует кошелёк, выведенный из suffix `root.key`;
- `Custom` использует derivation:
`sha256(base64(secret32) + "|wallet." + customName)`;
- если имя `custom`-кошелька ещё не задано, нажатие `Custom` открывает экран текстового ввода;
- если `custom` уже выбран, повторное нажатие на него открывает экран редактирования имени;
- после выбора кошелька устройство возвращается на экран `WALLET`.
## Экран WALLET_QR
Экран показывает:
- крупный реальный `QR` для строки `solana:<wallet_address>`;
- снизу крупный текст самого адреса кошелька.
Поведение:
- отдельная текстовая подсказка возврата не показывается;
- возврат на главный экран выполняется обычным тапом по экрану.
Показывает:
- QR-код для строки вида:
`solana:<wallet>`;
- мелкую подпись с полным адресом кошелька под QR.
Поведение:
- QR должен быть сканируемым, а не декоративным;
- адрес кошелька берётся из текущего активного выбранного кошелька;
- нажатие в любую точку экрана возвращает пользователя на `WALLET`.
## Экран WALLET_SIGN_REQUEST
Экран показывается, когда браузерное wallet-расширение присылает на ESP32 запрос `sign_transaction`.
Показывает:
- заголовок `SIGN REQUEST`;
- вопрос о подписи транзакции текущим активным кошельком;
- сокращённый публичный ключ кошелька;
- комментарий, присланный полем `comment`;
- две кнопки:
- `REJECT`
- `APPROVE`
Поведение:
- если пользователь нажимает `APPROVE`, ESP32 подписывает транзакцию текущим активным кошельком и отправляет ответ в расширение;
- если пользователь нажимает `REJECT`, ESP32 отправляет отказ `rejected_by_user`;
- пока запрос активен, свайпом этот экран не закрывается;
- после ответа расширению устройство возвращается на предыдущий экран.
## Экран REQUESTS
Экран доступен только если homeserver уже авторизован в SHiNE.
Показывает:
- слева сверху кнопку `REFRESH`;
- заголовок `REQUESTS:` немного правее стандартного левого положения;
- справа сверху только большую цифру числа активных заявок;
- ниже прокручиваемый список активных pairing-заявок;
- на экране одновременно видны примерно две плитки.
Каждая плитка показывает:
- код подключения из `10` цифр в виде `5` пар: `XX XX XX XX XX`;
- строку `Session: <platform/name>`;
- строку `Kind: Client session` или `Kind: Wallet session`.
Поведение:
- список берётся из живой операции `ListTrustedDeviceLoginRequests`;
- если заявок нет, экран показывает `No active requests`;
- отдельная строка вида `Active requests: N` на этом экране не показывается;
- вертикальный скролл позволяет просматривать все активные заявки;
- нажатие по плитке открывает `REQUEST_DETAIL`;
- свайп вправо возвращает в `SETTINGS`.
## Экран REQUEST_DETAIL
Показывает детали выбранной pairing-заявки:
- вопрос `Connect session ...?`;
- код подключения `XX XX XX XX XX`;
- строку `Session: <platform/name>`;
- строку `Kind: Client session` или `Kind: Wallet session`;
- пояснение:
- для client session: `Only client key will be transferred. No additional keys will be sent.`
- для wallet session: `No keys will be transferred.`
Кнопки:
- `YES`
- `NO`
Поведение:
- `YES` подтверждает заявку:
- для client session устройство передаёт только `client key`;
- для wallet session устройство выпускает отдельную `wallet-session` без передачи ключей;
- `NO` отклоняет заявку;
- после любого решения устройство возвращается в список `REQUESTS` и обновляет его;
- свайп вправо возвращает в `REQUESTS`.
## Экран SETTINGS
Показывает вертикальное меню крупных пунктов.
Если homeserver ещё не авторизован в SHiNE:
- `1. Wi-Fi`
- `2. Server`
- `3. Account`
Если homeserver уже авторизован:
- `1. Device requests`
- `2. Wi-Fi`
- `3. Server`
- `4. Account`
Поведение:
- одновременно видны только две карточки;
- список листается свайпом вверх/вниз;
- свайп вправо возвращает на `HOME`;
- пункт `Device requests` должен быть первым и появляется только для авторизованного homeserver.
## Экран PIN_EDIT
Используется для ввода нового PIN.
Правила:
- допустимы только цифры;
- длина PIN: 4..8 символов;
- после сохранения новый PIN немедленно пишется в `NVS`.
## Экран TEXT_INPUT
Это общий экран редактирования текстовых полей.
Используется для:
- `SSID`
- `Пароль Wi-Fi`
- `Логин`
- `Имя homeserver`
- `API URL`
- `RPC URL`
- `WS URL`
Состав экрана:
- заголовок поля;
- текущее значение;
- программная клавиатура;
- кнопки `Стереть`, `OK`, `Отмена`.
## Экран CONFIRM
Это модальный экран подтверждения.
Используется для:
- регистрации;
- очистки секрета;
- полного сброса.
Кнопки:
- `Подтвердить`
- `Отмена`
## Сценарий первой настройки
Ожидаемый путь пользователя:
1. открыть устройство сразу после старта; экран PIN временно не требуется;
2. открыть `Подключение -> Wi-Fi`;
3. ввести `SSID` и пароль, нажать `Проверить`;
4. открыть `Подключение -> Серверы`;
5. проверить или задать `SHiNE server login` (по умолчанию `shineupme`);
6. открыть `Аккаунт`;
7. ввести логин;
8. задать имя homeserver;
9. сгенерировать секрет;
10. открыть `Кошелёк`;
11. при необходимости пополнить баланс;
12. вернуться на `HOME`;
13. нажать `REGISTER ACCOUNT`;
14. на экране проверки ещё раз увидеть `login`, статус свободного `PDA`, баланс, `homeserver1`, серверный login и при необходимости сообщение о неподключённом `Wi-Fi`;
15. нажать `ЗАРЕГИСТРИРОВАТЬ В СИЯНИИ`;
16. после завершения увидеть либо экран успеха с `user_pda` и `tx signature`, либо подробную ошибку;
17. после успешной регистрации увидеть статус `Homeserver активен`.
Примечание:
- устройство реально отправляет `create_user_pda` в `shine_users`, а после подтверждения сохраняет `PDA` и `tx signature`;
- при первой регистрации для обычного `user PDA` не заполняется `serverAddress`, а `accessServers` получает `shineupme` или другой выбранный `SHiNE server login`.
## Сценарий входящего запроса
1. открыть `Запросы`;
2. выбрать один из запросов;
3. прочитать детали;
4. нажать `Разрешить` или `Отклонить`;
5. убедиться, что статус запроса изменился.
## Временный режим текста
В текущей диагностической версии:
- строковые литералы экранного UI должны оставаться английскими ASCII-совместимыми;
- возвращение кириллицы допустимо только после отдельной доработки шрифтов и реальной проверки на устройстве;
- рендер выполняется стандартным шрифтом `Arduino_GFX`;
- это обходной режим, пока `U8g2`-шрифты на устройстве не начнут рисоваться стабильно.
## Критерии ручной проверки
Минимально нужно проверить:
1. устройство загружается и сразу открывает `HOME`; экран блокировки временно отключён;
2. весь экранный текст отображается читаемо на английском без замены символов;
3. ввод по экранной клавиатуре работает;
4. после перезагрузки сохранённые поля остаются в памяти;
5. секрет и адрес кошелька сохраняются на устройстве;
6. экран `QR и URI` рисует читаемый QR-код;
7. регистрация блокируется, пока условия не выполнены;
8. после выполнения условий регистрация становится доступной;
9. список запросов и экран подтверждения работают;
10. полный сброс действительно очищает сохранённое состояние.
+7
View File
@@ -0,0 +1,7 @@
# SHiNE
## План запуска DAO
План запуска DAO зафиксирован в [DAO_запуск/README.md](DAO_запуск/README.md).
Это рабочий список задач по `этап1` и `этап2`. Дальше ведём его как основной чек-лист запуска DAO и отмечаем в нём выполненные пункты по мере готовности.
+44
View File
@@ -0,0 +1,44 @@
.gradle
build/
node_modules/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
.kotlin
### IntelliJ IDEA ###
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### Eclipse ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/
### Mac OS ###
.DS_Store
@@ -3,6 +3,8 @@
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+1
View File
@@ -0,0 +1 @@
ESP-wallet
@@ -5,18 +5,12 @@
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleHome" value="" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/shine-server-blockchain" />
<option value="$PROJECT_DIR$/shine-server-config" />
<option value="$PROJECT_DIR$/shine-server-crypto" />
<option value="$PROJECT_DIR$/shine-server-db" />
<option value="$PROJECT_DIR$/shine-server-net-protocol" />
<option value="$PROJECT_DIR$/shine-server-net-server" />
</set>
</option>
<option name="myGradleHome" value="" />
</GradleProjectSettings>
</option>
</component>
@@ -1,10 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="FrameworkDetectionExcludesConfiguration">
<file type="web" url="file://$PROJECT_DIR$" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17 (2)" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="" vcs="Git" />
</component>
</project>
+45
View File
@@ -0,0 +1,45 @@
# SHiNE Browser Plugin Wallet
Chrome-compatible Manifest V3 plugin for SHiNE wallet-session login.
## Что уже умеет
- создать `wallet-session` через `StartTrustedDeviceLogin`;
- показать код подключения;
- дождаться подтверждения на доверенном устройстве;
- принять `session-only` payload без передачи `clientKey/rootKey/blockchainKey`;
- сохранить `sessionPriv/sessionKey/sessionId` в локальном хранилище plugin;
- восстанавливать session через `SessionChallenge -> SessionLogin`;
- держать wallet-state в `background service worker`, а side panel использовать как UI.
- принимать не адрес сервера, а логин серверного аккаунта SHiNE и находить точный `https://...` / `wss://...` адрес через его PDA.
- внедрять legacy `window.solana` / `window.phantom.solana` provider для сайтов.
- регистрировать кошелёк как `Wallet Standard` wallet для dapp, которые ищут стандартные кошельки.
## Как загрузить локально
1. Открой `chrome://extensions/`
2. Включи `Developer mode`
3. Нажми `Load unpacked`
4. Выбери папку `SHiNE-browser-plugin-wallet/`
5. Нажми на иконку расширения в toolbar: откроется side panel SHiNE Wallet
## Ограничения текущего этапа
- plugin пока не держит постоянный фоновый WS-канал без активного действия пользователя, но хранит wallet-state в `background`;
- на этом этапе реализован только `session-only login`;
- запросы на подпись будут следующим этапом.
- pairing-пароль, если он используется, должен генерироваться в формате `sha256$<hex>` от строки `shine-pairing|loginLower|password`.
- сторона side panel в Chromium выбирается самим браузером/пользователем; extension не закрепляет панель принудительно слева.
- для совместимости с некоторыми dapp расширение одновременно держит и legacy provider, и Wallet Standard регистрацию.
## Сборка crypto bundle
Для обычной загрузки plugin это не нужно: bundled crypto-файл уже лежит в репозитории.
Если понадобится пересобрать локальный crypto bundle:
```bash
npm install
npx esbuild js/lib/vendor/noble-ed25519-entry.js --bundle --format=esm --platform=browser --outfile=js/lib/vendor/noble-ed25519-bundle.js
npx esbuild js/lib/vendor/solana-publickey-entry.js --bundle --format=esm --platform=browser --outfile=js/lib/vendor/solana-publickey-bundle.js
```
+958
View File
@@ -0,0 +1,958 @@
import { createRequesterPairingMaterial, decryptPairingPayloadFromEnvelope, deriveEspPairingPasswordHash } from './js/lib/device-pairing.js';
import { loadPluginSettings, loadSessionMaterial, savePluginSettings, saveSessionMaterial, clearSessionMaterial } from './js/lib/session-store.js';
import { ShineApiClient } from './js/lib/shine-api.js';
import {
DEFAULT_SHINE_SERVER_LOGIN,
buildHttpBase,
readWalletProfileByLogin,
resolveShineServerByUserLogin,
} from './js/lib/shine-server-resolver.js';
const state = {
api: null,
settings: {
serverLogin: DEFAULT_SHINE_SERVER_LOGIN,
serverHttp: buildHttpBase('shineup.me'),
serverUrl: 'wss://shineup.me/ws',
login: '',
},
requesterMaterial: null,
pairingId: '',
expiresAtMs: 0,
shortCode: '',
trustedSessionOnline: false,
pollTimer: 0,
activeSession: null,
connectionOnline: false,
walletProfile: null,
signing: {
selectedDeviceName: '',
devicesResolvedAtMs: 0,
},
currentWallet: null,
pendingApprovals: [],
siteApprovalChain: Promise.resolve(),
sessionAttachInProgress: false,
statusText: '',
statusKind: 'info',
};
const WALLET_RPC_REQUEST_TYPE = 9100;
const WALLET_RPC_RESPONSE_TYPE = 9101;
async function configureSidePanelBehavior() {
if (!chrome.sidePanel?.setPanelBehavior) {
return;
}
try {
await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
} catch (error) {
console.warn('Failed to configure SHiNE side panel behavior:', error);
}
}
function normalizeOrigin(value = '') {
const raw = String(value || '').trim();
if (!raw) return '';
try {
return new URL(raw).origin;
} catch {
return raw;
}
}
function makeCodeError(message, code) {
const error = new Error(String(message || 'Wallet error'));
error.code = String(code || '').trim().toUpperCase();
return error;
}
function setStatus(message = '', kind = 'info') {
state.statusText = String(message || '');
state.statusKind = kind === 'error' ? 'error' : 'info';
}
function makePendingApprovalSnapshot(payload = {}) {
const pendingId = String(payload?.id || '').trim();
const pendingApprovals = Array.isArray(state.pendingApprovals) ? state.pendingApprovals : [];
const queueIndex = pendingApprovals.findIndex((item) => String(item?.id || '').trim() === pendingId);
const queueLength = pendingApprovals.length;
return {
id: pendingId,
kind: String(payload?.kind || 'sign_transaction').trim() || 'sign_transaction',
origin: String(payload?.origin || '').trim(),
publicKeyBase58: String(payload?.publicKeyBase58 || '').trim(),
comment: String(payload?.comment || '').trim(),
createdAtMs: Number(payload?.createdAtMs || Date.now()),
status: String(payload?.status || 'queued').trim() || 'queued',
queuePosition: queueIndex >= 0 ? queueIndex + 1 : 1,
queueLength: queueLength || 1,
transactionSummary: payload?.transactionSummary && typeof payload.transactionSummary === 'object'
? { ...payload.transactionSummary }
: null,
};
}
function getCurrentPendingApproval() {
return Array.isArray(state.pendingApprovals) && state.pendingApprovals.length
? state.pendingApprovals[0]
: null;
}
function removePendingApproval(pendingId, { rejectError = null } = {}) {
const pendingApprovals = Array.isArray(state.pendingApprovals) ? state.pendingApprovals : [];
const index = pendingApprovals.findIndex((item) => String(item?.id || '').trim() === String(pendingId || '').trim());
if (index < 0) return;
const [pending] = pendingApprovals.splice(index, 1);
if (pending.timeoutId) {
clearTimeout(pending.timeoutId);
}
if (rejectError && pending.abortController) {
try {
pending.abortController.abort(rejectError);
} catch {}
}
}
async function openSidePanelForSender(sender) {
if (!chrome.sidePanel?.open || !sender?.tab?.id) return;
try {
await chrome.sidePanel.open({ tabId: sender.tab.id });
} catch {}
}
function stopPoll() {
if (state.pollTimer) {
clearTimeout(state.pollTimer);
state.pollTimer = 0;
}
}
function clearPairingState() {
stopPoll();
state.requesterMaterial = null;
state.pairingId = '';
state.expiresAtMs = 0;
state.shortCode = '';
state.trustedSessionOnline = false;
}
function ensureApi(serverUrl = state.settings.serverUrl) {
const normalized = String(serverUrl || '').trim() || 'wss://shineup.me/ws';
if (!state.api || state.api.serverUrl !== normalized) {
state.api?.close();
state.api = new ShineApiClient(normalized);
}
return state.api;
}
async function loadStateFromStorage() {
const settings = await loadPluginSettings();
state.settings = {
serverLogin: String(settings?.serverLogin || state.settings.serverLogin || DEFAULT_SHINE_SERVER_LOGIN).trim(),
serverHttp: String(settings?.serverHttp || state.settings.serverHttp || buildHttpBase('shineup.me')).trim() || buildHttpBase('shineup.me'),
serverUrl: String(settings?.serverUrl || state.settings.serverUrl || 'wss://shineup.me/ws').trim() || 'wss://shineup.me/ws',
login: String(settings?.login || '').trim(),
connectedOrigins: Array.isArray(settings?.connectedOrigins) ? settings.connectedOrigins.map((item) => normalizeOrigin(item)).filter(Boolean) : [],
};
const storedSession = await loadSessionMaterial();
if (storedSession || !state.sessionAttachInProgress) {
state.activeSession = storedSession;
}
state.walletProfile = state.activeSession?.walletProfile || null;
state.signing = {
selectedDeviceName: String(state.activeSession?.selectedDeviceName || ''),
devicesResolvedAtMs: Number(state.activeSession?.devicesResolvedAtMs || 0),
};
state.currentWallet = state.activeSession?.currentWallet || null;
}
async function persistSettings(nextSettings = {}) {
state.settings = {
...state.settings,
...nextSettings,
};
if (!Array.isArray(state.settings.connectedOrigins)) {
state.settings.connectedOrigins = [];
}
await savePluginSettings(state.settings);
return state.settings;
}
function isOriginApproved(origin) {
const normalized = normalizeOrigin(origin);
return !!normalized && Array.isArray(state.settings.connectedOrigins) && state.settings.connectedOrigins.includes(normalized);
}
async function setOriginApproved(origin, approved) {
const normalized = normalizeOrigin(origin);
const current = new Set(Array.isArray(state.settings.connectedOrigins) ? state.settings.connectedOrigins : []);
if (approved) {
if (normalized) current.add(normalized);
} else if (normalized) {
current.delete(normalized);
}
await persistSettings({ connectedOrigins: [...current] });
}
async function resolveServerForLogin(login) {
const cleanLogin = String(login || state.settings.login || '').trim();
if (!cleanLogin) {
state.settings = {
...state.settings,
login: '',
serverLogin: '',
};
await savePluginSettings(state.settings);
return { ok: true, resolved: false };
}
const resolved = await resolveShineServerByUserLogin(cleanLogin);
state.settings = {
...state.settings,
login: cleanLogin,
serverLogin: resolved.serverLogin,
serverHttp: resolved.serverHttp,
serverUrl: resolved.serverUrl,
};
await savePluginSettings(state.settings);
return { ok: true, resolved: true, ...resolved };
}
async function saveActiveSessionRecord() {
if (!state.activeSession) return;
const nextRecord = {
...state.activeSession,
walletProfile: state.walletProfile,
selectedDeviceName: state.signing.selectedDeviceName,
devicesResolvedAtMs: state.signing.devicesResolvedAtMs,
currentWallet: state.currentWallet,
};
state.activeSession = nextRecord;
await saveSessionMaterial(nextRecord);
}
function shortKey(value = '', size = 10) {
const raw = String(value || '').trim();
return raw ? raw.slice(0, size) : '';
}
function extractErrorCode(message = '') {
const match = String(message || '').match(/\(([A-Z0-9_]+)\)\s*$/i);
return match ? String(match[1]).toUpperCase() : '';
}
function toWalletErrorMessage(error, fallback = 'Не удалось выполнить операцию кошелька.') {
const raw = String(error?.message || '').trim();
const code = String(error?.code || extractErrorCode(raw) || '').toUpperCase();
if (code === 'PAIRING_NOT_AVAILABLE') {
return 'Для этого логина ещё не включено подключение по коду. На доверенном устройстве откройте «Подключить по коду» и нажмите «Включить подключение по коду» или задайте дополнительный пароль.';
}
if (code === 'PAIRING_NO_TRUSTED_SESSION_ONLINE') {
return 'Сейчас нет ни одной онлайн доверенной сессии этого пользователя. Откройте SHiNE на другом уже подключённом устройстве и держите его в сети.';
}
if (code === 'PAIRING_PASSWORD_INVALID') {
return 'Дополнительный пароль подключения не подходит.';
}
return raw || fallback;
}
function homeserverSessionNameFromClientInfo(value = '') {
const raw = String(value || '').trim();
const match = raw.match(/^ESP32 homeserver:(.+)$/i);
return match ? String(match[1] || '').trim() : '';
}
function mergeHomeserverStatuses(publishedHomeservers = [], serverSessions = []) {
const published = Array.isArray(publishedHomeservers) ? publishedHomeservers : [];
const homeserverSessions = Array.isArray(serverSessions)
? serverSessions.filter((item) => Number(item?.sessionType || 0) === 100)
: [];
const onlineHomeservers = homeserverSessions.filter((item) => !!item?.onlineOnThisServer);
const byName = new Map();
onlineHomeservers.forEach((item) => {
const sessionName = homeserverSessionNameFromClientInfo(item?.clientInfoFromClient);
if (sessionName) {
byName.set(sessionName, item);
}
});
return published.map((item) => {
const matched = byName.get(String(item?.sessionName || '').trim()) || null;
let onlineState = matched ? 'online' : 'offline';
let activeSessionId = matched?.sessionId ? String(matched.sessionId) : '';
if (!matched && published.length === 1 && onlineHomeservers.length === 1) {
onlineState = 'online';
activeSessionId = String(onlineHomeservers[0]?.sessionId || '');
}
return {
...item,
activeSessionId,
onlineState,
onlineLabel: onlineState === 'online' ? 'online' : onlineState === 'offline' ? 'offline' : 'unknown',
};
});
}
async function hydrateWalletProfile(login) {
const cleanLogin = String(login || state.activeSession?.login || state.settings.login || '').trim();
if (!cleanLogin) throw new Error('Нет логина для чтения PDA кошелька.');
const profile = await readWalletProfileByLogin(cleanLogin);
const selectedDeviceName = state.signing.selectedDeviceName
|| String(profile?.homeserverSessions?.[0]?.sessionName || '');
state.walletProfile = {
...profile,
homeserverSessions: Array.isArray(profile.homeserverSessions) ? profile.homeserverSessions.map((item) => ({
...item,
onlineState: 'unknown',
onlineLabel: 'unknown',
activeSessionId: '',
})) : [],
};
state.signing = {
...state.signing,
selectedDeviceName,
};
await saveActiveSessionRecord();
return state.walletProfile;
}
async function resumeActiveSession({ keepConnected = false } = {}) {
const sessionRecord = await loadSessionMaterial();
state.activeSession = sessionRecord;
if (!sessionRecord) {
state.connectionOnline = false;
setStatus('Wallet-session ещё не подключена.', 'info');
return { ok: true, connected: false };
}
try {
await persistSettings({
serverLogin: String(sessionRecord?.serverLogin || state.settings.serverLogin || DEFAULT_SHINE_SERVER_LOGIN).trim(),
serverHttp: String(sessionRecord?.serverHttp || state.settings.serverHttp || buildHttpBase('shineup.me')).trim(),
serverUrl: String(sessionRecord?.serverUrl || state.settings.serverUrl || 'wss://shineup.me/ws').trim(),
login: String(sessionRecord?.login || state.settings.login || '').trim(),
});
const resumed = await ensureApi().resumeSession(sessionRecord);
state.connectionOnline = !!keepConnected;
if (!keepConnected) {
ensureApi().close();
state.api = null;
setStatus(`Wallet-session сохранена для @${resumed.login}. Подключение будет открываться только по действию.`, 'info');
} else {
setStatus(`Wallet-session активна для @${resumed.login}.`, 'info');
}
return { ok: true, connected: true, login: resumed.login, sessionId: resumed.sessionId };
} catch (error) {
state.connectionOnline = false;
setStatus(error.message || 'Не удалось восстановить wallet-session.', 'error');
return { ok: false, connected: false, error: state.statusText };
}
}
async function attachApprovedSession(payload) {
if (String(payload?.type || '') !== 'shine-esp-session-attach') {
throw new Error('Доверенное устройство вернуло неподдерживаемый payload. Для plugin нужен session-only approve.');
}
const login = String(payload?.login || state.settings.login || '').trim();
const approvedSession = payload?.session || {};
const sessionRecord = {
login,
sessionId: String(approvedSession?.sessionId || '').trim(),
sessionKey: state.requesterMaterial?.sessionKey || '',
sessionPrivPkcs8: state.requesterMaterial?.sessionPrivPkcs8 || '',
sessionType: Number(approvedSession?.sessionType || 50) || 50,
serverLogin: state.settings.serverLogin,
serverHttp: state.settings.serverHttp,
serverUrl: state.settings.serverUrl,
};
if (!sessionRecord.login || !sessionRecord.sessionId || !sessionRecord.sessionKey || !sessionRecord.sessionPrivPkcs8) {
throw new Error('Получен неполный session-only payload');
}
state.sessionAttachInProgress = true;
try {
state.activeSession = sessionRecord;
state.walletProfile = null;
state.currentWallet = null;
state.signing = {
...state.signing,
selectedDeviceName: '',
devicesResolvedAtMs: 0,
};
await saveActiveSessionRecord();
await hydrateWalletProfile(login);
await saveActiveSessionRecord();
await persistSettings({
login: sessionRecord.login,
serverLogin: sessionRecord.serverLogin,
serverHttp: sessionRecord.serverHttp,
serverUrl: sessionRecord.serverUrl,
});
state.connectionOnline = false;
state.currentWallet = null;
} finally {
state.sessionAttachInProgress = false;
}
}
async function pollPairingStatus() {
if (!state.pairingId || !state.requesterMaterial) return;
try {
const payload = await ensureApi().getTrustedDeviceLoginStatus(state.pairingId);
const stateValue = String(payload?.state || '');
if (stateValue === 'created') {
state.pollTimer = setTimeout(() => {
void pollPairingStatus();
}, 2200);
return;
}
if (stateValue === 'approved') {
const decoded = await decryptPairingPayloadFromEnvelope(payload?.encryptedPayload, state.requesterMaterial);
await attachApprovedSession(decoded);
clearPairingState();
setStatus('Wallet-session создана и сохранена. Кошелёк остаётся офлайн до запроса подписи.', 'info');
return;
}
if (stateValue === 'rejected') {
clearPairingState();
setStatus('Заявка отклонена на доверенном устройстве.', 'error');
return;
}
if (stateValue === 'expired' || stateValue === 'canceled') {
clearPairingState();
setStatus('Ожидание подключения завершено.', 'error');
return;
}
state.pollTimer = setTimeout(() => {
void pollPairingStatus();
}, 2200);
} catch (error) {
clearPairingState();
setStatus(error.message || 'Не удалось проверить pairing-статус.', 'error');
}
}
async function startPairing({ login, usePassword, password }) {
const cleanLogin = String(login || '').trim();
if (!cleanLogin) {
throw new Error('Введите логин.');
}
await persistSettings({ login: cleanLogin });
await resolveServerForLogin(cleanLogin);
clearPairingState();
setStatus('Проверяем пользователя и создаём wallet-session заявку...', 'info');
const api = ensureApi();
const user = await api.getUser(cleanLogin);
if (user?.exists !== true) {
throw new Error('Пользователь не найден.');
}
state.requesterMaterial = await createRequesterPairingMaterial();
const passwordHash = usePassword
? await deriveEspPairingPasswordHash(cleanLogin, String(password || ''))
: '';
const payload = await api.startTrustedDeviceLogin({
login: cleanLogin,
passwordHash,
requesterSessionKey: state.requesterMaterial.sessionKey,
payloadType: 1,
});
state.pairingId = String(payload?.pairingId || '').trim();
state.expiresAtMs = Number(payload?.expiresAtMs || 0);
state.shortCode = String(payload?.shortCode || '');
state.trustedSessionOnline = !!payload?.trustedSessionOnline;
if (!state.pairingId) {
throw new Error('Сервер не вернул pairingId.');
}
state.pollTimer = setTimeout(() => {
void pollPairingStatus();
}, 1800);
setStatus('Wallet-session заявка создана. Ожидаем подтверждение на доверенном устройстве.', 'info');
return {
pairingId: state.pairingId,
shortCode: String(payload?.shortCode || ''),
expiresAtMs: state.expiresAtMs,
trustedSessionOnline: !!payload?.trustedSessionOnline,
};
}
async function cancelPairing() {
if (!state.pairingId || !state.requesterMaterial?.sessionKey) {
clearPairingState();
return { ok: true };
}
await ensureApi().cancelTrustedDeviceLogin(state.pairingId, state.requesterMaterial.sessionKey);
clearPairingState();
setStatus('Ожидание подключения отменено.', 'info');
return { ok: true };
}
async function disconnectSession() {
ensureApi().close();
state.api = null;
await clearSessionMaterial();
state.activeSession = null;
state.connectionOnline = false;
state.walletProfile = null;
state.signing = {
selectedDeviceName: '',
devicesResolvedAtMs: 0,
};
state.currentWallet = null;
setStatus('Сохранённая wallet-session удалена из plugin.', 'info');
return { ok: true };
}
async function refreshWalletDevices() {
if (!state.activeSession?.login) {
throw new Error('Сначала подключите wallet-session.');
}
await hydrateWalletProfile(state.activeSession.login);
const resumed = await resumeActiveSession({ keepConnected: true });
if (!resumed.ok) {
throw new Error(resumed.error || 'Не удалось открыть wallet-session.');
}
try {
const sessions = await ensureApi().listSessions();
state.walletProfile = {
...state.walletProfile,
homeserverSessions: mergeHomeserverStatuses(state.walletProfile?.homeserverSessions, sessions),
};
state.signing.devicesResolvedAtMs = Date.now();
if (!state.signing.selectedDeviceName && state.walletProfile.homeserverSessions[0]?.sessionName) {
state.signing.selectedDeviceName = state.walletProfile.homeserverSessions[0].sessionName;
}
await saveActiveSessionRecord();
setStatus('Список доверенных homeserver-устройств обновлён.', 'info');
return {
ok: true,
devices: state.walletProfile.homeserverSessions,
};
} finally {
ensureApi().close();
state.api = null;
state.connectionOnline = false;
}
}
async function updateSigningSelection({ selectedDeviceName } = {}) {
state.signing = {
...state.signing,
selectedDeviceName: String(selectedDeviceName || state.signing.selectedDeviceName || ''),
};
await saveActiveSessionRecord();
return { ok: true };
}
async function resolveSelectedHomeserverSession() {
if (!state.activeSession?.login) {
throw new Error('Сначала подключите wallet-session.');
}
if (!state.signing.selectedDeviceName) {
throw new Error('Не выбрано устройство homeserver.');
}
let selectedDevice = (state.walletProfile?.homeserverSessions || []).find((item) => item.sessionName === state.signing.selectedDeviceName);
if (!selectedDevice) {
throw new Error('Выбранное устройство не найдено в PDA аккаунта.');
}
if (!selectedDevice.activeSessionId) {
await refreshWalletDevices();
selectedDevice = (state.walletProfile?.homeserverSessions || []).find((item) => item.sessionName === state.signing.selectedDeviceName);
}
if (!selectedDevice?.activeSessionId) {
throw new Error('Выбранный homeserver сейчас не найден онлайн на сервере SHiNE.');
}
return selectedDevice;
}
async function callWalletRpc(requestData, timeoutMs = 8000, abortSignal = null) {
const selectedDevice = await resolveSelectedHomeserverSession();
const resumed = await resumeActiveSession({ keepConnected: true });
if (!resumed.ok) {
throw new Error(resumed.error || 'Не удалось открыть wallet-session.');
}
const requestId = String(requestData?.requestId || `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`);
const callId = `wallet-rpc-${requestId}`;
const payload = {
...requestData,
requestId,
timeMs: Number(requestData?.timeMs || Date.now()),
};
try {
const response = await new Promise((resolve, reject) => {
let settled = false;
let timeoutId = 0;
let off = () => {};
let removeAbortListener = () => {};
const cleanup = () => {
if (settled) return;
settled = true;
if (timeoutId) clearTimeout(timeoutId);
off();
removeAbortListener();
};
timeoutId = setTimeout(() => {
cleanup();
reject(new Error('Таймаут ответа от ESP32.'));
}, timeoutMs);
off = ensureApi().onEvent('IncomingCallSignal', (evt) => {
const eventPayload = evt?.payload || {};
if (String(eventPayload?.callId || '') !== callId) return;
if (Number(eventPayload?.type || 0) !== WALLET_RPC_RESPONSE_TYPE) return;
cleanup();
try {
resolve(JSON.parse(String(eventPayload?.data || '{}')));
} catch {
reject(new Error('ESP32 вернул некорректный JSON.'));
}
});
if (abortSignal) {
const onAbort = () => {
cleanup();
reject(abortSignal.reason instanceof Error ? abortSignal.reason : new Error('Ожидание подписи отменено.'));
};
if (abortSignal.aborted) {
onAbort();
return;
}
abortSignal.addEventListener('abort', onAbort, { once: true });
removeAbortListener = () => {
abortSignal.removeEventListener('abort', onAbort);
};
}
ensureApi().callSignalToSession({
toLogin: state.activeSession.login,
targetSessionId: selectedDevice.activeSessionId,
callId,
type: WALLET_RPC_REQUEST_TYPE,
data: JSON.stringify(payload),
}).catch((error) => {
cleanup();
reject(error);
});
});
return { response, selectedDevice, requestId };
} finally {
ensureApi().close();
state.api = null;
state.connectionOnline = false;
}
}
function verifyWalletAgainstPda(wallet) {
const type = String(wallet?.type || '').trim();
const pub = String(wallet?.publicKeyBase58 || '').trim();
const rootKey = String(state.walletProfile?.publicKeys?.rootKeyBase58 || '').trim();
const clientKey = String(
state.walletProfile?.publicKeys?.clientKeyBase58 || '',
).trim();
if (type === 'client.key') {
return {
verified: !!clientKey && clientKey === pub,
verificationText: clientKey === pub ? 'Совпадает с clientKey из PDA.' : 'Не совпадает с clientKey из PDA.',
};
}
if (type === 'root.key') {
return {
verified: !!rootKey && rootKey === pub,
verificationText: rootKey === pub ? 'Совпадает с rootKey из PDA.' : 'Не совпадает с rootKey из PDA.',
};
}
return {
verified: null,
verificationText: 'Для custom-кошелька проверка через PDA пока не выполняется.',
};
}
async function requestCurrentWallet() {
const { response, selectedDevice, requestId } = await callWalletRpc({
v: 1,
operation: 'get_wallet_public_key',
requestId: `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
});
if (!response?.ok) {
throw new Error(`ESP32 отклонил запрос: ${String(response?.error || 'unknown_error')}`);
}
state.currentWallet = {
type: String(response?.wallet?.type || '').trim(),
publicKeyBase58: String(response?.wallet?.publicKeyBase58 || '').trim(),
homeserverName: selectedDevice.sessionName,
requestId: String(response?.requestId || requestId),
timeMs: Number(response?.timeMs || 0),
...verifyWalletAgainstPda(response?.wallet || {}),
};
await saveActiveSessionRecord();
setStatus(`Кошелёк получен с ${selectedDevice.sessionName}.`, 'info');
return { ok: true, wallet: state.currentWallet };
}
async function cancelPendingSiteApproval() {
const pending = getCurrentPendingApproval();
if (!pending) {
setStatus('Сейчас нет активного ожидания подписи.', 'info');
return { ok: true };
}
removePendingApproval(pending.id, {
rejectError: makeCodeError('User canceled request in extension.', 'USER_REJECTED'),
});
setStatus('Ожидание подписи отменено в расширении.', 'info');
return { ok: true };
}
async function markPendingSiteApprovalResolved(pendingId) {
removePendingApproval(pendingId);
}
function enqueueSiteApproval(work) {
const run = state.siteApprovalChain.then(work, work);
state.siteApprovalChain = run.catch(() => {});
return run;
}
async function activatePendingApproval(pending, sender = null) {
const abortController = new AbortController();
pending.status = 'active';
pending.abortController = abortController;
pending.timeoutId = setTimeout(() => {
removePendingApproval(pending.id, {
rejectError: makeCodeError('Signing request timed out in extension.', 'USER_REJECTED'),
});
setStatus('Ожидание подписи истекло в расширении.', 'error');
}, 120000);
setStatus(`Сайт ${pending.origin} запросил подпись. Подтвердите или отмените на доверенном устройстве.`, 'info');
await openSidePanelForSender(sender);
return pending;
}
function beginSiteTransactionFlow(payload = {}) {
const pending = makePendingApprovalSnapshot({
...payload,
kind: 'sign_transaction',
id: `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
createdAtMs: Date.now(),
status: 'queued',
});
state.pendingApprovals.push({
...pending,
timeoutId: 0,
abortController: null,
});
return pending;
}
async function siteConnect({ origin, onlyIfTrusted = false } = {}) {
const normalizedOrigin = normalizeOrigin(origin);
if (!normalizedOrigin) {
throw makeCodeError('Site origin is missing.', 'BAD_ORIGIN');
}
if (onlyIfTrusted && !isOriginApproved(normalizedOrigin)) {
throw makeCodeError('Site is not trusted yet.', 'NOT_TRUSTED');
}
const result = await requestCurrentWallet();
const publicKeyBase58 = String(result?.wallet?.publicKeyBase58 || '').trim();
if (!publicKeyBase58) {
throw makeCodeError('Wallet public key is not available.', 'WALLET_UNAVAILABLE');
}
if (!isOriginApproved(normalizedOrigin)) {
await setOriginApproved(normalizedOrigin, true);
}
setStatus(`Site ${normalizedOrigin} connected to ${shortKey(publicKeyBase58, 8)}.`, 'info');
return {
ok: true,
publicKeyBase58,
walletType: String(result?.wallet?.type || '').trim(),
};
}
async function siteDisconnect({ origin } = {}) {
const normalizedOrigin = normalizeOrigin(origin);
setStatus(normalizedOrigin ? `Site ${normalizedOrigin} disconnected.` : 'Site disconnected.', 'info');
return { ok: true };
}
async function siteSignTransaction({ origin, publicKeyBase58, transactionBase64, comment, transactionSummary } = {}, sender = null) {
const normalizedOrigin = normalizeOrigin(origin);
if (!normalizedOrigin) {
throw makeCodeError('Site origin is missing.', 'BAD_ORIGIN');
}
if (!isOriginApproved(normalizedOrigin)) {
throw makeCodeError('Site is not trusted yet.', 'NOT_TRUSTED');
}
const cleanPub = String(publicKeyBase58 || '').trim();
const cleanTx = String(transactionBase64 || '').trim();
if (!cleanPub || !cleanTx) {
throw makeCodeError('Transaction payload is incomplete.', 'BAD_REQUEST');
}
const pending = beginSiteTransactionFlow({
origin: normalizedOrigin,
publicKeyBase58: cleanPub,
comment: String(comment || '').trim(),
transactionSummary: transactionSummary || null,
});
return enqueueSiteApproval(async () => {
await activatePendingApproval(getCurrentPendingApproval() || pending, sender);
const activePending = getCurrentPendingApproval() || pending;
const requestId = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
const signComment = String(comment || '').trim() || `Site ${normalizedOrigin} requested transaction signature`;
try {
const { response } = await callWalletRpc({
v: 1,
operation: 'sign_transaction',
requestId,
publicKeyBase58: cleanPub,
transactionBase64: cleanTx,
comment: signComment,
}, 120000, activePending.abortController?.signal || null);
if (!response?.ok) {
const errorCode = String(response?.error || 'unknown_error').trim().toUpperCase();
if (errorCode === 'REJECTED_BY_USER') {
throw makeCodeError('User rejected transaction signature on ESP32.', 'USER_REJECTED');
}
throw makeCodeError(`ESP32 rejected transaction signature: ${String(response?.error || 'unknown_error')}`, errorCode || 'RPC_REJECTED');
}
setStatus(`Подпись для ${normalizedOrigin} завершена.`, 'info');
return {
ok: true,
publicKeyBase58: String(response?.publicKeyBase58 || cleanPub).trim(),
signedTransactionBase64: String(response?.signedTransactionBase64 || '').trim(),
signatureBase58: String(response?.signatureBase58 || '').trim(),
};
} finally {
await markPendingSiteApprovalResolved(activePending.id);
}
});
}
function snapshot() {
return {
settings: { ...state.settings },
pairing: {
active: !!state.pairingId,
pairingId: state.pairingId,
expiresAtMs: state.expiresAtMs,
shortCode: state.shortCode,
trustedSessionOnline: state.trustedSessionOnline,
},
session: state.activeSession ? { ...state.activeSession } : null,
connectionOnline: !!state.activeSession,
walletProfile: state.walletProfile ? { ...state.walletProfile } : null,
currentWallet: state.currentWallet ? { ...state.currentWallet } : null,
pendingApproval: getCurrentPendingApproval() ? makePendingApprovalSnapshot(getCurrentPendingApproval()) : null,
signing: { ...state.signing },
status: {
text: state.statusText,
kind: state.statusKind,
},
};
}
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
(async () => {
const type = String(message?.type || '');
if (type === 'wallet:getState') {
await loadStateFromStorage();
sendResponse({ ok: true, state: snapshot() });
return;
}
if (type === 'wallet:saveSettings') {
await persistSettings(message?.payload || {});
sendResponse({ ok: true, state: snapshot() });
return;
}
if (type === 'wallet:resolveServerInfo') {
const result = await resolveServerForLogin(String(message?.payload?.login || '').trim());
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:startPairing') {
const result = await startPairing(message?.payload || {});
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:cancelPairing') {
const result = await cancelPairing();
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:resumeSession') {
const result = await resumeActiveSession();
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:refreshWalletDevices') {
const result = await refreshWalletDevices();
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:updateSigningSelection') {
const result = await updateSigningSelection(message?.payload || {});
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:requestCurrentWallet') {
const result = await requestCurrentWallet();
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:disconnectSession') {
const result = await disconnectSession();
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:cancelPendingSiteApproval') {
const result = await cancelPendingSiteApproval();
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:siteConnect') {
const result = await siteConnect(message?.payload || {});
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:siteDisconnect') {
const result = await siteDisconnect(message?.payload || {});
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:siteSignTransaction') {
const result = await siteSignTransaction(message?.payload || {}, _sender);
sendResponse({ ok: true, result, state: snapshot() });
return;
}
sendResponse({ ok: false, error: 'UNKNOWN_MESSAGE' });
})().catch((error) => {
const message = toWalletErrorMessage(error, 'Unknown error');
setStatus(message, 'error');
sendResponse({ ok: false, error: message, code: String(error?.code || ''), state: snapshot() });
});
return true;
});
chrome.runtime.onInstalled.addListener(() => {
void configureSidePanelBehavior();
});
chrome.runtime.onStartup.addListener(() => {
void configureSidePanelBehavior();
});
void configureSidePanelBehavior();
void loadStateFromStorage().then(async () => {
if (state.activeSession?.login) {
await hydrateWalletProfile(state.activeSession.login).catch(() => {});
setStatus(`Wallet-session сохранена для @${state.activeSession.login}. Подключение будет открываться только по действию.`, 'info');
}
}).catch((error) => {
setStatus(error?.message || 'Не удалось инициализировать wallet plugin.', 'error');
});
+20
View File
@@ -0,0 +1,20 @@
plugins {
id 'java'
}
group = 'org.example'
version = '1.0-SNAPSHOT'
repositories {
mavenCentral()
}
dependencies {
testImplementation platform('org.junit:junit-bom:6.0.0')
testImplementation 'org.junit.jupiter:junit-jupiter'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
test {
useJUnitPlatform()
}
@@ -0,0 +1,93 @@
const PAGE_REQUEST = 'shine-wallet-page-request';
const PAGE_RESPONSE = 'shine-wallet-page-response';
const PAGE_MESSAGE_TARGET_ORIGIN = '*';
function injectProviderBridge() {
const root = document.head || document.documentElement;
if (!root) return;
const script = document.createElement('script');
script.type = 'module';
script.src = chrome.runtime.getURL('provider-bridge.js');
script.dataset.shineWalletProvider = '1';
root.appendChild(script);
script.remove();
}
function respondToPage(id, ok, result, error, code) {
window.postMessage({
target: PAGE_RESPONSE,
id: String(id || ''),
ok: !!ok,
result: result || null,
error: error ? String(error) : '',
code: code ? String(code) : '',
}, PAGE_MESSAGE_TARGET_ORIGIN);
}
function sendRuntimeMessage(type, payload = {}) {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type, payload }, (response) => {
if (chrome.runtime.lastError) {
const raw = String(chrome.runtime.lastError.message || 'Runtime message failed');
if (/Extension context invalidated/i.test(raw)) {
const error = new Error('Расширение было перезагружено или отключено. Обновите страницу и откройте кошелёк заново.');
error.code = 'EXTENSION_CONTEXT_INVALIDATED';
reject(error);
return;
}
reject(new Error(raw));
return;
}
if (!response?.ok) {
const error = new Error(String(response?.error || 'Wallet operation failed'));
error.code = String(response?.code || '');
reject(error);
return;
}
resolve(response);
});
});
}
window.addEventListener('message', (event) => {
if (event.source !== window) return;
const data = event.data || {};
if (data?.target !== PAGE_REQUEST) return;
const id = String(data?.id || '');
const method = String(data?.method || '');
const params = data?.params || {};
const origin = window.location.origin;
(async () => {
if (method === 'connect') {
const response = await sendRuntimeMessage('wallet:siteConnect', {
origin,
onlyIfTrusted: !!params?.onlyIfTrusted,
});
respondToPage(id, true, response.result || null);
return;
}
if (method === 'disconnect') {
const response = await sendRuntimeMessage('wallet:siteDisconnect', { origin });
respondToPage(id, true, response.result || null);
return;
}
if (method === 'signTransaction') {
const response = await sendRuntimeMessage('wallet:siteSignTransaction', {
origin,
publicKeyBase58: String(params?.publicKeyBase58 || '').trim(),
transactionBase64: String(params?.transactionBase64 || '').trim(),
comment: String(params?.comment || '').trim(),
transactionSummary: params?.transactionSummary || null,
});
respondToPage(id, true, response.result || null);
return;
}
respondToPage(id, false, null, 'Unsupported provider method', 'UNSUPPORTED_METHOD');
})().catch((error) => {
respondToPage(id, false, null, error?.message || 'Wallet bridge error', error?.code || '');
});
});
injectProviderBridge();
@@ -0,0 +1,6 @@
#Mon Apr 13 16:45:38 MSK 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+234
View File
@@ -0,0 +1,234 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+89
View File
@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
@@ -0,0 +1,78 @@
function getCryptoApi() {
const api = globalThis.crypto;
if (!api?.subtle || typeof api.getRandomValues !== 'function') {
throw new Error('WebCrypto недоступен в текущем браузере.');
}
return api;
}
function getSubtleApi() {
return getCryptoApi().subtle;
}
function base64UrlToBase64(value) {
const normalized = String(value || '').trim().replace(/-/g, '+').replace(/_/g, '/');
return normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
}
export function utf8Bytes(value) {
return new TextEncoder().encode(String(value ?? ''));
}
export function bytesToBase64(bytes) {
let binary = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
const slice = bytes.subarray(i, i + chunk);
binary += String.fromCharCode(...slice);
}
return btoa(binary);
}
export function base64ToBytes(value) {
const binary = atob(base64UrlToBase64(value));
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
return out;
}
export async function generateEd25519Pair() {
return getSubtleApi().generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
}
export async function exportEd25519PublicKeyB64(publicKey) {
const raw = await getSubtleApi().exportKey('raw', publicKey);
return bytesToBase64(new Uint8Array(raw));
}
export async function exportPkcs8B64(privateKey) {
const raw = await getSubtleApi().exportKey('pkcs8', privateKey);
return bytesToBase64(new Uint8Array(raw));
}
export async function importPkcs8Ed25519(pkcs8B64) {
return getSubtleApi().importKey('pkcs8', base64ToBytes(pkcs8B64), { name: 'Ed25519' }, false, ['sign']);
}
export async function signBase64(privateKey, text) {
const signature = await getSubtleApi().sign({ name: 'Ed25519' }, privateKey, utf8Bytes(text));
return bytesToBase64(new Uint8Array(signature));
}
export async function sha256Bytes(bytes) {
const digest = await getSubtleApi().digest('SHA-256', bytes);
return new Uint8Array(digest);
}
export async function sha256Text(text) {
return sha256Bytes(utf8Bytes(text));
}
export function randomBase64(size) {
const bytes = getCryptoApi().getRandomValues(new Uint8Array(size));
return bytesToBase64(bytes);
}
export function bytesToHex(bytes) {
return [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('');
}
@@ -0,0 +1,99 @@
import {
base64ToBytes,
bytesToBase64,
bytesToHex,
exportEd25519PublicKeyB64,
exportPkcs8B64,
generateEd25519Pair,
sha256Bytes,
sha256Text,
utf8Bytes,
} from './crypto-utils.js';
import { edwardsToMontgomeryPriv, x25519 } from './vendor/noble-ed25519-bundle.js';
const PAIRING_ENVELOPE_PREFIX = 'shine-esp-pairing-v1:';
const PAIRING_HASH_PREFIX = 'sha256$';
const PAIRING_HASH_VERSION = 'shine-pairing';
const ED25519_PKCS8_PREFIX = new Uint8Array([
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
]);
function getCryptoApi() {
const api = globalThis.crypto;
if (!api?.subtle || typeof api.getRandomValues !== 'function') {
throw new Error('WebCrypto недоступен.');
}
return api;
}
async function importAesKeyFromSharedSecret(sharedSecretBytes) {
const digest = await sha256Bytes(sharedSecretBytes);
return getCryptoApi().subtle.importKey('raw', digest, { name: 'AES-GCM' }, false, ['decrypt']);
}
function base64UrlToBytes(value) {
const normalized = String(value || '').trim().replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
return base64ToBytes(padded);
}
function extractSeedFromPkcs8(pkcs8B64) {
const raw = base64ToBytes(pkcs8B64);
if (raw.length !== ED25519_PKCS8_PREFIX.length + 32) {
throw new Error('Некорректный приватный Ed25519 ключ');
}
for (let i = 0; i < ED25519_PKCS8_PREFIX.length; i += 1) {
if (raw[i] !== ED25519_PKCS8_PREFIX[i]) {
throw new Error('Неподдерживаемый формат приватного Ed25519 ключа');
}
}
return raw.slice(ED25519_PKCS8_PREFIX.length);
}
export async function createRequesterPairingMaterial() {
const sessionPair = await generateEd25519Pair();
const sessionPublicB64 = await exportEd25519PublicKeyB64(sessionPair.publicKey);
return {
sessionKey: `ed25519/${sessionPublicB64}`,
sessionPrivPkcs8: await exportPkcs8B64(sessionPair.privateKey),
};
}
export function normalizePairingShortCode(value, digits = 10) {
return String(value || '').replace(/\D+/g, '').slice(0, digits).padStart(digits, '0');
}
export function formatPairingShortCode(value) {
const normalized = normalizePairingShortCode(value, 10);
return normalized.match(/.{1,2}/g)?.join(' ') || normalized;
}
export async function deriveEspPairingPasswordHash(login, password) {
const loginLower = String(login || '').trim().toLowerCase();
const preimage = `${PAIRING_HASH_VERSION}|${loginLower}|${String(password ?? '')}`;
const digest = await sha256Text(preimage);
return `${PAIRING_HASH_PREFIX}${bytesToHex(digest)}`;
}
export async function decryptPairingPayloadFromEnvelope(encryptedPayload, requesterPairingMaterial) {
const raw = String(encryptedPayload || '').trim();
if (!raw.startsWith(PAIRING_ENVELOPE_PREFIX)) {
throw new Error('Неподдерживаемый формат pairing payload');
}
const jsonBytes = base64UrlToBytes(raw.slice(PAIRING_ENVELOPE_PREFIX.length));
const envelope = JSON.parse(new TextDecoder().decode(jsonBytes));
if (Number(envelope?.v) !== 1 || String(envelope?.alg || '') !== 'x25519-aes256-gcm') {
throw new Error('Неподдерживаемая версия pairing payload');
}
const requesterSeed = extractSeedFromPkcs8(String(requesterPairingMaterial?.sessionPrivPkcs8 || ''));
const requesterMontPriv = edwardsToMontgomeryPriv(requesterSeed);
const sharedSecret = x25519.getSharedSecret(requesterMontPriv, base64ToBytes(String(envelope?.ephPubB64 || '')));
const aesKey = await importAesKeyFromSharedSecret(sharedSecret);
const plain = await getCryptoApi().subtle.decrypt(
{ name: 'AES-GCM', iv: base64ToBytes(String(envelope?.ivB64 || '')) },
aesKey,
base64ToBytes(String(envelope?.cipherB64 || '')),
);
return JSON.parse(new TextDecoder().decode(plain));
}
@@ -0,0 +1,152 @@
import { base64ToBytes, bytesToBase64 } from './crypto-utils.js';
const DB_NAME = 'shine-wallet-plugin';
const DB_VERSION = 1;
const STORE_META = 'meta';
const STORE_VAULT = 'vault';
const SESSION_ENTRY_ID = 'active-session';
const VAULT_KEY_ID = 'session-wrap-key';
function openDb() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_META)) {
db.createObjectStore(STORE_META, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(STORE_VAULT)) {
db.createObjectStore(STORE_VAULT, { keyPath: 'id' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('IndexedDB недоступен'));
});
}
async function withStore(storeName, mode, run) {
const db = await openDb();
try {
return await new Promise((resolve, reject) => {
const tx = db.transaction(storeName, mode);
const store = tx.objectStore(storeName);
let settled = false;
const done = (fn) => (value) => {
if (settled) return;
settled = true;
fn(value);
};
tx.oncomplete = () => done(resolve)(undefined);
tx.onerror = () => done(reject)(tx.error || new Error('IndexedDB transaction failed'));
Promise.resolve(run(store, tx, done)).catch((error) => done(reject)(error));
});
} finally {
db.close();
}
}
async function put(storeName, value) {
return withStore(storeName, 'readwrite', (store) => {
store.put(value);
});
}
async function get(storeName, key) {
const db = await openDb();
try {
return await new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readonly');
const req = tx.objectStore(storeName).get(key);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => reject(req.error || new Error('Ошибка чтения из IndexedDB'));
});
} finally {
db.close();
}
}
async function deleteById(storeName, key) {
return withStore(storeName, 'readwrite', (store) => {
store.delete(key);
});
}
async function getOrCreateVaultKey() {
const current = await get(STORE_META, VAULT_KEY_ID);
if (current?.key) return current.key;
const key = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
);
await put(STORE_META, { id: VAULT_KEY_ID, key, createdAtMs: Date.now() });
return key;
}
async function encryptJson(value) {
const key = await getOrCreateVaultKey();
const iv = crypto.getRandomValues(new Uint8Array(12));
const plainBytes = new TextEncoder().encode(JSON.stringify(value));
const cipher = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plainBytes);
return {
ivB64: bytesToBase64(iv),
cipherB64: bytesToBase64(new Uint8Array(cipher)),
};
}
async function decryptJson(envelope) {
const key = await getOrCreateVaultKey();
const plain = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: base64ToBytes(envelope.ivB64) },
key,
base64ToBytes(envelope.cipherB64),
);
return JSON.parse(new TextDecoder().decode(plain));
}
function storageApi() {
if (globalThis.chrome?.storage?.local) return globalThis.chrome.storage.local;
return null;
}
export async function savePluginSettings(settings) {
const api = storageApi();
if (api) {
await api.set({ shineWalletSettings: settings });
return;
}
localStorage.setItem('shineWalletSettings', JSON.stringify(settings));
}
export async function loadPluginSettings() {
const api = storageApi();
if (api) {
const row = await api.get('shineWalletSettings');
return row?.shineWalletSettings || {};
}
try {
return JSON.parse(localStorage.getItem('shineWalletSettings') || '{}');
} catch {
return {};
}
}
export async function saveSessionMaterial(sessionRecord) {
const encrypted = await encryptJson(sessionRecord);
await put(STORE_VAULT, {
id: SESSION_ENTRY_ID,
encrypted,
updatedAtMs: Date.now(),
});
}
export async function loadSessionMaterial() {
const row = await get(STORE_VAULT, SESSION_ENTRY_ID);
if (!row?.encrypted) return null;
return decryptJson(row.encrypted);
}
export async function clearSessionMaterial() {
await deleteById(STORE_VAULT, SESSION_ENTRY_ID);
}
@@ -0,0 +1,133 @@
import { importPkcs8Ed25519, signBase64 } from './crypto-utils.js';
import { WsJsonClient } from './ws-client.js';
const SESSION_TYPE_WALLET = 50;
function normalizeServerUrl(url) {
const value = String(url || '').trim();
if (!value) return 'wss://shineup.me/ws';
if (value.startsWith('ws://') || value.startsWith('wss://')) return value;
if (value.startsWith('http://') || value.startsWith('https://')) {
const parsed = new URL(value);
parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
return parsed.toString();
}
return value;
}
function opError(op, response) {
const payload = response?.payload || {};
const message = payload?.message || response?.message || payload?.error || response?.error || 'Unknown server error';
const code = String(payload?.code || response?.code || payload?.error || response?.error || 'UNKNOWN').toUpperCase();
const error = new Error(`${op}: ${message} (${code})`);
error.op = op;
error.code = code;
error.status = response?.status || 0;
return error;
}
export class ShineApiClient {
constructor(serverUrl) {
this.serverUrl = normalizeServerUrl(serverUrl);
this.ws = new WsJsonClient(this.serverUrl);
}
async getUser(login) {
const response = await this.ws.request('GetUser', { login: String(login || '').trim() });
if (response.status !== 200) throw opError('GetUser', response);
return response.payload || {};
}
async startTrustedDeviceLogin({ login, passwordHash, requesterSessionKey, payloadType = 1 }) {
const response = await this.ws.request('StartTrustedDeviceLogin', {
login: String(login || '').trim(),
passwordHash: String(passwordHash || '').trim(),
requesterSessionKey: String(requesterSessionKey || '').trim(),
requesterSessionType: SESSION_TYPE_WALLET,
requesterClientPlatform: 'Chrome Extension Wallet',
payloadType: Number(payloadType) || 1,
});
if (response.status !== 200) throw opError('StartTrustedDeviceLogin', response);
return response.payload || {};
}
async getTrustedDeviceLoginStatus(pairingId) {
const response = await this.ws.request('GetTrustedDeviceLoginStatus', {
pairingId: String(pairingId || '').trim(),
});
if (response.status !== 200) throw opError('GetTrustedDeviceLoginStatus', response);
return response.payload || {};
}
async cancelTrustedDeviceLogin(pairingId, requesterSessionKey) {
const response = await this.ws.request('CancelTrustedDeviceLogin', {
pairingId: String(pairingId || '').trim(),
requesterSessionKey: String(requesterSessionKey || '').trim(),
});
if (response.status !== 200) throw opError('CancelTrustedDeviceLogin', response);
return response.payload || {};
}
async listSessions() {
const response = await this.ws.request('ListSessions', {});
if (response.status !== 200) throw opError('ListSessions', response);
return Array.isArray(response?.payload?.sessions) ? response.payload.sessions : [];
}
async callSignalToSession({ toLogin, targetSessionId, callId, type, data = '' }) {
const response = await this.ws.request('CallSignalToSession', {
toLogin: String(toLogin || '').trim(),
targetSessionId: String(targetSessionId || '').trim(),
callId: String(callId || '').trim(),
type: Number(type) || 0,
data: String(data || ''),
});
if (response.status !== 200) throw opError('CallSignalToSession', response);
return response.payload || {};
}
onEvent(op, handler) {
return this.ws.on(op, handler);
}
async resumeSession(sessionRecord) {
const login = String(sessionRecord?.login || '').trim();
const sessionId = String(sessionRecord?.sessionId || '').trim();
const sessionKey = String(sessionRecord?.sessionKey || '').trim();
const sessionPrivPkcs8 = String(sessionRecord?.sessionPrivPkcs8 || '').trim();
if (!login || !sessionId || !sessionKey || !sessionPrivPkcs8) {
throw new Error('Сохранённая wallet-session неполная');
}
const privateKey = await importPkcs8Ed25519(sessionPrivPkcs8);
const challengeResp = await this.ws.request('SessionChallenge', { sessionId });
if (challengeResp.status !== 200) throw opError('SessionChallenge', challengeResp);
const nonce = challengeResp?.payload?.nonce;
if (!nonce) throw new Error('SessionChallenge: сервер не вернул nonce');
const timeMs = Date.now();
const preimage = `SESSION_LOGIN:${sessionId}:${timeMs}:${nonce}`;
const signatureB64 = await signBase64(privateKey, preimage);
const loginResp = await this.ws.request('SessionLogin', {
sessionId,
sessionKey,
timeMs,
signatureB64,
sessionType: Number(sessionRecord?.sessionType || SESSION_TYPE_WALLET) || SESSION_TYPE_WALLET,
clientPlatform: 'Chrome Extension Wallet',
clientInfo: 'SHiNE Browser Plugin Wallet',
});
if (loginResp.status !== 200) throw opError('SessionLogin', loginResp);
return {
login,
sessionId,
storagePwd: String(loginResp?.payload?.storagePwd || '').trim(),
};
}
close() {
this.ws.close();
}
}
@@ -0,0 +1,247 @@
import { base64ToBytes } from './crypto-utils.js';
import { PublicKey } from './vendor/solana-publickey-bundle.js';
const SOLANA_ENDPOINT_DEFAULT = 'https://solana-rpc.publicnode.com';
const SHINE_USERS_PROGRAM_ID = 'SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6';
const SHINE_USERS_USER_PDA_SEED_PREFIX = 'user_login=';
const DEFAULT_SHINE_SERVER_LOGIN = 'shineupme';
const DEFAULT_SHINE_SERVER_ADDRESS = 'shineup.me';
function normalizeHostLike(value) {
const raw = String(value || '').trim();
if (!raw) return '';
try {
const withScheme = /^[a-z]+:\/\//i.test(raw) ? raw : `https://${raw}`;
const parsed = new URL(withScheme);
return String(parsed.host || '').trim().toLowerCase();
} catch {
return raw.replace(/^https?:\/\//i, '').replace(/^wss?:\/\//i, '').replace(/\/.*$/, '').trim().toLowerCase();
}
}
function normalizeServerLogin(value) {
return String(value || '').trim().toLowerCase();
}
function buildHttpBase(address) {
const host = normalizeHostLike(address) || DEFAULT_SHINE_SERVER_ADDRESS;
return `https://${host}`;
}
function buildWsUrl(address) {
const host = normalizeHostLike(address) || DEFAULT_SHINE_SERVER_ADDRESS;
return `wss://${host}/ws`;
}
function readU8(bytes, cursorRef) {
if (cursorRef.value >= bytes.length) throw new Error('Повреждённый формат PDA');
return bytes[cursorRef.value++];
}
function readBytes(bytes, cursorRef, length) {
if (cursorRef.value + length > bytes.length) throw new Error('Повреждённый формат PDA');
const out = bytes.slice(cursorRef.value, cursorRef.value + length);
cursorRef.value += length;
return out;
}
function readStrU8(bytes, cursorRef) {
const length = readU8(bytes, cursorRef);
return new TextDecoder().decode(readBytes(bytes, cursorRef, length));
}
function parseServerFieldsFromUserPda(dataBytes) {
const bytes = dataBytes instanceof Uint8Array ? dataBytes : new Uint8Array(dataBytes || []);
if (bytes.length < 5) throw new Error('Некорректный формат PDA');
const cursorRef = { value: 0 };
const magic = new TextDecoder().decode(readBytes(bytes, cursorRef, 5));
if (magic !== 'SHiNE') throw new Error('Некорректный формат PDA');
cursorRef.value += 1; // format_major
cursorRef.value += 1; // format_minor
cursorRef.value += 2; // record_len
cursorRef.value += 8; // created_at_ms
cursorRef.value += 8; // updated_at_ms
cursorRef.value += 4; // record_number
cursorRef.value += 32; // prev_record_hash
readStrU8(bytes, cursorRef); // login
const blocksCount = readU8(bytes, cursorRef);
let isServer = false;
let serverAddress = '';
let accessServers = [];
let recoveryKey32 = null;
let rootKey32 = null;
let clientKey32 = null;
let blockchainKey32 = null;
let blockchainName = '';
let homeserverSessions = [];
for (let i = 0; i < blocksCount; i += 1) {
const blockType = readU8(bytes, cursorRef);
cursorRef.value += 1; // block_version
if (blockType === 0 || blockType === 1 || blockType === 2) {
const key32 = readBytes(bytes, cursorRef, 32);
if (blockType === 0) recoveryKey32 = key32;
if (blockType === 1) rootKey32 = key32;
if (blockType === 2) clientKey32 = key32;
continue;
}
if (blockType === 3) {
const count = readU8(bytes, cursorRef);
for (let j = 0; j < count; j += 1) {
cursorRef.value += 1;
const currentBlockchainName = readStrU8(bytes, cursorRef);
const currentBlockchainKey32 = readBytes(bytes, cursorRef, 32);
if (!blockchainKey32) {
blockchainKey32 = currentBlockchainKey32;
blockchainName = currentBlockchainName;
}
cursorRef.value += 8 + 8 + 4 + 32 + 64;
const arPresent = readU8(bytes, cursorRef);
if (arPresent === 1) readStrU8(bytes, cursorRef);
}
continue;
}
if (blockType === 30) {
isServer = readU8(bytes, cursorRef) === 1;
if (isServer) {
cursorRef.value += 1; // address_format_type
cursorRef.value += 1; // address_format_version
serverAddress = readStrU8(bytes, cursorRef);
const syncCount = readU8(bytes, cursorRef);
for (let j = 0; j < syncCount; j += 1) readStrU8(bytes, cursorRef);
}
continue;
}
if (blockType === 40) {
const accessCount = readU8(bytes, cursorRef);
accessServers = [];
for (let j = 0; j < accessCount; j += 1) accessServers.push(readStrU8(bytes, cursorRef));
continue;
}
if (blockType === 50) {
cursorRef.value += 1;
const sessionsCount = readU8(bytes, cursorRef);
for (let j = 0; j < sessionsCount; j += 1) {
const sessionType = readU8(bytes, cursorRef);
const sessionVersion = readU8(bytes, cursorRef);
const sessionName = readStrU8(bytes, cursorRef);
const sessionPubKey32 = readBytes(bytes, cursorRef, 32);
if (sessionType === 100) {
homeserverSessions.push({
sessionType,
sessionVersion,
sessionName,
sessionPubKeyBase58: new PublicKey(sessionPubKey32).toBase58(),
sessionPubKeyB64: `ed25519/${btoa(String.fromCharCode(...sessionPubKey32))}`,
});
}
}
continue;
}
if (blockType === 70) {
cursorRef.value += 1;
continue;
}
throw new Error(`Неизвестный блок PDA: ${blockType}`);
}
return {
isServer,
serverAddress: normalizeHostLike(serverAddress),
accessServers: accessServers.map((value) => normalizeServerLogin(value)).filter(Boolean),
publicKeys: {
recoveryKeyBase58: recoveryKey32 ? new PublicKey(recoveryKey32).toBase58() : '',
rootKeyBase58: rootKey32 ? new PublicKey(rootKey32).toBase58() : '',
clientKeyBase58: clientKey32 ? new PublicKey(clientKey32).toBase58() : '',
blockchainKeyBase58: blockchainKey32 ? new PublicKey(blockchainKey32).toBase58() : '',
blockchainName,
},
homeserverSessions,
};
}
async function fetchUserPda(login, solanaEndpoint = SOLANA_ENDPOINT_DEFAULT) {
const cleanLogin = normalizeServerLogin(login);
if (!cleanLogin) throw new Error('Не указан логин для чтения PDA.');
const usersProgram = new PublicKey(SHINE_USERS_PROGRAM_ID);
const enc = new TextEncoder();
const [userPda] = PublicKey.findProgramAddressSync(
[enc.encode(SHINE_USERS_USER_PDA_SEED_PREFIX), enc.encode(cleanLogin)],
usersProgram,
);
const response = await fetch(String(solanaEndpoint || SOLANA_ENDPOINT_DEFAULT), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'getAccountInfo',
params: [userPda.toBase58(), { encoding: 'base64', commitment: 'confirmed' }],
}),
});
if (!response.ok) throw new Error('Не удалось прочитать Solana RPC.');
const json = await response.json();
const dataB64 = json?.result?.value?.data?.[0];
if (!dataB64) throw new Error(`PDA не найдена для логина @${cleanLogin}.`);
return parseServerFieldsFromUserPda(base64ToBytes(dataB64));
}
export async function resolveShineServerByServerLogin(serverLogin, solanaEndpoint = SOLANA_ENDPOINT_DEFAULT) {
const cleanServerLogin = normalizeServerLogin(serverLogin) || DEFAULT_SHINE_SERVER_LOGIN;
const parsed = await fetchUserPda(cleanServerLogin, solanaEndpoint);
if (!parsed.isServer) {
throw new Error(`Логин @${cleanServerLogin} не опубликован как сервер SHiNE.`);
}
if (!parsed.serverAddress) {
throw new Error(`У server PDA пользователя @${cleanServerLogin} не задан server_address.`);
}
return {
serverLogin: cleanServerLogin,
serverAddress: parsed.serverAddress,
serverHttp: buildHttpBase(parsed.serverAddress),
serverUrl: buildWsUrl(parsed.serverAddress),
};
}
export async function resolveShineServerByUserLogin(login, solanaEndpoint = SOLANA_ENDPOINT_DEFAULT) {
const cleanLogin = normalizeServerLogin(login);
if (!cleanLogin) throw new Error('Не указан логин пользователя.');
const parsed = await fetchUserPda(cleanLogin, solanaEndpoint);
const serverLogin = normalizeServerLogin(parsed.accessServers?.[0] || '');
if (!serverLogin) {
throw new Error(`У пользователя @${cleanLogin} в PDA не найден первый сервер доступа.`);
}
const resolved = await resolveShineServerByServerLogin(serverLogin, solanaEndpoint);
return {
login: cleanLogin,
accessServers: parsed.accessServers,
serverLogin: resolved.serverLogin,
serverAddress: resolved.serverAddress,
serverHttp: resolved.serverHttp,
serverUrl: resolved.serverUrl,
};
}
export async function readWalletProfileByLogin(login, solanaEndpoint = SOLANA_ENDPOINT_DEFAULT) {
const cleanLogin = normalizeServerLogin(login);
const parsed = await fetchUserPda(cleanLogin, solanaEndpoint);
return {
login: cleanLogin,
accessServers: parsed.accessServers,
publicKeys: parsed.publicKeys,
homeserverSessions: parsed.homeserverSessions,
};
}
export {
DEFAULT_SHINE_SERVER_ADDRESS,
DEFAULT_SHINE_SERVER_LOGIN,
SOLANA_ENDPOINT_DEFAULT,
buildHttpBase,
buildWsUrl,
normalizeServerLogin,
};
@@ -0,0 +1,995 @@
// node_modules/@noble/curves/node_modules/@noble/hashes/esm/_assert.js
function isBytes(a) {
return a instanceof Uint8Array || a != null && typeof a === "object" && a.constructor.name === "Uint8Array";
}
function bytes(b, ...lengths) {
if (!isBytes(b))
throw new Error("Uint8Array expected");
if (lengths.length > 0 && !lengths.includes(b.length))
throw new Error(`Uint8Array expected of length ${lengths}, not of length=${b.length}`);
}
function exists(instance, checkFinished = true) {
if (instance.destroyed)
throw new Error("Hash instance has been destroyed");
if (checkFinished && instance.finished)
throw new Error("Hash#digest() has already been called");
}
function output(out, instance) {
bytes(out);
const min = instance.outputLen;
if (out.length < min) {
throw new Error(`digestInto() expects output buffer of length at least ${min}`);
}
}
// node_modules/@noble/curves/node_modules/@noble/hashes/esm/crypto.js
var crypto = typeof globalThis === "object" && "crypto" in globalThis ? globalThis.crypto : void 0;
// node_modules/@noble/curves/node_modules/@noble/hashes/esm/utils.js
var createView = (arr) => new DataView(arr.buffer, arr.byteOffset, arr.byteLength);
var isLE = new Uint8Array(new Uint32Array([287454020]).buffer)[0] === 68;
function utf8ToBytes(str) {
if (typeof str !== "string")
throw new Error(`utf8ToBytes expected string, got ${typeof str}`);
return new Uint8Array(new TextEncoder().encode(str));
}
function toBytes(data) {
if (typeof data === "string")
data = utf8ToBytes(data);
bytes(data);
return data;
}
var Hash = class {
// Safe version that clones internal state
clone() {
return this._cloneInto();
}
};
var toStr = {}.toString;
function wrapConstructor(hashCons) {
const hashC = (msg) => hashCons().update(toBytes(msg)).digest();
const tmp = hashCons();
hashC.outputLen = tmp.outputLen;
hashC.blockLen = tmp.blockLen;
hashC.create = () => hashCons();
return hashC;
}
function randomBytes(bytesLength = 32) {
if (crypto && typeof crypto.getRandomValues === "function") {
return crypto.getRandomValues(new Uint8Array(bytesLength));
}
throw new Error("crypto.getRandomValues must be defined");
}
// node_modules/@noble/curves/node_modules/@noble/hashes/esm/_md.js
function setBigUint64(view, byteOffset, value, isLE2) {
if (typeof view.setBigUint64 === "function")
return view.setBigUint64(byteOffset, value, isLE2);
const _32n2 = BigInt(32);
const _u32_max = BigInt(4294967295);
const wh = Number(value >> _32n2 & _u32_max);
const wl = Number(value & _u32_max);
const h = isLE2 ? 4 : 0;
const l = isLE2 ? 0 : 4;
view.setUint32(byteOffset + h, wh, isLE2);
view.setUint32(byteOffset + l, wl, isLE2);
}
var HashMD = class extends Hash {
constructor(blockLen, outputLen, padOffset, isLE2) {
super();
this.blockLen = blockLen;
this.outputLen = outputLen;
this.padOffset = padOffset;
this.isLE = isLE2;
this.finished = false;
this.length = 0;
this.pos = 0;
this.destroyed = false;
this.buffer = new Uint8Array(blockLen);
this.view = createView(this.buffer);
}
update(data) {
exists(this);
const { view, buffer, blockLen } = this;
data = toBytes(data);
const len = data.length;
for (let pos = 0; pos < len; ) {
const take = Math.min(blockLen - this.pos, len - pos);
if (take === blockLen) {
const dataView = createView(data);
for (; blockLen <= len - pos; pos += blockLen)
this.process(dataView, pos);
continue;
}
buffer.set(data.subarray(pos, pos + take), this.pos);
this.pos += take;
pos += take;
if (this.pos === blockLen) {
this.process(view, 0);
this.pos = 0;
}
}
this.length += data.length;
this.roundClean();
return this;
}
digestInto(out) {
exists(this);
output(out, this);
this.finished = true;
const { buffer, view, blockLen, isLE: isLE2 } = this;
let { pos } = this;
buffer[pos++] = 128;
this.buffer.subarray(pos).fill(0);
if (this.padOffset > blockLen - pos) {
this.process(view, 0);
pos = 0;
}
for (let i = pos; i < blockLen; i++)
buffer[i] = 0;
setBigUint64(view, blockLen - 8, BigInt(this.length * 8), isLE2);
this.process(view, 0);
const oview = createView(out);
const len = this.outputLen;
if (len % 4)
throw new Error("_sha2: outputLen should be aligned to 32bit");
const outLen = len / 4;
const state = this.get();
if (outLen > state.length)
throw new Error("_sha2: outputLen bigger than state");
for (let i = 0; i < outLen; i++)
oview.setUint32(4 * i, state[i], isLE2);
}
digest() {
const { buffer, outputLen } = this;
this.digestInto(buffer);
const res = buffer.slice(0, outputLen);
this.destroy();
return res;
}
_cloneInto(to) {
to || (to = new this.constructor());
to.set(...this.get());
const { blockLen, buffer, length, finished, destroyed, pos } = this;
to.length = length;
to.pos = pos;
to.finished = finished;
to.destroyed = destroyed;
if (length % blockLen)
to.buffer.set(buffer);
return to;
}
};
// node_modules/@noble/curves/node_modules/@noble/hashes/esm/_u64.js
var U32_MASK64 = /* @__PURE__ */ BigInt(2 ** 32 - 1);
var _32n = /* @__PURE__ */ BigInt(32);
function fromBig(n, le = false) {
if (le)
return { h: Number(n & U32_MASK64), l: Number(n >> _32n & U32_MASK64) };
return { h: Number(n >> _32n & U32_MASK64) | 0, l: Number(n & U32_MASK64) | 0 };
}
function split(lst, le = false) {
let Ah = new Uint32Array(lst.length);
let Al = new Uint32Array(lst.length);
for (let i = 0; i < lst.length; i++) {
const { h, l } = fromBig(lst[i], le);
[Ah[i], Al[i]] = [h, l];
}
return [Ah, Al];
}
var toBig = (h, l) => BigInt(h >>> 0) << _32n | BigInt(l >>> 0);
var shrSH = (h, _l, s) => h >>> s;
var shrSL = (h, l, s) => h << 32 - s | l >>> s;
var rotrSH = (h, l, s) => h >>> s | l << 32 - s;
var rotrSL = (h, l, s) => h << 32 - s | l >>> s;
var rotrBH = (h, l, s) => h << 64 - s | l >>> s - 32;
var rotrBL = (h, l, s) => h >>> s - 32 | l << 64 - s;
var rotr32H = (_h, l) => l;
var rotr32L = (h, _l) => h;
var rotlSH = (h, l, s) => h << s | l >>> 32 - s;
var rotlSL = (h, l, s) => l << s | h >>> 32 - s;
var rotlBH = (h, l, s) => l << s - 32 | h >>> 64 - s;
var rotlBL = (h, l, s) => h << s - 32 | l >>> 64 - s;
function add(Ah, Al, Bh, Bl) {
const l = (Al >>> 0) + (Bl >>> 0);
return { h: Ah + Bh + (l / 2 ** 32 | 0) | 0, l: l | 0 };
}
var add3L = (Al, Bl, Cl) => (Al >>> 0) + (Bl >>> 0) + (Cl >>> 0);
var add3H = (low, Ah, Bh, Ch) => Ah + Bh + Ch + (low / 2 ** 32 | 0) | 0;
var add4L = (Al, Bl, Cl, Dl) => (Al >>> 0) + (Bl >>> 0) + (Cl >>> 0) + (Dl >>> 0);
var add4H = (low, Ah, Bh, Ch, Dh) => Ah + Bh + Ch + Dh + (low / 2 ** 32 | 0) | 0;
var add5L = (Al, Bl, Cl, Dl, El) => (Al >>> 0) + (Bl >>> 0) + (Cl >>> 0) + (Dl >>> 0) + (El >>> 0);
var add5H = (low, Ah, Bh, Ch, Dh, Eh) => Ah + Bh + Ch + Dh + Eh + (low / 2 ** 32 | 0) | 0;
var u64 = {
fromBig,
split,
toBig,
shrSH,
shrSL,
rotrSH,
rotrSL,
rotrBH,
rotrBL,
rotr32H,
rotr32L,
rotlSH,
rotlSL,
rotlBH,
rotlBL,
add,
add3L,
add3H,
add4L,
add4H,
add5H,
add5L
};
var u64_default = u64;
// node_modules/@noble/curves/node_modules/@noble/hashes/esm/sha512.js
var [SHA512_Kh, SHA512_Kl] = /* @__PURE__ */ (() => u64_default.split([
"0x428a2f98d728ae22",
"0x7137449123ef65cd",
"0xb5c0fbcfec4d3b2f",
"0xe9b5dba58189dbbc",
"0x3956c25bf348b538",
"0x59f111f1b605d019",
"0x923f82a4af194f9b",
"0xab1c5ed5da6d8118",
"0xd807aa98a3030242",
"0x12835b0145706fbe",
"0x243185be4ee4b28c",
"0x550c7dc3d5ffb4e2",
"0x72be5d74f27b896f",
"0x80deb1fe3b1696b1",
"0x9bdc06a725c71235",
"0xc19bf174cf692694",
"0xe49b69c19ef14ad2",
"0xefbe4786384f25e3",
"0x0fc19dc68b8cd5b5",
"0x240ca1cc77ac9c65",
"0x2de92c6f592b0275",
"0x4a7484aa6ea6e483",
"0x5cb0a9dcbd41fbd4",
"0x76f988da831153b5",
"0x983e5152ee66dfab",
"0xa831c66d2db43210",
"0xb00327c898fb213f",
"0xbf597fc7beef0ee4",
"0xc6e00bf33da88fc2",
"0xd5a79147930aa725",
"0x06ca6351e003826f",
"0x142929670a0e6e70",
"0x27b70a8546d22ffc",
"0x2e1b21385c26c926",
"0x4d2c6dfc5ac42aed",
"0x53380d139d95b3df",
"0x650a73548baf63de",
"0x766a0abb3c77b2a8",
"0x81c2c92e47edaee6",
"0x92722c851482353b",
"0xa2bfe8a14cf10364",
"0xa81a664bbc423001",
"0xc24b8b70d0f89791",
"0xc76c51a30654be30",
"0xd192e819d6ef5218",
"0xd69906245565a910",
"0xf40e35855771202a",
"0x106aa07032bbd1b8",
"0x19a4c116b8d2d0c8",
"0x1e376c085141ab53",
"0x2748774cdf8eeb99",
"0x34b0bcb5e19b48a8",
"0x391c0cb3c5c95a63",
"0x4ed8aa4ae3418acb",
"0x5b9cca4f7763e373",
"0x682e6ff3d6b2b8a3",
"0x748f82ee5defb2fc",
"0x78a5636f43172f60",
"0x84c87814a1f0ab72",
"0x8cc702081a6439ec",
"0x90befffa23631e28",
"0xa4506cebde82bde9",
"0xbef9a3f7b2c67915",
"0xc67178f2e372532b",
"0xca273eceea26619c",
"0xd186b8c721c0c207",
"0xeada7dd6cde0eb1e",
"0xf57d4f7fee6ed178",
"0x06f067aa72176fba",
"0x0a637dc5a2c898a6",
"0x113f9804bef90dae",
"0x1b710b35131c471b",
"0x28db77f523047d84",
"0x32caab7b40c72493",
"0x3c9ebe0a15c9bebc",
"0x431d67c49c100d4c",
"0x4cc5d4becb3e42b6",
"0x597f299cfc657e2a",
"0x5fcb6fab3ad6faec",
"0x6c44198c4a475817"
].map((n) => BigInt(n))))();
var SHA512_W_H = /* @__PURE__ */ new Uint32Array(80);
var SHA512_W_L = /* @__PURE__ */ new Uint32Array(80);
var SHA512 = class extends HashMD {
constructor() {
super(128, 64, 16, false);
this.Ah = 1779033703 | 0;
this.Al = 4089235720 | 0;
this.Bh = 3144134277 | 0;
this.Bl = 2227873595 | 0;
this.Ch = 1013904242 | 0;
this.Cl = 4271175723 | 0;
this.Dh = 2773480762 | 0;
this.Dl = 1595750129 | 0;
this.Eh = 1359893119 | 0;
this.El = 2917565137 | 0;
this.Fh = 2600822924 | 0;
this.Fl = 725511199 | 0;
this.Gh = 528734635 | 0;
this.Gl = 4215389547 | 0;
this.Hh = 1541459225 | 0;
this.Hl = 327033209 | 0;
}
// prettier-ignore
get() {
const { Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl } = this;
return [Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl];
}
// prettier-ignore
set(Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl) {
this.Ah = Ah | 0;
this.Al = Al | 0;
this.Bh = Bh | 0;
this.Bl = Bl | 0;
this.Ch = Ch | 0;
this.Cl = Cl | 0;
this.Dh = Dh | 0;
this.Dl = Dl | 0;
this.Eh = Eh | 0;
this.El = El | 0;
this.Fh = Fh | 0;
this.Fl = Fl | 0;
this.Gh = Gh | 0;
this.Gl = Gl | 0;
this.Hh = Hh | 0;
this.Hl = Hl | 0;
}
process(view, offset) {
for (let i = 0; i < 16; i++, offset += 4) {
SHA512_W_H[i] = view.getUint32(offset);
SHA512_W_L[i] = view.getUint32(offset += 4);
}
for (let i = 16; i < 80; i++) {
const W15h = SHA512_W_H[i - 15] | 0;
const W15l = SHA512_W_L[i - 15] | 0;
const s0h = u64_default.rotrSH(W15h, W15l, 1) ^ u64_default.rotrSH(W15h, W15l, 8) ^ u64_default.shrSH(W15h, W15l, 7);
const s0l = u64_default.rotrSL(W15h, W15l, 1) ^ u64_default.rotrSL(W15h, W15l, 8) ^ u64_default.shrSL(W15h, W15l, 7);
const W2h = SHA512_W_H[i - 2] | 0;
const W2l = SHA512_W_L[i - 2] | 0;
const s1h = u64_default.rotrSH(W2h, W2l, 19) ^ u64_default.rotrBH(W2h, W2l, 61) ^ u64_default.shrSH(W2h, W2l, 6);
const s1l = u64_default.rotrSL(W2h, W2l, 19) ^ u64_default.rotrBL(W2h, W2l, 61) ^ u64_default.shrSL(W2h, W2l, 6);
const SUMl = u64_default.add4L(s0l, s1l, SHA512_W_L[i - 7], SHA512_W_L[i - 16]);
const SUMh = u64_default.add4H(SUMl, s0h, s1h, SHA512_W_H[i - 7], SHA512_W_H[i - 16]);
SHA512_W_H[i] = SUMh | 0;
SHA512_W_L[i] = SUMl | 0;
}
let { Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl } = this;
for (let i = 0; i < 80; i++) {
const sigma1h = u64_default.rotrSH(Eh, El, 14) ^ u64_default.rotrSH(Eh, El, 18) ^ u64_default.rotrBH(Eh, El, 41);
const sigma1l = u64_default.rotrSL(Eh, El, 14) ^ u64_default.rotrSL(Eh, El, 18) ^ u64_default.rotrBL(Eh, El, 41);
const CHIh = Eh & Fh ^ ~Eh & Gh;
const CHIl = El & Fl ^ ~El & Gl;
const T1ll = u64_default.add5L(Hl, sigma1l, CHIl, SHA512_Kl[i], SHA512_W_L[i]);
const T1h = u64_default.add5H(T1ll, Hh, sigma1h, CHIh, SHA512_Kh[i], SHA512_W_H[i]);
const T1l = T1ll | 0;
const sigma0h = u64_default.rotrSH(Ah, Al, 28) ^ u64_default.rotrBH(Ah, Al, 34) ^ u64_default.rotrBH(Ah, Al, 39);
const sigma0l = u64_default.rotrSL(Ah, Al, 28) ^ u64_default.rotrBL(Ah, Al, 34) ^ u64_default.rotrBL(Ah, Al, 39);
const MAJh = Ah & Bh ^ Ah & Ch ^ Bh & Ch;
const MAJl = Al & Bl ^ Al & Cl ^ Bl & Cl;
Hh = Gh | 0;
Hl = Gl | 0;
Gh = Fh | 0;
Gl = Fl | 0;
Fh = Eh | 0;
Fl = El | 0;
({ h: Eh, l: El } = u64_default.add(Dh | 0, Dl | 0, T1h | 0, T1l | 0));
Dh = Ch | 0;
Dl = Cl | 0;
Ch = Bh | 0;
Cl = Bl | 0;
Bh = Ah | 0;
Bl = Al | 0;
const All = u64_default.add3L(T1l, sigma0l, MAJl);
Ah = u64_default.add3H(All, T1h, sigma0h, MAJh);
Al = All | 0;
}
({ h: Ah, l: Al } = u64_default.add(this.Ah | 0, this.Al | 0, Ah | 0, Al | 0));
({ h: Bh, l: Bl } = u64_default.add(this.Bh | 0, this.Bl | 0, Bh | 0, Bl | 0));
({ h: Ch, l: Cl } = u64_default.add(this.Ch | 0, this.Cl | 0, Ch | 0, Cl | 0));
({ h: Dh, l: Dl } = u64_default.add(this.Dh | 0, this.Dl | 0, Dh | 0, Dl | 0));
({ h: Eh, l: El } = u64_default.add(this.Eh | 0, this.El | 0, Eh | 0, El | 0));
({ h: Fh, l: Fl } = u64_default.add(this.Fh | 0, this.Fl | 0, Fh | 0, Fl | 0));
({ h: Gh, l: Gl } = u64_default.add(this.Gh | 0, this.Gl | 0, Gh | 0, Gl | 0));
({ h: Hh, l: Hl } = u64_default.add(this.Hh | 0, this.Hl | 0, Hh | 0, Hl | 0));
this.set(Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl);
}
roundClean() {
SHA512_W_H.fill(0);
SHA512_W_L.fill(0);
}
destroy() {
this.buffer.fill(0);
this.set(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
}
};
var sha512 = /* @__PURE__ */ wrapConstructor(() => new SHA512());
// node_modules/@noble/curves/esm/abstract/utils.js
var _0n = /* @__PURE__ */ BigInt(0);
var _1n = /* @__PURE__ */ BigInt(1);
var _2n = /* @__PURE__ */ BigInt(2);
function isBytes2(a) {
return a instanceof Uint8Array || a != null && typeof a === "object" && a.constructor.name === "Uint8Array";
}
function abytes(item) {
if (!isBytes2(item))
throw new Error("Uint8Array expected");
}
var hexes = /* @__PURE__ */ Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
function bytesToHex(bytes2) {
abytes(bytes2);
let hex = "";
for (let i = 0; i < bytes2.length; i++) {
hex += hexes[bytes2[i]];
}
return hex;
}
function hexToNumber(hex) {
if (typeof hex !== "string")
throw new Error("hex string expected, got " + typeof hex);
return BigInt(hex === "" ? "0" : `0x${hex}`);
}
var asciis = { _0: 48, _9: 57, _A: 65, _F: 70, _a: 97, _f: 102 };
function asciiToBase16(char) {
if (char >= asciis._0 && char <= asciis._9)
return char - asciis._0;
if (char >= asciis._A && char <= asciis._F)
return char - (asciis._A - 10);
if (char >= asciis._a && char <= asciis._f)
return char - (asciis._a - 10);
return;
}
function hexToBytes(hex) {
if (typeof hex !== "string")
throw new Error("hex string expected, got " + typeof hex);
const hl = hex.length;
const al = hl / 2;
if (hl % 2)
throw new Error("padded hex string expected, got unpadded hex of length " + hl);
const array = new Uint8Array(al);
for (let ai = 0, hi = 0; ai < al; ai++, hi += 2) {
const n1 = asciiToBase16(hex.charCodeAt(hi));
const n2 = asciiToBase16(hex.charCodeAt(hi + 1));
if (n1 === void 0 || n2 === void 0) {
const char = hex[hi] + hex[hi + 1];
throw new Error('hex string expected, got non-hex character "' + char + '" at index ' + hi);
}
array[ai] = n1 * 16 + n2;
}
return array;
}
function bytesToNumberBE(bytes2) {
return hexToNumber(bytesToHex(bytes2));
}
function bytesToNumberLE(bytes2) {
abytes(bytes2);
return hexToNumber(bytesToHex(Uint8Array.from(bytes2).reverse()));
}
function numberToBytesBE(n, len) {
return hexToBytes(n.toString(16).padStart(len * 2, "0"));
}
function numberToBytesLE(n, len) {
return numberToBytesBE(n, len).reverse();
}
function ensureBytes(title, hex, expectedLength) {
let res;
if (typeof hex === "string") {
try {
res = hexToBytes(hex);
} catch (e) {
throw new Error(`${title} must be valid hex string, got "${hex}". Cause: ${e}`);
}
} else if (isBytes2(hex)) {
res = Uint8Array.from(hex);
} else {
throw new Error(`${title} must be hex string or Uint8Array`);
}
const len = res.length;
if (typeof expectedLength === "number" && len !== expectedLength)
throw new Error(`${title} expected ${expectedLength} bytes, got ${len}`);
return res;
}
var isPosBig = (n) => typeof n === "bigint" && _0n <= n;
function inRange(n, min, max) {
return isPosBig(n) && isPosBig(min) && isPosBig(max) && min <= n && n < max;
}
function aInRange(title, n, min, max) {
if (!inRange(n, min, max))
throw new Error(`expected valid ${title}: ${min} <= n < ${max}, got ${typeof n} ${n}`);
}
var bitMask = (n) => (_2n << BigInt(n - 1)) - _1n;
var validatorFns = {
bigint: (val) => typeof val === "bigint",
function: (val) => typeof val === "function",
boolean: (val) => typeof val === "boolean",
string: (val) => typeof val === "string",
stringOrUint8Array: (val) => typeof val === "string" || isBytes2(val),
isSafeInteger: (val) => Number.isSafeInteger(val),
array: (val) => Array.isArray(val),
field: (val, object) => object.Fp.isValid(val),
hash: (val) => typeof val === "function" && Number.isSafeInteger(val.outputLen)
};
function validateObject(object, validators, optValidators = {}) {
const checkField = (fieldName, type, isOptional) => {
const checkVal = validatorFns[type];
if (typeof checkVal !== "function")
throw new Error(`Invalid validator "${type}", expected function`);
const val = object[fieldName];
if (isOptional && val === void 0)
return;
if (!checkVal(val, object)) {
throw new Error(`Invalid param ${String(fieldName)}=${val} (${typeof val}), expected ${type}`);
}
};
for (const [fieldName, type] of Object.entries(validators))
checkField(fieldName, type, false);
for (const [fieldName, type] of Object.entries(optValidators))
checkField(fieldName, type, true);
return object;
}
// node_modules/@noble/curves/esm/abstract/modular.js
var _0n2 = BigInt(0);
var _1n2 = BigInt(1);
var _2n2 = BigInt(2);
var _3n = BigInt(3);
var _4n = BigInt(4);
var _5n = BigInt(5);
var _8n = BigInt(8);
var _9n = BigInt(9);
var _16n = BigInt(16);
function mod(a, b) {
const result = a % b;
return result >= _0n2 ? result : b + result;
}
function pow(num, power, modulo) {
if (modulo <= _0n2 || power < _0n2)
throw new Error("Expected power/modulo > 0");
if (modulo === _1n2)
return _0n2;
let res = _1n2;
while (power > _0n2) {
if (power & _1n2)
res = res * num % modulo;
num = num * num % modulo;
power >>= _1n2;
}
return res;
}
function pow2(x, power, modulo) {
let res = x;
while (power-- > _0n2) {
res *= res;
res %= modulo;
}
return res;
}
function invert(number, modulo) {
if (number === _0n2 || modulo <= _0n2) {
throw new Error(`invert: expected positive integers, got n=${number} mod=${modulo}`);
}
let a = mod(number, modulo);
let b = modulo;
let x = _0n2, y = _1n2, u = _1n2, v = _0n2;
while (a !== _0n2) {
const q = b / a;
const r = b % a;
const m = x - u * q;
const n = y - v * q;
b = a, a = r, x = u, y = v, u = m, v = n;
}
const gcd = b;
if (gcd !== _1n2)
throw new Error("invert: does not exist");
return mod(x, modulo);
}
function tonelliShanks(P) {
const legendreC = (P - _1n2) / _2n2;
let Q, S, Z;
for (Q = P - _1n2, S = 0; Q % _2n2 === _0n2; Q /= _2n2, S++)
;
for (Z = _2n2; Z < P && pow(Z, legendreC, P) !== P - _1n2; Z++)
;
if (S === 1) {
const p1div4 = (P + _1n2) / _4n;
return function tonelliFast(Fp2, n) {
const root = Fp2.pow(n, p1div4);
if (!Fp2.eql(Fp2.sqr(root), n))
throw new Error("Cannot find square root");
return root;
};
}
const Q1div2 = (Q + _1n2) / _2n2;
return function tonelliSlow(Fp2, n) {
if (Fp2.pow(n, legendreC) === Fp2.neg(Fp2.ONE))
throw new Error("Cannot find square root");
let r = S;
let g = Fp2.pow(Fp2.mul(Fp2.ONE, Z), Q);
let x = Fp2.pow(n, Q1div2);
let b = Fp2.pow(n, Q);
while (!Fp2.eql(b, Fp2.ONE)) {
if (Fp2.eql(b, Fp2.ZERO))
return Fp2.ZERO;
let m = 1;
for (let t2 = Fp2.sqr(b); m < r; m++) {
if (Fp2.eql(t2, Fp2.ONE))
break;
t2 = Fp2.sqr(t2);
}
const ge = Fp2.pow(g, _1n2 << BigInt(r - m - 1));
g = Fp2.sqr(ge);
x = Fp2.mul(x, ge);
b = Fp2.mul(b, g);
r = m;
}
return x;
};
}
function FpSqrt(P) {
if (P % _4n === _3n) {
const p1div4 = (P + _1n2) / _4n;
return function sqrt3mod4(Fp2, n) {
const root = Fp2.pow(n, p1div4);
if (!Fp2.eql(Fp2.sqr(root), n))
throw new Error("Cannot find square root");
return root;
};
}
if (P % _8n === _5n) {
const c1 = (P - _5n) / _8n;
return function sqrt5mod8(Fp2, n) {
const n2 = Fp2.mul(n, _2n2);
const v = Fp2.pow(n2, c1);
const nv = Fp2.mul(n, v);
const i = Fp2.mul(Fp2.mul(nv, _2n2), v);
const root = Fp2.mul(nv, Fp2.sub(i, Fp2.ONE));
if (!Fp2.eql(Fp2.sqr(root), n))
throw new Error("Cannot find square root");
return root;
};
}
if (P % _16n === _9n) {
}
return tonelliShanks(P);
}
var isNegativeLE = (num, modulo) => (mod(num, modulo) & _1n2) === _1n2;
function FpPow(f, num, power) {
if (power < _0n2)
throw new Error("Expected power > 0");
if (power === _0n2)
return f.ONE;
if (power === _1n2)
return num;
let p = f.ONE;
let d = num;
while (power > _0n2) {
if (power & _1n2)
p = f.mul(p, d);
d = f.sqr(d);
power >>= _1n2;
}
return p;
}
function FpInvertBatch(f, nums) {
const tmp = new Array(nums.length);
const lastMultiplied = nums.reduce((acc, num, i) => {
if (f.is0(num))
return acc;
tmp[i] = acc;
return f.mul(acc, num);
}, f.ONE);
const inverted = f.inv(lastMultiplied);
nums.reduceRight((acc, num, i) => {
if (f.is0(num))
return acc;
tmp[i] = f.mul(acc, tmp[i]);
return f.mul(acc, num);
}, inverted);
return tmp;
}
function nLength(n, nBitLength) {
const _nBitLength = nBitLength !== void 0 ? nBitLength : n.toString(2).length;
const nByteLength = Math.ceil(_nBitLength / 8);
return { nBitLength: _nBitLength, nByteLength };
}
function Field(ORDER, bitLen, isLE2 = false, redef = {}) {
if (ORDER <= _0n2)
throw new Error(`Expected Field ORDER > 0, got ${ORDER}`);
const { nBitLength: BITS, nByteLength: BYTES } = nLength(ORDER, bitLen);
if (BYTES > 2048)
throw new Error("Field lengths over 2048 bytes are not supported");
const sqrtP = FpSqrt(ORDER);
const f = Object.freeze({
ORDER,
BITS,
BYTES,
MASK: bitMask(BITS),
ZERO: _0n2,
ONE: _1n2,
create: (num) => mod(num, ORDER),
isValid: (num) => {
if (typeof num !== "bigint")
throw new Error(`Invalid field element: expected bigint, got ${typeof num}`);
return _0n2 <= num && num < ORDER;
},
is0: (num) => num === _0n2,
isOdd: (num) => (num & _1n2) === _1n2,
neg: (num) => mod(-num, ORDER),
eql: (lhs, rhs) => lhs === rhs,
sqr: (num) => mod(num * num, ORDER),
add: (lhs, rhs) => mod(lhs + rhs, ORDER),
sub: (lhs, rhs) => mod(lhs - rhs, ORDER),
mul: (lhs, rhs) => mod(lhs * rhs, ORDER),
pow: (num, power) => FpPow(f, num, power),
div: (lhs, rhs) => mod(lhs * invert(rhs, ORDER), ORDER),
// Same as above, but doesn't normalize
sqrN: (num) => num * num,
addN: (lhs, rhs) => lhs + rhs,
subN: (lhs, rhs) => lhs - rhs,
mulN: (lhs, rhs) => lhs * rhs,
inv: (num) => invert(num, ORDER),
sqrt: redef.sqrt || ((n) => sqrtP(f, n)),
invertBatch: (lst) => FpInvertBatch(f, lst),
// TODO: do we really need constant cmov?
// We don't have const-time bigints anyway, so probably will be not very useful
cmov: (a, b, c) => c ? b : a,
toBytes: (num) => isLE2 ? numberToBytesLE(num, BYTES) : numberToBytesBE(num, BYTES),
fromBytes: (bytes2) => {
if (bytes2.length !== BYTES)
throw new Error(`Fp.fromBytes: expected ${BYTES}, got ${bytes2.length}`);
return isLE2 ? bytesToNumberLE(bytes2) : bytesToNumberBE(bytes2);
}
});
return Object.freeze(f);
}
// node_modules/@noble/curves/esm/abstract/montgomery.js
var _0n3 = BigInt(0);
var _1n3 = BigInt(1);
function validateOpts(curve) {
validateObject(curve, {
a: "bigint"
}, {
montgomeryBits: "isSafeInteger",
nByteLength: "isSafeInteger",
adjustScalarBytes: "function",
domain: "function",
powPminus2: "function",
Gu: "bigint"
});
return Object.freeze({ ...curve });
}
function montgomery(curveDef) {
const CURVE = validateOpts(curveDef);
const { P } = CURVE;
const modP = (n) => mod(n, P);
const montgomeryBits = CURVE.montgomeryBits;
const montgomeryBytes = Math.ceil(montgomeryBits / 8);
const fieldLen = CURVE.nByteLength;
const adjustScalarBytes2 = CURVE.adjustScalarBytes || ((bytes2) => bytes2);
const powPminus2 = CURVE.powPminus2 || ((x) => pow(x, P - BigInt(2), P));
function cswap(swap, x_2, x_3) {
const dummy = modP(swap * (x_2 - x_3));
x_2 = modP(x_2 - dummy);
x_3 = modP(x_3 + dummy);
return [x_2, x_3];
}
const a24 = (CURVE.a - BigInt(2)) / BigInt(4);
function montgomeryLadder(u, scalar) {
aInRange("u", u, _0n3, P);
aInRange("scalar", scalar, _0n3, P);
const k = scalar;
const x_1 = u;
let x_2 = _1n3;
let z_2 = _0n3;
let x_3 = u;
let z_3 = _1n3;
let swap = _0n3;
let sw;
for (let t = BigInt(montgomeryBits - 1); t >= _0n3; t--) {
const k_t = k >> t & _1n3;
swap ^= k_t;
sw = cswap(swap, x_2, x_3);
x_2 = sw[0];
x_3 = sw[1];
sw = cswap(swap, z_2, z_3);
z_2 = sw[0];
z_3 = sw[1];
swap = k_t;
const A = x_2 + z_2;
const AA = modP(A * A);
const B = x_2 - z_2;
const BB = modP(B * B);
const E = AA - BB;
const C = x_3 + z_3;
const D = x_3 - z_3;
const DA = modP(D * A);
const CB = modP(C * B);
const dacb = DA + CB;
const da_cb = DA - CB;
x_3 = modP(dacb * dacb);
z_3 = modP(x_1 * modP(da_cb * da_cb));
x_2 = modP(AA * BB);
z_2 = modP(E * (AA + modP(a24 * E)));
}
sw = cswap(swap, x_2, x_3);
x_2 = sw[0];
x_3 = sw[1];
sw = cswap(swap, z_2, z_3);
z_2 = sw[0];
z_3 = sw[1];
const z2 = powPminus2(z_2);
return modP(x_2 * z2);
}
function encodeUCoordinate(u) {
return numberToBytesLE(modP(u), montgomeryBytes);
}
function decodeUCoordinate(uEnc) {
const u = ensureBytes("u coordinate", uEnc, montgomeryBytes);
if (fieldLen === 32)
u[31] &= 127;
return bytesToNumberLE(u);
}
function decodeScalar(n) {
const bytes2 = ensureBytes("scalar", n);
const len = bytes2.length;
if (len !== montgomeryBytes && len !== fieldLen)
throw new Error(`Expected ${montgomeryBytes} or ${fieldLen} bytes, got ${len}`);
return bytesToNumberLE(adjustScalarBytes2(bytes2));
}
function scalarMult(scalar, u) {
const pointU = decodeUCoordinate(u);
const _scalar = decodeScalar(scalar);
const pu = montgomeryLadder(pointU, _scalar);
if (pu === _0n3)
throw new Error("Invalid private or public key received");
return encodeUCoordinate(pu);
}
const GuBytes = encodeUCoordinate(CURVE.Gu);
function scalarMultBase(scalar) {
return scalarMult(scalar, GuBytes);
}
return {
scalarMult,
scalarMultBase,
getSharedSecret: (privateKey, publicKey) => scalarMult(privateKey, publicKey),
getPublicKey: (privateKey) => scalarMultBase(privateKey),
utils: { randomPrivateKey: () => CURVE.randomBytes(CURVE.nByteLength) },
GuBytes
};
}
// node_modules/@noble/curves/esm/ed25519.js
var ED25519_P = BigInt("57896044618658097711785492504343953926634992332820282019728792003956564819949");
var ED25519_SQRT_M1 = /* @__PURE__ */ BigInt("19681161376707505956807079304988542015446066515923890162744021073123829784752");
var _0n4 = BigInt(0);
var _1n4 = BigInt(1);
var _2n3 = BigInt(2);
var _3n2 = BigInt(3);
var _5n2 = BigInt(5);
var _8n2 = BigInt(8);
function ed25519_pow_2_252_3(x) {
const _10n = BigInt(10), _20n = BigInt(20), _40n = BigInt(40), _80n = BigInt(80);
const P = ED25519_P;
const x2 = x * x % P;
const b2 = x2 * x % P;
const b4 = pow2(b2, _2n3, P) * b2 % P;
const b5 = pow2(b4, _1n4, P) * x % P;
const b10 = pow2(b5, _5n2, P) * b5 % P;
const b20 = pow2(b10, _10n, P) * b10 % P;
const b40 = pow2(b20, _20n, P) * b20 % P;
const b80 = pow2(b40, _40n, P) * b40 % P;
const b160 = pow2(b80, _80n, P) * b80 % P;
const b240 = pow2(b160, _80n, P) * b80 % P;
const b250 = pow2(b240, _10n, P) * b10 % P;
const pow_p_5_8 = pow2(b250, _2n3, P) * x % P;
return { pow_p_5_8, b2 };
}
function adjustScalarBytes(bytes2) {
bytes2[0] &= 248;
bytes2[31] &= 127;
bytes2[31] |= 64;
return bytes2;
}
function uvRatio(u, v) {
const P = ED25519_P;
const v3 = mod(v * v * v, P);
const v7 = mod(v3 * v3 * v, P);
const pow3 = ed25519_pow_2_252_3(u * v7).pow_p_5_8;
let x = mod(u * v3 * pow3, P);
const vx2 = mod(v * x * x, P);
const root1 = x;
const root2 = mod(x * ED25519_SQRT_M1, P);
const useRoot1 = vx2 === u;
const useRoot2 = vx2 === mod(-u, P);
const noRoot = vx2 === mod(-u * ED25519_SQRT_M1, P);
if (useRoot1)
x = root1;
if (useRoot2 || noRoot)
x = root2;
if (isNegativeLE(x, P))
x = mod(-x, P);
return { isValid: useRoot1 || useRoot2, value: x };
}
var Fp = /* @__PURE__ */ (() => Field(ED25519_P, void 0, true))();
var ed25519Defaults = /* @__PURE__ */ (() => ({
// Param: a
a: BigInt(-1),
// Fp.create(-1) is proper; our way still works and is faster
// d is equal to -121665/121666 over finite field.
// Negative number is P - number, and division is invert(number, P)
d: BigInt("37095705934669439343138083508754565189542113879843219016388785533085940283555"),
// Finite field 𝔽p over which we'll do calculations; 2n**255n - 19n
Fp,
// Subgroup order: how many points curve has
// 2n**252n + 27742317777372353535851937790883648493n;
n: BigInt("7237005577332262213973186563042994240857116359379907606001950938285454250989"),
// Cofactor
h: _8n2,
// Base point (x, y) aka generator point
Gx: BigInt("15112221349535400772501151409588531511454012693041857206046113283949847762202"),
Gy: BigInt("46316835694926478169428394003475163141307993866256225615783033603165251855960"),
hash: sha512,
randomBytes,
adjustScalarBytes,
// dom2
// Ratio of u to v. Allows us to combine inversion and square root. Uses algo from RFC8032 5.1.3.
// Constant-time, u/√v
uvRatio
}))();
var x25519 = /* @__PURE__ */ (() => montgomery({
P: ED25519_P,
a: BigInt(486662),
montgomeryBits: 255,
// n is 253 bits
nByteLength: 32,
Gu: BigInt(9),
powPminus2: (x) => {
const P = ED25519_P;
const { pow_p_5_8, b2 } = ed25519_pow_2_252_3(x);
return mod(pow2(pow_p_5_8, _3n2, P) * b2, P);
},
adjustScalarBytes,
randomBytes
}))();
function edwardsToMontgomeryPriv(edwardsPriv) {
const hashed = ed25519Defaults.hash(edwardsPriv.subarray(0, 32));
return ed25519Defaults.adjustScalarBytes(hashed).subarray(0, 32);
}
export {
edwardsToMontgomeryPriv,
x25519
};
/*! Bundled license information:
@noble/hashes/esm/utils.js:
(*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) *)
@noble/curves/esm/abstract/utils.js:
@noble/curves/esm/abstract/modular.js:
@noble/curves/esm/abstract/montgomery.js:
@noble/curves/esm/ed25519.js:
(*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) *)
*/
@@ -0,0 +1,3 @@
import { edwardsToMontgomeryPriv, x25519 } from '../../../node_modules/@noble/curves/esm/ed25519.js';
export { edwardsToMontgomeryPriv, x25519 };
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
import { PublicKey, Transaction, VersionedTransaction } from '@solana/web3.js';
export { PublicKey, Transaction, VersionedTransaction };
@@ -0,0 +1,141 @@
const DEFAULT_TIMEOUT_MS = 12000;
const runtimeTimers = globalThis;
function buildWsUrl(raw) {
const value = String(raw || '').trim();
if (!value) return 'wss://shineup.me/ws';
if (value.startsWith('ws://') || value.startsWith('wss://')) return value;
if (value.startsWith('http://') || value.startsWith('https://')) {
const parsed = new URL(value);
parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
return parsed.toString();
}
return value;
}
function createRequestId(op) {
return `${op}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export class WsJsonClient {
constructor(url) {
this.url = buildWsUrl(url);
this.ws = null;
this.openPromise = null;
this.pending = new Map();
this.listeners = new Map();
}
async open() {
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
if (this.openPromise) return this.openPromise;
this.openPromise = new Promise((resolve, reject) => {
const ws = new WebSocket(this.url);
this.ws = ws;
ws.addEventListener('open', () => resolve(), { once: true });
ws.addEventListener('error', () => reject(new Error(`Не удалось подключиться к ${this.url}`)), { once: true });
ws.addEventListener('close', () => this.failPending('WebSocket соединение закрыто'));
ws.addEventListener('message', (event) => this.handleMessage(event.data));
}).finally(() => {
this.openPromise = null;
});
return this.openPromise;
}
async request(op, payload = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
await this.open();
const requestId = createRequestId(op);
const body = { op, requestId, payload };
const response = new Promise((resolve, reject) => {
const timer = runtimeTimers.setTimeout(() => {
this.pending.delete(requestId);
reject(new Error(`Таймаут ответа для операции ${op}`));
}, timeoutMs);
this.pending.set(requestId, {
resolve: (value) => {
runtimeTimers.clearTimeout(timer);
resolve(value);
},
reject: (error) => {
runtimeTimers.clearTimeout(timer);
reject(error);
},
});
});
this.ws.send(JSON.stringify(body));
return response;
}
handleMessage(raw) {
let data;
try {
data = JSON.parse(raw);
} catch {
return;
}
if (data?.event) {
this.emit(String(data?.op || ''), data);
return;
}
const requestId = data?.requestId;
if (!requestId) {
this.emit(String(data?.op || ''), data);
return;
}
const slot = this.pending.get(requestId);
if (!slot) {
this.emit(String(data?.op || ''), data);
return;
}
this.pending.delete(requestId);
slot.resolve(data);
}
on(op, handler) {
const key = String(op || '').trim();
if (!key || typeof handler !== 'function') {
return () => {};
}
if (!this.listeners.has(key)) {
this.listeners.set(key, new Set());
}
this.listeners.get(key).add(handler);
return () => {
const bucket = this.listeners.get(key);
if (!bucket) return;
bucket.delete(handler);
if (!bucket.size) {
this.listeners.delete(key);
}
};
}
emit(op, payload) {
const bucket = this.listeners.get(String(op || '').trim());
if (!bucket?.size) return;
for (const handler of [...bucket]) {
try {
handler(payload);
} catch {}
}
}
failPending(message) {
const error = new Error(message);
for (const slot of this.pending.values()) slot.reject(error);
this.pending.clear();
}
close() {
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
}
+45
View File
@@ -0,0 +1,45 @@
{
"manifest_version": 3,
"name": "SHiNE Browser Plugin Wallet",
"version": "0.1.0",
"description": "Wallet-session plugin for SHiNE with session-only login via trusted device.",
"permissions": [
"storage",
"sidePanel"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"content_scripts": [
{
"matches": [
"<all_urls>"
],
"js": [
"content-script.js"
],
"run_at": "document_start"
}
],
"web_accessible_resources": [
{
"resources": [
"provider-bridge.js",
"js/lib/vendor/solana-publickey-bundle.js"
],
"matches": [
"<all_urls>"
]
}
],
"action": {
"default_title": "Open SHiNE Wallet"
},
"side_panel": {
"default_path": "popup.html"
}
}
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "shine-browser-plugin-wallet",
"version": "1.0.0",
"description": "Chrome-compatible Manifest V3 plugin for SHiNE wallet-session login.",
"main": "popup.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@noble/curves": "^1.5.0",
"@solana/web3.js": "^1.98.4"
},
"devDependencies": {
"esbuild": "^0.28.1"
}
}
+265
View File
@@ -0,0 +1,265 @@
* {
box-sizing: border-box;
}
html {
min-height: 100%;
}
body {
margin: 0;
min-width: 320px;
max-width: none;
min-height: 100vh;
background: #0f1720;
color: #e8eef6;
font: 14px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.side-panel-body {
width: 100%;
}
.layout {
padding: 12px;
min-height: 100vh;
}
.panel {
display: flex;
flex-direction: column;
gap: 12px;
min-height: calc(100vh - 24px);
}
.panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.panel-header h1 {
margin: 0;
font-size: 18px;
}
.muted {
margin: 2px 0 0;
color: #9aabbd;
}
.small {
font-size: 12px;
}
.pill {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.pill-offline {
background: #4b1f28;
color: #ffc4cf;
}
.pill-online {
background: #153926;
color: #b7f5ce;
}
.card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
border: 1px solid #253446;
border-radius: 8px;
background: #131d29;
}
.card-title {
font-size: 13px;
font-weight: 700;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field span {
font-size: 12px;
color: #b8c4d1;
}
input[type="text"],
input[type="password"],
select {
width: 100%;
padding: 10px 12px;
border: 1px solid #314459;
border-radius: 8px;
background: #0d141d;
color: #edf3fb;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 8px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.btn {
min-height: 36px;
padding: 0 12px;
border: 0;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
}
.btn.primary {
background: #2f7df4;
color: #fff;
}
.btn.secondary {
background: #243446;
color: #e8eef6;
}
.btn.danger {
background: #6a2430;
color: #ffd6de;
}
.btn:disabled {
opacity: 0.55;
cursor: default;
}
.code {
font-size: 30px;
font-weight: 700;
letter-spacing: 0.12em;
}
.summary-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.summary-row code {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #bed5f5;
}
.status {
padding: 10px 12px;
border-radius: 8px;
font-size: 13px;
}
.status.info {
background: #172838;
color: #d8ebff;
}
.status.error {
background: #4d1e26;
color: #ffd0d8;
}
.hidden {
display: none;
}
.device-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.detail-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.device-row {
padding: 8px 10px;
border: 1px solid #243446;
border-radius: 8px;
background: #0d141d;
}
.detail-row {
display: grid;
gap: 4px;
padding: 8px 10px;
border: 1px solid #243446;
border-radius: 8px;
background: #0d141d;
}
.detail-label {
font-size: 12px;
color: #9aabbd;
}
.detail-value {
color: #e8eef6;
word-break: break-word;
}
.detail-value.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: #bed5f5;
}
.device-state {
font-size: 12px;
text-transform: lowercase;
}
.device-state-online {
color: #b7f5ce;
}
.device-state-offline {
color: #ffc4cf;
}
.device-state-unknown {
color: #f8e2a0;
}
.wallet-pubkey {
display: block;
width: 100%;
padding: 10px 12px;
border: 1px solid #314459;
border-radius: 8px;
background: #0d141d;
color: #bed5f5;
white-space: normal;
line-break: anywhere;
}
+100
View File
@@ -0,0 +1,100 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SHiNE Wallet</title>
<link rel="stylesheet" href="./popup.css" />
</head>
<body class="side-panel-body">
<main class="layout">
<section class="panel">
<div class="panel-header">
<div>
<h1>SHiNE Wallet</h1>
<p class="muted">Session-only wallet plugin</p>
</div>
<span id="connection-pill" class="pill pill-offline">не подключено</span>
</div>
<p id="server-login-info" class="muted small">Сервер SHiNE: —</p>
<div id="connect-card" class="card">
<div class="card-title">Подключение</div>
<label class="field">
<span>Логин</span>
<input id="login-input" type="text" autocomplete="username" />
</label>
<label class="checkbox-row">
<input id="use-password" type="checkbox" />
<span>Использовать доп. пароль</span>
</label>
<label id="password-field" class="field hidden">
<span>Пароль подключения</span>
<input id="password-input" type="password" autocomplete="current-password" />
</label>
<button id="start-btn" class="btn primary" type="button">Подключить</button>
</div>
<div id="pairing-card" class="card hidden">
<div class="card-title">Код подключения</div>
<div id="short-code" class="code">00 00 00 00 00</div>
<p id="pairing-hint" class="muted small">Покажите код на доверенном устройстве.</p>
<p id="pairing-expire" class="muted small"></p>
<div class="actions">
<button id="cancel-btn" class="btn secondary" type="button">Отменить</button>
</div>
</div>
<div id="session-card" class="card hidden">
<div class="card-title">Подключено</div>
<div class="summary-row"><span>Логин</span><strong id="session-login"></strong></div>
<div class="actions">
<button id="resume-btn" class="btn secondary" type="button">Проверить session</button>
<button id="disconnect-btn" class="btn danger" type="button">Отключить</button>
</div>
</div>
<div id="wallet-card" class="card hidden">
<div class="card-title">Текущий кошелёк ESP32</div>
<label class="field">
<span>Homeserver</span>
<select id="device-select"></select>
</label>
<div id="homeserver-list" class="device-list"></div>
<div class="actions">
<button id="refresh-devices-btn" class="btn secondary" type="button">Обновить устройства</button>
<button id="request-wallet-btn" class="btn primary" type="button">Запросить кошелёк</button>
</div>
</div>
<div id="pending-approval-card" class="card hidden">
<div class="card-title">Ожидается подпись</div>
<p id="pending-approval-subtitle" class="muted small">Сайт запросил подписание транзакции.</p>
<div id="pending-approval-details" class="device-list"></div>
<p class="muted small">Запрос уже отправлен на доверенное устройство. Здесь можно только отменить ожидание.</p>
<div class="actions">
<button id="cancel-pending-approval-btn" class="btn danger" type="button">Отменить</button>
</div>
</div>
<div id="wallet-result-card" class="card hidden">
<div class="card-title">Полученный кошелёк</div>
<div class="summary-row"><span>Тип</span><strong id="wallet-type"></strong></div>
<div class="field">
<span>Public key</span>
<code id="wallet-pubkey" class="wallet-pubkey"></code>
</div>
<p id="wallet-verify" class="muted small"></p>
<div class="actions">
<button id="copy-wallet-btn" class="btn secondary" type="button">Копировать ключ</button>
</div>
</div>
<div id="status" class="status hidden"></div>
</section>
</main>
<script type="module" src="./popup.js"></script>
</body>
</html>
+425
View File
@@ -0,0 +1,425 @@
import { formatPairingShortCode } from './js/lib/device-pairing.js';
const els = {
serverLoginInfo: document.querySelector('#server-login-info'),
loginInput: document.querySelector('#login-input'),
usePassword: document.querySelector('#use-password'),
passwordField: document.querySelector('#password-field'),
passwordInput: document.querySelector('#password-input'),
startBtn: document.querySelector('#start-btn'),
connectCard: document.querySelector('#connect-card'),
pairingCard: document.querySelector('#pairing-card'),
shortCode: document.querySelector('#short-code'),
pairingHint: document.querySelector('#pairing-hint'),
pairingExpire: document.querySelector('#pairing-expire'),
cancelBtn: document.querySelector('#cancel-btn'),
status: document.querySelector('#status'),
sessionCard: document.querySelector('#session-card'),
sessionLogin: document.querySelector('#session-login'),
resumeBtn: document.querySelector('#resume-btn'),
refreshDevicesBtn: document.querySelector('#refresh-devices-btn'),
disconnectBtn: document.querySelector('#disconnect-btn'),
walletCard: document.querySelector('#wallet-card'),
deviceSelect: document.querySelector('#device-select'),
homeserverList: document.querySelector('#homeserver-list'),
requestWalletBtn: document.querySelector('#request-wallet-btn'),
pendingApprovalCard: document.querySelector('#pending-approval-card'),
pendingApprovalSubtitle: document.querySelector('#pending-approval-subtitle'),
pendingApprovalDetails: document.querySelector('#pending-approval-details'),
cancelPendingApprovalBtn: document.querySelector('#cancel-pending-approval-btn'),
walletResultCard: document.querySelector('#wallet-result-card'),
walletType: document.querySelector('#wallet-type'),
walletPubkey: document.querySelector('#wallet-pubkey'),
walletVerify: document.querySelector('#wallet-verify'),
copyWalletBtn: document.querySelector('#copy-wallet-btn'),
connectionPill: document.querySelector('#connection-pill'),
};
let state = {
settings: {
serverLogin: 'shineupme',
serverHttp: 'https://shineup.me',
login: '',
},
pairing: {
active: false,
expiresAtMs: 0,
shortCode: '',
},
session: null,
walletProfile: null,
signing: {
selectedDeviceName: '',
},
currentWallet: null,
pendingApproval: null,
status: {
text: '',
kind: 'info',
},
};
let refreshTimer = 0;
let saveSettingsTimer = 0;
function setStatus(message, kind = 'info') {
els.status.textContent = String(message || '');
els.status.className = `status ${kind === 'error' ? 'error' : 'info'}`;
els.status.classList.toggle('hidden', !message);
}
function setConnectedPill(connected) {
els.connectionPill.textContent = connected ? 'подключено' : 'не подключено';
els.connectionPill.className = connected ? 'pill pill-online' : 'pill pill-offline';
}
function formatRemaining(ms) {
const safe = Math.max(0, Math.floor(Number(ms || 0) / 1000));
const minutes = Math.floor(safe / 60);
const seconds = safe % 60;
return `${minutes} мин ${seconds} сек`;
}
function shortKey(value, size = 10) {
const raw = String(value || '').trim();
return raw ? `${raw.slice(0, size)}...` : '—';
}
function renderHomeserverList(items = []) {
els.homeserverList.innerHTML = '';
if (!items.length) {
const empty = document.createElement('p');
empty.className = 'muted small';
empty.textContent = 'В PDA пока нет опубликованных homeserver-сессий.';
els.homeserverList.append(empty);
return;
}
items.forEach((item) => {
const row = document.createElement('div');
row.className = 'summary-row device-row';
const label = document.createElement('span');
label.textContent = `${item.sessionName} (${shortKey(item.sessionPubKeyBase58, 8)})`;
const badge = document.createElement('strong');
const stateValue = String(item.onlineState || 'unknown');
badge.textContent = stateValue;
badge.className = `device-state device-state-${stateValue}`;
row.append(label, badge);
els.homeserverList.append(row);
});
}
function renderPendingApproval(pendingApproval) {
els.pendingApprovalDetails.innerHTML = '';
if (!pendingApproval) return;
const summary = pendingApproval.transactionSummary || {};
const programs = Array.isArray(summary.programs) && summary.programs.length
? summary.programs.join(', ')
: 'не определены';
const details = [
{ label: 'Сайт', value: pendingApproval.origin || '—', mono: true },
{ label: 'Кошелёк', value: pendingApproval.publicKeyBase58 || '—', mono: true },
{ label: 'Очередь', value: `${pendingApproval.queuePosition || 1} из ${pendingApproval.queueLength || 1}` },
{ label: 'Комментарий', value: pendingApproval.comment || 'Транзакция запрошена сайтом' },
{ label: 'Тип', value: summary.kind || 'legacy' },
{ label: 'Инструкций', value: String(summary.instructionCount ?? 0) },
{ label: 'Программы', value: programs, mono: true },
];
if (summary.feePayer) {
details.push({ label: 'Fee payer', value: summary.feePayer, mono: true });
}
if (summary.recentBlockhash) {
details.push({ label: 'Blockhash', value: summary.recentBlockhash, mono: true });
}
for (const item of details) {
const row = document.createElement('div');
row.className = 'detail-row';
const label = document.createElement('div');
label.className = 'detail-label';
label.textContent = item.label;
const value = document.createElement('div');
value.className = `detail-value${item.mono ? ' mono' : ''}`;
value.textContent = item.value;
row.append(label, value);
els.pendingApprovalDetails.append(row);
}
}
function applyState(nextState) {
state = nextState || state;
const loginValue = String(state?.settings?.login || '');
const resolvedServerLogin = String(state?.settings?.serverLogin || '').trim();
const resolvedServerAddress = String(state?.settings?.serverHttp || '').trim();
els.serverLoginInfo.textContent = resolvedServerLogin && resolvedServerAddress
? `Сервер SHiNE: ${resolvedServerLogin} (${resolvedServerAddress})`
: 'Сервер SHiNE: —';
if (document.activeElement !== els.loginInput) {
els.loginInput.value = loginValue;
}
setConnectedPill(!!state?.session);
setStatus(state?.status?.text || '', state?.status?.kind || 'info');
const session = state?.session;
const walletProfile = state?.walletProfile;
const signing = state?.signing || {};
const currentWallet = state?.currentWallet || null;
const pendingApproval = state?.pendingApproval || null;
els.connectCard.classList.toggle('hidden', !!session);
els.sessionCard.classList.toggle('hidden', !session);
els.walletCard.classList.toggle('hidden', !session);
els.pendingApprovalCard.classList.toggle('hidden', !pendingApproval);
if (session) {
els.sessionLogin.textContent = session.login || '—';
}
const homeservers = Array.isArray(walletProfile?.homeserverSessions) ? walletProfile.homeserverSessions : [];
els.deviceSelect.innerHTML = '';
homeservers.forEach((item) => {
const option = document.createElement('option');
option.value = item.sessionName;
option.textContent = `${item.sessionName} [${item.onlineState || 'offline'}]`;
option.selected = item.sessionName === signing.selectedDeviceName;
els.deviceSelect.append(option);
});
renderHomeserverList(homeservers);
els.requestWalletBtn.disabled = !session || !signing.selectedDeviceName;
if (pendingApproval) {
const queueSuffix = (pendingApproval.queueLength || 1) > 1
? ` В очереди ${pendingApproval.queueLength} транзакции.`
: '';
els.pendingApprovalSubtitle.textContent = pendingApproval.origin
? `Сайт ${pendingApproval.origin} запросил подписание транзакции.${queueSuffix}`
: `Сайт запросил подписание транзакции.${queueSuffix}`;
renderPendingApproval(pendingApproval);
} else {
els.pendingApprovalSubtitle.textContent = 'Сайт запросил подписание транзакции.';
els.pendingApprovalDetails.innerHTML = '';
}
if (currentWallet?.publicKeyBase58) {
els.walletResultCard.classList.remove('hidden');
els.walletType.textContent = currentWallet.type || '—';
els.walletPubkey.textContent = currentWallet.publicKeyBase58 || '—';
els.walletVerify.textContent = currentWallet.verificationText || '—';
} else {
els.walletResultCard.classList.add('hidden');
els.walletType.textContent = '—';
els.walletPubkey.textContent = '—';
els.walletVerify.textContent = '—';
}
const pairing = state?.pairing || {};
if (pairing.active) {
els.pairingCard.classList.remove('hidden');
els.shortCode.textContent = formatPairingShortCode(String(pairing.shortCode || ''));
const leftMs = Number(pairing.expiresAtMs || 0) - Date.now();
els.pairingExpire.textContent = leftMs > 0 ? `Код действителен ещё ${formatRemaining(leftMs)}.` : 'Время ожидания истекло.';
els.startBtn.disabled = true;
} else {
els.pairingCard.classList.add('hidden');
els.shortCode.textContent = formatPairingShortCode('');
els.pairingExpire.textContent = '';
els.startBtn.disabled = false;
}
}
function normalizeError(response, fallback) {
return response?.error || fallback || 'Unknown error';
}
function sendMessage(type, payload = {}) {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type, payload }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message || 'Runtime message failed'));
return;
}
if (!response?.ok) {
reject(new Error(normalizeError(response, 'Wallet operation failed')));
return;
}
if (response?.state) applyState(response.state);
resolve(response);
});
});
}
async function refreshState() {
const response = await sendMessage('wallet:getState');
applyState(response.state);
}
async function saveSettings() {
await sendMessage('wallet:saveSettings', {
login: String(els.loginInput.value || '').trim(),
});
}
async function resolveServerInfo() {
const login = String(els.loginInput.value || '').trim();
if (!login) {
await sendMessage('wallet:saveSettings', { login: '' });
return;
}
try {
await sendMessage('wallet:resolveServerInfo', { login });
} catch (error) {
setStatus(error.message || 'Не удалось определить сервер SHiNE по PDA.', 'error');
}
}
function scheduleSaveSettings() {
if (saveSettingsTimer) {
window.clearTimeout(saveSettingsTimer);
}
saveSettingsTimer = window.setTimeout(() => {
saveSettingsTimer = 0;
void saveSettings();
}, 250);
}
async function startPairing() {
const login = String(els.loginInput.value || '').trim();
if (!login) {
setStatus('Введите логин.', 'error');
return;
}
setStatus('Создаём wallet-session заявку...', 'info');
try {
await sendMessage('wallet:startPairing', {
login,
usePassword: !!els.usePassword.checked,
password: String(els.passwordInput.value || ''),
});
} catch (error) {
setStatus(error.message || 'Не удалось начать pairing.', 'error');
}
}
async function cancelPairing() {
try {
await sendMessage('wallet:cancelPairing');
} catch (error) {
setStatus(error.message || 'Не удалось отменить pairing.', 'error');
}
}
async function resumeSession() {
setStatus('Проверяем сохранённую wallet-session...', 'info');
try {
await sendMessage('wallet:resumeSession');
} catch (error) {
setStatus(error.message || 'Не удалось восстановить session.', 'error');
}
}
async function disconnectSession() {
try {
await sendMessage('wallet:disconnectSession');
} catch (error) {
setStatus(error.message || 'Не удалось удалить session.', 'error');
}
}
async function refreshDevices() {
setStatus('Обновляем trusted homeserver-устройства...', 'info');
try {
await sendMessage('wallet:refreshWalletDevices');
} catch (error) {
setStatus(error.message || 'Не удалось обновить список устройств.', 'error');
}
}
async function updateDeviceSelection() {
try {
await sendMessage('wallet:updateSigningSelection', {
selectedDeviceName: String(els.deviceSelect.value || '').trim(),
});
} catch (error) {
setStatus(error.message || 'Не удалось обновить выбор homeserver.', 'error');
}
}
async function requestCurrentWallet() {
setStatus('Запрашиваем текущий кошелёк с ESP32...', 'info');
try {
await sendMessage('wallet:requestCurrentWallet');
} catch (error) {
setStatus(error.message || 'Не удалось получить кошелёк с ESP32.', 'error');
}
}
async function copyWalletKey() {
const value = String(els.walletPubkey.textContent || '').trim();
if (!value || value === '—') return;
try {
await navigator.clipboard.writeText(value);
setStatus('Публичный ключ скопирован.', 'info');
} catch (error) {
setStatus(error.message || 'Не удалось скопировать ключ.', 'error');
}
}
async function cancelPendingApproval() {
try {
await sendMessage('wallet:cancelPendingSiteApproval');
} catch (error) {
setStatus(error.message || 'Не удалось отменить ожидание подписи.', 'error');
}
}
function startUiRefreshLoop() {
stopUiRefreshLoop();
refreshTimer = window.setInterval(() => {
void refreshState();
}, 1000);
}
function stopUiRefreshLoop() {
if (refreshTimer) {
window.clearInterval(refreshTimer);
refreshTimer = 0;
}
}
function bindUi() {
els.usePassword.addEventListener('change', () => {
els.passwordField.classList.toggle('hidden', !els.usePassword.checked);
if (!els.usePassword.checked) {
els.passwordInput.value = '';
}
});
els.loginInput.addEventListener('input', () => { scheduleSaveSettings(); });
els.loginInput.addEventListener('change', () => {
void saveSettings();
void resolveServerInfo();
});
els.startBtn.addEventListener('click', () => { void startPairing(); });
els.cancelBtn.addEventListener('click', () => { void cancelPairing(); });
els.resumeBtn.addEventListener('click', () => { void resumeSession(); });
els.refreshDevicesBtn.addEventListener('click', () => { void refreshDevices(); });
els.disconnectBtn.addEventListener('click', () => { void disconnectSession(); });
els.deviceSelect.addEventListener('change', () => { void updateDeviceSelection(); });
els.requestWalletBtn.addEventListener('click', () => { void requestCurrentWallet(); });
els.copyWalletBtn.addEventListener('click', () => { void copyWalletKey(); });
els.cancelPendingApprovalBtn.addEventListener('click', () => { void cancelPendingApproval(); });
}
async function init() {
bindUi();
await refreshState();
startUiRefreshLoop();
}
window.addEventListener('beforeunload', () => {
stopUiRefreshLoop();
if (saveSettingsTimer) {
window.clearTimeout(saveSettingsTimer);
saveSettingsTimer = 0;
}
});
void init();
@@ -0,0 +1,433 @@
import { PublicKey, Transaction, VersionedTransaction } from './js/lib/vendor/solana-publickey-bundle.js';
const PAGE_REQUEST = 'shine-wallet-page-request';
const PAGE_RESPONSE = 'shine-wallet-page-response';
const PAGE_MESSAGE_TARGET_ORIGIN = '*';
const STANDARD_REGISTER_EVENT = 'wallet-standard:register-wallet';
const STANDARD_APP_READY_EVENT = 'wallet-standard:app-ready';
const SOLANA_CHAINS = ['solana:mainnet', 'solana:devnet', 'solana:testnet'];
const SOLANA_STANDARD_FEATURES = ['solana:signTransaction'];
const WALLET_ICON = `data:image/svg+xml;base64,${btoa(
'<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" fill="none"><rect width="96" height="96" rx="20" fill="#101722"/><path d="M23 28h50l-8 10H31l-8-10Z" fill="#3FB0FF"/><path d="M31 44h34l8 10H23l8-10Z" fill="#77D67A"/><path d="M23 68h50l-8-10H31l-8 10Z" fill="#A881FF"/></svg>'
)}`;
function bytesToBase64(bytes) {
let binary = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
const slice = bytes.subarray(i, i + chunk);
binary += String.fromCharCode(...slice);
}
return btoa(binary);
}
function base64ToBytes(value) {
const binary = atob(String(value || '').trim());
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
out[i] = binary.charCodeAt(i);
}
return out;
}
function createProviderError(message, code = '') {
const error = new Error(String(message || 'Wallet provider error'));
if (code === 'USER_REJECTED' || code === 'NOT_TRUSTED') {
error.code = 4001;
} else if (code) {
error.code = code;
}
return error;
}
function summarizeTransaction(transaction) {
const summary = {
kind: 'legacy',
instructionCount: 0,
accountCount: 0,
feePayer: '',
recentBlockhash: '',
programs: [],
};
if (!transaction) return summary;
const isVersioned = typeof transaction?.version === 'number' || transaction instanceof VersionedTransaction;
summary.kind = isVersioned ? `versioned:${String(transaction.version)}` : 'legacy';
summary.feePayer = String(transaction?.feePayer?.toBase58?.() || '').trim();
summary.recentBlockhash = String(transaction?.recentBlockhash || transaction?.message?.recentBlockhash || '').trim();
if (isVersioned) {
const message = transaction?.message || {};
const staticKeys = Array.isArray(message?.staticAccountKeys) ? message.staticAccountKeys : [];
const instructions = Array.isArray(message?.compiledInstructions) ? message.compiledInstructions : [];
summary.instructionCount = instructions.length;
summary.accountCount = staticKeys.length;
summary.programs = instructions
.map((instruction) => staticKeys[instruction?.programIdIndex]?.toBase58?.() || '')
.filter(Boolean)
.slice(0, 5);
return summary;
}
const instructions = Array.isArray(transaction?.instructions) ? transaction.instructions : [];
summary.instructionCount = instructions.length;
summary.accountCount = Array.isArray(transaction?.signatures) ? transaction.signatures.length : 0;
summary.programs = instructions
.map((instruction) => instruction?.programId?.toBase58?.() || '')
.filter(Boolean)
.slice(0, 5);
return summary;
}
function createRequest(method, params = {}) {
const id = `shine-wallet-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
return new Promise((resolve, reject) => {
const onMessage = (event) => {
if (event.source !== window) return;
const data = event.data || {};
if (data?.target !== PAGE_RESPONSE || String(data?.id || '') !== id) return;
window.removeEventListener('message', onMessage);
if (!data?.ok) {
reject(createProviderError(data?.error || 'Wallet request failed', String(data?.code || '')));
return;
}
resolve(data?.result || {});
};
window.addEventListener('message', onMessage);
window.postMessage({
target: PAGE_REQUEST,
id,
method,
params,
}, PAGE_MESSAGE_TARGET_ORIGIN);
});
}
function serializeTransactionBase64(transaction) {
if (!transaction || typeof transaction.serialize !== 'function') {
throw createProviderError('Unsupported transaction object', 'UNSUPPORTED_TRANSACTION');
}
let raw;
try {
raw = transaction.serialize({ requireAllSignatures: false, verifySignatures: false });
} catch {
raw = transaction.serialize();
}
const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw);
return bytesToBase64(bytes);
}
function deserializeSignedTransaction(base64, originalTransaction) {
const bytes = base64ToBytes(base64);
const ctor = originalTransaction?.constructor;
if (ctor && typeof ctor.deserialize === 'function') {
return ctor.deserialize(bytes);
}
if (ctor && typeof ctor.from === 'function') {
return ctor.from(bytes);
}
if (typeof originalTransaction?.version === 'number') {
return VersionedTransaction.deserialize(bytes);
}
return Transaction.from(bytes);
}
class ShineWalletAccount {
constructor(publicKeyBase58) {
this.address = publicKeyBase58;
this.publicKey = new Uint8Array(new PublicKey(publicKeyBase58).toBytes());
this.chains = SOLANA_CHAINS.slice();
this.features = SOLANA_STANDARD_FEATURES.slice();
this.label = 'SHiNE Wallet';
this.icon = WALLET_ICON;
}
}
class ShineProviderCore {
constructor() {
this.publicKey = null;
this.isConnected = false;
this._legacyListeners = new Map();
this._standardListeners = new Set();
this._accounts = [];
}
get publicKeyBase58() {
return this.publicKey?.toBase58?.() || '';
}
get standardAccounts() {
return this._accounts.slice();
}
async connect(options = {}) {
const onlyIfTrusted = !!options?.onlyIfTrusted || !!options?.silent;
const result = await createRequest('connect', { onlyIfTrusted });
const nextKey = new PublicKey(String(result?.publicKeyBase58 || '').trim());
this.publicKey = nextKey;
this.isConnected = true;
this._accounts = [new ShineWalletAccount(nextKey.toBase58())];
this.emitLegacy('connect', nextKey);
this.emitLegacy('accountChanged', nextKey);
this.emitStandardChange();
return {
publicKey: nextKey,
accounts: this.standardAccounts,
};
}
async disconnect() {
await createRequest('disconnect', {});
this.isConnected = false;
this.publicKey = null;
this._accounts = [];
this.emitLegacy('disconnect');
this.emitLegacy('accountChanged', null);
this.emitStandardChange();
}
async signTransaction(transaction, comment = '') {
if (!this.publicKey) {
await this.connect();
}
const transactionBase64 = serializeTransactionBase64(transaction);
const transactionSummary = summarizeTransaction(transaction);
const result = await createRequest('signTransaction', {
publicKeyBase58: this.publicKeyBase58,
transactionBase64,
comment: String(comment || '').trim() || `Site ${window.location.origin} requested transaction signature`,
transactionSummary,
});
return deserializeSignedTransaction(String(result?.signedTransactionBase64 || ''), transaction);
}
async signTransactionBytes(transactionBytes, comment = '') {
if (!this.publicKey) {
await this.connect();
}
const transactionSummary = {
kind: 'raw-bytes',
instructionCount: 0,
accountCount: 0,
feePayer: this.publicKeyBase58,
recentBlockhash: '',
programs: [],
byteLength: Number(transactionBytes?.length || 0),
};
const result = await createRequest('signTransaction', {
publicKeyBase58: this.publicKeyBase58,
transactionBase64: bytesToBase64(transactionBytes),
comment: String(comment || '').trim() || `Site ${window.location.origin} requested transaction signature`,
transactionSummary,
});
return base64ToBytes(String(result?.signedTransactionBase64 || '').trim());
}
onLegacy(event, handler) {
const key = String(event || '');
if (!this._legacyListeners.has(key)) {
this._legacyListeners.set(key, new Set());
}
this._legacyListeners.get(key).add(handler);
return this;
}
offLegacy(event, handler) {
const key = String(event || '');
const bucket = this._legacyListeners.get(key);
if (!bucket) return this;
bucket.delete(handler);
if (!bucket.size) this._legacyListeners.delete(key);
return this;
}
emitLegacy(event, payload) {
const bucket = this._legacyListeners.get(String(event || ''));
if (!bucket?.size) return;
for (const handler of [...bucket]) {
try {
handler(payload);
} catch {}
}
}
onStandardChange(listener) {
this._standardListeners.add(listener);
return () => {
this._standardListeners.delete(listener);
};
}
emitStandardChange() {
const properties = { accounts: this.standardAccounts };
for (const listener of [...this._standardListeners]) {
try {
listener(properties);
} catch {}
}
}
}
class ShineSolanaProvider {
constructor(core) {
this.core = core;
this.isSHiNE = true;
this.isPhantom = true;
}
get publicKey() {
return this.core.publicKey;
}
get isConnected() {
return this.core.isConnected;
}
on(event, handler) {
return this.core.onLegacy(event, handler);
}
off(event, handler) {
return this.core.offLegacy(event, handler);
}
removeListener(event, handler) {
return this.off(event, handler);
}
async connect(options = {}) {
const result = await this.core.connect(options);
return { publicKey: result.publicKey };
}
async disconnect() {
await this.core.disconnect();
}
async signTransaction(transaction) {
return this.core.signTransaction(transaction);
}
async signAllTransactions(transactions = []) {
const list = Array.isArray(transactions) ? transactions : [];
const outputs = [];
for (const transaction of list) {
outputs.push(await this.core.signTransaction(transaction));
}
return outputs;
}
async request(args = {}) {
const method = String(args?.method || '');
const params = args?.params;
if (method === 'connect') {
return this.connect(Array.isArray(params) ? params[0] : params || {});
}
if (method === 'disconnect') {
return this.disconnect();
}
if (method === 'signTransaction') {
const tx = Array.isArray(params) ? params[0] : params?.transaction || params;
return this.signTransaction(tx);
}
if (method === 'signAllTransactions') {
const transactions = Array.isArray(params)
? params
: Array.isArray(params?.transactions) ? params.transactions : [];
return this.signAllTransactions(transactions);
}
throw createProviderError(`Unsupported request method: ${method}`, 'UNSUPPORTED_METHOD');
}
}
class ShineStandardWallet {
constructor(core) {
this.core = core;
this.version = '1.0.0';
this.name = 'SHiNE Wallet';
this.icon = WALLET_ICON;
this.chains = SOLANA_CHAINS.slice();
this.features = {
'standard:connect': {
version: '1.0.0',
connect: async (input = {}) => {
const result = await this.core.connect({ silent: !!input?.silent });
return { accounts: result.accounts };
},
},
'standard:disconnect': {
version: '1.0.0',
disconnect: async () => {
await this.core.disconnect();
},
},
'standard:events': {
version: '1.0.0',
on: (event, listener) => {
if (event !== 'change' || typeof listener !== 'function') {
return () => {};
}
return this.core.onStandardChange(listener);
},
},
'solana:signTransaction': {
version: '1.0.0',
supportedTransactionVersions: ['legacy', 0],
signTransaction: async (...inputs) => {
const outputs = [];
for (const input of inputs) {
const accountAddress = String(input?.account?.address || '').trim();
if (accountAddress && this.core.publicKeyBase58 && accountAddress !== this.core.publicKeyBase58) {
throw createProviderError('Requested account does not match current wallet account', 'ACCOUNT_MISMATCH');
}
const comment = `Site ${window.location.origin} requested transaction signature`;
const signedTransaction = await this.core.signTransactionBytes(new Uint8Array(input.transaction), comment);
outputs.push({ signedTransaction });
}
return outputs;
},
},
};
}
get accounts() {
return this.core.standardAccounts;
}
}
function registerStandardWallet(wallet) {
const callback = ({ register }) => register(wallet);
try {
window.dispatchEvent(new CustomEvent(STANDARD_REGISTER_EVENT, { detail: callback }));
} catch (error) {
console.error('wallet-standard register dispatch failed', error);
}
try {
window.addEventListener(STANDARD_APP_READY_EVENT, ({ detail }) => {
try {
callback(detail);
} catch (error) {
console.error('wallet-standard app-ready callback failed', error);
}
});
} catch (error) {
console.error('wallet-standard app-ready listener failed', error);
}
try {
window.navigator.wallets = window.navigator.wallets || [];
window.navigator.wallets.push(callback);
} catch {}
}
const core = new ShineProviderCore();
const legacyProvider = new ShineSolanaProvider(core);
const standardWallet = new ShineStandardWallet(core);
registerStandardWallet(standardWallet);
if (!window.solana) {
window.solana = legacyProvider;
window.phantom = window.phantom || {};
window.phantom.solana = legacyProvider;
window.dispatchEvent(new Event('solana#initialized'));
}
@@ -0,0 +1 @@
rootProject.name = 'SHiNE-browser-plugin-wallet'
+71
View File
@@ -0,0 +1,71 @@
# AGENTS.md — SHiNE-server
## Назначение
SHiNE-server — серверная часть мессенджера SHiNE: WebSocket-сервер, хранение блоков блокчейна
пользователей, доставка личных сообщений (DM), звонки.
## Структура папок
- `shine-server-net-server/` — точка входа, запуск HTTP/WS сервера
- `shine-server-net-protocol/` — обработчики операций (RPC и события WS)
- `shine-server-db/` — DAO, SQL-схема, PostgreSQL runtime
- `shine-server-blockchain/` — логика хранения и проверки блоков блокчейна
- `shine-server-crypto/` — криптографические утилиты
- `shine-server-config/` — конфигурация сервера
- `shine-server-log/` — логирование
- `shine-server-geo/` — геолокация IP
## Настройка сервера в Solana (Solana PDA)
Серверный аккаунт SHiNE регистрируется в Solana в виде `user_pda` с флагом `is_server=true`.
В PDA хранятся:
- **адрес сервера** (URL WebSocket/HTTPS, например `https://shineup.me/ws`);
- **список серверов синхронизации** (`sync_servers`) — логины SHiNE-аккаунтов серверов-партнёров,
с которыми синхронизируются пользовательские блокчейны;
- **корневой ключ** сервера (`root_key`).
Клиенты читают PDA напрямую из Solana, чтобы узнать адрес сервера и при необходимости подключиться.
**Управление серверной PDA выполняется через Web-панель администратора:**
```
shine-UI/server-ui.html
```
Страницы:
- `shine-UI/server-ui/create-server-pda.html` — первичная регистрация серверного аккаунта;
- `shine-UI/server-ui/update-server-pda.html` — обновление адреса или списка sync_servers.
Для регистрации нужен полный keyBundle (root + device + blockchain).
Для обновления — только root + device (blockchain-ключ не нужен).
Актуальные адреса программ Solana (devnet):
- `shine_users`: `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6`
- `shine_payments`: `SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v`
Подробнее: `docs/Инициализация_Solana_регистрации/README.md`
## Синхронизация с партнёрскими серверами
Сервер должен синхронизировать блоки пользовательских блокчейнов с
серверами-партнёрами из `sync_servers`. DM между партнёрами не реплицируются:
они доставляются только на первый access-сервер получателя.
Детали: `docs/Blockchain/sync-between-servers.md`
## Деплой
- Основные инструкции по деплою находятся в `../deploy/AGENTS.md`.
- Deploy выполнять shell-скриптами из `../deploy/scripts/`.
- Gradle deploy-задачи не использовать: Gradle остаётся для сборки и локального запуска.
- Любые изменения на production (`shineup.me`, `server2.shineup.me`) делать только после отдельного явного подтверждения пользователя.
- Перед production deploy обязательно обновить/проверить backup в `deploy/backup/archive/`.
Логи на проде:
- `/home/player/SHiNE/shine-server/logs/app.log`
- `/home/player/SHiNE/shine-server/logs/call-delivery-events.log`
## Язык
Комментарии в коде, документация и commit-сообщения — на русском языке.
Binary file not shown.
@@ -0,0 +1,31 @@
plugins {
id 'java'
}
group = 'shine'
version = '1.0.0'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
repositories { mavenCentral() }
dependencies {
implementation project(':shine-server-config')
implementation project(':shine-server-db')
implementation project(':shine-server-crypto')
implementation project(':shine-server-blockchain')
implementation project(':shine-server-net-protocol')
implementation project(':shine-server-solana-users-sync')
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2'
implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1'
implementation 'org.slf4j:slf4j-api:2.0.16'
testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0'
}
test { useJUnitPlatform() }
@@ -0,0 +1,56 @@
package server.archive;
import java.nio.file.Path;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
/** Единые локальные имена больших архивных файлов. */
public final class ArchiveFileNames {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd.MM.yy");
private ArchiveFileNames() {}
/**
* Временное имя полностью собранного файла до появления Arweave TX ID.
* Пример: archive01.00001.11.09.26.tmp.SHiNE-archive
*/
public static String pendingFileName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone) {
return baseName(login, bigBlockNumber, createdAtMs, zone) + ".tmp.SHiNE-archive";
}
/**
* Постоянное имя после успешной загрузки в Arweave.
* Пример: archive01.00001.11.09.26.Xm32...kP9.SHiNE-archive
*/
public static String uploadedFileName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone, byte[] txId) {
if (txId == null || txId.length != 32) throw new IllegalArgumentException("Arweave TX ID должен быть 32 bytes");
String tx = Base64.getUrlEncoder().withoutPadding().encodeToString(txId);
return baseName(login, bigBlockNumber, createdAtMs, zone) + "." + tx + ".SHiNE-archive";
}
public static Path pendingFilePath(Path archiveDir, String login, long bigBlockNumber, long createdAtMs, ZoneId zone) {
return archiveDir.resolve(pendingFileName(login, bigBlockNumber, createdAtMs, zone));
}
public static Path uploadedFilePath(Path archiveDir, String login, long bigBlockNumber, long createdAtMs, ZoneId zone, byte[] txId) {
return archiveDir.resolve(uploadedFileName(login, bigBlockNumber, createdAtMs, zone, txId));
}
private static String baseName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone) {
if (bigBlockNumber < 0 || bigBlockNumber > 0xffff_ffffL) {
throw new IllegalArgumentException("Номер большого блока вне u32: " + bigBlockNumber);
}
String safeLogin = sanitize(login);
String number = String.format("%05d", bigBlockNumber);
String date = DATE.format(Instant.ofEpochMilli(createdAtMs).atZone(zone == null ? ZoneId.systemDefault() : zone));
return safeLogin + "." + number + "." + date;
}
static String sanitize(String login) {
String s = login == null ? "server" : login.trim();
if (s.isEmpty()) s = "server";
return s.replaceAll("[^A-Za-z0-9._-]", "_");
}
}
@@ -0,0 +1,37 @@
package server.archive;
import utils.config.AppConfig;
import java.nio.file.Path;
import java.util.*;
/** Настройки чтения архивов других доверенных SHiNE-серверов. Пустой whitelist = импорт выключен. */
public record ArchiveImportConfig(
Set<String> allowedPublishers,
int intervalMinutes,
String arweaveGateway,
Path workDir
) {
public static ArchiveImportConfig load() {
AppConfig c = AppConfig.getInstance();
Set<String> approved = new LinkedHashSet<>();
String raw = c.getParam("archive.import.allowedPublishers");
if (raw != null) {
for (String item : raw.split(",")) {
String login = item.trim().toLowerCase(Locale.ROOT);
if (!login.isEmpty()) approved.add(login);
}
}
String gateway = c.getParam("archive.arweave.gateway");
if (gateway == null || gateway.isBlank()) gateway = "https://arweave.net";
String dir = c.getParam("archive.import.workDir");
if (dir == null || dir.isBlank()) dir = "data/archive-import";
return new ArchiveImportConfig(
Set.copyOf(approved),
Math.max(1, c.getInt("archive.import.intervalMinutes", 60)),
gateway.trim(),
Path.of(dir));
}
public boolean enabled() { return !allowedPublishers.isEmpty(); }
}
@@ -0,0 +1,54 @@
package server.archive;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.*;
/**
* Фоновая проверка уже синхронизированной локальной таблицы User PDA.
* Solana отдельно здесь не опрашивается: первый запуск через 10 секунд после старта,
* затем по умолчанию раз в час.
*/
public final class ArchiveImportScheduler {
private static final Logger log = LoggerFactory.getLogger(ArchiveImportScheduler.class);
private static ScheduledExecutorService executor;
private ArchiveImportScheduler() {}
public static synchronized void startOrLog() {
if (executor != null) return;
ArchiveImportConfig cfg = ArchiveImportConfig.load();
if (!cfg.enabled()) {
log.info("Archive importer выключен: archive.import.allowedPublishers пуст");
return;
}
try {
ArchiveImportService service = new ArchiveImportService(cfg);
executor = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "archive-importer");
t.setDaemon(true);
return t;
});
executor.scheduleWithFixedDelay(
() -> {
try { service.pollOnce(); }
catch (Exception e) { log.error("Archive importer cycle failed", e); }
},
10,
Math.multiplyExact((long) cfg.intervalMinutes(), 60L),
TimeUnit.SECONDS);
log.info("Archive importer включён. approvedPublishers={} interval={}min",
cfg.allowedPublishers(), cfg.intervalMinutes());
} catch (Exception e) {
log.error("Archive importer не запущен", e);
}
}
public static synchronized void close() {
if (executor != null) {
executor.shutdownNow();
executor = null;
}
}
}
@@ -0,0 +1,229 @@
package server.archive;
import blockchain.BchBlockEntry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
import shine.db.archive.*;
import shine.db.dao.*;
import shine.db.entities.BlockchainStateEntry;
import java.io.IOException;
import java.net.URI;
import java.net.http.*;
import java.nio.file.*;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;
/** Импорт archive head только от серверов из server-side whitelist. */
public final class ArchiveImportService {
private static final Logger log = LoggerFactory.getLogger(ArchiveImportService.class);
private final ArchiveImportConfig cfg;
private final ArchiveImportDAO dao = ArchiveImportDAO.getInstance();
private final BlockchainStateDAO stateDao = BlockchainStateDAO.getInstance();
private final BlocksDAO blocksDao = BlocksDAO.getInstance();
private final ShineArchiveReader reader = new ShineArchiveReader();
private final Net_AddBlock_Handler addBlockHandler = new Net_AddBlock_Handler();
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(20))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
public ArchiveImportService(ArchiveImportConfig cfg) throws IOException {
this.cfg = Objects.requireNonNull(cfg);
Files.createDirectories(cfg.workDir());
}
/**
* Никаких отдельных Solana RPC запросов здесь нет.
* Читаем только solana_user_pda_current, которую уже обновляет обычный Solana users sync.
*/
public void pollOnce() throws Exception {
if (!cfg.enabled()) return;
for (ArchivePublisherHead head : dao.listPendingPublisherHeads(cfg.allowedPublishers())) {
try {
importHead(head);
} catch (Exception e) {
log.error("Archive import publisher={} tx={} failed", head.login(), head.archiveHeadTxId(), e);
}
}
}
private void importHead(ArchivePublisherHead publisher) throws Exception {
String headTxId = publisher.archiveHeadTxId();
byte[] headHash = hex(publisher.archiveHeadHash());
// Если процесс успел импортировать head, но упал до установки archive_imported=true,
// следующий цикл просто завершает отметку без повторного скачивания всей истории.
if (headTxId.equals(publisher.lastImportedArchiveTxId())) {
if (!dao.markHeadImported(publisher.login(), headTxId)) {
log.info("Archive head changed while finalizing import marker publisher={} oldHead={}",
publisher.login(), headTxId);
}
return;
}
Path headFile = download(headTxId);
ShineArchiveReader.Document head;
try {
head = reader.readAndVerify(headFile, publisher.login(), headHash, publisher.rootKey());
} finally {
Files.deleteIfExists(headFile);
}
// FULL reference table позволяет догнать все archive blocks, пропущенные пока сервер был выключен.
List<ShineArchiveReader.Reference> previous = new ArrayList<>(head.references());
previous.sort(Comparator.comparingLong(ShineArchiveReader.Reference::bigBlockNumber));
int startIndex = findResumeIndex(previous, publisher.lastImportedArchiveTxId(), headTxId);
for (int i = startIndex; i < previous.size(); i++) {
ShineArchiveReader.Reference ref = previous.get(i);
importOne(publisher, ref.arweaveTxId(), ref.archiveHash());
if (!dao.advanceImportCursor(publisher.login(), headTxId, ref.arweaveTxId())) {
throw new IllegalStateException("Archive head changed during import for publisher " + publisher.login());
}
}
// Head уже скачан и проверен выше второй раз его из Arweave не качаем.
importDocument(publisher, headTxId, head);
if (!dao.advanceImportCursor(publisher.login(), headTxId, headTxId)) {
throw new IllegalStateException("Archive head changed during final import for publisher " + publisher.login());
}
if (!dao.markHeadImported(publisher.login(), headTxId)) {
throw new IllegalStateException("Archive head changed before imported marker for publisher " + publisher.login());
}
log.info("Archive head fully imported publisher={} bigBlock={} tx={}",
publisher.login(), head.bigBlockNumber(), headTxId);
}
/**
* Возвращает индекс первого ещё не импортированного previous big block.
* Пустой cursor = новый сервер, импортируем всю FULL историю.
*/
private static int findResumeIndex(List<ShineArchiveReader.Reference> previous,
String lastImportedTxId,
String currentHeadTxId) {
String last = lastImportedTxId == null ? "" : lastImportedTxId.trim();
if (last.isEmpty()) return 0;
if (last.equals(currentHeadTxId)) return previous.size();
for (int i = 0; i < previous.size(); i++) {
if (last.equals(previous.get(i).arweaveTxId())) return i + 1;
}
throw new IllegalStateException(
"Последний импортированный archive TX не найден в FULL history текущего head: " + last);
}
private void importOne(ArchivePublisherHead publisher, String txId, byte[] expectedHash) throws Exception {
Path file = download(txId);
try {
ShineArchiveReader.Document doc = reader.readAndVerify(
file, publisher.login(), expectedHash, publisher.rootKey());
importDocument(publisher, txId, doc);
} finally {
Files.deleteIfExists(file);
}
}
private void importDocument(ArchivePublisherHead publisher, String txId,
ShineArchiveReader.Document doc) throws Exception {
for (ShineArchiveReader.Chunk chunk : doc.chunks()) {
processChunk(publisher.login(), txId, doc, chunk);
}
log.info("Archive imported publisher={} bigBlock={} tx={} chunks={}",
publisher.login(), doc.bigBlockNumber(), txId, doc.chunks().size());
}
private void processChunk(String publisherLogin, String txId,
ShineArchiveReader.Document doc,
ShineArchiveReader.Chunk chunk) throws Exception {
ReentrantLock lock = BlockchainLocks.lockFor(chunk.blockchainName());
lock.lock();
try {
ensureState(chunk.blockchainName());
BlockchainStateEntry state = stateDao.getByBlockchainName(chunk.blockchainName());
int localLast = state == null ? -1 : state.getLastBlockNumber();
long archiveLast = -1;
for (byte[] raw : chunk.rawRecords()) {
BchBlockEntry block = new BchBlockEntry(raw);
archiveLast = Integer.toUnsignedLong(block.blockNumber);
if (block.blockNumber <= localLast) {
byte[] localHash = blocksDao.getHashByNumber(chunk.blockchainName(), block.blockNumber);
if (!Arrays.equals(localHash, block.getHash32())) {
throw new IllegalStateException(
"Archive conflict " + chunk.blockchainName() + "#" + block.blockNumber);
}
continue;
}
if (block.blockNumber != localLast + 1) {
throw new IllegalStateException(
"Archive gap " + chunk.blockchainName() + ": local=" + localLast +
" next=" + block.blockNumber);
}
var result = addBlockHandler.addBlockFromArchive(chunk.blockchainName(), raw);
if (!result.ok()) {
throw new IllegalStateException(
"Archive AddBlock rejected " + chunk.blockchainName() + "#" +
block.blockNumber + ": " + result.reasonCode());
}
localLast = block.blockNumber;
}
if (archiveLast >= 0) {
dao.upsertLocation(new ArchiveBlockchainLocation(
chunk.blockchainName(), publisherLogin, txId, doc.archiveHash(), doc.bigBlockNumber(),
chunk.offset(), chunk.size(), archiveLast, System.currentTimeMillis()));
}
} finally {
lock.unlock();
}
}
private void ensureState(String blockchainName) throws Exception {
if (stateDao.getByBlockchainName(blockchainName) != null) return;
ArchiveImportDAO.BlockchainIdentity id = dao.getBlockchainIdentity(blockchainName);
if (id == null) throw new IllegalStateException("Нет User PDA для blockchain " + blockchainName);
BlockchainStateEntry s = new BlockchainStateEntry();
s.setBlockchainName(id.blockchainName());
s.setLogin(id.login());
s.setBlockchainKey(id.blockchainKey());
s.setSizeLimit(id.sizeLimit());
s.setFileSizeBytes(0);
s.setLastBlockNumber(-1);
s.setLastBlockHash(null);
s.setUpdatedAtMs(System.currentTimeMillis());
stateDao.insertIfMissing(s);
}
private Path download(String txId) throws Exception {
String gateway = cfg.arweaveGateway().replaceAll("/+$", "");
Path target = Files.createTempFile(cfg.workDir(), "archive-", ".download");
HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + "/" + txId))
.timeout(Duration.ofMinutes(10))
.GET()
.build();
HttpResponse<Path> resp = http.send(req, HttpResponse.BodyHandlers.ofFile(target));
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
Files.deleteIfExists(target);
throw new IOException("Arweave HTTP " + resp.statusCode() + " tx=" + txId);
}
return target;
}
private static byte[] hex(String value) {
String s = String.valueOf(value == null ? "" : value).trim();
if (s.length() != 64) throw new IllegalArgumentException("archive_head_hash должен быть HEX64");
byte[] out = new byte[32];
for (int i = 0; i < 32; i++) {
out[i] = (byte) Integer.parseInt(s.substring(i * 2, i * 2 + 2), 16);
}
return out;
}
}
@@ -0,0 +1,75 @@
package server.archive;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import sync.util.Base58Util;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;
/** Читает Ed25519 seed32 из распространённых SHiNE/Solana форматов ключей. */
final class ArchiveKeyLoader {
private static final ObjectMapper JSON = new ObjectMapper();
private ArchiveKeyLoader() {}
static byte[] loadSeed32(Path path) throws IOException {
if (path == null) throw new IllegalArgumentException("Путь ключа не задан");
byte[] raw = Files.readAllBytes(path);
if (raw.length == 32) return raw.clone();
if (raw.length == 64) return first32(raw);
String text = new String(raw, StandardCharsets.UTF_8).trim();
if (text.isEmpty()) throw new IOException("Пустой key file: " + path);
// Solana keypair JSON: [64 чисел].
if (text.startsWith("[")) {
JsonNode arr = JSON.readTree(text);
if (!arr.isArray() || (arr.size() != 32 && arr.size() != 64)) {
throw new IOException("Ожидался JSON keypair на 32/64 байта: " + path);
}
byte[] bytes = new byte[arr.size()];
for (int i = 0; i < arr.size(); i++) {
int v = arr.get(i).asInt(-1);
if (v < 0 || v > 255) throw new IOException("Некорректный байт keypair: " + path);
bytes[i] = (byte) v;
}
return bytes.length == 32 ? bytes : first32(bytes);
}
// Base58 seed32 или Solana secret key 64 bytes.
try {
byte[] decoded = Base58Util.decode(text);
if (decoded.length == 32) return decoded;
if (decoded.length == 64) return first32(decoded);
} catch (IllegalArgumentException ignored) {
// Пробуем остальные текстовые форматы ниже.
}
// Base64 raw seed / keypair / Ed25519 PKCS8. В SHiNE PKCS8 seed находится в последних 32 байтах.
try {
byte[] decoded = Base64.getDecoder().decode(text.replaceAll("\\s+", ""));
if (decoded.length == 32) return decoded;
if (decoded.length == 64) return first32(decoded);
if (decoded.length > 32) {
byte[] seed = new byte[32];
System.arraycopy(decoded, decoded.length - 32, seed, 0, 32);
return seed;
}
} catch (IllegalArgumentException ignored) {
// Ниже выдадим понятную общую ошибку.
}
throw new IOException("Не удалось распознать Ed25519 key file: " + path
+ ". Поддерживаются raw seed32, raw/keypair64, Solana JSON, Base58 seed/keypair и Base64 PKCS8.");
}
private static byte[] first32(byte[] bytes) {
byte[] out = new byte[32];
System.arraycopy(bytes, 0, out, 0, 32);
return out;
}
}
@@ -0,0 +1,98 @@
package server.archive;
import utils.config.AppConfig;
import utils.config.SolanaProgramsConfig;
import java.nio.file.Path;
import java.time.LocalTime;
import java.time.ZoneId;
/** Настройки опционального архивного publisher-а. По умолчанию функция выключена. */
public record ArchivePublisherConfig(
boolean enabled,
LocalTime publishTime,
ZoneId publishZone,
long maxFileBytes,
Path workDir,
String serverLogin,
String arweaveGateway,
Path arweaveWalletJwkPath,
int arweaveMinConfirmations,
int arweaveConfirmPollSeconds,
int arweaveConfirmTimeoutMinutes,
Path solanaRootKeyPath,
Path solanaClientKeyPath,
String solanaRpcUrl,
String solanaUsersProgramId,
String solanaPaymentsProgramId,
int solanaConfirmPollSeconds,
int solanaConfirmTimeoutMinutes,
String solanaCommitment
) {
public static ArchivePublisherConfig load() {
AppConfig c = AppConfig.getInstance();
return new ArchivePublisherConfig(
c.getBoolean("archive.publish.enabled", false),
parseTime(orDefault(c.getParam("archive.publish.time"), "00:00")),
parseZone(c.getParam("archive.publish.zoneId")),
positiveLong(parseLong(c.getParam("archive.maxFileBytes"), 4_000_000_000L), "archive.maxFileBytes"),
Path.of(orDefault(c.getParam("archive.workDir"), "data/archive")),
requiredIfEnabled(c, "server.SHiNE.login"),
orDefault(c.getParam("archive.arweave.gateway"), "https://arweave.net"),
optionalPath(c.getParam("archive.arweave.walletJwkPath")),
nonNegative(c.getInt("archive.arweave.minConfirmations", 1), "archive.arweave.minConfirmations"),
positive(c.getInt("archive.arweave.confirmPollSeconds", 30), "archive.arweave.confirmPollSeconds"),
positive(c.getInt("archive.arweave.confirmTimeoutMinutes", 180), "archive.arweave.confirmTimeoutMinutes"),
optionalPath(c.getParam("archive.solana.rootKeyPath")),
optionalPath(c.getParam("archive.solana.clientKeyPath")),
firstNonBlank(c.getParam("solana.users.sync.rpcUrl"), c.getParam("solana.rpcUrl"), SolanaProgramsConfig.SOLANA_RPC_URL),
firstNonBlank(c.getParam("solana.users.sync.programId"), SolanaProgramsConfig.SHINE_USERS_PROGRAM_ID),
SolanaProgramsConfig.SHINE_PAYMENTS_PROGRAM_ID,
positive(c.getInt("archive.solana.confirmPollSeconds", 5), "archive.solana.confirmPollSeconds"),
positive(c.getInt("archive.solana.confirmTimeoutMinutes", 30), "archive.solana.confirmTimeoutMinutes"),
orDefault(c.getParam("archive.solana.commitment"), "finalized")
);
}
public void validateForStart() {
if (!enabled) return;
if (serverLogin == null || serverLogin.isBlank()) throw new IllegalArgumentException("server.SHiNE.login обязателен для archive publisher");
if (arweaveWalletJwkPath == null) throw new IllegalArgumentException("archive.arweave.walletJwkPath обязателен");
if (solanaRootKeyPath == null) throw new IllegalArgumentException("archive.solana.rootKeyPath обязателен");
if (solanaClientKeyPath == null) throw new IllegalArgumentException("archive.solana.clientKeyPath обязателен");
if (solanaRpcUrl == null || solanaRpcUrl.isBlank()) throw new IllegalArgumentException("solana.users.sync.rpcUrl или solana.rpcUrl обязателен");
if (maxFileBytes >= 0x1_0000_0000L) {
throw new IllegalArgumentException("SHINE-ARCHIVE v1 требует archive.maxFileBytes < 4294967296");
}
}
private static String requiredIfEnabled(AppConfig c, String name) {
String v = c.getParam(name);
return v == null ? "" : v.trim();
}
private static Path optionalPath(String s) { return s == null || s.isBlank() ? null : Path.of(s.trim()); }
private static String firstNonBlank(String... values) {
if (values != null) for (String v : values) if (v != null && !v.isBlank()) return v.trim();
return "";
}
private static String orDefault(String v, String d) { return v == null || v.isBlank() ? d : v.trim(); }
private static long parseLong(String s, long d) { return s == null || s.isBlank() ? d : Long.parseLong(s.trim()); }
private static LocalTime parseTime(String value) {
try {
return LocalTime.parse(value.trim());
} catch (Exception e) {
throw new IllegalArgumentException("archive.publish.time должен быть в формате HH:mm, например 00:00", e);
}
}
private static ZoneId parseZone(String value) {
if (value == null || value.isBlank()) return ZoneId.systemDefault();
try {
return ZoneId.of(value.trim());
} catch (Exception e) {
throw new IllegalArgumentException("Некорректный archive.publish.zoneId: " + value, e);
}
}
private static int positive(int v, String n) { if (v <= 0) throw new IllegalArgumentException(n + " должен быть > 0"); return v; }
private static int nonNegative(int v, String n) { if (v < 0) throw new IllegalArgumentException(n + " должен быть >= 0"); return v; }
private static long positiveLong(long v, String n) { if (v <= 0) throw new IllegalArgumentException(n + " должен быть > 0"); return v; }
}
@@ -0,0 +1,90 @@
package server.archive;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Duration;
import java.time.ZonedDateTime;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/** Опциональный scheduler: один новый archive job в заданное локальное время каждый день. */
public final class ArchivePublisherScheduler {
private static final Logger log = LoggerFactory.getLogger(ArchivePublisherScheduler.class);
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
private static ScheduledExecutorService executor;
private ArchivePublisherScheduler() {}
public static void startOrLog() {
ArchivePublisherConfig cfg;
try {
cfg = ArchivePublisherConfig.load();
} catch (Exception e) {
log.error("Не удалось прочитать archive publisher config", e);
return;
}
if (!cfg.enabled()) {
log.info("Archive publisher выключен (archive.publish.enabled=false)");
return;
}
if (!STARTED.compareAndSet(false, true)) return;
try {
ArchivePublisherService service = new ArchivePublisherService(cfg);
executor = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {
@Override public Thread newThread(Runnable r) {
Thread t = new Thread(r, "shine-archive-publisher");
t.setDaemon(true);
return t;
}
});
// Незавершённую публикацию после рестарта продолжаем сразу, а новый snapshot
// создаём только в очередное назначенное суточное время.
executor.execute(() -> {
try {
service.resumeUnfinishedJob();
} catch (Exception e) {
log.error("Не удалось продолжить незавершённый archive job", e);
}
});
scheduleNextDaily(service, cfg);
log.info("Archive publisher включён: login={} dir={} dailyAt={} zone={}",
cfg.serverLogin(), cfg.workDir(), cfg.publishTime(), cfg.publishZone());
} catch (Exception e) {
STARTED.set(false);
log.error("Archive publisher НЕ запущен из-за ошибки preflight", e);
}
}
private static void scheduleNextDaily(ArchivePublisherService service, ArchivePublisherConfig cfg) {
ScheduledExecutorService e = executor;
if (e == null || e.isShutdown()) return;
ZonedDateTime now = ZonedDateTime.now(cfg.publishZone());
ZonedDateTime next = now.toLocalDate().atTime(cfg.publishTime()).atZone(cfg.publishZone());
if (!next.isAfter(now)) next = next.plusDays(1);
long delayMs = Math.max(1L, Duration.between(now, next).toMillis());
e.schedule(() -> {
try {
service.runCycle();
} catch (Exception ex) {
log.error("Archive publisher daily cycle failed; retry нового snapshot будет в следующее назначенное время", ex);
} finally {
scheduleNextDaily(service, cfg);
}
}, delayMs, TimeUnit.MILLISECONDS);
log.info("Следующая архивная публикация запланирована на {}", next);
}
public static void close() {
ScheduledExecutorService e = executor;
if (e != null) e.shutdownNow();
}
}
@@ -0,0 +1,341 @@
package server.archive;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
import shine.db.archive.ArchiveBigBlockRef;
import shine.db.archive.ArchiveChainCursor;
import shine.db.archive.ArchivePublishJob;
import shine.db.archive.ArchivePublishJobChain;
import shine.db.dao.ArchivePublicationDAO;
import shine.db.dao.ArchiveImportDAO;
import shine.db.dao.BlockchainStateDAO;
import shine.db.dao.BlocksDAO;
import shine.db.entities.BlockEntry;
import shine.db.entities.BlockchainStateEntry;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.ReentrantLock;
/** Один crash-safe цикл публикации большого SHINE-ARCHIVE блока. */
public final class ArchivePublisherService {
private static final Logger log = LoggerFactory.getLogger(ArchivePublisherService.class);
private final ArchivePublisherConfig cfg;
private final ArchivePublicationDAO archiveDao = ArchivePublicationDAO.getInstance();
private final ArchiveImportDAO archiveImportDao = ArchiveImportDAO.getInstance();
private final BlockchainStateDAO stateDao = BlockchainStateDAO.getInstance();
private final BlocksDAO blocksDao = BlocksDAO.getInstance();
private final ShineArchiveWriter writer = new ShineArchiveWriter();
private final ArweaveArchiveService arweave;
private final SolanaArchiveHeadWriter solana;
private final byte[] archiveSigningSeed32;
private final AtomicBoolean running = new AtomicBoolean(false);
public ArchivePublisherService(ArchivePublisherConfig cfg) throws Exception {
this.cfg = Objects.requireNonNull(cfg);
cfg.validateForStart();
Files.createDirectories(cfg.workDir());
this.archiveSigningSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaRootKeyPath());
this.arweave = new ArweaveArchiveService(cfg);
this.solana = new SolanaArchiveHeadWriter(cfg);
this.solana.validateKeysAgainstPda();
backfillOwnLocationIndex();
}
/**
* После обновления старого publisher-сервера заполняет универсальный location index
* из уже существующих finalized cursors, не дожидаясь следующего суточного archive block.
*/
private void backfillOwnLocationIndex() throws Exception {
Map<Long, ArchiveBigBlockRef> finalized = new HashMap<>();
for (ArchiveBigBlockRef ref : archiveDao.listFinalizedBigBlocks()) finalized.put(ref.bigBlockNumber(), ref);
if (finalized.isEmpty()) return;
long now = System.currentTimeMillis();
int updated = 0;
for (BlockchainStateEntry state : stateDao.listAll()) {
if (state == null || state.getBlockchainName() == null || state.getBlockchainName().isBlank()) continue;
ArchiveChainCursor cursor = archiveDao.getCursor(state.getBlockchainName());
if (cursor == null) continue;
ArchiveBigBlockRef ref = finalized.get(cursor.lastArchiveBigBlockNumber());
if (ref == null || ref.arweaveTxId() == null || ref.arweaveTxId().length != 32) continue;
archiveImportDao.upsertLocation(new shine.db.archive.ArchiveBlockchainLocation(
cursor.blockchainName(), cfg.serverLogin(), b64url(ref.arweaveTxId()),
cursor.lastArchiveBigBlockHash(), cursor.lastArchiveBigBlockNumber(),
cursor.lastChunkOffset(), cursor.lastChunkSize(), cursor.lastArchivedSourceBlockNumber(), now));
updated++;
}
if (updated > 0) log.info("Archive publisher: восстановлен location index для {} blockchain", updated);
}
/** После рестарта продолжает только уже существующий незавершённый job; новый snapshot не создаёт. */
public boolean resumeUnfinishedJob() throws Exception {
if (!running.compareAndSet(false, true)) return false;
try {
ArchivePublishJob job = archiveDao.findUnfinishedJob();
if (job == null) return false;
log.info("Archive publisher: после рестарта продолжаем job id={} block={} status={}",
job.id(), job.bigBlockNumber(), job.status());
process(job.id());
return true;
} finally {
running.set(false);
}
}
/** Запускает суточный цикл: продолжает незавершённый job либо создаёт новый frozen snapshot. */
public boolean runCycle() throws Exception {
if (!running.compareAndSet(false, true)) {
log.info("Archive publisher: предыдущий цикл ещё работает, новый пропущен");
return false;
}
try {
ArchivePublishJob job = archiveDao.findUnfinishedJob();
if (job == null) {
job = createFrozenJob();
if (job == null) {
log.info("Archive publisher: новых SHiNE-блоков нет");
return false;
}
} else {
log.info("Archive publisher: продолжаем незавершённый job id={} block={} status={}",
job.id(), job.bigBlockNumber(), job.status());
}
process(job.id());
return true;
} finally {
running.set(false);
}
}
private ArchivePublishJob createFrozenJob() throws Exception {
List<ArchivePublishJobChain> chains = new ArrayList<>();
for (BlockchainStateEntry listed : stateDao.listAll()) {
if (listed == null || listed.getBlockchainName() == null || listed.getBlockchainName().isBlank()) continue;
String name = listed.getBlockchainName();
ReentrantLock lock = BlockchainLocks.lockFor(name);
lock.lock();
try {
BlockchainStateEntry state = stateDao.getByBlockchainName(name);
if (state == null || state.getLastBlockNumber() < 0) continue;
ArchiveChainCursor cursor = archiveDao.getCursor(name);
long from;
byte[] previousSourceHash;
Long prevBig = null;
byte[] prevBigHash = null;
Long prevChunkOffset = null;
Long prevChunkSize = null;
if (cursor == null) {
from = 0L;
previousSourceHash = new byte[32];
} else {
long cursorBlock = cursor.lastArchivedSourceBlockNumber();
if (cursorBlock < 0 || cursorBlock > 0xffff_ffffL) {
throw new IllegalStateException("Некорректный archive cursor для " + name + ": " + cursorBlock);
}
byte[] actualCursorHash = blocksDao.getHashByNumber(name, (int) cursorBlock);
if (!Arrays.equals(actualCursorHash, cursor.lastArchivedSourceBlockHash())) {
throw new IllegalStateException("Archive cursor hash не совпадает с локальной blockchain: " + name + "#" + cursorBlock);
}
from = cursorBlock + 1L;
previousSourceHash = cursor.lastArchivedSourceBlockHash();
prevBig = cursor.lastArchiveBigBlockNumber();
prevBigHash = cursor.lastArchiveBigBlockHash();
prevChunkOffset = cursor.lastChunkOffset();
prevChunkSize = cursor.lastChunkSize();
}
long to = Integer.toUnsignedLong(state.getLastBlockNumber());
if (from > to) continue;
if (from > 0xffff_ffffL || to > 0xffff_ffffL) throw new IllegalStateException("Source block number вне u32: " + name);
byte[] lastHash = state.getLastBlockHash();
if (lastHash == null || lastHash.length != 32) {
lastHash = blocksDao.getHashByNumber(name, (int) to);
}
require32(lastHash, "last source block hash " + name);
chains.add(new ArchivePublishJobChain(
0L, name, from, to, previousSourceHash, lastHash,
prevBig, prevBigHash, prevChunkOffset, prevChunkSize, null, null));
} finally {
lock.unlock();
}
}
if (chains.isEmpty()) return null;
chains.sort(Comparator.comparing(ArchivePublishJobChain::blockchainName));
long bigBlock = archiveDao.nextBigBlockNumber();
if (bigBlock > 0xffff_ffffL) throw new IllegalStateException("Закончился диапазон u32 big_block_number");
long createdAt = System.currentTimeMillis();
long id = archiveDao.createJobWithChains(bigBlock, createdAt, chains);
log.info("Archive snapshot создан: job={} bigBlock={} chains={}", id, bigBlock, chains.size());
return archiveDao.getJob(id);
}
private void process(long jobId) throws Exception {
while (true) {
ArchivePublishJob job = archiveDao.getJob(jobId);
if (job == null) throw new IllegalStateException("Archive job исчез: " + jobId);
switch (job.status()) {
case "SNAPSHOT_CREATED" -> buildFile(job);
case "FILE_BUILT" -> uploadArweave(job);
case "ARWEAVE_UPLOADED" -> waitArweave(job);
case "ARWEAVE_CONFIRMED" -> submitSolana(job);
case "SOLANA_SUBMITTED" -> waitSolana(job);
case "SOLANA_FINALIZED" -> commitCursors(job);
case "CURSORS_COMMITTED" -> {
log.info("Archive job завершён: id={} bigBlock={} file={} tx={}", job.id(), job.bigBlockNumber(), job.localArchivePath(), b64url(job.arweaveTxId()));
return;
}
case "FAILED" -> throw new IllegalStateException("Archive job помечен FAILED: " + job.errorText());
default -> throw new IllegalStateException("Неизвестный archive job status: " + job.status());
}
}
}
private void buildFile(ArchivePublishJob job) throws Exception {
List<ArchivePublishJobChain> chains = archiveDao.listJobChains(job.id());
if (chains.isEmpty()) throw new IllegalStateException("SNAPSHOT_CREATED job не содержит chains: " + job.id());
Map<String,List<BlockEntry>> records = new LinkedHashMap<>();
for (ArchivePublishJobChain ch : chains) {
ReentrantLock lock = BlockchainLocks.lockFor(ch.blockchainName());
lock.lock();
try {
if (ch.fromSourceBlockNumber() > 0) {
byte[] prev = blocksDao.getHashByNumber(ch.blockchainName(), (int)(ch.fromSourceBlockNumber() - 1));
if (!Arrays.equals(prev, ch.previousSourceBlockHash())) {
throw new IllegalStateException("Frozen previous source hash изменился: " + ch.blockchainName());
}
}
List<BlockEntry> range = blocksDao.listRangeByNumber(
ch.blockchainName(), (int)ch.fromSourceBlockNumber(), (int)ch.toSourceBlockNumber());
if (range.isEmpty()) throw new IllegalStateException("Frozen range пуст: " + ch.blockchainName());
byte[] last = range.get(range.size()-1).getBlockHash();
if (!Arrays.equals(last, ch.lastSourceBlockHash())) {
throw new IllegalStateException("Frozen last source hash изменился: " + ch.blockchainName());
}
records.put(ch.blockchainName(), range);
} finally {
lock.unlock();
}
}
List<ArchiveBigBlockRef> previous = archiveDao.listFinalizedBigBlocks();
Path file = ArchiveFileNames.pendingFilePath(
cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone());
ShineArchiveWriter.BuildResult result = writer.build(
file, cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), previous,
chains, records, archiveSigningSeed32, cfg.maxFileBytes());
for (Map.Entry<String, ShineArchiveWriter.ChunkPosition> e : result.chunks().entrySet()) {
archiveDao.updateNewChunkPosition(job.id(), e.getKey(), e.getValue().offset(), e.getValue().size());
}
archiveDao.markFileBuilt(job.id(), result.file().toString(), result.archiveHash());
log.info("SHINE-ARCHIVE локально сохранён: block={} file={} bytes={} hash={}",
job.bigBlockNumber(), result.file(), result.fileSize(), hex(result.archiveHash()));
}
private void uploadArweave(ArchivePublishJob job) throws Exception {
Path file = requireLocalFile(job);
byte[] verified = ShineArchiveWriter.verifyLocalFile(file);
if (!Arrays.equals(verified, job.archiveHash())) throw new IllegalStateException("Локальный archive hash отличается от job DB");
ArweaveArchiveService.UploadResult upload = arweave.upload(file, cfg.serverLogin(), job.bigBlockNumber(), job.archiveHash());
// Сначала надёжно фиксируем полученный TX ID в БД. Если процесс упадёт до rename,
// recovery на следующем шаге переименует тот же временный файл без повторной загрузки.
archiveDao.markArweaveUploaded(job.id(), upload.txIdBytes());
Path uploadedFile = markLocalFileUploaded(job, file, upload.txIdBytes());
log.info("SHINE-ARCHIVE загружен в Arweave: block={} tx={} file={}",
job.bigBlockNumber(), upload.txId(), uploadedFile);
}
private void waitArweave(ArchivePublishJob job) throws Exception {
ensureUploadedLocalName(job);
String tx = b64url(job.arweaveTxId());
int confirmations = arweave.waitForConfirmations(tx);
archiveDao.markArweaveConfirmed(job.id(), confirmations);
log.info("Arweave archive закреплён: block={} tx={} confirmations={}", job.bigBlockNumber(), tx, confirmations);
}
/** После успешного Arweave upload временный файл получает реальный TX ID в имени и остаётся локально. */
private Path markLocalFileUploaded(ArchivePublishJob job, Path currentFile, byte[] txId) throws Exception {
require32(txId, "arweave tx id");
Path target = ArchiveFileNames.uploadedFilePath(
cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone(), txId);
Files.createDirectories(target.toAbsolutePath().getParent());
if (!currentFile.equals(target)) {
try {
Files.move(currentFile, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(currentFile, target, StandardCopyOption.REPLACE_EXISTING);
}
}
archiveDao.updateLocalArchivePath(job.id(), target.toString());
return target;
}
/** Crash recovery: TX уже сохранён, а временный файл ещё не успели переименовать. */
private void ensureUploadedLocalName(ArchivePublishJob job) throws Exception {
require32(job.arweaveTxId(), "job.arweave_tx_id");
Path target = ArchiveFileNames.uploadedFilePath(
cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone(), job.arweaveTxId());
if (Files.isRegularFile(target)) {
if (!target.toString().equals(job.localArchivePath())) {
archiveDao.updateLocalArchivePath(job.id(), target.toString());
}
return;
}
if (job.localArchivePath() == null || job.localArchivePath().isBlank()) return;
Path current = Path.of(job.localArchivePath());
if (Files.isRegularFile(current)) {
markLocalFileUploaded(job, current, job.arweaveTxId());
}
}
private void submitSolana(ArchivePublishJob job) throws Exception {
require32(job.arweaveTxId(), "job.arweave_tx_id");
require32(job.archiveHash(), "job.archive_hash");
SolanaArchiveHeadWriter.SubmitResult submitted = solana.submitArchiveHead(job.arweaveTxId(), job.archiveHash());
archiveDao.markSolanaSubmitted(job.id(), submitted.signature());
}
private void waitSolana(ArchivePublishJob job) throws Exception {
if (job.solanaSignature() == null || job.solanaSignature().isBlank()) throw new IllegalStateException("SOLANA_SUBMITTED без signature");
solana.waitFinalized(job.solanaSignature());
if (!solana.currentHeadMatches(job.arweaveTxId(), job.archiveHash())) {
throw new IllegalStateException("После Solana finalized User PDA archive head не совпадает с job");
}
archiveDao.markSolanaFinalized(job.id());
log.info("Solana archive head finalized: block={} signature={}", job.bigBlockNumber(), job.solanaSignature());
}
private void commitCursors(ArchivePublishJob job) throws Exception {
archiveDao.commitCursors(job.id(), job.archiveHash());
// Тот же универсальный индекс используется и для собственных опубликованных архивов.
// Поэтому UI publisher-сервера получает ссылку даже если он не импортирует самого себя через whitelist.
String txId = b64url(job.arweaveTxId());
long now = System.currentTimeMillis();
for (ArchivePublishJobChain ch : archiveDao.listJobChains(job.id())) {
if (ch.newChunkOffset() == null || ch.newChunkSize() == null) continue;
archiveImportDao.upsertLocation(new shine.db.archive.ArchiveBlockchainLocation(
ch.blockchainName(), cfg.serverLogin(), txId, job.archiveHash(), job.bigBlockNumber(),
ch.newChunkOffset(), ch.newChunkSize(), ch.toSourceBlockNumber(), now));
}
}
private static Path requireLocalFile(ArchivePublishJob job) {
if (job.localArchivePath() == null || job.localArchivePath().isBlank()) throw new IllegalStateException("FILE_BUILT без local_archive_path");
Path p = Path.of(job.localArchivePath());
if (!Files.isRegularFile(p)) throw new IllegalStateException("Локальный archive-файл отсутствует: " + p);
return p;
}
private static void require32(byte[] v, String name) {
if (v == null || v.length != 32) throw new IllegalStateException(name + " должен быть 32 bytes");
}
private static String b64url(byte[] v) { return v == null ? "" : Base64.getUrlEncoder().withoutPadding().encodeToString(v); }
private static String hex(byte[] b){StringBuilder s=new StringBuilder();for(byte x:b)s.append(String.format("%02x",x));return s.toString();}
}
@@ -0,0 +1,259 @@
package server.archive;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.math.BigInteger;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.*;
import java.security.spec.MGF1ParameterSpec;
import java.security.spec.PSSParameterSpec;
import java.security.spec.RSAPrivateCrtKeySpec;
import java.time.Duration;
import java.util.*;
/**
* Публикация готовых локальных SHINE-ARCHIVE файлов в Arweave v2.
* Поддерживает стандартный data Merkle tree и загрузку через /chunk.
*/
public final class ArweaveArchiveService {
private static final Logger log = LoggerFactory.getLogger(ArweaveArchiveService.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder B64D = Base64.getUrlDecoder();
private final ArchivePublisherConfig cfg;
private final HttpClient http;
private volatile Wallet wallet;
public ArweaveArchiveService(ArchivePublisherConfig cfg) {
this.cfg = Objects.requireNonNull(cfg);
this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
}
public record UploadResult(String txId, byte[] txIdBytes, String gateway) {}
public UploadResult upload(Path archiveFile, String publisherLogin, long bigBlockNumber, byte[] archiveHash)
throws Exception {
long dataSize = Files.size(archiveFile);
if (dataSize <= 0) throw new IllegalArgumentException("Пустой SHINE-ARCHIVE");
Wallet w = wallet();
String gateway = trimGateway(cfg.arweaveGateway());
ArweaveMerkle.Prepared merkle = ArweaveMerkle.prepare(archiveFile);
String anchor = getRequiredText(gateway, "/tx_anchor");
String reward = getRequiredText(gateway, "/price/" + dataSize);
if (!reward.matches("^\\d+$")) throw new IOException("Arweave вернул некорректную цену: " + reward);
verifyBalance(gateway, w.address(), reward);
List<List<byte[]>> tagList = new ArrayList<>();
List<Map<String,String>> jsonTags = new ArrayList<>();
addTag(jsonTags, tagList, "Content-Type", "application/octet-stream");
addTag(jsonTags, tagList, "App-Name", "SHiNE");
addTag(jsonTags, tagList, "SHiNE-Type", "archive");
addTag(jsonTags, tagList, "SHiNE-Archive-Version", "1.0");
addTag(jsonTags, tagList, "SHiNE-Publisher", publisherLogin);
addTag(jsonTags, tagList, "SHiNE-Big-Block", Long.toUnsignedString(bigBlockNumber));
addTag(jsonTags, tagList, "SHiNE-Archive-SHA256", hex(archiveHash));
byte[] signaturePayload = deepHash(List.of(
utf8("2"),
b64Decode(w.owner()),
new byte[0],
utf8("0"),
utf8(reward),
b64Decode(anchor),
tagList,
utf8(Long.toString(dataSize)),
merkle.dataRoot()
));
byte[] rawSignature = signPayload(w.privateKey(), signaturePayload);
byte[] txIdBytes = sha256(rawSignature);
String txId = b64(txIdBytes);
Map<String,Object> tx = new LinkedHashMap<>();
tx.put("format", 2);
tx.put("id", txId);
tx.put("last_tx", anchor);
tx.put("owner", w.owner());
tx.put("tags", jsonTags);
tx.put("target", "");
tx.put("quantity", "0");
tx.put("data_root", b64(merkle.dataRoot()));
tx.put("data_size", Long.toString(dataSize));
tx.put("data", "");
tx.put("reward", reward);
tx.put("signature", b64(rawSignature));
postJsonExpect2xx(gateway + "/tx", tx, Duration.ofSeconds(60));
log.info("Arweave tx header принят: tx={}, chunks={}, bytes={}", txId, merkle.chunks().size(), dataSize);
for (int i=0; i<merkle.chunks().size(); i++) {
ArweaveMerkle.Chunk chunkMeta = merkle.chunks().get(i);
ArweaveMerkle.Proof proof = merkle.proofs().get(i);
byte[] chunk = ArweaveMerkle.readChunk(archiveFile, chunkMeta);
Map<String,Object> body = new LinkedHashMap<>();
body.put("data_root", b64(merkle.dataRoot()));
body.put("data_size", Long.toString(dataSize));
body.put("data_path", b64(proof.proof()));
body.put("offset", Long.toString(proof.offset()));
body.put("chunk", b64(chunk));
postChunkWithRetry(gateway, body, i, merkle.chunks().size());
}
return new UploadResult(txId, txIdBytes, gateway);
}
/** Ждёт включения tx в блок и заданного количества подтверждений. */
public int waitForConfirmations(String txId) throws Exception {
if (cfg.arweaveMinConfirmations() <= 0) return 0;
String gateway = trimGateway(cfg.arweaveGateway());
long deadline = System.currentTimeMillis() + cfg.arweaveConfirmTimeoutMinutes() * 60_000L;
int last = 0;
while (System.currentTimeMillis() < deadline) {
HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + "/tx/" + txId + "/status"))
.timeout(Duration.ofSeconds(20)).GET().build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (resp.statusCode() == 200) {
JsonNode json = MAPPER.readTree(resp.body());
last = json.path("number_of_confirmations").asInt(0);
if (last >= cfg.arweaveMinConfirmations()) return last;
} else if (resp.statusCode() != 202 && resp.statusCode() != 404) {
log.warn("Arweave status tx={} HTTP {}: {}", txId, resp.statusCode(), safe(resp.body()));
}
Thread.sleep(cfg.arweaveConfirmPollSeconds() * 1000L);
}
throw new IOException("Истёк timeout ожидания Arweave confirmations для " + txId + ", last=" + last);
}
private void postChunkWithRetry(String gateway, Map<String,Object> body, int index, int total) throws Exception {
Exception last = null;
for (int attempt=1; attempt<=12; attempt++) {
try {
postJsonExpect2xx(gateway + "/chunk", body, Duration.ofSeconds(60));
if ((index + 1) % 25 == 0 || index + 1 == total) {
log.info("Arweave chunks: {}/{}", index + 1, total);
}
return;
} catch (Exception e) {
last = e;
if (attempt == 12) break;
log.warn("Arweave chunk {}/{} attempt {}/12: {}", index + 1, total, attempt, e.getMessage());
Thread.sleep(5_000L);
}
}
throw new IOException("Не удалось загрузить Arweave chunk " + (index + 1) + "/" + total, last);
}
private void postJsonExpect2xx(String url, Object body, Duration timeout) throws Exception {
String json = MAPPER.writeValueAsString(body);
HttpRequest req = HttpRequest.newBuilder(URI.create(url))
.timeout(timeout)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8))
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
throw new IOException("HTTP " + resp.statusCode() + " " + safe(resp.body()));
}
}
private void verifyBalance(String gateway, String address, String reward) throws Exception {
if (address == null || address.isBlank()) return;
String balance = getRequiredText(gateway, "/wallet/" + address + "/balance");
if (balance.matches("^\\d+$") && new BigInteger(balance).compareTo(new BigInteger(reward)) < 0) {
throw new IllegalStateException("Недостаточно AR: balance=" + balance + ", reward=" + reward);
}
}
private String getRequiredText(String gateway, String path) throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + path)).timeout(Duration.ofSeconds(20)).GET().build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
throw new IOException("Arweave HTTP " + resp.statusCode() + " для " + path + ": " + safe(resp.body()));
}
String s = resp.body() == null ? "" : resp.body().trim();
if (s.isBlank()) throw new IOException("Пустой Arweave ответ для " + path);
return s;
}
private Wallet wallet() throws Exception {
Wallet local = wallet;
if (local != null) return local;
synchronized (this) {
if (wallet != null) return wallet;
JsonNode jwk = MAPPER.readTree(Files.readString(cfg.arweaveWalletJwkPath(), StandardCharsets.UTF_8));
String owner = required(jwk, "n");
PrivateKey pk = buildPrivateKey(jwk);
String address = b64(sha256(b64Decode(owner)));
wallet = new Wallet(owner, address, pk);
return wallet;
}
}
private static PrivateKey buildPrivateKey(JsonNode jwk) throws GeneralSecurityException {
RSAPrivateCrtKeySpec spec = new RSAPrivateCrtKeySpec(
big(required(jwk,"n")), big(required(jwk,"e")), big(required(jwk,"d")),
big(required(jwk,"p")), big(required(jwk,"q")), big(required(jwk,"dp")),
big(required(jwk,"dq")), big(required(jwk,"qi")));
return KeyFactory.getInstance("RSA").generatePrivate(spec);
}
private static byte[] signPayload(PrivateKey key, byte[] payload) throws GeneralSecurityException {
Signature signature = Signature.getInstance("RSASSA-PSS");
signature.setParameter(new PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1));
signature.initSign(key);
signature.update(payload);
return signature.sign();
}
private static byte[] deepHash(Object data) throws GeneralSecurityException {
if (data instanceof List<?> list) {
byte[] tag = concat(utf8("list"), utf8(Integer.toString(list.size())));
return deepHashChunks(list, sha384(tag));
}
if (!(data instanceof byte[] bytes)) throw new IllegalArgumentException("deepHash: ожидается byte[]/List");
byte[] tag = concat(utf8("blob"), utf8(Integer.toString(bytes.length)));
return sha384(concat(sha384(tag), sha384(bytes)));
}
private static byte[] deepHashChunks(List<?> list, byte[] acc) throws GeneralSecurityException {
byte[] cur = acc;
for (Object item : list) cur = sha384(concat(cur, deepHash(item)));
return cur;
}
private static void addTag(List<Map<String,String>> json, List<List<byte[]>> raw, String name, String value) {
byte[] n=utf8(name), v=utf8(value);
Map<String,String> item = new LinkedHashMap<>();
item.put("name", b64(n)); item.put("value", b64(v));
json.add(item); raw.add(List.of(n,v));
}
private static String required(JsonNode node, String field) {
String s = node == null ? "" : node.path(field).asText("").trim();
if (s.isBlank()) throw new IllegalStateException("В Arweave JWK отсутствует " + field);
return s;
}
private static BigInteger big(String s) { return new BigInteger(1, b64Decode(s)); }
private static String trimGateway(String s) { return (s == null ? "https://arweave.net" : s.trim()).replaceAll("/+$", ""); }
private static byte[] utf8(String s) { return String.valueOf(s == null ? "" : s).getBytes(StandardCharsets.UTF_8); }
private static byte[] b64Decode(String s) { return B64D.decode(s); }
private static String b64(byte[] b) { return B64.encodeToString(b); }
private static byte[] sha256(byte[] b) throws GeneralSecurityException { return MessageDigest.getInstance("SHA-256").digest(b); }
private static byte[] sha384(byte[] b) throws GeneralSecurityException { return MessageDigest.getInstance("SHA-384").digest(b); }
private static String hex(byte[] b) { StringBuilder s=new StringBuilder(); for(byte x:b)s.append(String.format("%02x",x)); return s.toString(); }
private static byte[] concat(byte[]... arrays) { int n=0; for(byte[] a:arrays)n=Math.addExact(n,a.length); byte[] o=new byte[n]; int p=0; for(byte[]a:arrays){System.arraycopy(a,0,o,p,a.length);p+=a.length;} return o; }
private static String safe(String s) { s=String.valueOf(s==null?"":s).replace('\n',' ').replace('\r',' ').trim(); return s.length()<=250?s:s.substring(0,250)+"..."; }
private record Wallet(String owner, String address, PrivateKey privateKey) {}
}
@@ -0,0 +1,157 @@
package server.archive;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
/**
* Arweave v2 data Merkle tree/proofs. Алгоритм повторяет arweave-js merkle.ts.
*/
final class ArweaveMerkle {
static final int MAX_CHUNK_SIZE = 256 * 1024;
static final int MIN_CHUNK_SIZE = 32 * 1024;
record Chunk(byte[] dataHash, long minByteRange, long maxByteRange) {
long size() { return maxByteRange - minByteRange; }
}
record Proof(long offset, byte[] proof) {}
record Prepared(byte[] dataRoot, List<Chunk> chunks, List<Proof> proofs) {}
private sealed interface Node permits Leaf, Branch {
byte[] id();
long maxByteRange();
}
private record Leaf(byte[] id, byte[] dataHash, long minByteRange, long maxByteRange) implements Node {}
private record Branch(byte[] id, long byteRange, long maxByteRange, Node left, Node right) implements Node {}
private ArweaveMerkle() {}
static Prepared prepare(Path file) throws IOException {
long fileSize = Files.size(file);
List<Chunk> chunks = chunkFile(file, fileSize);
List<Node> leaves = new ArrayList<>(chunks.size());
for (Chunk c : chunks) {
byte[] id = sha256(concat(sha256(c.dataHash()), sha256(note(c.maxByteRange()))));
leaves.add(new Leaf(id, c.dataHash(), c.minByteRange(), c.maxByteRange()));
}
if (leaves.isEmpty()) throw new IOException("Arweave Merkle: нет leaves");
Node root = buildLayers(leaves);
List<Proof> proofs = new ArrayList<>();
resolveProofs(root, new byte[0], proofs);
// arweave-js строит root вместе с нулевым последним chunk при точном кратном 256KiB,
// но сам нулевой chunk/proof не загружает.
if (!chunks.isEmpty() && chunks.get(chunks.size() - 1).size() == 0) {
chunks = new ArrayList<>(chunks);
chunks.remove(chunks.size() - 1);
proofs.remove(proofs.size() - 1);
}
if (chunks.size() != proofs.size()) throw new IOException("Arweave Merkle: chunks/proofs mismatch");
return new Prepared(root.id(), List.copyOf(chunks), List.copyOf(proofs));
}
private static List<Chunk> chunkFile(Path file, long fileSize) throws IOException {
List<Chunk> out = new ArrayList<>();
long remaining = fileSize;
long cursor = 0;
try (InputStream in = new BufferedInputStream(Files.newInputStream(file))) {
while (remaining >= MAX_CHUNK_SIZE) {
long chunkSize = MAX_CHUNK_SIZE;
long next = remaining - MAX_CHUNK_SIZE;
if (next > 0 && next < MIN_CHUNK_SIZE) {
chunkSize = (remaining + 1) / 2;
}
byte[] data = readExact(in, (int)chunkSize);
out.add(new Chunk(sha256(data), cursor, cursor + chunkSize));
cursor += chunkSize;
remaining -= chunkSize;
}
byte[] rest = readExact(in, (int)remaining);
out.add(new Chunk(sha256(rest), cursor, cursor + remaining));
}
return out;
}
private static Node buildLayers(List<Node> nodes) {
if (nodes.size() < 2) return nodes.get(0);
List<Node> next = new ArrayList<>((nodes.size() + 1) / 2);
for (int i = 0; i < nodes.size(); i += 2) {
Node left = nodes.get(i);
Node right = i + 1 < nodes.size() ? nodes.get(i + 1) : null;
if (right == null) {
next.add(left);
continue;
}
long byteRange = left.maxByteRange();
byte[] id = sha256(concat(
sha256(left.id()),
sha256(right.id()),
sha256(note(byteRange))
));
next.add(new Branch(id, byteRange, right.maxByteRange(), left, right));
}
return buildLayers(next);
}
private static void resolveProofs(Node node, byte[] prefix, List<Proof> out) {
if (node instanceof Leaf leaf) {
out.add(new Proof(leaf.maxByteRange() - 1,
concat(prefix, leaf.dataHash(), note(leaf.maxByteRange()))));
return;
}
Branch b = (Branch) node;
byte[] partial = concat(prefix, b.left().id(), b.right().id(), note(b.byteRange()));
resolveProofs(b.left(), partial, out);
resolveProofs(b.right(), partial, out);
}
static byte[] readChunk(Path file, Chunk c) throws IOException {
long len = c.size();
if (len < 0 || len > Integer.MAX_VALUE) throw new IOException("Некорректный Arweave chunk size: " + len);
try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) {
raf.seek(c.minByteRange());
byte[] data = new byte[(int)len];
raf.readFully(data);
return data;
}
}
private static byte[] readExact(InputStream in, int len) throws IOException {
byte[] out = new byte[len];
int pos = 0;
while (pos < len) {
int n = in.read(out, pos, len - pos);
if (n < 0) throw new EOFException("Неожиданный EOF");
pos += n;
}
return out;
}
private static byte[] note(long value) {
if (value < 0) throw new IllegalArgumentException("negative note");
byte[] out = new byte[32];
long v = value;
for (int i = 31; i >= 0 && v != 0; i--) {
out[i] = (byte)(v & 0xff);
v >>>= 8;
}
return out;
}
private static byte[] sha256(byte[] data) {
try { return MessageDigest.getInstance("SHA-256").digest(data); }
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
private static byte[] concat(byte[]... arrays) {
int len = 0;
for (byte[] a : arrays) len = Math.addExact(len, a.length);
byte[] out = new byte[len];
int p=0;
for (byte[] a : arrays) { System.arraycopy(a,0,out,p,a.length); p+=a.length; }
return out;
}
}
@@ -0,0 +1,101 @@
package server.archive;
import sync.util.Base58Util;
import utils.crypto.Ed25519Util;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.security.MessageDigest;
import java.util.*;
/** Строгий reader/verifier формата SHINE-ARCHIVE v1.0. */
public final class ShineArchiveReader {
public record Reference(long bigBlockNumber, byte[] archiveHash, String arweaveTxId) {}
public record Chunk(long offset, long size, String blockchainName, List<byte[]> rawRecords,
long previousBigBlockRef, long previousChunkOffset, long previousChunkSize) {}
public record Document(long bigBlockNumber, long createdAtMs, String creatorLogin,
List<Reference> references, List<Chunk> chunks, byte[] archiveHash) {}
public Document readAndVerify(Path file, String expectedPublisherLogin, byte[] expectedHash, String rootPublicKeyBase58) throws Exception {
long fileSize = Files.size(file);
if (fileSize < 13 + 38 + 1 + 32 + 64) throw new IOException("SHINE-ARCHIVE слишком короткий");
if (fileSize >= 0x1_0000_0000L) throw new IOException("SHINE-ARCHIVE v1 >= 4 GiB не поддерживается");
byte[] computedHash = digestPrefix(file, fileSize - 96);
try (RandomAccessFile in = new RandomAccessFile(file.toFile(), "r")) {
byte[] magic = new byte[ShineArchiveWriter.MAGIC.length];
in.readFully(magic);
if (!Arrays.equals(magic, ShineArchiveWriter.MAGIC)) throw new IOException("Bad SHINE-ARCHIVE magic");
int major=in.readUnsignedByte(), minor=in.readUnsignedByte();
if (major != 1 || minor != 0) throw new IOException("Unsupported SHINE-ARCHIVE version " + major + "." + minor);
long headerSize=u32(in); long bigBlock=u32(in); long createdAt=in.readLong();
int creatorLen=in.readUnsignedByte(); int referencesMode=in.readUnsignedByte();
long refsCount=u32(in); int refSize=in.readUnsignedShort(); long parentRef=u32(in);
long chunksCount=u32(in); long recordsCount=u32(in);
if (referencesMode != ShineArchiveWriter.REFERENCES_FULL || refSize != ShineArchiveWriter.REFERENCE_ENTRY_SIZE) {
throw new IOException("Unsupported references table");
}
String creator=readUtf8(in,creatorLen);
if (!creator.equalsIgnoreCase(String.valueOf(expectedPublisherLogin))) {
throw new SecurityException("Archive creator " + creator + " != trusted publisher " + expectedPublisherLogin);
}
if (headerSize != in.getFilePointer()) throw new IOException("Некорректный header_size");
if (refsCount > 10_000_000L || chunksCount > 10_000_000L) throw new IOException("Слишком много refs/chunks");
List<Reference> refs=new ArrayList<>((int)Math.min(refsCount, Integer.MAX_VALUE));
for(long i=0;i<refsCount;i++) {
long number=u32(in); byte[] hash=readFixed(in,32); byte[] tx=readFixed(in,32);
refs.add(new Reference(number,hash,Base64.getUrlEncoder().withoutPadding().encodeToString(tx)));
}
if (refs.isEmpty() && parentRef != ShineArchiveWriter.NO_REFERENCE_U32) throw new IOException("Некорректный parent reference");
if (!refs.isEmpty() && parentRef >= refs.size()) throw new IOException("parent_reference_index вне таблицы");
List<Chunk> chunks=new ArrayList<>((int)Math.min(chunksCount, Integer.MAX_VALUE));
long parsedRecords=0;
for(long ci=0;ci<chunksCount;ci++) {
long chunkOffset=in.getFilePointer(); long chunkSize=u32(in); long chunkEnd=chunkOffset+chunkSize;
if (chunkSize < 21 || chunkEnd > fileSize-96) throw new IOException("Некорректный chunk_size");
int nameLen=in.readUnsignedByte(); String bch=readUtf8(in,nameLen); long count=u32(in);
if (count > 10_000_000L) throw new IOException("Слишком много records в chunk " + bch);
List<byte[]> records=new ArrayList<>((int)Math.min(count,Integer.MAX_VALUE));
for(long ri=0;ri<count;ri++) {
long n=u32(in); if(n<=0 || n>Integer.MAX_VALUE || in.getFilePointer()+n>chunkEnd-12) throw new IOException("Некорректный record_size");
records.add(readFixed(in,(int)n)); parsedRecords++;
}
long prevRef=u32(in), prevOffset=u32(in), prevSize=u32(in);
if (prevRef != ShineArchiveWriter.NO_REFERENCE_U32 && prevRef >= refs.size()) throw new IOException("previous_big_block_ref вне таблицы");
if (in.getFilePointer()!=chunkEnd) throw new IOException("Chunk framing mismatch: " + bch);
chunks.add(new Chunk(chunkOffset,chunkSize,bch,List.copyOf(records),prevRef,prevOffset,prevSize));
}
if (parsedRecords != recordsCount) throw new IOException("total_user_records_count mismatch");
int closerLen=in.readUnsignedByte(); String closer=readUtf8(in,closerLen);
if(!closer.equals(creator)) throw new SecurityException("creator_login != closer_login");
if(in.getFilePointer()!=fileSize-96) throw new IOException("Некорректная граница footer/hash");
byte[] storedHash=readFixed(in,32); byte[] signature=readFixed(in,64);
if(in.getFilePointer()!=fileSize) throw new IOException("Лишние данные после signature");
if(!Arrays.equals(storedHash,computedHash)) throw new SecurityException("SHA-256 archive не совпадает с footer");
if(expectedHash!=null && !Arrays.equals(expectedHash,computedHash)) throw new SecurityException("SHA-256 archive не совпадает с ожидаемым hash");
byte[] root=Base58Util.decode(rootPublicKeyBase58);
if(root.length!=32) throw new SecurityException("Некорректный root public key publisher-а");
byte[] signed=new byte[ShineArchiveWriter.SIGNATURE_DOMAIN.length+32];
System.arraycopy(ShineArchiveWriter.SIGNATURE_DOMAIN,0,signed,0,ShineArchiveWriter.SIGNATURE_DOMAIN.length);
System.arraycopy(computedHash,0,signed,ShineArchiveWriter.SIGNATURE_DOMAIN.length,32);
if(!Ed25519Util.verify(signed,signature,root)) throw new SecurityException("Не прошла подпись SHINE-ARCHIVE publisher-а");
return new Document(bigBlock,createdAt,creator,List.copyOf(refs),List.copyOf(chunks),computedHash);
}
}
private static byte[] digestPrefix(Path file,long bytes) throws Exception {
MessageDigest md=MessageDigest.getInstance("SHA-256");
try(InputStream in=new BufferedInputStream(Files.newInputStream(file))) {
byte[] buf=new byte[1024*1024]; long left=bytes;
while(left>0){int n=in.read(buf,0,(int)Math.min(buf.length,left)); if(n<0) throw new EOFException(); md.update(buf,0,n); left-=n;}
}
return md.digest();
}
private static long u32(RandomAccessFile in) throws IOException { return Integer.toUnsignedLong(in.readInt()); }
private static byte[] readFixed(RandomAccessFile in,int n) throws IOException { byte[] b=new byte[n]; in.readFully(b); return b; }
private static String readUtf8(RandomAccessFile in,int n) throws IOException { return new String(readFixed(in,n), StandardCharsets.UTF_8); }
}
@@ -0,0 +1,310 @@
package server.archive;
import shine.db.archive.ArchiveBigBlockRef;
import shine.db.archive.ArchivePublishJobChain;
import shine.db.entities.BlockEntry;
import utils.crypto.Ed25519Util;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.security.DigestOutputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
/**
* Writer бинарного SHINE-ARCHIVE v1.0.
*
* Один blockchain_name встречается максимум один раз и содержит все новые raw records этой цепочки.
*/
public final class ShineArchiveWriter {
public static final byte VERSION_MAJOR = 1;
public static final byte VERSION_MINOR = 0;
public static final byte REFERENCES_FULL = 0;
public static final long NO_REFERENCE_U32 = 0xffff_ffffL;
public static final int REFERENCE_ENTRY_SIZE = 68;
public static final byte[] MAGIC = "SHINE-ARCHIVE".getBytes(StandardCharsets.US_ASCII);
public static final byte[] SIGNATURE_DOMAIN = "SHINE-ARCHIVE-V1".getBytes(StandardCharsets.US_ASCII);
public record ChunkPosition(long offset, long size) {}
public record BuildResult(Path file, byte[] archiveHash, Map<String, ChunkPosition> chunks, long fileSize) {}
public BuildResult build(
Path finalPath,
String creatorLogin,
long bigBlockNumber,
long createdAtMs,
List<ArchiveBigBlockRef> previousBigBlocks,
List<ArchivePublishJobChain> chains,
Map<String, List<BlockEntry>> recordsByBlockchain,
byte[] signingPrivateSeed32,
long maxFileBytes
) throws IOException {
Objects.requireNonNull(finalPath, "finalPath");
Objects.requireNonNull(previousBigBlocks, "previousBigBlocks");
Objects.requireNonNull(chains, "chains");
Objects.requireNonNull(recordsByBlockchain, "recordsByBlockchain");
requireU32(bigBlockNumber, "big_block_number");
if (signingPrivateSeed32 == null || signingPrivateSeed32.length != 32) {
throw new IllegalArgumentException("Archive signing key должен быть 32-byte Ed25519 seed");
}
byte[] loginBytes = utf8U8(creatorLogin, "creator_login");
if (previousBigBlocks.size() > 0xffff_ffffL || chains.size() > 0xffff_ffffL) {
throw new IllegalArgumentException("Слишком много references/chunks для v1");
}
List<ArchivePublishJobChain> sortedChains = new ArrayList<>(chains);
sortedChains.sort(Comparator.comparing(ArchivePublishJobChain::blockchainName));
long totalRecords = 0;
for (ArchivePublishJobChain ch : sortedChains) {
List<BlockEntry> rs = recordsByBlockchain.get(ch.blockchainName());
if (rs == null || rs.isEmpty()) {
throw new IllegalArgumentException("Нет frozen records для " + ch.blockchainName());
}
totalRecords += rs.size();
requireU32(totalRecords, "total_user_records_count");
}
Map<Long, Integer> referenceIndexByBlock = new HashMap<>();
for (int i = 0; i < previousBigBlocks.size(); i++) {
ArchiveBigBlockRef ref = previousBigBlocks.get(i);
requireU32(ref.bigBlockNumber(), "PreviousBigBlockReference.big_block_number");
require32(ref.archiveHash(), "PreviousBigBlockReference.big_block_hash");
require32(ref.arweaveTxId(), "PreviousBigBlockReference.arweave_tx_id");
referenceIndexByBlock.put(ref.bigBlockNumber(), i);
}
Files.createDirectories(finalPath.toAbsolutePath().getParent());
Path tmp = finalPath.resolveSibling(finalPath.getFileName() + ".tmp");
Files.deleteIfExists(tmp);
Map<String, ChunkPosition> positions = new LinkedHashMap<>();
MessageDigest digest = sha256();
try (OutputStream fileOut = new BufferedOutputStream(Files.newOutputStream(tmp, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE));
CountingOutputStream counting = new CountingOutputStream(fileOut);
DigestOutputStream digestOut = new DigestOutputStream(counting, digest);
DataOutputStream out = new DataOutputStream(digestOut)) {
// ---------- header ----------
out.write(MAGIC);
out.writeByte(VERSION_MAJOR);
out.writeByte(VERSION_MINOR);
long headerSize = fixedHeaderSize() + loginBytes.length;
writeU32(out, headerSize);
writeU32(out, bigBlockNumber);
out.writeLong(createdAtMs);
out.writeByte(loginBytes.length);
out.writeByte(REFERENCES_FULL);
writeU32(out, previousBigBlocks.size());
out.writeShort(REFERENCE_ENTRY_SIZE);
long parentIndex = previousBigBlocks.isEmpty() ? NO_REFERENCE_U32 : previousBigBlocks.size() - 1L;
writeU32(out, parentIndex);
writeU32(out, sortedChains.size());
writeU32(out, totalRecords);
out.write(loginBytes);
// ---------- полный список предыдущих больших блоков ----------
for (ArchiveBigBlockRef ref : previousBigBlocks) {
writeU32(out, ref.bigBlockNumber());
out.write(ref.archiveHash());
out.write(ref.arweaveTxId());
}
// ---------- по одному chunk на blockchain_name ----------
for (ArchivePublishJobChain ch : sortedChains) {
List<BlockEntry> records = new ArrayList<>(recordsByBlockchain.get(ch.blockchainName()));
records.sort(Comparator.comparingInt(BlockEntry::getBlockNumber));
validateFrozenRange(ch, records);
byte[] name = utf8U8(ch.blockchainName(), "blockchain_name");
long chunkSize = calculateChunkSize(name.length, records);
requireU32(chunkSize, "chunk_size");
long chunkOffset = counting.getCount();
requireU32(chunkOffset, "chunk_offset");
if (chunkOffset + chunkSize > maxFileBytes) {
throw new ArchiveTooLargeException("Большой архив превысит archive.maxFileBytes: " + (chunkOffset + chunkSize));
}
writeU32(out, chunkSize);
out.writeByte(name.length);
out.write(name);
writeU32(out, records.size());
for (BlockEntry record : records) {
byte[] raw = record.getBlockBytes();
if (raw == null) throw new IllegalArgumentException("block_bytes=NULL: " + ch.blockchainName() + "#" + record.getBlockNumber());
writeU32(out, raw.length);
out.write(raw);
}
if (ch.previousArchiveBigBlockNumber() == null) {
writeU32(out, NO_REFERENCE_U32);
writeU32(out, 0);
writeU32(out, 0);
} else {
Integer refIndex = referenceIndexByBlock.get(ch.previousArchiveBigBlockNumber());
if (refIndex == null) {
throw new IllegalStateException("Предыдущий big block blockchain отсутствует в FULL reference table: "
+ ch.blockchainName() + " -> " + ch.previousArchiveBigBlockNumber());
}
ArchiveBigBlockRef previousRef = previousBigBlocks.get(refIndex);
require32(ch.previousArchiveBigBlockHash(), "previous_archive_big_block_hash " + ch.blockchainName());
if (!Arrays.equals(previousRef.archiveHash(), ch.previousArchiveBigBlockHash())) {
throw new IllegalStateException("Hash предыдущего big block не совпадает с FULL reference table: "
+ ch.blockchainName() + " -> " + ch.previousArchiveBigBlockNumber());
}
if (ch.previousChunkOffset() == null || ch.previousChunkSize() == null) {
throw new IllegalStateException("Нет предыдущих chunk offset/size: " + ch.blockchainName());
}
writeU32(out, refIndex);
writeU32(out, ch.previousChunkOffset());
writeU32(out, ch.previousChunkSize());
}
positions.put(ch.blockchainName(), new ChunkPosition(chunkOffset, chunkSize));
}
// ---------- footer, login входит в hash ----------
out.writeByte(loginBytes.length);
out.write(loginBytes);
out.flush();
byte[] archiveHash = digest.digest();
byte[] signedPayload = new byte[SIGNATURE_DOMAIN.length + archiveHash.length];
System.arraycopy(SIGNATURE_DOMAIN, 0, signedPayload, 0, SIGNATURE_DOMAIN.length);
System.arraycopy(archiveHash, 0, signedPayload, SIGNATURE_DOMAIN.length, archiveHash.length);
byte[] signature = Ed25519Util.sign(signedPayload, signingPrivateSeed32);
// hash/signature не должны попасть в hash самого блока.
digestOut.on(false);
out.write(archiveHash);
out.write(signature);
out.flush();
long finalSize = counting.getCount();
if (finalSize >= 0x1_0000_0000L || finalSize > maxFileBytes) {
throw new ArchiveTooLargeException("Размер SHINE-ARCHIVE v1 превышает лимит: " + finalSize);
}
} catch (Exception e) {
Files.deleteIfExists(tmp);
if (e instanceof IOException ioe) throw ioe;
throw new IOException("Не удалось собрать SHINE-ARCHIVE", e);
}
// До Arweave upload существует уже готовый локальный файл с окончательным именем.
try {
Files.move(tmp, finalPath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(tmp, finalPath, StandardCopyOption.REPLACE_EXISTING);
}
byte[] hash = readStoredArchiveHash(finalPath);
long size = Files.size(finalPath);
return new BuildResult(finalPath, hash, Collections.unmodifiableMap(positions), size);
}
/** Проверяет локальный файл перед оплатой Arweave. */
public static byte[] verifyLocalFile(Path file) throws IOException {
long size = Files.size(file);
if (size < MAGIC.length + 2 + 96) throw new IOException("Слишком короткий SHINE-ARCHIVE: " + file);
byte[] stored = readStoredArchiveHash(file);
MessageDigest md = sha256();
long hashInputSize = size - 96; // [archive_hash 32][signature 64]
try (InputStream in = Files.newInputStream(file)) {
byte[] buf = new byte[1024 * 1024];
long left = hashInputSize;
while (left > 0) {
int n = in.read(buf, 0, (int)Math.min(buf.length, left));
if (n < 0) throw new EOFException("Неожиданный EOF при проверке архива");
md.update(buf, 0, n);
left -= n;
}
}
byte[] actual = md.digest();
if (!Arrays.equals(stored, actual)) throw new IOException("SHA-256 локального SHINE-ARCHIVE не совпадает с footer");
return actual;
}
public static byte[] readStoredArchiveHash(Path file) throws IOException {
long size = Files.size(file);
if (size < 96) throw new EOFException("Файл слишком короткий");
try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) {
raf.seek(size - 96);
byte[] hash = new byte[32];
raf.readFully(hash);
return hash;
}
}
private static long fixedHeaderSize() {
// magic + major/minor + headerSize + blockNo + time + creatorLen + mode + refCount + refSize + parentIndex + chunkCount + recordCount
return 13L + 1 + 1 + 4 + 4 + 8 + 1 + 1 + 4 + 2 + 4 + 4 + 4;
}
private static long calculateChunkSize(int nameLen, List<BlockEntry> records) {
long size = 4L + 1L + nameLen + 4L + 12L;
for (BlockEntry e : records) {
byte[] raw = e.getBlockBytes();
if (raw == null) throw new IllegalArgumentException("raw record is null");
size += 4L + raw.length;
}
return size;
}
private static void validateFrozenRange(ArchivePublishJobChain ch, List<BlockEntry> records) {
long expected = ch.toSourceBlockNumber() - ch.fromSourceBlockNumber() + 1;
if (expected <= 0 || expected != records.size()) {
throw new IllegalArgumentException("Frozen range неполон для " + ch.blockchainName()
+ ": ожидалось=" + expected + ", получено=" + records.size());
}
long n = ch.fromSourceBlockNumber();
for (BlockEntry e : records) {
if (Integer.toUnsignedLong(e.getBlockNumber()) != n) {
throw new IllegalArgumentException("Нарушена непрерывность " + ch.blockchainName()
+ ": ожидался block " + n + ", получен " + Integer.toUnsignedLong(e.getBlockNumber()));
}
n++;
}
}
private static byte[] utf8U8(String value, String name) {
if (value == null) throw new IllegalArgumentException(name + " == null");
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
if (bytes.length == 0 || bytes.length > 255) throw new IllegalArgumentException(name + " должен занимать 1..255 UTF-8 байт");
return bytes;
}
private static void require32(byte[] v, String name) {
if (v == null || v.length != 32) throw new IllegalArgumentException(name + " должен быть 32 bytes");
}
private static void requireU32(long v, String name) {
if (v < 0 || v > 0xffff_ffffL) throw new IllegalArgumentException(name + " вне u32: " + v);
}
private static void writeU32(DataOutputStream out, long v) throws IOException {
requireU32(v, "u32");
out.writeInt((int)v); // DataOutputStream = big-endian; битовый шаблон корректен и для unsigned > 2^31.
}
private static MessageDigest sha256() {
try { return MessageDigest.getInstance("SHA-256"); }
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
/** OutputStream со счётчиком без ограничения int. */
private static final class CountingOutputStream extends FilterOutputStream {
private long count;
CountingOutputStream(OutputStream out) { super(out); }
long getCount() { return count; }
@Override public void write(int b) throws IOException { out.write(b); count++; }
@Override public void write(byte[] b, int off, int len) throws IOException { out.write(b, off, len); count += len; }
}
public static final class ArchiveTooLargeException extends IOException {
public ArchiveTooLargeException(String message) { super(message); }
}
}
@@ -0,0 +1,373 @@
package server.archive;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sync.codec.ShineUsersCodec;
import sync.codec.ShineUsersCodec.UserPdaSnapshot;
import sync.util.Base58Util;
import sync.util.SolanaPdaUtil;
import utils.crypto.Ed25519Util;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.MessageDigest;
import java.time.Duration;
import java.util.*;
/**
* Обновляет ArchiveHeadBlock (type=100) обычной инструкцией update_user_pda.
* Отдельной Solana-инструкции для архива нет.
*/
public final class SolanaArchiveHeadWriter {
private static final Logger log = LoggerFactory.getLogger(SolanaArchiveHeadWriter.class);
private static final ObjectMapper JSON = new ObjectMapper();
private static final String SYSTEM_PROGRAM = "11111111111111111111111111111111";
private static final String ED25519_PROGRAM = "Ed25519SigVerify111111111111111111111111111";
private static final String COMPUTE_BUDGET_PROGRAM = "ComputeBudget111111111111111111111111111111";
private static final String SYSVAR_INSTRUCTIONS = "Sysvar1nstructions1111111111111111111111111";
private static final String USER_SEED_PREFIX = "user_login=";
private static final String ECONOMY_SEED = "shine_users_economy_config";
private static final String INFLOW_SEED = "shine_payments_inflow_vault";
private static final byte[] LAST_BLOCK_STATE_PREFIX = "SHiNE_LAST_BLOCK".getBytes(StandardCharsets.UTF_8);
private final ArchivePublisherConfig cfg;
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
private final byte[] rootSeed32;
private final byte[] clientSeed32;
public SolanaArchiveHeadWriter(ArchivePublisherConfig cfg) throws IOException {
this.cfg = Objects.requireNonNull(cfg);
this.rootSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaRootKeyPath());
this.clientSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaClientKeyPath());
}
public record SubmitResult(String signature, String userPda) {}
/** Preflight: проверяет, что локальные root/client private keys соответствуют текущему User PDA. */
public void validateKeysAgainstPda() throws Exception {
Current current = loadCurrent();
byte[] expectedRoot = Base58Util.decode(current.snapshot.rootKey());
byte[] expectedClient = Base58Util.decode(current.snapshot.clientKey());
if (!Arrays.equals(expectedRoot, Ed25519Util.derivePublicKey(rootSeed32))) {
throw new IllegalStateException("archive.solana.rootKeyPath не соответствует root_key User PDA " + cfg.serverLogin());
}
if (!Arrays.equals(expectedClient, Ed25519Util.derivePublicKey(clientSeed32))) {
throw new IllegalStateException("archive.solana.clientKeyPath не соответствует client_key User PDA " + cfg.serverLogin());
}
}
public SubmitResult submitArchiveHead(byte[] arweaveTxId32, byte[] archiveHash32) throws Exception {
require32(arweaveTxId32, "Arweave TX ID");
require32(archiveHash32, "archive hash");
Current current = loadCurrent();
UserPdaSnapshot old = current.snapshot;
byte[] rootPub = Ed25519Util.derivePublicKey(rootSeed32);
byte[] clientPub = Ed25519Util.derivePublicKey(clientSeed32);
if (!Arrays.equals(rootPub, Base58Util.decode(old.rootKey()))) {
throw new IllegalStateException("Root key archive publisher-а не совпадает с User PDA");
}
if (!Arrays.equals(clientPub, Base58Util.decode(old.clientKey()))) {
throw new IllegalStateException("Client key archive publisher-а не совпадает с User PDA");
}
byte[] currentRaw = Base64.getDecoder().decode(old.rawDataBase64());
int currentRecordLen = u16le(currentRaw, 7);
byte[] currentUnsigned = Arrays.copyOf(currentRaw, currentRecordLen - 64);
byte[] prevHash = sha256(currentUnsigned);
long updatedAt = System.currentTimeMillis();
String txB64Url = Base64.getUrlEncoder().withoutPadding().encodeToString(arweaveTxId32);
String archiveHashHex = hex(archiveHash32);
String zeroSig = Base58Util.encode(new byte[64]);
UserPdaSnapshot nextUnsignedSnapshot = new UserPdaSnapshot(
old.pdaAddress(), old.login(), old.recordNumber() + 1, old.slot(), "",
old.recoveryKey(), old.rootKey(), old.clientKey(), old.blockchainName(), old.blockchainKey(),
old.paidLimitBytes(), old.usedBytes(), old.lastBlockNumber(), old.lastBlockHash(),
old.lastBlockSignature(), old.arweaveTxId(), txB64Url, archiveHashHex,
old.isServer(), old.addressFormatType(), old.addressFormatVersion(), old.serverAddress(),
old.syncServers(), old.accessServers(), old.sessionsMode(), old.sessions(), old.trustedCount(),
old.createdAtMs(), updatedAt, hex(prevHash), zeroSig, ""
);
byte[] nextFull = Base64.getDecoder().decode(ShineUsersCodec.serializeSnapshotToBase64(nextUnsignedSnapshot));
int nextRecordLen = u16le(nextFull, 7);
byte[] nextUnsigned = Arrays.copyOf(nextFull, nextRecordLen - 64);
byte[] nextUnsignedHash = sha256(nextUnsigned);
byte[] rootSignature = Ed25519Util.sign(nextUnsignedHash, rootSeed32);
byte[] updateData = buildUpdateInstruction(old, updatedAt, prevHash, arweaveTxId32, archiveHash32, rootSignature);
byte[] lastStateHash = sha256(buildLastBlockState(old));
byte[] lastBlockSignature = Base58Util.decode(old.lastBlockSignature());
byte[] blockchainPub = Base58Util.decode(old.blockchainKey());
byte[] rootEdData = buildEd25519Instruction(rootSignature, rootPub, nextUnsignedHash);
byte[] bchEdData = buildEd25519Instruction(lastBlockSignature, blockchainPub, lastStateHash);
String blockhash = getLatestBlockhash();
byte[] message = buildLegacyMessage(current.userPda, updateData, rootEdData, bchEdData, blockhash, clientPub);
byte[] clientSignature = Ed25519Util.sign(message, clientSeed32);
byte[] transaction = serializeTransaction(clientSignature, message);
String signature = sendTransaction(transaction);
log.info("Archive head отправлен в Solana: login={} pda={} signature={}", cfg.serverLogin(), current.userPda, signature);
return new SubmitResult(signature, current.userPda);
}
public void waitFinalized(String signature) throws Exception {
long deadline = System.currentTimeMillis() + cfg.solanaConfirmTimeoutMinutes() * 60_000L;
while (System.currentTimeMillis() < deadline) {
JsonNode result = rpc("getSignatureStatuses", List.of(List.of(signature), Map.of("searchTransactionHistory", true))).path("result");
JsonNode values = result.path("value");
JsonNode status = values.isArray() && values.size() > 0 ? values.get(0) : null;
if (status != null && !status.isNull()) {
if (status.hasNonNull("err")) {
throw new IOException("Solana archive-head tx завершилась ошибкой: " + status.path("err"));
}
String confirmation = status.path("confirmationStatus").asText("");
if ("finalized".equals(confirmation)) return;
}
Thread.sleep(cfg.solanaConfirmPollSeconds() * 1000L);
}
throw new IOException("Timeout ожидания Solana finalized: " + signature);
}
public boolean currentHeadMatches(byte[] txId32, byte[] hash32) throws Exception {
Current current = loadCurrent();
if (current.snapshot.archiveHeadTxId() == null || current.snapshot.archiveHeadTxId().isBlank()) return false;
byte[] currentTx = Base64.getUrlDecoder().decode(current.snapshot.archiveHeadTxId());
byte[] currentHash = unhex(current.snapshot.archiveHeadHash());
return Arrays.equals(currentTx, txId32) && Arrays.equals(currentHash, hash32);
}
private Current loadCurrent() throws Exception {
String normalized = cfg.serverLogin().trim().toLowerCase(Locale.ROOT);
String userPda = SolanaPdaUtil.findProgramAddress(
List.of(USER_SEED_PREFIX.getBytes(StandardCharsets.UTF_8), normalized.getBytes(StandardCharsets.UTF_8)),
cfg.solanaUsersProgramId());
JsonNode accountResult = rpc("getAccountInfo", List.of(userPda, Map.of("encoding", "base64", "commitment", cfg.solanaCommitment()))).path("result");
JsonNode value = accountResult.path("value");
if (value.isMissingNode() || value.isNull()) throw new IOException("User PDA не найден для " + cfg.serverLogin());
String owner = value.path("owner").asText("");
if (!cfg.solanaUsersProgramId().equals(owner)) throw new IOException("User PDA принадлежит другой программе: " + owner);
JsonNode data = value.path("data");
if (!data.isArray() || data.size() < 1) throw new IOException("Некорректный getAccountInfo.data");
String rawB64 = data.get(0).asText();
UserPdaSnapshot snapshot = ShineUsersCodec.parseUserPdaAccount(userPda, 0L, rawB64, "");
return new Current(userPda, snapshot);
}
private byte[] buildUpdateInstruction(UserPdaSnapshot s, long updatedAt, byte[] prevHash,
byte[] archiveTx, byte[] archiveHash, byte[] rootSig) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(4); // IX_UPDATE_USER_PDA
strU8(out, s.login());
out.write(Base58Util.decode(s.recoveryKey()));
out.write(Base58Util.decode(s.rootKey()));
u64le(out, s.createdAtMs());
u64le(out, updatedAt);
u32le(out, Integer.toUnsignedLong(s.recordNumber() + 1));
out.write(prevHash);
u64le(out, 0L); // additional_limit
out.write(Base58Util.decode(s.clientKey()));
out.write(Base58Util.decode(s.blockchainKey()));
strU8(out, s.blockchainName());
u64le(out, s.usedBytes());
u32le(out, Integer.toUnsignedLong(s.lastBlockNumber()));
out.write(unhex(s.lastBlockHash()));
out.write(Base58Util.decode(s.lastBlockSignature()));
strU8(out, s.arweaveTxId());
out.write(s.isServer() ? 1 : 0);
if (s.isServer()) {
out.write(s.addressFormatType() & 0xff);
out.write(s.addressFormatVersion() & 0xff);
strU8(out, s.serverAddress());
if (s.syncServers().size() > 255) throw new IOException("Слишком много sync_servers");
out.write(s.syncServers().size());
for (String v : s.syncServers()) strU8(out, v);
}
if (s.accessServers().size() > 255) throw new IOException("Слишком много access_servers");
out.write(s.accessServers().size());
for (String v : s.accessServers()) strU8(out, v);
out.write(s.sessionsMode() & 0xff);
if (s.sessions().size() > 255) throw new IOException("Слишком много sessions");
out.write(s.sessions().size());
for (ShineUsersCodec.UserSessionSnapshot session : s.sessions()) {
out.write(session.sessionType() & 0xff);
out.write(session.sessionVersion() & 0xff);
strU8(out, session.sessionName());
out.write(Base58Util.decode(session.sessionPubKey()));
}
out.write(s.trustedCount() & 0xff);
// Archive extension к обычному update_user_pda: marker=1 + tx32 + hash32.
out.write(1);
out.write(archiveTx);
out.write(archiveHash);
out.write(rootSig);
return out.toByteArray();
}
private static byte[] buildLastBlockState(UserPdaSnapshot s) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(LAST_BLOCK_STATE_PREFIX);
strU8(out, s.login());
strU8(out, s.blockchainName());
u32le(out, Integer.toUnsignedLong(s.lastBlockNumber()));
out.write(unhex(s.lastBlockHash()));
u64le(out, s.usedBytes());
return out.toByteArray();
}
private byte[] buildLegacyMessage(String userPda, byte[] updateData, byte[] rootEd, byte[] bchEd,
String recentBlockhash, byte[] clientPub) throws IOException {
String economy = SolanaPdaUtil.findProgramAddress(List.of(ECONOMY_SEED.getBytes(StandardCharsets.UTF_8)), cfg.solanaUsersProgramId());
String inflow = SolanaPdaUtil.findProgramAddress(List.of(INFLOW_SEED.getBytes(StandardCharsets.UTF_8)), cfg.solanaPaymentsProgramId());
List<Account> accounts = List.of(
new Account(Base58Util.encode(clientPub), true, true),
new Account(userPda, false, true),
new Account(inflow, false, true),
new Account(SYSTEM_PROGRAM, false, false),
new Account(SYSVAR_INSTRUCTIONS, false, false),
new Account(economy, false, false),
new Account(COMPUTE_BUDGET_PROGRAM, false, false),
new Account(ED25519_PROGRAM, false, false),
new Account(cfg.solanaUsersProgramId(), false, false)
);
Map<String,Integer> ix = new HashMap<>();
for (int i=0;i<accounts.size();i++) ix.put(accounts.get(i).pubkey, i);
List<CompiledInstruction> instructions = new ArrayList<>();
instructions.add(new CompiledInstruction(ix.get(COMPUTE_BUDGET_PROGRAM), new int[0], computeHeapData(262_144)));
instructions.add(new CompiledInstruction(ix.get(COMPUTE_BUDGET_PROGRAM), new int[0], computeLimitData(800_000)));
instructions.add(new CompiledInstruction(ix.get(ED25519_PROGRAM), new int[0], rootEd));
instructions.add(new CompiledInstruction(ix.get(ED25519_PROGRAM), new int[0], bchEd));
instructions.add(new CompiledInstruction(ix.get(cfg.solanaUsersProgramId()), new int[]{
ix.get(Base58Util.encode(clientPub)), ix.get(userPda), ix.get(SYSTEM_PROGRAM), ix.get(inflow),
ix.get(SYSVAR_INSTRUCTIONS), ix.get(economy)
}, updateData));
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(1); // numRequiredSignatures
out.write(0); // numReadonlySignedAccounts
out.write(6); // readonly unsigned: system, sysvar, economy, compute, ed25519, users
shortVec(out, accounts.size());
for (Account a : accounts) {
byte[] pk = Base58Util.decode(a.pubkey);
if (pk.length != 32) throw new IOException("Solana pubkey !=32: " + a.pubkey);
out.write(pk);
}
byte[] bh = Base58Util.decode(recentBlockhash);
if (bh.length != 32) throw new IOException("recentBlockhash !=32");
out.write(bh);
shortVec(out, instructions.size());
for (CompiledInstruction ci : instructions) {
out.write(ci.programIdIndex);
shortVec(out, ci.accounts.length);
for (int a : ci.accounts) out.write(a);
shortVec(out, ci.data.length);
out.write(ci.data);
}
return out.toByteArray();
}
private String getLatestBlockhash() throws Exception {
JsonNode result = rpc("getLatestBlockhash", List.of(Map.of("commitment", cfg.solanaCommitment()))).path("result");
String blockhash = result.path("value").path("blockhash").asText("");
if (blockhash.isBlank()) throw new IOException("Solana getLatestBlockhash не вернул blockhash");
return blockhash;
}
private String sendTransaction(byte[] tx) throws Exception {
String txB64 = Base64.getEncoder().encodeToString(tx);
JsonNode result = rpc("sendTransaction", List.of(txB64, Map.of(
"encoding", "base64",
"skipPreflight", false,
"preflightCommitment", "confirmed",
"maxRetries", 5
))).path("result");
String sig = result.asText("");
if (sig.isBlank()) throw new IOException("Solana sendTransaction не вернул signature");
return sig;
}
private JsonNode rpc(String method, List<?> params) throws Exception {
Map<String,Object> body = new LinkedHashMap<>();
body.put("jsonrpc", "2.0"); body.put("id", 1); body.put("method", method); body.put("params", params);
HttpRequest req = HttpRequest.newBuilder(URI.create(cfg.solanaRpcUrl()))
.timeout(Duration.ofSeconds(60))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body), StandardCharsets.UTF_8))
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (resp.statusCode() < 200 || resp.statusCode() >= 300) throw new IOException("Solana RPC HTTP " + resp.statusCode() + ": " + safe(resp.body()));
JsonNode root = JSON.readTree(resp.body());
if (root.hasNonNull("error")) throw new IOException("Solana RPC " + method + " error: " + root.path("error"));
return root;
}
private static byte[] serializeTransaction(byte[] signature64, byte[] message) throws IOException {
if (signature64.length != 64) throw new IOException("Solana signature !=64");
ByteArrayOutputStream out = new ByteArrayOutputStream();
shortVec(out, 1);
out.write(signature64);
out.write(message);
return out.toByteArray();
}
private static byte[] buildEd25519Instruction(byte[] signature64, byte[] publicKey32, byte[] message) {
if (signature64.length != 64 || publicKey32.length != 32) throw new IllegalArgumentException("Некорректный Ed25519 ix key/signature");
int sigOff=16, pkOff=sigOff+64, msgOff=pkOff+32;
if (message.length > 0xffff) throw new IllegalArgumentException("Ed25519 message слишком длинный");
byte[] data = new byte[msgOff + message.length];
data[0]=1; data[1]=0;
putU16le(data,2,sigOff); putU16le(data,4,0xffff);
putU16le(data,6,pkOff); putU16le(data,8,0xffff);
putU16le(data,10,msgOff); putU16le(data,12,message.length); putU16le(data,14,0xffff);
System.arraycopy(signature64,0,data,sigOff,64);
System.arraycopy(publicKey32,0,data,pkOff,32);
System.arraycopy(message,0,data,msgOff,message.length);
return data;
}
private static byte[] computeHeapData(int bytes) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream(); out.write(1); u32le(out, Integer.toUnsignedLong(bytes)); return out.toByteArray();
}
private static byte[] computeLimitData(int units) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream(); out.write(2); u32le(out, Integer.toUnsignedLong(units)); return out.toByteArray();
}
private static void shortVec(ByteArrayOutputStream out, int value) {
int rem=value;
while (true) {
int elem=rem & 0x7f; rem >>>=7;
if (rem==0) { out.write(elem); return; }
out.write(elem | 0x80);
}
}
private static void strU8(ByteArrayOutputStream out, String value) throws IOException {
byte[] b=String.valueOf(value==null?"":value).getBytes(StandardCharsets.UTF_8);
if (b.length>255) throw new IOException("Строка >255 bytes"); out.write(b.length); out.write(b);
}
private static void u32le(ByteArrayOutputStream out, long v) { for(int i=0;i<4;i++) out.write((int)(v >>> (8*i)) & 0xff); }
private static void u64le(ByteArrayOutputStream out, long v) { for(int i=0;i<8;i++) out.write((int)(v >>> (8*i)) & 0xff); }
private static void putU16le(byte[] d,int o,int v){d[o]=(byte)v;d[o+1]=(byte)(v>>>8);}
private static int u16le(byte[] b,int o){return (b[o]&255)|((b[o+1]&255)<<8);}
private static byte[] sha256(byte[] bytes) throws Exception { return MessageDigest.getInstance("SHA-256").digest(bytes); }
private static String hex(byte[] b){StringBuilder s=new StringBuilder(b.length*2);for(byte x:b)s.append(String.format("%02x",x));return s.toString();}
private static byte[] unhex(String s){String x=String.valueOf(s==null?"":s).trim(); if(x.length()!=64) throw new IllegalArgumentException("hex32 expected");byte[]o=new byte[32];for(int i=0;i<32;i++)o[i]=(byte)Integer.parseInt(x.substring(i*2,i*2+2),16);return o;}
private static void require32(byte[] b,String n){if(b==null||b.length!=32)throw new IllegalArgumentException(n+" должен быть 32 bytes");}
private static String safe(String s){String x=String.valueOf(s==null?"":s).replace('\n',' ').replace('\r',' ').trim();return x.length()<=500?x:x.substring(0,500)+"...";}
private record Current(String userPda, UserPdaSnapshot snapshot) {}
private record Account(String pubkey, boolean signer, boolean writable) {}
private record CompiledInstruction(int programIdIndex, int[] accounts, byte[] data) {}
}
@@ -0,0 +1,34 @@
package server.archive;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.time.ZoneId;
import java.util.Base64;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ArchiveFileNamesTest {
private static final long CREATED_AT = Instant.parse("2026-09-11T00:00:00Z").toEpochMilli();
private static final ZoneId UTC = ZoneId.of("UTC");
@Test
void usesTemporaryAndFinalArweaveNames() {
byte[] tx = new byte[32];
for (int i = 0; i < tx.length; i++) tx[i] = (byte) i;
String txText = Base64.getUrlEncoder().withoutPadding().encodeToString(tx);
assertEquals("archive01.00001.11.09.26.tmp.SHiNE-archive",
ArchiveFileNames.pendingFileName("archive01", 1, CREATED_AT, UTC));
assertEquals("archive01.00001.11.09.26." + txText + ".SHiNE-archive",
ArchiveFileNames.uploadedFileName("archive01", 1, CREATED_AT, UTC, tx));
assertEquals("archive01.100000.11.09.26.tmp.SHiNE-archive",
ArchiveFileNames.pendingFileName("archive01", 100000, CREATED_AT, UTC));
}
@Test
void sanitizesLoginForFileSystemOnly() {
assertEquals("archive_user.00002.11.09.26.tmp.SHiNE-archive",
ArchiveFileNames.pendingFileName("archive/user", 2, CREATED_AT, UTC));
}
}

Some files were not shown because too many files have changed in this diff Show More