Compare commits

...
Author SHA256 Message Date
tram 97f90167f1 Добавить поддержку уведомлений 2026-08-21 13:23:41 +03: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
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
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
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
762 changed files with 66686 additions and 53406 deletions
+12 -2
View File
@@ -13,6 +13,7 @@ build/
.kotlin
### IntelliJ IDEA ###
.idea/
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
@@ -76,6 +77,7 @@ shine-solana/shine/scripts/**/*.env
shine-solana/shine/scripts/**/TEMP_*.md
# Локальные артефакты и внешние материалы ESP32-подпроекта
ESP32/esp32-config-tool/
ESP32/**/.git/
ESP32/**/.idea/
ESP32-wallet/.idea/
@@ -101,5 +103,13 @@ ESP32/**/*.d
ESP32/**/*.a
# Полные серверные бэкапы (тяжёлые архивы, не коммитим)
server-backup/archive/**
!server-backup/archive/.gitkeep
deploy/backup/archive/**
!deploy/backup/archive/.gitkeep
# Локальная дев-обвязка AI-агентов (сессии, планы, настройки) — не коммитим
.agents/
.codex/
.claude/
# Рабочие бэкапы/превью-ассеты UI — не для репозитория
*.bak.png
shine-UI/assets/navbar_preview.png
-8
View File
@@ -1,8 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
Generated
-1
View File
@@ -1 +0,0 @@
shine-server-server
-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>
-25
View File
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<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-geo" />
<option value="$PROJECT_DIR$/shine-server-log" />
<option value="$PROJECT_DIR$/shine-server-net-protocol" />
<option value="$PROJECT_DIR$/shine-server-net-server" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>
-10
View File
@@ -1,10 +0,0 @@
<?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">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
Generated
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo" vcs="Git" />
<mapping directory="$PROJECT_DIR$/tools/understand-anything-lab/upstream" vcs="Git" />
</component>
</project>
+55 -52
View File
@@ -14,14 +14,15 @@
- Веб-панель администратора сервера (управление Solana PDA сервера) находится в `shine-UI/`:
- точка входа `shine-UI/server-ui.html`;
- остальные файлы серверного UI — в `shine-UI/server-ui/`.
- Локальный Telegram-бот агента-кодера находится в папке `SHiNE-agent-bot-coder/` и не является кодом основного серверного приложения.
- Локальный Telegram-бот агента-кодера живёт рядом с репозиторием продукта, обычно в `../SHiNE-agent-bot-coder/`, и не входит в публичный код основного приложения.
- Solana/Anchor-модуль находится в папке `shine-solana/shine/` и ведётся отдельно от основного server/UI деплоя.
## Сервис агента-кодера
- В проекте есть локальный Telegram-бот-сервис агента-кодера в папке `SHiNE-agent-bot-coder/`.
- Локальный Telegram-бот-сервис агента-кодера находится вне этого git-репозитория, обычно в `../SHiNE-agent-bot-coder/`.
- Сервис принимает сообщения из Telegram, ведёт историю диалога, ставит задачи в очередь и вызывает Codex CLI для обработки запросов по проекту.
- Автоматически читаемые инструкции для Codex внутри сервиса держать в `SHiNE-agent-bot-coder/AGENTS.md`.
- Подробные служебные правила Telegram-обработчика, его очередь, история, systemd-запуск и особенности ответов описывать в `SHiNE-agent-bot-coder/AGENT.md`.
- Рабочая папка 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
@@ -36,61 +37,74 @@
- Модуль логически связан с 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-модуля с `deployServer`/`deployUI` основного проекта.
- Для Solana deploy/push использовать правила из локального `shine-solana/shine/AGENTS.md`; не смешивать deploy Solana-модуля со скриптами основного server/UI deploy из `deploy/scripts/`.
- Для регистрации пользователей в Solana (программа `shine_users`) единая актуальная инструкция по деплою/инициализации, адресам программ, и куда их прописывать в UI/сервере находится в:
- `Dev_Docs/Инициализация_Solana_регистрации/README.md`
- `docs/Инициализация_Solana_регистрации/README.md`
- Этот файл считать основной справкой (single source of truth) по деплою и первичной инициализации Solana-регистрации в текущем проекте.
- Актуальная архитектурная справка по устройству Solana-программ, PDA-счетам, ролям DAO и движению средств находится в:
- `Dev_Docs/Solana_Architecture/README.md`
- `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`
## Документация блокчейна
- Актуальная документация по форматам блокчейна находится в `Dev_Docs/Blockchain/README.md`.
- Актуальная документация по форматам блокчейна находится в `docs/Blockchain/README.md`.
- Это точка входа (оглавление), рядом расположены детальные файлы по форматам, типам каналов и командным сообщениям.
- При любом изменении кода, связанного с блокчейном (формат блока, типы каналов, правила чтения/записи, команды), обязательно обновлять соответствующие документы в `Dev_Docs/Blockchain/`.
- Дополнительно обязательно вести `Dev_Docs/Blockchain/CHANGELOG.md`: дописывать изменения построчно с указанием даты/времени и хэша коммита, после которого внесено изменение.
- При любом изменении кода, связанного с блокчейном (формат блока, типы каналов, правила чтения/записи, команды), обязательно обновлять соответствующие документы в `docs/Blockchain/`.
- Дополнительно обязательно вести `docs/Blockchain/CHANGELOG.md`: дописывать изменения построчно с указанием даты/времени и хэша коммита, после которого внесено изменение.
- Перед любым изменением формата блокчейна обязательно заранее предупреждать пользователя, что формат будет изменён.
- Изменять формат блокчейна можно только после явного подтверждения пользователя (без подтверждения формат не менять).
- Добавление любых данных в блокчейн выполнять только через операцию `AddBlock`.
- Перед каждым `AddBlock` обязательно проверять/актуализировать текущее состояние вершины блокчейна (`last global number/hash`) и использовать его при формировании блока.
## Документация личных сообщений (DM)
- Актуальная документация по логике личных сообщений находится в `Dev_Docs/Personal_Messages/README.md`.
- При любом изменении кода, связанного с личными сообщениями (формат подписанного DM-блока, типы DM-сообщений, правила доставки/ACK/read-receipt, роутинг по сессиям, UI-логика чатов), обязательно обновлять `Dev_Docs/Personal_Messages/README.md`.
- Логика личных сообщений в коде должна всегда соответствовать `Dev_Docs/Personal_Messages/README.md`.
- Актуальная документация по логике личных сообщений находится в `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 сервера находится в `Dev_Docs/API/`.
- При любом изменении серверного API/эндпоинтов/операций `op` обязательно обновлять соответствующие документы в `Dev_Docs/API/`.
- Актуальная документация по публичному JSON/WebSocket API сервера находится в `docs/API/`.
- При любом изменении серверного API/эндпоинтов/операций `op` обязательно обновлять соответствующие документы в `docs/API/`.
- Перед изменением самого серверного API обязательно явно предупредить пользователя, какие операции, поля запросов/ответов или коды ошибок будут изменены, и запросить отдельное подтверждение.
- Без явного подтверждения пользователя формат серверного API не менять; допускается только приведение документации в соответствие уже существующему коду.
- Если добавляется новая операция `op`, нужно обновить общий список операций в `Dev_Docs/API/09_Operations_Index.md` или создать его, если файла ещё нет.
- Если добавляется новая операция `op`, нужно обновить общий список операций в `docs/API/09_Operations_Index.md` или создать его, если файла ещё нет.
## Известная проблема (временная пометка)
- Мнения по связям пользователя (`known_person`, `shine_confirmed`, `shine_seen`) в UI могут отображаться нестабильно.
- Требуется отдельная доработка и финальная проверка end-to-end: запись мнения в блокчейн → обновление `connections_state` → ответ `GetUserConnectionsGraph` → отображение в UI.
- До фикса считать эту часть функционала незавершённой и обязательно перепроверять вручную после каждого деплоя.
## Документация Figma
- Актуальная документация по переносу экранов SHiNE в Figma и обратному переносу из Figma в код находится в `docs/Figma/`.
- Точка входа: `docs/Figma/README.md`.
- Подробный рабочий регламент: `docs/Figma/TRANSFER_UI_SCREENS.md`.
- Для экранов регистрации, входа и других чувствительных UI-flow по умолчанию переносить экраны в Figma по одному, а не пачкой, если пользователь отдельно не подтвердил иной способ.
## Версионирование
- Единый файл версий проекта: `VERSION.properties` (в корне репозитория).
- Перед каждым новым коммитом обязательно увеличивать версии в `VERSION.properties`:
- `client.version` — версия клиентского UI.
- `server.version` — версия серверной части.
- Базовое правило инкремента: `+1` по последнему числовому сегменту (patch), если не оговорено иное.
- Обычные коммиты делать стандартным `git commit`; переменная `$GITEA_TOKEN` для коммитов не нужна и не используется.
- Все правила по коммитам, merge в `main`, `git push` и обновлению `VERSION.properties` находятся в `COMMIT_AND_VERSION_RULES.md`.
- Этот файл считать единым источником истины по правилам версионирования и коммитов для данного репозитория.
## Deploy
- Все документы и заметки по деплою хранить в папке `Dev_Docs/deploy/`.
- Базовый целевой хост для деплоя по умолчанию: `player@93.170.12.154` (`shineup.me`).
- Базовый путь на сервере для SHiNE: `/home/player` (проекты SHiNE размещать в `/home/player/SHiNE/...`).
- Все документы, инструкции, 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.
- По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке.
- Для операций `git push` при необходимости использовать токен из переменной окружения `$GITEA_TOKEN`.
- Для серверного деплоя использовать один gradle task: `./gradlew deployServer`.
- Для UI деплоя использовать один gradle task: `./gradlew deployUI`.
- Любые изменения и любой деплой на 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` (клиент → сервер).
@@ -109,30 +123,19 @@
- `unknown_error`
- В этих записях искать поля `reason`, `failureStage`, `pcConnectionState`, `pcIceConnectionState`, `routeLabel`, `configuredTurnHosts*`, `reachableTurnHosts*`.
## Недопроверенные фичи (обязательно)
- Папка для учёта недопроверенных фич: `Dev_Docs/Pending_Features/`.
- По каждой новой доработке, которая требует ручной проверки, добавлять отдельный markdown-файл в `Dev_Docs/Pending_Features/`.
- Рекомендуемый формат имени файла: `YYYY-MM-DD_HHMM_<short-feature-name>.md`.
- Имена новых файлов и краткие описания фич по возможности писать на русском языке.
- Внутри файла обязательно указывать:
- краткое описание фичи;
- что именно проверять;
- ожидаемый результат;
- статус (например: `pending`, `in_progress`, `done`).
- После подтверждения, что фича проверена и работает корректно, соответствующий файл удалять.
- В `Dev_Docs/Pending_Features/README.md` вести краткий регламент и поддерживать актуальность.
## Будущие фичи
- Папка для задач, сознательно отложенных на будущее: `Dev_Docs/Future_Features/`.
- Точка входа по планам: `Dev_Docs/Future_Features/README.md`.
- Внутри планы разделены по горизонтам: `near/`, `medium/`, `far/`.
- Если пользователь спрашивает, какие есть планы или что можно продолжить, сначала читать `Dev_Docs/Future_Features/README.md`, затем при необходимости конкретные файлы из горизонтов.
## Будущие фичи / TODO
- Папка для задач, сознательно отложенных на будущее: `TODO/`.
- Точка входа по планам: `TODO/README.md`.
- Внутри планы разделены по горизонтам: `near/`, `medium/`, `far/` и тематическим подпапкам.
- Если пользователь спрашивает, какие есть планы или что можно продолжить, сначала читать `TODO/README.md`, затем при необходимости конкретные файлы из подпапок.
- Файлы из этой папки не считать активными задачами и не начинать реализацию без явной просьбы пользователя.
- Если часть кода временно отключена или закомментирована, в файле будущей фичи подробно описывать:
- Старую папку `docs/Future_Features/` считать выведенной из использования и больше не использовать для новых записей.
- Если часть кода временно отключена или закомментирована, либо удалена как временная заглушка, в TODO-файле подробно описывать:
- какие файлы и участки отключены;
- что осталось в коде как заготовка;
- какие документы нужно обновить при возврате;
- с какого сценария продолжать разработку.
- с какого сценария продолжать разработку;
- из какого коммита брать последнюю полную реализацию.
## Коммуникация по новым задачам (обязательно)
- При получении нового задания сначала кратко пересказать задачу своими словами.
+1 -1
View File
@@ -5,7 +5,7 @@
@shine-UI/AGENTS.md
## Справка по подпроектам
- При работе внутри `SHiNE-agent-bot-coder/` — читать `SHiNE-agent-bot-coder/AGENTS.md` и `SHiNE-agent-bot-coder/AGENT.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`.
-255
View File
@@ -1,255 +0,0 @@
# DAO_запуск
Рабочий документ по тому, что ещё нужно сделать для первого запуска DAO-сценария SHiNE.
Логика документа:
- `этап1` — то, без чего нельзя считать сценарий первого запуска собранным даже в тестовом виде;
- `этап2` — то, что полезно и, вероятно, потребуется дальше, но это можно делать после старта `этап1` или параллельно без блокировки первого результата.
Базовая среда первого прохода:
- сеть `Solana devnet`;
- модель синхронизации: `server-to-server`;
- `Solana + Arweave` используются как якорь и архив;
- DAO понимается как стандартный governance/smart-contract контур, который управляет отдельными программами SHiNE, приносящими деньги.
## Краткий вывод
Для первого запуска DAO в тестовом виде текущего списка в целом хватает, но только если понимать запуск как:
- можно развернуть и проверить базовый DAO-контур;
- можно зарегистрировать пользователей и ключевые сущности;
- можно провести тестовую покупку билета через smart contract;
- можно завести тестовый денежный поток в программы, управляемые DAO;
- можно проверить опорную межсерверную синхронизацию и фиксацию состояния в архивный слой.
Если же под "запуском" понимать уже полностью устойчивую production-схему с ротацией ключей, восстановлением любого сервера из архива, железными устройствами подписи и полным циклом администрирования, то текущий список нужно будет ещё расширять.
## Этап1
Цель этапа: собрать минимально жизнеспособный DAO-сценарий в `devnet`, который можно пройти руками от регистрации до базовой экономики и проверки архитектуры.
### 1. Переписать и стабилизировать регистрацию пользователей без Anchor
Что сделать:
- довести `shine_users` в чистом Rust/Solana SDK до рабочего и проверенного состояния;
- убедиться, что `shine_login_guard` и связанный сценарий регистрации совместимы с новым ABI;
- проверить создание и чтение `user_pda`;
- проверить update пользовательской записи и связанные экономические параметры;
- синхронизировать сервер, UI и lazy-import с новым форматом и seed'ами.
Почему это в `этап1`:
- без стабильной пользовательской регистрации дальше нельзя строить ни DAO-сценарий, ни привязку устройств, ни платёжные сценарии.
### 2. Проверить полный сценарий регистрации и базовой Solana-интеграции
Что сделать:
- руками прогнать регистрацию нового пользователя;
- руками прогнать создание и update server PDA там, где это требуется текущему сценарию;
- убедиться, что сервер читает новые PDA без anchor-зависимостей и без старых discriminator'ов;
- зафиксировать, какие именно части сценария уже подтверждены руками, а какие ещё нет.
Почему это в `этап1`:
- сейчас в проекте уже есть признаки перехода на pure Rust, но без ручной проверки это нельзя считать завершённым.
### 3. Создать стандартный DAO smart contract / governance-контур
Что сделать:
- определить и реализовать стандартный DAO-контур, который будет управлять программами SHiNE;
- зафиксировать, какие права сразу передаются DAO, а какие временно остаются на отдельных ключах;
- подготовить тестовую DAO-структуру в `devnet`.
Минимум для первого запуска:
- DAO существует как управляемая сущность;
- DAO может владеть или контролировать ключевые права управления денежными программами;
- есть понятный путь, как DAO влияет на доходные программы SHiNE.
Почему это в `этап1`:
- без этого "DAO-запуск" будет только запуском отдельных Solana-программ, но не запуском управляемой DAO-системы.
### 4. Доработать смарт-контракт выплат с третьей очередью
Что сделать:
- добавить в `shine_payments` третью очередь, о которой уже принято решение;
- проверить совместимость с текущей моделью тикетов, выплат и DAO-управления;
- убедиться, что логика очередей соответствует ожидаемой экономике проекта.
Почему это в `этап1`:
- по текущей постановке это нужно именно для сценария регистрации DAO и дальнейшей экономики.
### 5. Сделать UI для покупки билетов и просмотра очереди
Что сделать:
- добавить UI-сценарий покупки билетов через smart contract;
- показать пользователю, сколько перед ним человек в очереди;
- убедиться, что UI отражает актуальное состояние контрактной логики, а не локальные предположения.
Почему это в `этап1`:
- покупка билетов у тебя обозначена как часть DAO-сценария, а не как побочная функция;
- без UI можно тестировать контракт вручную, но нельзя считать сценарий запуска достаточно собранным для нормальной проверки.
### 6. Реализовать базовую синхронизацию серверов
Что сделать:
- сделать обмен состоянием между серверами по модели `server-to-server`;
- определить минимальный набор данных, который обязан синхронизироваться;
- предусмотреть фиксацию синхронизированного состояния в `Arweave`, а `Solana` использовать как якорь и ссылочный слой;
- описать, какой сервер считается источником истины в спорных случаях или как решается конфликт.
Почему это в `этап1`:
- без межсерверной синхронизации трудно обосновать архитектуру сети как воспроизводимую и переносимую;
- это напрямую связано с идеей, что любой сможет поднять свой сервер.
### 7. Подготовить базовый сценарий архивирования и восстановления
Что сделать:
- описать и частично реализовать схему: серверы синхронизируются между собой, архив состояния уходит в `Arweave`, ссылка/якорь фиксируется через `Solana`;
- определить минимальный сценарий восстановления блоков или состояния из архивного слоя;
- подтвердить, что новый сервер может получить достаточно данных для старта.
Почему это в `этап1`:
- это один из ключевых признаков независимой и воспроизводимой DAO-инфраструктуры.
## Этап2
Цель этапа: усилить безопасность, автономность и удобство системы после того, как минимальный DAO-сценарий уже запустился и проверен в `devnet`.
### 1. Смена ключей цифровой подписи
Что сделать:
- продумать и реализовать смену `root key`, `device key`, `blockchain key`;
- описать ограничения, кто и в каком сценарии может менять каждый тип ключа;
- продумать, как не потерять доступ и как обновлять доверие к новым ключам.
Почему это в `этап2`:
- для production это очень важно;
- для первого тестового запуска можно временно использовать фиксированный набор ключей.
### 2. Полная повторная перепроверка всех сценариев
Что сделать:
- повторно прогнать регистрацию, DAO, выплаты, билеты, синхронизацию и архивирование после стабилизации `этап1`;
- оформить итоговый чек-лист ручной проверки;
- отдельно проверить пограничные сценарии и восстановление после ошибок.
Почему это в `этап2`:
- это обязательный шаг перед переходом от "собрали" к "доверяем".
### 3. Устройство на ESP32 как homeserver с ключами
Что сделать:
- дописать прошивку, чтобы устройство могло выступать homeserver с ключами;
- дать ему возможность регистрироваться и подключаться к серверу;
- определить, какие операции устройство подписывает и где хранит ключевой материал.
Почему это в `этап2`:
- это очень сильное развитие архитектуры, но оно не должно блокировать первый DAO-запуск.
### 4. Логин и подпись через коробочки / устройства
Что сделать:
- реализовать сценарий входа через устройство или хотя бы сценарий подписи сообщений и ключей через устройство;
- определить, как это встраивается в регистрацию DAO и подтверждение действий;
- проверить, можно ли через это безопасно регистрировать DAO или подписывать критичные команды.
Почему это в `этап2`:
- это следующий уровень безопасности и UX, но не минимальный блокер первого старта.
### 5. Создание тестового DAO с использованием устройств подписи
Что сделать:
- после готовности устройств собрать тестовый DAO-сценарий уже с аппаратным участием;
- проверить, где устройство достаточно, а где всё ещё нужен обычный кошелёк или управляющий ключ.
Почему это в `этап2`:
- это проверка усиленной модели, а не базового старта.
### 6. Расписание синхронизации серверов
Что сделать:
- определить периодичность и правила фоновой синхронизации;
- продумать ручной и автоматический режим;
- решить, как часто публиковать архивные снимки и якоря.
Почему это в `этап2`:
- сначала важнее добиться самой работающей синхронизации, а потом уже делать её регулярной и автономной.
### 7. Полное восстановление блоков из Solana/Arweave
Что сделать:
- довести процедуру восстановления до сценария "любой может поднять свой сервер";
- определить минимальный bootstrap-набор;
- проверить восстановление на чистом окружении.
Почему это в `этап2`:
- для концепции сети это критично, но как полноценная задача обычно идёт после появления базового архива и первичной синхронизации.
## Что блокирует первый запуск сильнее всего
Если расставить приоритет внутри `этап1`, то самый жёсткий порядок сейчас выглядит так:
1. pure Rust регистрация пользователей и ручная проверка сценария;
2. DAO/gov-контур и его права управления;
3. доработка выплат с третьей очередью;
4. покупка билетов через smart contract и UI-проверка очереди;
5. межсерверная синхронизация;
6. архивирование в `Arweave` с якорем в `Solana`;
7. минимальное восстановление состояния новым сервером.
## Что уже частично похоже на готовое
По текущим документам и следам в проекте уже видно, что:
- переход `shine_users` и `shine_login_guard` на pure Rust уже начат и в значительной степени сделан;
- архитектура DAO, `shine_users` и `shine_payments` уже описана;
- часть Solana-структуры и PDA-форматов уже формализована;
- тема ESP32 уже отдельно присутствует в проекте как направление.
Это хорошо, потому что документ получается не "с нуля", а как сборка того, что уже назрело в коде и планах.
## Вопросы, которые всё ещё стоит уточнить
1. Какой именно стандарт DAO планируется использовать в первом проходе: готовый governance-стек Solana или собственная минимальная обвязка вокруг управляющих кошельков?
2. Третья очередь в `shine_payments` уже точно определена по смыслу, или пока есть только решение "она нужна", но без финальной экономики?
3. Что именно считается единицей синхронизации между серверами: блоки SHiNE, агрегированные снапшоты, PDA-состояния, или смесь этих вариантов?
4. Нужен ли для `этап1` уже полноценный автоматический recovery нового сервера, или достаточно доказать это в полу-ручном сценарии?
5. Покупка билетов должна в первом проходе работать только через web/UI, или также нужен отдельный сценарий из серверного UI или скриптов?
## Рекомендуемый следующий практический шаг
Если идти без распыления, то следующим рабочим фокусом стоит считать:
1. закрыть ручную проверку pure Rust регистрации;
2. после этого формализовать минимальный DAO-контур;
3. затем переходить к третьей очереди выплат и к UI покупки билетов;
4. после этого делать синхронизацию, архив и восстановление.
-92
View File
@@ -1,92 +0,0 @@
# DEBUG: тестирование сетевого соединения между двумя клиентами
Документ описывает временный debug-контур для проверки WebRTC соединения между двумя активными WS-сессиями.
## 1) Подготовка
0. Убедись, что в `application.properties` включен параметр:
`debug.tempApi.enabled=true`
1. Создай файл `.debug-token` в корне проекта на основе `debug-token.example`.
2. В `.debug-token` должна быть одна строка: секретный токен.
3. Перезапусти сервер.
## 2) API debug
Базовый заголовок для всех запросов:
```bash
-H "Authorization: Bearer <YOUR_DEBUG_TOKEN>"
```
### 2.1 Получить список живых клиентов
```bash
curl -s \
-H "Authorization: Bearer <YOUR_DEBUG_TOKEN>" \
http://localhost:7070/debug/ws/clients | jq
```
Ответ содержит `sessionId`, `login`, `ip`, `userAgent`, и клиентскую информацию.
### 2.2 Запустить debug-соединение между двумя сессиями
```bash
curl -s -X POST \
-H "Authorization: Bearer <YOUR_DEBUG_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"initiatorSessionId": "SESSION_ID_A",
"responderSessionId": "SESSION_ID_B",
"clearDebugLog": false
}' \
http://localhost:7070/debug/ws/connect | jq
```
В ответе придёт `runId`. Его используй для фильтра логов.
### 2.3 Читать последние N debug-логов
```bash
curl -s \
-H "Authorization: Bearer <YOUR_DEBUG_TOKEN>" \
"http://localhost:7070/debug/ws/logs?limit=200&runId=<RUN_ID>" | jq
```
## 3) Операционный сценарий “Codex + пользователь”
1. Codex поднимает сервер и сообщает пользователю ссылку на UI.
2. Codex пишет пользователю: **«Запусти двух клиентов и скажи “продолжай”»**.
3. Пользователь запускает два клиента (лучше под разными логинами).
4. Пользователь пишет: **«продолжай»**.
5. Codex:
- вызывает `/debug/ws/clients`,
- выбирает 2 сессии,
- вызывает `/debug/ws/connect`,
- получает `runId`,
- читает `/debug/ws/logs?runId=...` и сообщает прогресс.
6. Если соединение не удалось:
- Codex сообщает ошибки по логам,
- при необходимости просит перезапустить 2 клиента,
- повторяет запуск debug-run.
## 4) Какие сообщения считать успехом
- `peer_connection_connected`
- `debug_connection_success`
- `signal_sent_200/210/220` без ошибок
## 5) Что говорить пользователю в ходе прогона (через «колонку»/чат)
Рекомендуемые фразы:
- «Сервер запущен. Запусти двух клиентов и напиши “продолжай”.»
- «Вижу 2 активные сессии, запускаю тест соединения.»
- «Тест запущен, runId=... Сейчас проверяю логи.»
- «Соединение установлено / не установлено. Ниже причины и следующий шаг.»
## 6) Ограничения
- Механизм временный, не для production-эксплуатации.
- Доступ к debug API имеет любой, кто знает токен.
- Рекомендуется тестить между разными логинами.
@@ -1,62 +0,0 @@
0. ПЕРЕДЕЛАТЬ ВСЁ НА НОВЫЙ ФОРМАТ!!
ВЫНЕСТИ ЭТИ ТРИ ВЕЩИ В ОБЩИЙ ПАРСЕР
* [2] type - тип соощения
* [2] Sиbtype - субтип сообщения
* [2] version - версия формата соощения
А ОСТАЛЬНОЕ В РЕАЛИЗАЦИЮ
ПЕРЕДЕЛАЕМ БД
1. СДЕЛАЕМ ЛИНИЮ ТОЛЬКО ДЛЯ ТЕХ ТИПОВ КОМУ НАДО (ЛАЙКАМ И ОТВЕТАМ НЕ НАДО)
(НОМЕР СООБЩЕНИЯ В ЛИНИИ ХРАНИТЬ В БЛОКАХ ВРОДЕ И НЕ НАДО ТЕМ БОЛЕЕ ЕГО ПОТОМ ПЕРЕПРОВЕРЯТЬ ВСЁ РАВНО)
А МОЖЕТ И НАДО ТК КАК ПО ОДНОМУ БЛОКУ ( ИЛИ ЧАСТИ БЛОКОВ ПОНЯТЬ КАКАЯ ЭТО ЧАСТЬ ПЕРЕПИСКИ - ВЕДЬ ГЛОБАЛ НОМЕР ВООБЩЕ НЕ ПОКАЗАТЕЛЬ)
В БД ПОМЕЧАТЬ ЧТО БЛОК ИЗ ЭТОЙ ЛИНИИ (ДЛЯ БЫСТРОГО ПОИСКА)
А УНИКАЛЬНЫЙ НОМЕР ЛИНИИ ЭТО ПО СУТИ НОМЕР СООБЩЕНИЯ СОЗДАВШЕГО ЛИНИЮ КАНАЛ (НУ И ФОРМАТ СООБЩЕНИЯ НАЧАЛА ЛИНИИ - КАНАЛА)
3. СООТВЕТСТВЕННО удалить НАПИСАТЬ/ПЕРОВЕРИТЬ НОРМАЛЬНЫЙ SubscriptionsDAO - ТК СТАРЫЙ РАБОТАЛ НО НА ДРУГОМ ФОРМАТЕ И ТИПО КРИВО
и дальше:
ЗДЕЛАТЬ ТРИ ЗАПРОСА:
СПИСОК КАНАЛОВ НА КОГО ПОДПИСАН И ПО СКОЛЬКО СООБЩЕНИЙ И ПОСЛДНИЙ ТЕКСТ
ДОДЕЛАТЬ И СВЯЗ ПОДПИСАН УЖЕ НЕ ТОЛЬКО НА ЧЕЛА НО И НА КАНАЛ. (И ПОЛУЧАЕТСЯ ЕСТЬ ОБЩИЙ КАНАЛЛ ПОСТОВ (НО НЕКОТОРЫЕ ПОСТЫ В НИКУДА-
А НЕКОТОРЫЕ ПОСТЫ ОБЪЯВЛЕНИЕ КАНАЛА
СПИСОК СООБЩЕНИЙ В КАНАЛЕ
ОПСИСАНИЕ ОДНОГО СОООБЩЕНИЯ (С ИСТОРИЕЙ ДО НАЧАЛА ВЕТКИ И СО ВСЕМИ ОТВЕТАМИ НА НЕГО)
(НУ И В БУДУЩЕМ четвёртый ИСТОРИЮ сообщения ПО ЕДИТУ)
И ПОМЯТКА
ВСЕГДА СЧИТАЕМ ПО ПОСЛЕДНЕМУ БЛОКЧЕЙНУ ДОСТУПНОМУ ПОЛЬЗОВАТЕЛЮ
ХОТЯ ССЫЛКА ПО НОМЕРУ БЛОКЧЕЙНА КУДА ДОБАВИЛИ
ЛАЙКИ И ОТВЕТЫ ПИШЕМ НА НОМЕР СООБЩЕНИЯ ЕДИТА
(СЧИТАЕМ ТРИГЕРОМ И НА ОРИГИНАЛЬНЫЙ СУМАРНОЕ И ОТДЕЛЬНО НА НЕГО, И НА КАЖДЫЙ ЕДИТ ОТДЕЛЬНО)
ОТВЕТЫ ПОКАЗЫВАЕМ ВСЕ ВРАЗ
@@ -1,10 +0,0 @@
Сделать возможность убрать свой лайк. (пока не надо а сложность что надо больше проверок) - хотя можно и без проверки, просто за двойной лайк или за снятие двойное лайка. Будет двойное проникновение :)) тому кто изменил код клиента и убрал проверку на клиенте - и блокчейн заблокируется и всё.
поэтому просто на каждую реакцию добавиться убрать эту ракцию .
- это просто
сделатьпотом что бы в солану_юзерс хранилось имя текущего блокчейна пользователя. Что бы потом можно было грузить именно актуальный ТО ЕСТЬ потом можно будет менять блокченый!
сделать сессион пасворд тоже ключём подписи устройства!!
-22
View File
@@ -1,22 +0,0 @@
Перечень библиотек и их краткое описание
shine-server-log
Статический “сиренный” метод для максимально заметного критического лога администратору
shine-server-config
Минимальный конфиг-лоадер, который один раз читает application.properties и даёт доступ к параметрам.
shine-server-geo
Утилиты, которые вытаскивают IP/язык/UA из Jetty WebSocket и (опционально) резолвят гео по IP с кэшем в БД.
shine-server-crypto
Базовые крипто-утилиты для SHA-256 и Ed25519 (BouncyCastle) + проверка подписи/хэша для .bch сущностей и маленький self-test.
shine-server-bd
Библиотека реалезующая всю работу с БД:
shine-server-blockchain
Библиотека, которая задаёт единый бинарный формат блоков (RAW+signature+hash), парсит/валидирует “тело” блока по type/version, и проверяет целостность/подпись цепочки через SHA-256 + Ed25519 с привязкой к login и предыдущим хэшам.
shine-server-protocol
Библиотека JSON-протокол поверх WebSocket для взаимодействия с клиентами.
-17
View File
@@ -1,17 +0,0 @@
shine-server-bd — это библиотека реалезующая всю работу с БД:
хранит пользователей/сессии/параметры/кэш IP→гео и данные блокчейна (состояние + блоки), предоставляя единый SqliteDbController для соединений, набор DAO под каждую таблицу (Singleton, методы с Connection для транзакций и без Connection — сами открывают/закрывают), и простые entity-модели как контейнеры данных для маппинга ResultSet↔Java.
Логика структуры классов (в двух словах):
shine.db.SqliteDbController — один вход в БД: читает db.path, при отсутствии файла создаёт БД, выдаёт новые Connection и настраивает PRAGMA.
shine.db.DatabaseInitializer — разовая сборка схемы (таблицы + индексы).
shine.db.entities.* — POJO-модели строк таблиц (без логики, только поля/геттеры/сеттеры + иногда удобные методы вроде getDeviceKeyByte()).
shine.db.dao.* — DAO по таблицам: ActiveSessionsDAO, SolanaUsersDAO, UserParamsDAO, IpGeoCacheDAO, BlockchainStateDAO, BlocksDAO; плюс “сервисные” DAO:
UserCreateDAO — атомарная регистрация пользователя в транзакции (BEGIN IMMEDIATE + rollback/commit).
// Временное решение позволяющее регистрировать новых пользователей
// атомарно и добавляет запись и в solana_users и в BlockchainState
-209
View File
@@ -1,209 +0,0 @@
SHiNE — структура БД (актуальная версия)
Перечень таблиц и назначение
solana_users
Справочник пользователей: логин + ключ устройства + (опционально) Solana-ключ.
Базовая таблица, используется как FK почти везде.
active_sessions
Активные сессии авторизации/работы клиента: секреты, тайминги, WebPush-данные, IP и информация о клиенте.
users_params
Хранилище актуальных параметров пользователя.
Для каждой пары (login, param) хранится только самая новая версия по time_ms.
ip_geo_cache
Кеш геолокации по IP для снижения нагрузки на внешние сервисы.
blockchain_state
Агрегированное состояние блокчейна по blockchain_name:
лимиты, текущий размер, последний глобальный блок и состояние линий 0..7.
blocks
Журнал всех блоков и сообщений.
Содержит историю событий: тексты, реакции, ответы, связи.
PRIMARY KEY намеренно отсутствует.
connections_state ⭐
Актуальное состояние связей между пользователями
(друг / контакт / подписка).
Обновляется автоматически на основе событий из blocks.
message_stats ⭐
Агрегированные счётчики лайков и ответов на конкретные сообщения.
Поддерживается триггерами из blocks.
Таблицы подробно
solana_users
login — TEXT PK — уникальный логин пользователя
device_key — TEXT NOT NULL — публичный ключ устройства (Base64(32) / HEX(64))
solana_key — TEXT NULL — публичный ключ Solana-аккаунта
active_sessions
session_id — TEXT PK — идентификатор сессии
login — TEXT NOT NULL, FK → solana_users(login)
session_pwd — TEXT NOT NULL — секрет сессии
storage_pwd — TEXT NOT NULL — секрет storage
session_created_at_ms — INTEGER NOT NULL
last_authirificated_at_ms — INTEGER NOT NULL
push_endpoint — TEXT NULL
push_p256dh_key — TEXT NULL
push_auth_key — TEXT NULL
client_ip — TEXT NULL
client_info_from_client — TEXT NULL
client_info_from_request — TEXT NULL
user_language — TEXT NULL
users_params
login — TEXT NOT NULL, FK → solana_users(login)
param — TEXT NOT NULL
time_ms — INTEGER NOT NULL
value — TEXT NOT NULL
device_key — TEXT NULL
signature — TEXT NULL
Ограничение:
UNIQUE(login, param)
Логика:
обновление принимается только если excluded.time_ms > users_params.time_ms
ip_geo_cache
ip — TEXT PK
geo — TEXT NULL
updated_at_ms — INTEGER NOT NULL
blockchain_state
blockchain_name — TEXT PK
login — TEXT NOT NULL, FK → solana_users(login)
blockchain_key — TEXT NOT NULL
size_limit — INTEGER NOT NULL
file_size_bytes — INTEGER NOT NULL
last_global_number — INTEGER NOT NULL (-1 = genesis)
last_global_hash — TEXT NOT NULL
updated_at_ms — INTEGER NOT NULL
Линии 0..7:
для каждой линии:
lineX_last_number
lineX_last_hash
blocks
login — TEXT NOT NULL
bch_name — TEXT NOT NULL
block_global_number — INTEGER NOT NULL
block_global_pre_hash — TEXT NOT NULL
block_line_index — INTEGER NOT NULL
block_line_number — INTEGER NOT NULL
block_line_pre_hash — TEXT NOT NULL
msg_type — INTEGER NOT NULL
msg_sub_type — INTEGER NOT NULL
block_bytes — BLOB NULL
Ссылка на другой блок (nullable):
to_login
to_bch_name
to_block_global_number
to_block_hash
connections_state ⭐
Текущее агрегированное состояние связей.
login — TEXT NOT NULL
rel_type — INTEGER NOT NULL
10 = FRIEND
20 = CONTACT
30 = FOLLOW
to_login — TEXT NOT NULL
to_bch_name — TEXT NOT NULL
to_block_global_number — INTEGER NULL
to_block_hash — TEXT NULL
Ограничение:
UNIQUE(login, rel_type, to_login)
message_stats ⭐
Счётчики активности по целевому сообщению.
to_login — TEXT NOT NULL
to_bch_name — TEXT NOT NULL
to_block_global_number — INTEGER NOT NULL
to_block_hash — TEXT NOT NULL
likes_count — INTEGER NOT NULL DEFAULT 0
replies_count — INTEGER NOT NULL DEFAULT 0
UNIQUE:
(to_login, to_bch_name, to_block_global_number, to_block_hash)
Триггеры БД (полная логика)
3.1 Связи пользователей
trg_blocks_connection_state_ai
AFTER INSERT ON blocks
Условие:
msg_type = 3 (connection)
Добавление / обновление связи
msg_sub_type IN (10,20,30)
выполняется UPSERT в connections_state
Удаление связи
msg_sub_type IN (11,21,31)
удаляется соответствующая связь:
11 → 10
21 → 20
31 → 30
Итог:
blocks — журнал событий
connections_state — всегда актуальное состояние
3.2 Подсчёт лайков ⭐
trg_blocks_message_stats_like_ai
AFTER INSERT ON blocks
Условие:
msg_type = 2 (reaction)
msg_sub_type = 1 (like)
Действие:
определяется цель по to_bch_name, to_block_global_number, to_block_hash
to_login вычисляется как
substr(to_bch_name, 1, length(to_bch_name) - 3)
выполняется UPSERT в message_stats
likes_count += 1
3.3 Подсчёт ответов ⭐
trg_blocks_message_stats_reply_ai
AFTER INSERT ON blocks
Условие:
msg_type = 1 (text)
msg_sub_type = 2 (reply)
Действие:
цель определяется аналогично лайкам
выполняется UPSERT в message_stats
replies_count += 1
Индексы (смысл)
idx_solana_users_login — поиск пользователя
idx_active_sessions_login — сессии пользователя
idx_users_params_login — параметры пользователя
idx_ip_geo_cache_updated_at — чистка кеша
idx_blockchain_state_login — блокчейны пользователя
idx_blockchain_state_updated_at — обслуживание
idx_blocks_chain_global — чтение цепочки
idx_blocks_to_target — реакции / ответы
idx_message_stats_target — быстрый доступ к счётчикам
Итоговая модель мышления
blocks — неизменяемый журнал событий
connections_state — проекция связей
message_stats — проекция активности
всё вычисляется детерминированно через триггеры
@@ -1,75 +0,0 @@
# Протокол звонков (MVP)
Версия: browser-to-browser, runtime-only signaling.
## Цели
- Технические сообщения звонка не сохраняются в БД direct_messages.
- Первый INVITE рассылается всем активным сессиям получателя и дублируется web push.
- Последующие сигналы идут только в конкретную sessionId и не дублируются в push.
## Операции API
### 1) CallInviteBroadcast
Отправляет общий вызов пользователю.
Запрос payload:
- `toLogin: string`
- `callId: string`
- `type: 100` (INVITE)
Поведение сервера:
- Рассылает `IncomingCallInvite` во все активные WS-сессии `toLogin`.
- В payload события передаёт:
- `fromLogin`
- `fromSessionId` (session инициатора)
- `toLogin`
- `callId`
- `type=100`
- `timeMs`
- Отправляет web push уведомление о входящем вызове.
Ответ payload:
- `callId`
- `deliveredWsSessions`
- `deliveredFcmSessions`
### 2) CallSignalToSession
Отправляет технический сигнал в конкретную сессию.
Запрос payload:
- `toLogin: string`
- `targetSessionId: string`
- `callId: string`
- `type: int`
- `data: string` (для SDP/ICE/служебных строк)
Поведение сервера:
- Ищет только `targetSessionId`.
- Проверяет, что сессия принадлежит `toLogin`.
- Отправляет `IncomingCallSignal` только в эту сессию.
- В БД ничего не сохраняет.
- Push не отправляет.
Ответ payload:
- `delivered: boolean`
## Коды type
- `100` INVITE
- `110` RINGING
- `120` ACCEPT
- `130` DECLINE_BUSY
- `140` TIMEOUT
- `150` HANGUP
- `200` OFFER
- `210` ANSWER
- `220` ICE
## Правила UI/логики
- Если уже есть активный звонок и пришел новый INVITE -> автоответ `DECLINE_BUSY` без UI.
- После ACCEPT `callId` остаётся во всех OFFER/ANSWER/ICE сообщениях до конца звонка.
- При параллельных звонках A<->B допускается детерминированное правило, кто создаёт OFFER.
## Тайминги MVP
- Ожидание подтверждения/реакции после INVITE: до 5с (у инициатора).
- Ожидание принятия у входящего звонка: 20с.
- Общий лимит ожидания до соединения: 22с.
@@ -1,9 +0,0 @@
Дальше делать:
Описание форматов.
Запросы клиент-сервер.
Промт на клиента.
---
Потом в сервак дописать синхронизацию серверов.
-53
View File
@@ -1,53 +0,0 @@
# SHiNE Deployment Servers Inventory
## Scope
This folder contains all deployment-related notes and server records for SHiNE.
## Legacy Production Server
- Name: `VPS-02` (legacy)
- Access: `root@194.87.0.247`
- Current role: old production server
- Confirmed services:
- `coturn` is installed and active (`systemd: active/running`)
- `caddy` is installed (reported by project context; verify version on host if needed)
- TURN configuration observed on host:
- `listening-port=3478`
- `external-ip=194.87.0.247`
- `relay-ip=194.87.0.247`
- auth mode: `use-auth-secret` + `static-auth-secret`
- SHiNE deployment note:
- This host is used as current/legacy runtime for SHiNE.
- Gradle-based deployment is used in this project (see repository deploy tasks and scripts).
## Target Production Server (Migration)
- Name: `VPS-05` (new)
- Access: `root@45.136.124.227`
- Planned role: new primary production server for gradual migration
- Baseline setup done:
- `ripgrep` installed
- user `player` created
- user `player` added to `sudo` group
- deployment directory created: `/home/player/SHiNE`
- Rule:
- All SHiNE-related runtime files and deployments on VPS-05 should be placed under `/home/player/SHiNE`.
## Additional TURN Node
- Name: `promo-node-93`
- Access: `ubuntu@93.170.12.154` (and `player` user for SHiNE operations)
- Role: additional TURN node for SHiNE calls
- TURN setup:
- `coturn` installed and active
- `listening-port=3478`
- `tls-listening-port=5349`
- `use-auth-secret` + shared `static-auth-secret`
- relay UDP port range: `49152-50152`
- Runtime files:
- `/etc/turnserver.conf`
- `/home/player/SHiNE/coturn/turnserver.conf`
- Cleanup done:
- Disabled old reverse SSH tunnel (`reverse-ssh.service`) that exposed `0.0.0.0:1200 -> localhost:22` to `194.87.0.247`.
## Next Migration Steps (recommended)
1. Install and configure runtime dependencies (JDK, Caddy, DB, TURN if required).
2. Mirror SHiNE deployment process from VPS-02 using existing Gradle deployment flow.
3. Move traffic gradually and validate logs/metrics before final cutover.
@@ -1,176 +0,0 @@
# API для разработчиков: 04 — Добавление блока в блокчейн (AddBlock)
Документ описывает **текущий рабочий формат** сетевого вызова `AddBlock`, который используется для записи **любого** блока в блокчейн пользователя.
> Важный принцип: на уровне JSON API сейчас есть **один универсальный метод** записи — `AddBlock`.
> Конкретный смысл записи задаётся типом самого бинарного блока (`type/subType/version` в заголовке блока).
## 1. Что делает `AddBlock`
`AddBlock`:
- принимает имя блокчейна и base64 бинарного блока;
- проверяет непрерывность цепочки (`blockNumber`, `prevHash`);
- проверяет формат и подпись Ed25519;
- валидирует `body` по правилам типа блока;
- сохраняет блок и обновляет состояние цепочки.
## 2. JSON формат запроса
`op = "AddBlock"`.
```json
{
"op": "AddBlock",
"requestId": "req-1001",
"payload": {
"blockchainName": "alice-001",
"blockNumber": 12,
"prevBlockHash": "ab12...ff",
"blockBytesB64": "AAAB..."
}
}
```
Поля `payload`:
- `blockchainName` — обязательно, формат `login-NNN`.
- `blockNumber` — обязательно (временное legacy-поле для совместимости; должно совпасть с номером внутри бинарного блока).
- `prevBlockHash` — legacy-поле, сейчас сервер использует `prevHash` из бинарного блока и состояние цепочки.
- `blockBytesB64` — обязательно: **полный бинарный блок** (`preimage + sigMarker + signature`) в Base64.
## 3. Успешный ответ
```json
{
"op": "AddBlock",
"requestId": "req-1001",
"status": 200,
"ok": true,
"payload": {
"reasonCode": null,
"serverLastGlobalNumber": 12,
"serverLastGlobalHash": "9f0e...a1"
}
}
```
## 4. Ошибка (единый формат)
При ошибках сервер отдаёт `Net_Exception_Response` со стандартными полями и дополнительно с состоянием сервера для ресинка:
```json
{
"op": "AddBlock",
"requestId": "req-1001",
"status": 400,
"ok": false,
"error": "bad_prev_hash",
"message": "Некорректный prevHash (цепочка не совпадает)",
"payload": {
"serverLastGlobalNumber": 11,
"serverLastGlobalHash": "c3d4...98"
}
}
```
### Основные `reasonCode`
- `empty_blockchain_name`, `bad_blockchain_name`
- `blockchain_state_not_found`
- `bad_block_base64`, `bad_block_format`, `bad_block_body`
- `bad_block_number`, `req_global_mismatch`, `bad_prev_hash`
- `bad_signature`, `signature_verify_failed`
- `prev_line_block_not_found`, `bad_prev_line_hash`
- `limit_exceeded`
- `repost_disabled` — репосты временно отключены до будущей реализации
- `internal_error`
## 5. Какие блоки реально можно добавлять через `AddBlock`
Через `AddBlock` можно писать поддержанные форматы, кроме явно отключённых временных фич:
1. **TECH (type=0)**
- `HEADER_COMPAT (subType=0)`
- `TECH_CREATE_CHANNEL (subType=1)`
2. **TEXT (type=1)**
- `TEXT_POST (10)`
- `TEXT_EDIT_POST (11)`
- `TEXT_REPLY (20)`
- `TEXT_EDIT_REPLY (21)`
- `TEXT_REPOST (30)` — формат зарезервирован, но новые блоки временно отклоняются с `repost_disabled`
3. **REACTION (type=2)**
- `REACTION_LIKE (1)`
4. **CONNECTION (type=3)**
- `CONNECTION_FRIEND (10)`
- `CONNECTION_UNFRIEND (11)`
- `CONNECTION_CONTACT (20)`
- `CONNECTION_UNCONTACT (21)`
- `CONNECTION_FOLLOW (30)`
- `CONNECTION_UNFOLLOW (31)`
- `CONNECTION_SPOUSE (40)`
- `CONNECTION_UNSPOUSE (41)`
- `CONNECTION_PARENT (50)`
- `CONNECTION_UNPARENT (51)`
- `CONNECTION_CHILD (52)`
- `CONNECTION_UNCHILD (53)`
- `CONNECTION_SIBLING (54)`
- `CONNECTION_UNSIBLING (55)`
- `CONNECTION_KNOWN_PERSON (60)`
- `CONNECTION_UNKNOWN_PERSON (61)`
- `CONNECTION_SHINE_CONFIRMED (70)`
- `CONNECTION_SHINE_UNCONFIRMED (71)`
- `CONNECTION_SHINE_SEEN (74)`
- `CONNECTION_SHINE_UNSEEN (75)`
5. **USER_PARAM (type=4)**
- `USER_PARAM_TEXT_TEXT (1)`
## 6. Хватает ли функций сейчас
Коротко: **для записи событий в блокчейн — хватает**, для полноценного клиентского чтения — **пока не хватает**.
Что есть:
- единый надёжный write-путь `AddBlock`;
- есть `GetFriendsLists` и API по `UserParam`;
- есть унифицированные коды ошибок и поля для ресинхронизации.
Что пока ограничивает продукт:
- нет полноценного read API для каналов/постов/тредов;
- нет API списка подписок с серверными счётчиками непрочитанного;
- нет ленты событий (новые ответы/лайки/подписки) как отдельного RPC.
## 7. Рекомендации по клиенту при записи блоков
1. Перед отправкой держать локальный `lastNumber/lastHash`.
2. При `bad_prev_hash` или `bad_block_number`:
- взять `serverLastGlobalNumber/serverLastGlobalHash` из ошибки,
- пересобрать следующий блок на актуальной вершине.
3. Для edit-блоков всегда ссылаться на **оригинальный** блок, а не на предыдущий edit.
4. Для связей/подписок использовать target на **root** (HEADER или CREATE_CHANNEL), а не на произвольный пост.
## 8. USER_PARAM для «личных данных»
Да, на текущем API это можно добавить **без изменения серверного кода**:
- в `UserParam` поле `param` сейчас не ограничено фиксированным справочником;
- сервер хранит пары `param -> value` как строки (при наличии корректной подписи и `time_ms`);
- чтение уже есть через `GetUserParam` и `ListUserParams`.
Рекомендуемый стартовый набор ключей для профиля (MVP):
- `name`
- `last_name`
- `address_physical`
- `address_web`
- `phone`
Практическая рекомендация: заранее зафиксировать единый словарь ключей в клиенте/документации, чтобы избежать дублей вида `lastname` vs `last_name`, `site` vs `address_web` и т.д.
Ограничения, которые важно учесть:
- сейчас нет серверной ACL-политики чтения параметров (в MVP их может читать любой клиент, который знает `login`);
- нет валидации формата значений для конкретных ключей (телефон, URL и т.д. проверяются только на стороне клиента);
- нет отдельного индекса/поиска по этим полям — только точечное чтение и listing по `login`.
-333
View File
@@ -1,333 +0,0 @@
# API для разработчиков: Технические запросы
Этот файл описывает технические WebSocket-запросы, которые нужны для служебной работы клиента с сервером. Часть операций доступна без авторизации, часть требует успешной авторизованной сессии.
Сейчас здесь шесть методов:
- `Ping` — keep-alive запрос для поддержания живого WebSocket-соединения;
- `GetServerInfo` — запрос базовой публичной информации о сервере для выбора узла в децентрализованной сети;
- `GetCallIceConfig` — выдача STUN/TURN конфигурации для звонков;
- `ClientErrorLog` — отправка клиентской ошибки в серверный лог;
- `ClientDebugLog` — отправка клиентского debug-события в серверный буфер;
- `CallDeliveryReport` — диагностический отчёт клиента о доставке/установке звонка.
Логика раздела такая:
- `Ping` нужен для регулярной проверки, что соединение всё ещё живо;
- `GetServerInfo` нужен до авторизации и до работы с данными, чтобы клиент понял, что сервер доступен, и показал пользователю краткую карточку этого узла.
Ниже сначала описаны назначение методов, затем точные форматы запросов и ответов.
## 1. `Ping`
### Назначение
Служебный keep-alive запрос.
Клиент может отправлять его периодически, чтобы:
- поддерживать активное WebSocket-соединение;
- понимать, что сервер отвечает;
- при необходимости получать текущее серверное время.
### Запрос
```json
{
"op": "Ping",
"requestId": "ping-001",
"payload": {
"ts": 1774700000123
}
}
```
Поле `ts` в запросе необязательно для логики сервера. Сервер его не валидирует и не использует для принятия решения.
### Успешный ответ
```json
{
"op": "Ping",
"requestId": "ping-001",
"status": 200,
"ok": true,
"payload": {
"ts": 1774700000456
}
}
```
### Специфические коды ошибок `Ping`
- У `Ping` нет специальных прикладных ошибок.
- Если произойдёт непредвиденная проблема, сервер вернёт общую ошибку из раздела `00`, обычно `500 / INTERNAL_ERROR`.
---
## 2. `GetServerInfo`
### Назначение
Запрос публичной информации о сервере.
Он нужен клиенту для выбора сервера в децентрализованной сети. По этому запросу клиент может:
- проверить, что сервер вообще доступен;
- показать URL и версию сервера;
- показать физический регион или адрес размещения;
- показать описание сервера;
- показать поле `origin` как комментарий о природе этого узла;
- показать дополнительную текстовую информацию.
Этот запрос доступен без авторизации.
### Источник данных
- `version` берётся из Gradle build и подставляется в `application.properties`;
- остальные поля читаются из настроек сервера;
- если значение в конфиге не задано, сервер возвращает пустую строку.
### Запрос
```json
{
"op": "GetServerInfo",
"requestId": "srv-001",
"payload": {
}
}
```
### Успешный ответ
```json
{
"op": "GetServerInfo",
"requestId": "srv-001",
"status": 200,
"ok": true,
"payload": {
"url": "wss://node.example.org/ws",
"version": "1.0",
"physicalRegion": "Грузия, Тбилиси",
"description": "Public community SHiNE node",
"origin": "Community-operated node",
"extraInfo": "IPv4 + IPv6; test federation enabled"
}
}
```
### Поля ответа
- `url` — публичный URL сервера.
- `version` — версия сервера из Gradle build.
- `physicalRegion` — физический регион или адрес размещения сервера.
- `description` — человекочитаемое описание сервера.
- `origin` — комментарий о том, какой это сервер.
- `extraInfo` — любая дополнительная информация о сервере.
### Специфические коды ошибок `GetServerInfo`
- У `GetServerInfo` нет специальных прикладных ошибок при штатной работе.
- Если произойдёт непредвиденная проблема, сервер вернёт общую ошибку из раздела `00`, обычно `500 / INTERNAL_ERROR`.
---
## 3. `GetCallIceConfig`
Доступно только после успешной авторизации.
### Запрос
```json
{
"op": "GetCallIceConfig",
"requestId": "ice-001",
"payload": {
}
}
```
### Успешный ответ
```json
{
"op": "GetCallIceConfig",
"requestId": "ice-001",
"status": 200,
"ok": true,
"payload": {
"stunUrls": ["stun:stun.example.org:3478"],
"turnUrls": ["turn:turn.example.org:3478?transport=udp"],
"turnUsername": "user",
"turnPassword": "password",
"turnServers": [
{
"id": "primary",
"urls": ["turn:turn.example.org:3478?transport=udp"],
"username": "user",
"password": "password"
}
],
"turnEnabled": true,
"generatedAtMs": 1774700000123,
"expiresAtMs": 1774700300123,
"ttlSec": 300
}
}
```
### Специфические коды ошибок `GetCallIceConfig`
- `422 / NOT_AUTHENTICATED` — требуется авторизация.
---
## 4. `ClientErrorLog`
### Запрос
```json
{
"op": "ClientErrorLog",
"requestId": "err-001",
"payload": {
"kind": "global_error",
"message": "TypeError: failed",
"stack": "...",
"sourceUrl": "https://shineup.me/app.js",
"lineNumber": 10,
"columnNumber": 20,
"route": "#/channel-view/own-0",
"href": "https://shineup.me/#/channel-view/own-0",
"userAgent": "...",
"clientTs": 1774700000123,
"requestOp": "GetChannelMessages",
"requestIdRef": "GetChannelMessages-123",
"contextJson": "{\"screen\":\"channels\"}"
}
}
```
### Успешный ответ
```json
{
"op": "ClientErrorLog",
"requestId": "err-001",
"status": 200,
"ok": true,
"payload": {
"serverTs": 1774700000456,
"accepted": true
}
}
```
### Специфические коды ошибок `ClientErrorLog`
- `400 / BAD_FIELDS` — обязательные поля ошибки не заполнены.
---
## 5. `ClientDebugLog`
### Запрос
```json
{
"op": "ClientDebugLog",
"requestId": "dbg-001",
"payload": {
"runId": "ui-run-1",
"level": "info",
"message": "opened channels tab",
"details": "{\"route\":\"#/channels\"}"
}
}
```
### Успешный ответ
```json
{
"op": "ClientDebugLog",
"requestId": "dbg-001",
"status": 200,
"ok": true,
"payload": {
"accepted": true,
"serverTs": 1774700000456
}
}
```
### Специфические коды ошибок `ClientDebugLog`
- `400 / BAD_FIELDS` — поле `message` не заполнено.
---
## 6. `CallDeliveryReport`
### Запрос
```json
{
"op": "CallDeliveryReport",
"requestId": "call-report-001",
"payload": {
"type": "outgoing_failed",
"value": "{\"reason\":\"ice_failed\",\"callId\":\"call-1\"}"
}
}
```
### Успешный ответ
```json
{
"op": "CallDeliveryReport",
"requestId": "call-report-001",
"status": 200,
"ok": true,
"payload": {
"serverTs": 1774700000456,
"accepted": true
}
}
```
### Специфические коды ошибок `CallDeliveryReport`
- `400 / BAD_FIELDS` — поле `type` не заполнено.
---
## 7. Короткое резюме
- `Ping` нужен для keep-alive и проверки, что WebSocket-соединение живо.
- `GetServerInfo` нужен для выбора сервера в сети и показа публичной информации об узле.
- `GetCallIceConfig` нужен для WebRTC-звонков и требует авторизации.
- `ClientErrorLog`, `ClientDebugLog`, `CallDeliveryReport` используются для диагностики клиента и звонков.
## 8. Прямое техническое сообщение в конкретную сессию
На текущий момент в публичном JSON API этого документа **нет отдельного RPC** для отправки произвольного технического сообщения в конкретную сессию пользователя (по `sessionId`).
Что уже есть в системе:
- сервер хранит `sessionId` активной сессии;
- есть `ListSessions`, чтобы клиент получил список sessionId своего пользователя;
- у сервера есть внутренний реестр активных WS-подключений по `sessionId`.
Чего не хватает для полноценной фичи «direct tech message by sessionId»:
1. отдельная API-операция (например, `SendSessionTechMessage`);
2. правило авторизации (кто имеет право писать в чужую/свою сессию);
3. унифицированный формат payload и события доставки;
4. коды ошибок (`SESSION_OFFLINE`, `SESSION_NOT_FOUND`, `FORBIDDEN` и т.п.).
Итог: как инфраструктурная база это почти готово, но нужен отдельный RPC-слой и политика доступа.
@@ -1,190 +0,0 @@
# API для разработчиков: DM, push и сигналы звонков
Документ описывает публичные операции, связанные с личными сообщениями, WebPush и сигналами звонков.
Подробная логика DM и бинарного формата:
- `Dev_Docs/Personal_Messages/README.md`
## 1. `UpsertPushToken`
Требует авторизации.
### Запрос
```json
{
"op": "UpsertPushToken",
"requestId": "push-upsert-001",
"payload": {
"sessionId": "SESSION_ID",
"endpoint": "https://push.example/...",
"p256dhKey": "BASE64",
"authKey": "BASE64",
"platform": "web",
"userAgent": "Mozilla/5.0 ..."
}
}
```
### Успешный ответ
```json
{
"op": "UpsertPushToken",
"requestId": "push-upsert-001",
"status": 200,
"ok": true,
"payload": {
"tokenId": "token-1",
"updatedAtMs": 1774700000123
}
}
```
## 2. `SendTestWebPush`
Требует авторизации.
### Запрос
```json
{
"op": "SendTestWebPush",
"requestId": "push-test-001",
"payload": {
"login": "alice",
"sessionId": "SESSION_ID",
"title": "Test",
"text": "Push body"
}
}
```
## 3. `SendMessagePair` и `ReceiveOutcomingMessage`
`ReceiveOutcomingMessage` — алиас `SendMessagePair`.
### Назначение
Передаёт пару signed DM-блоков:
- `incomingBlobB64` — блок `type=1` или `type=3`
- `outgoingBlobB64` — блок `type=2` или `type=4`
Для контентных сообщений `type=1/2` внутри base64 лежит бинарный формат `SHiNE_DM`.
### Запрос
```json
{
"op": "SendMessagePair",
"requestId": "dm-pair-001",
"payload": {
"incomingBlobB64": "BASE64_INCOMING_SIGNED_BLOCK",
"outgoingBlobB64": "BASE64_OUTGOING_SIGNED_BLOCK"
}
}
```
### Успешный ответ
```json
{
"op": "SendMessagePair",
"requestId": "dm-pair-001",
"status": 200,
"ok": true,
"payload": {
"baseKey": "from|to|time|nonce",
"incomingKey": "from|to|time|nonce|1",
"outgoingKey": "from|to|time|nonce|2",
"deliveredWsSessions": 1,
"deliveredWebPushSessions": 0
}
}
```
### Ошибки
- `400 / BAD_FIELDS` — пустой `incomingBlobB64` или `outgoingBlobB64`
- `400 / BAD_BLOCK_FORMAT` — base64 или бинарный контейнер повреждён
- `400 / BAD_CONTENT_FORMAT` — для контентного сообщения пришёл не `SHiNE_DM`
- `400 / ATTACHMENTS_DISABLED` — в `SHiNE_DM` пришёл `attachmentsCount != 0`
- `404 / USER_NOT_FOUND` — один из логинов не найден
- `460 / BAD_SIGNATURE` — подпись блока не прошла проверку
## 4. `ReceiveIncomingMessage`
Принимает только один входящий signed DM-блок.
### Назначение
Используется там, где нужно принять только incoming-вариант сообщения.
### Запрос
```json
{
"op": "ReceiveIncomingMessage",
"requestId": "dm-in-001",
"payload": {
"incomingBlobB64": "BASE64_INCOMING_SIGNED_BLOCK"
}
}
```
## 5. `AckSessionDelivery`
Требует авторизации. Подтверждает доставку в текущую сессию.
### Запрос
```json
{
"op": "AckSessionDelivery",
"requestId": "ack-001",
"payload": {
"messageKey": "from|to|time|nonce|1"
}
}
```
## 6. Событие `SignedMessageArrived`
Сервер присылает его по WebSocket в активные сессии адресата.
### Payload события
```json
{
"messageKey": "from|to|time|nonce|1",
"baseKey": "from|to|time|nonce",
"fromLogin": "alice",
"toLogin": "bob",
"targetLogin": "bob",
"messageType": 1,
"timeMs": 1774700000123,
"nonce": 123456789,
"blobB64": "BASE64_SIGNED_BLOCK",
"backlog": false
}
```
Если это новая ревизия того же письма, `messageKey` остаётся тем же, а `revisionTimeMs` меняется внутри бинарного блока.
## 7. `CallInviteBroadcast`
Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`.
## 8. `CallSignalToSession`
Требует авторизации. Шлёт сигнал звонка в конкретную сессию.
## 9. Замечания
- read-receipt `type=3/4` пока остаются в legacy-формате `SHiNE_dm2`
- контентные DM `type=1/2` используют `SHiNE_DM`
- сервер хранит только последнюю версию контентного сообщения по `messageKey`
- удаление сообщения реализуется новой ревизией с пустым телом и `attachmentsCount = 0`
- HTTP endpoints для DM-файлов сейчас отсутствуют
@@ -1,33 +0,0 @@
# Командные сообщения каналов
## 1. Общий префикс
Командные сообщения распознаются по префиксу:
`/.`
Пример:
- `/.desc Новый комментарий канала`
## 2. Поддерживаемые команды
### Для всех типов каналов (`0`, `1`, `100`, `200`)
- `/.desc <text>` — смена описания канала.
Примечание:
- Описание канала в чтении определяется последней командой `/.desc` в линии канала.
- Если `/.desc` не было, используется описание из `CreateChannel`.
### Дополнительно для `type=200`
- `/.add <login> <channelName>`
- `/.remove <login> <channelName>`
Формат аргументов фиксирован: через пробел.
## 3. Текущая модель применения
- Команды передаются как обычные `TEXT_POST` сообщения.
- Сервер уже применяет `/.desc` при вычислении актуального описания канала.
- Команды `/.add` и `/.remove` зарезервированы под расширенную модель участников `type=200` на уровне UI/агрегации.
## 4. Статус для MVP
- В текущем UI каналы `type=100` и `type=200` не используются.
- Соответственно, `/.add` и `/.remove` считаются запланированными и пока не участвуют в рабочем UI-сценарии.
-38
View File
@@ -1,38 +0,0 @@
# TEXT блоки (`type=1`, `version=1`)
TEXT-тип хранит сообщения и редактирования.
## Подтипы
1. `subType=10``TEXT_POST`
- пост в линии канала;
- содержит line-поля + текст.
2. `subType=11``TEXT_EDIT_POST`
- редактирование поста;
- line-поля + target на оригинальный POST + новый текст.
3. `subType=20``TEXT_REPLY`
- ответ на сообщение;
- target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`) + текст.
4. `subType=21``TEXT_EDIT_REPLY`
- редактирование ответа;
- target на исходный REPLY + новый текст.
- допускается пустой `text` для логического удаления сообщения (без физического удаления блока).
5. `subType=30``TEXT_REPOST`
- репост сообщения в линию канала;
- содержит line-поля + target на оригинальное сообщение + текст комментария;
- на текущем этапе продуктовой логики репост не редактируется (версии не накапливаются);
- временно отключён для записи через `AddBlock` до будущей реализации репостов.
## Правило для edit
`EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit.
## Пустой text в edit
- Для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` допустим `textLen=0`.
- Такой edit трактуется как логическое удаление содержимого сообщения.
- Для удаления используется именно edit-блок; отдельного `DELETE`-подтипа нет.
-48
View File
@@ -1,48 +0,0 @@
# История изменений документации блокчейна
## 2026-05-24 11:40:00 +0300
- Базовый коммит-ориентир: `abdce05`.
- `TEXT_REPOST (subType=30)` оставлен как зарезервированный формат, но новые блоки репоста временно отключены на уровне `AddBlock`.
- В `11_TEXT_Blocks.md` зафиксировано, что запись `TEXT_REPOST` временно не используется до будущей реализации.
- В `Dev_Docs/API/04_Add_Block_to_Blockchain_API.md` добавлен код отказа `repost_disabled`.
## 2026-05-21 19:05:00 +0300
- Базовый коммит-ориентир: `5344c42`.
- Добавлен новый TEXT-подтип `TEXT_REPOST (subType=30)`:
- обновлён перечень типов в `11_TEXT_Blocks.md`;
- обновлена быстрая карта типов в `00_Blockchain_Formats_and_Block_Types.md`.
- Уточнено API-описание поддержанных подтипов в `Dev_Docs/API/04_Add_Block_to_Blockchain_API.md`.
- В документе `Dev_Docs/API/08_MCP_Чтение_и_дозапись_персонального_публичного_чата.md` зафиксировано, что чтение канала учитывает `TEXT_POST` и `TEXT_REPOST`.
## 2026-05-20 11:34:17 +0300
- Базовый коммит-ориентир: `a53444b`.
- В `13_CONNECTION_Blocks.md` добавлены новые CONNECTION подтипы:
- `60/61``known_person / unknown_person` (знаю этого человека);
- `70/71``shine_confirmed / shine_unconfirmed` (точно уверен, что сияющий);
- `74/75``shine_seen / shine_unseen` (мало знаком, но видел сияющим).
- Обновлён список CONNECTION-подтипов в `Dev_Docs/API/04_Add_Block_to_Blockchain_API.md`.
## 2026-05-19 20:30:21 +0300
- Базовый коммит-ориентир: `7986184`.
- Уточнён документ `11_TEXT_Blocks.md`: для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` зафиксировано, что `textLen=0` допустим и трактуется как логическое удаление сообщения.
- Явно закреплено, что отдельного `DELETE`-подтипа нет, удаление выполняется edit-блоком.
## 2026-05-19 00:22:46 +0300
- Базовый коммит-ориентир: `c27da63a3e65`.
- Актуализирован `README.md` как точка входа для MVP-документации по протоколу.
- В документации явно зафиксировано, что `channelType=100` и `channelType=200` присутствуют в формате, но пока не используются в UI.
- Актуализирован перечень REACTION-подтипов: добавлен `REACTION_UNLIKE (subType=2)`.
- Актуализирован перечень CONNECTION-подтипов: добавлены `SPOUSE/PARENT/CHILD/SIBLING` и обратные операции.
- В документ `02_Blockchain_Kinds_and_Lines.md` добавлены фактические серверные правила валидации line-полей.
- Обновлён корневой `AGENTS.md`: формат блокчейна менять только после явного подтверждения пользователя и с предварительным предупреждением.
## 2026-05-13 00:02:32 +0300
- Базовый коммит-ориентир: `f63f40f1eb2f`.
- Добавлен текущий формат `CreateChannelBody` с полями `channelType (2 байта)` и `channelTypeVersion (2 байта)`.
- Зафиксированы типы каналов: `0=stories`, `1=public`, `100=personal`, `200=group`.
- Серверная уникальность имени канала изменена на `owner + type + name(slug)`.
- Root-канал `0` переименован в `stories` на уровне API-чтения.
- Для персонального канала (`type=100`) включена сборка парного потока при чтении (`A->B` + `B->A`, если существует).
- Добавлена поддержка командного префикса `/.` и команды `/.desc` для актуализации описания канала при чтении.
- Зафиксированы команды `/.add` и `/.remove` для каналов `type=200` (зарезервировано под расширение участниками).
- В `AGENTS.md` добавлено обязательное правило актуализации документации в `Dev_Docs/Blockchain/`.
-33
View File
@@ -1,33 +0,0 @@
# Документация блокчейна SHiNE (MVP)
Этот каталог описывает только текущий рабочий формат протокола для MVP.
## Основные документы
1. [01_Common_Block_Format.md](./01_Common_Block_Format.md)
Единый бинарный формат блока (Frame v0), подпись, базовые проверки.
2. [02_Blockchain_Kinds_and_Lines.md](./02_Blockchain_Kinds_and_Lines.md)
Виды цепочек и правила line-полей.
3. [10_TECH_Blocks.md](./10_TECH_Blocks.md)
Системные блоки (`msg_type=0`).
4. [11_TEXT_Blocks.md](./11_TEXT_Blocks.md)
Текстовые блоки (`msg_type=1`).
5. [12_REACTION_Blocks.md](./12_REACTION_Blocks.md)
Реакции (`msg_type=2`).
6. [13_CONNECTION_Blocks.md](./13_CONNECTION_Blocks.md)
Социальные связи (`msg_type=3`).
7. [14_USER_PARAM_Blocks.md](./14_USER_PARAM_Blocks.md)
Параметры пользователя (`msg_type=4`).
8. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md)
Типы каналов и формат `CreateChannelBody`.
9. [02_Channel_Commands.md](./02_Channel_Commands.md)
Команды в текстовых сообщениях каналов.
10. [CHANGELOG.md](./CHANGELOG.md)
Журнал изменений документации.
## Важные ограничения MVP
- Каналы `type=100` и `type=200` присутствуют в формате, но сейчас не используются в UI.
- Поддерживаемый рабочий сценарий UI на текущем этапе: `stories (type=0)` и `public (type=1)`.
## Обязательное сопровождение
- При любом изменении формата/правил блокчейна в коде документы этого каталога обновляются в том же наборе изменений.
- Каждое обновление документов фиксируется в `CHANGELOG.md` с датой/временем и хэшем коммита-основания.
-100
View File
@@ -1,100 +0,0 @@
# Синхронизация блоков и DM между серверами SHiNE
Документ описывает архитектуру и протокол синхронизации данных между партнёрскими серверами SHiNE.
## 1. Зачем нужна синхронизация
Пользователи SHiNE могут быть «приписаны» к разным серверам.
Когда пользователь A (на сервере X) пишет пользователю B (на сервере Y):
1. Сервер X принимает сообщение;
2. Сервер X должен переслать DM-блок серверу Y;
3. Сервер Y сохраняет блок и доставляет в активные сессии пользователя B.
Аналогично, блоки пользовательского блокчейна (записи `AddBlock`) должны синхронизироваться,
чтобы любой партнёрский сервер мог отдать полную историю пользователя.
## 2. Список серверов синхронизации (`sync_servers`)
Каждый сервер регистрирует в своей Solana PDA список `sync_servers`
логины SHiNE-аккаунтов партнёрских серверов, с которыми он синхронизируется.
- Список хранится в блоке `ServerProfileBlock` внутри `user_pda` сервера.
- Адрес каждого партнёрского сервера читается из его PDA на Solana.
- Синхронизация двусторонняя: оба сервера должны иметь друг друга в `sync_servers`.
## 3. Что синхронизируется
### 3.1 Личные сообщения (DM)
- Все DM-блоки форматов типов `1/2` (текст) и `3/4` (read-receipt).
- Сервер-отправитель: при получении пары блоков от клиента перенаправляет их серверу получателя.
- Сервер-получатель: сохраняет блоки в `signed_messages_v2`, доставляет в активные сессии.
- Дедупликация по уникальному `message_key = from|to|timeMs|nonce|type`.
### 3.2 Блоки пользовательского блокчейна
- Все блоки `AddBlock` пользователей, зарегистрированных на сервере или синхронизирующихся через него.
- Синхронизируются в обе стороны между всеми партнёрами из `sync_servers`.
- Порядок блоков сохраняется (по глобальному номеру блока и хэшу).
- Дедупликация по глобальному номеру блока и хэшу.
## 4. Протокол синхронизации (целевой, не реализован)
### 4.1 Межсерверное соединение
- Серверы устанавливают постоянное WebSocket-соединение друг с другом.
- Адрес партнёра определяется по `server_address` из его Solana PDA.
- Аутентификация: подпись Ed25519 корневым ключом сервера (`root_key` из PDA).
- При разрыве — переподключение с экспоненциальным backoff.
### 4.2 Доставка новых данных (push)
- При получении нового блока или DM сервер немедленно пушит его всем подключённым партнёрам.
- Партнёр подтверждает приём (ACK). Без ACK — повтор с backoff.
### 4.3 Начальная синхронизация (backfill)
- При первом подключении к партнёру серверы обмениваются «курсорами» состояния:
последний глобальный номер блока, последний известный DM-ключ.
- Сервер с более полной историей досылает недостающее партнёру.
### 4.4 Разрешение конфликтов
- Блоки пользовательского блокчейна: порядок определяется глобальным номером блока.
Конфликтующие ветки (fork) разрешаются по правилам `AddBlock` (см. `Dev_Docs/Blockchain/README.md`).
- DM: конфликтов нет, `message_key` уникален.
## 5. Маршрутизация DM между серверами
При отправке DM от пользователя A к пользователю B:
1. Клиент A отправляет пару блоков на свой сервер X.
2. Сервер X определяет, на каком сервере зарегистрирован пользователь B.
- Сначала проверяет локально (если B зарегистрирован на X).
- Иначе читает PDA пользователя B из Solana и смотрит `access_servers`.
- Выбирает первый доступный сервер из `access_servers` и перенаправляет туда DM.
3. Сервер Y (из `access_servers` B) сохраняет и доставляет блоки.
Кэш адресов серверов: обновляется раз в сессию (при ошибке соединения).
## 6. Безопасность
- Все блоки подписаны ключами пользователя на клиенте — сервер не может подделать содержимое.
- Серверы не расшифровывают DM-контент (шифрование — задача следующего этапа).
- При синхронизации каждый блок проходит валидацию подписи на принимающем сервере.
## 7. Статус реализации
| Компонент | Статус |
|-----------|--------|
| Регистрация серверной PDA в Solana | ✅ Реализовано |
| Чтение `sync_servers` из PDA | Нужна реализация |
| Межсерверный WebSocket-канал | Нужна реализация |
| Push новых DM партнёрам | Нужна реализация |
| Push блоков блокчейна партнёрам | Нужна реализация |
| Backfill при первом подключении | Нужна реализация |
| Маршрутизация DM через access_servers | Нужна реализация (заглушка) |
Текущая версия сервера работает без межсерверной синхронизации.
Синхронизация — задача следующего этапа разработки.
-48
View File
@@ -1,48 +0,0 @@
# Будущие фичи
Эта папка хранит задачи, которые сознательно отложены и сейчас не должны попадать в активную разработку или ручную проверку без отдельной команды пользователя.
## Горизонты планирования
- `near/` - ближайшие планы: задачи, к которым можно вернуться сегодня или завтра.
- `medium/` - среднесрочные планы: задачи на ближайшие недели или 1-2 месяца.
- `far/` - дальнее будущее: идеи без понятного срока возврата.
Если пользователь спрашивает, какие есть планы, агент должен смотреть эти три папки и кратко перечислять задачи по горизонтам.
## Как использовать
1. Каждая будущая фича описывается отдельным markdown-файлом в одном из горизонтов.
2. В файле нужно фиксировать:
- зачем нужна фича;
- к какому сроку или горизонту она относится;
- что нужно сделать;
- какие вопросы нужно уточнить перед реализацией;
- что уже было сделано в коде, если фича частично реализована;
- что временно отключено или закомментировано, если применимо;
- какие документы нужно обновить при возврате к задаче;
- с какого места продолжать разработку.
3. Агент не должен начинать реализацию файлов из этой папки без явной просьбы пользователя.
## Текущие планы
### Ближайшие
- `near/2026-05-25_1106_telegram_agent_players.md` - разрешённые пользователи Telegram для агента, отдельные папки игроков, персональные истории и публикация краткого вопроса/ответа в общий канал.
- `near/2026-05-25_1106_wallet_topup_solana_arweave.md` - пополнение Solana и Arweave через внешний сервис покупки с подсказкой и копированием адреса.
### Среднесрочные
- `medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md` - репосты в каналах и тредах.
- `medium/2026-05-25_1106_shine_balance_wallet.md` - кошелёк и пополнение баланса сияния через блокчейн.
- `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений.
- `medium/2026-06-03_подключение_других_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать.
- `medium/2026-06-02_сессионные_homeserver_в_pda.md` - несколько homeserver-ов пользователя как типизированные сессии в PDA с версией записи.
### DAO-запуск
- `dao_запуск/2026-06-05_esp32_hardware_wallet_device_session.md` - ESP32 как аппаратный кошелёк: постоянная device-сессия на сервере, подтверждение операций на экране, делегированные сессии для браузера/телефона.
### Дальнее будущее
- Сейчас задач нет.
@@ -1,64 +0,0 @@
# ESP32 как аппаратный кошелёк (device-сессия)
## Суть фичи
ESP32 становится аппаратным HSM (hardware security module): хранит ключи, постоянно подключён к SHiNE-серверу как device-сессия, подтверждает операции нажатием на экране. Другие устройства (браузер, телефон) взаимодействуют с ESP32 через сервер — без прямого соединения.
## Два ключевых сценария
### Сценарий 1 — Создание делегированной сессии
1. Браузер/телефон → сервер: «хочу делегированную сессию от имени пользователя X»
2. Сервер → ESP32 (device-сессия): «запрос на одобрение»
3. Пользователь нажимает «Да» на сенсорном экране ESP32
4. ESP32 → сервер: одобрено → сервер создаёт делегированную сессию для браузера
### Сценарий 2 — Подпись транзакции / блока
1. Браузер (через делегированную сессию) → сервер → ESP32: «подпиши вот это»
2. ESP32 показывает запрос на экране, пользователь подтверждает
3. ESP32 подписывает нужным ключом → ответ через сервер → браузер
## Что нужно сделать
### ESP32 (основная работа)
- [ ] Инициализация WiFi (SSID/пароль в NVS)
- [ ] WebSocket-клиент (`WebSocketsClient`) — постоянное соединение с сервером
- [ ] Авторизация на сервере: `AuthChallenge``CreateAuthSession` через `deviceKey` (уже есть в NVS), сохранить `sessionId` в NVS
- [ ] Обработчик входящих WebSocket-событий: JSON-парсинг, диспетчер по типу
- [ ] Новые UI-экраны: «Разрешить сессию?» и «Подписать?» с кнопками Да/Нет
- [ ] Расширенное хранилище ключей в NVS (произвольные именованные ключи сверх базовых трёх)
- [ ] Переподключение при разрыве (reconnect loop)
### Сервер (минимальные изменения)
- [ ] Добавить поле `sessionType` (`USER` / `DEVICE`) в таблицу `active_sessions`
- [ ] Новая операция `DeviceApprovalRequest` — браузер запрашивает одобрение у device-сессии
- [ ] Новая операция `DeviceApprovalResponse` — ESP32 отвечает (одобрено/отклонено)
- [ ] Новые операции `SignRequest` / `SignResponse` — запрос подписи и ответ
- [ ] Роутинг: при получении запроса найти device-сессию через `ActiveConnectionsRegistry.getByLogin(login)` + фильтр по `sessionType=DEVICE`, переслать туда
### Клиент (отдельный этап)
- [ ] Браузерное расширение или UI: создание делегированной сессии, отправка `SignRequest`
## Что уже готово (переиспользуем)
- **Роутинг сообщений** — `SendDirectMessage` с `TARGET_ONE_SESSION` и `CallSignalToSession` уже умеют точечно доставлять в конкретный `sessionId`. Механизм готов, нужно добавить только новые op-коды поверх него.
- **Ed25519 на ESP32** — библиотека `<Ed25519.h>` уже используется в скетче. Подписи работают.
- **NVS** — уже хранит логин, мастер-секрет, 3 пары ключей. Расширяется легко.
- **`ActiveConnectionsRegistry`** — поиск по `login` и `sessionId` уже есть на сервере.
- **Аутентификация** — схема `AuthChallenge``CreateAuthSession` через Ed25519 уже полностью реализована.
## Оценка сложности
| Компонент | Сложность |
|---|---|
| ESP32: WiFi + WebSocket-клиент + авторизация | Средняя |
| ESP32: обработчик входящих + UI подтверждений | Средняя |
| Сервер: флаг sessionType + 4 новых op-а + роутинг | Низкая–средняя |
| Браузерное расширение | Высокая (отдельный этап) |
**Итого фазы ESP32 + сервер: ~1–1.5 недели.**
## С чего начинать
1. Серверная часть проще и быстрее — начать с добавления `sessionType` и `DeviceApprovalRequest/Response`.
2. Затем ESP32: WiFi → WebSocket → авторизация → обработчик входящих → UI.
3. Браузерное расширение — отдельная итерация после того как ESP32 + сервер работают.
-5
View File
@@ -1,5 +0,0 @@
# Дальнее будущее
Сейчас в этом горизонте нет активных идей.
Сюда переносить задачи, у которых нет понятного срока возврата и которые не нужно учитывать в ближайшем или среднесрочном планировании.
@@ -1,62 +0,0 @@
# Кошелёк и пополнение баланса сияния
- Горизонт:
`medium`
- Ориентир:
среднесрочно
- Статус:
`proposal`
## Кратко
Нужно добавить кошелёк для внутреннего баланса сияния и пополнение этого баланса через блокчейн-логику проекта. Задача связана с регистрацией пользователя и будущим учётом баланса.
## Предполагаемый сценарий
1. Пользователь регистрируется и получает/подключает нужные кошельки.
2. В интерфейсе появляется баланс сияния.
3. Пользователь открывает пополнение баланса сияния.
4. Система создаёт или принимает блокчейн-операцию пополнения.
5. После подтверждения баланса UI обновляет значение.
## Что нужно продумать
1. Что именно является единицей баланса сияния.
2. Где хранится состояние баланса: в существующем блокчейне SHiNE, Solana-модуле или комбинированно.
3. Какая операция отвечает за пополнение.
4. Нужно ли делать отдельную регистрацию кошелька сияния или использовать существующую регистрацию пользователя.
5. Как баланс восстанавливается после перезагрузки клиента.
6. Какие права нужны для пополнения и списания.
7. Нужна ли история операций баланса.
## Вопросы перед реализацией
1. Пополнение баланса сияния должно идти через основной блокчейн SHiNE или через Solana-программу.
2. Нужна ли конвертация из SOL/AR в сияние.
3. Кто может выпускать или начислять сияние.
4. Нужно ли поддерживать перевод сияния между пользователями.
5. Нужны ли лимиты, комиссии или статусы подтверждения.
6. Какой экран должен показывать баланс: регистрация, профиль, кошелёк или отдельная страница.
7. Нужно ли отображать неподтверждённый баланс отдельно от подтверждённого.
## Важное ограничение
Если для баланса сияния потребуется новый формат блокчейн-блока или изменение существующего формата, перед реализацией нужно отдельно предупредить пользователя и получить явное подтверждение на изменение формата блокчейна.
Если потребуется новый серверный API или изменение существующих `op`, перед реализацией нужно отдельно предупредить пользователя и получить явное подтверждение на изменение API.
## Документы, которые обновить при реализации
- `Dev_Docs/Blockchain/`, если появятся или изменятся блоки баланса.
- `Dev_Docs/Blockchain/CHANGELOG.md`, если меняется блокчейн-формат.
- `Dev_Docs/API/`, если меняется серверный API.
- `Dev_Docs/Pending_Features/` - добавить файл ручной проверки после реализации.
- Документацию Solana-регистрации, если баланс будет связан с Solana-модулем.
## Минимальная проверка в будущем
1. Новый пользователь видит корректный начальный баланс.
2. Пополнение создаёт правильную операцию.
3. Баланс обновляется после подтверждения.
4. После перезагрузки UI баланс остаётся корректным.
5. Ошибочные или повторные операции не начисляют баланс дважды.
@@ -1,44 +0,0 @@
# ESP32S3 как личное файловое хранилище SHiNE
## Горизонт
Среднесрочный: ближайшие недели или 1-2 месяца.
## Зачем нужна фича
Нужно проработать маленький физический сервер на ESP32S3 как персональное или доверенное файловое хранилище SHiNE.
Идея: при обмене сообщениями пользователи смогут использовать такой сервер для хранения своих файлов, вложений, файлов общих переписок и связанных данных.
## Что нужно сделать
- Описать роль ESP32S3-сервера в общей архитектуре ключей и сессий.
- Определить, какие ключи может хранить такое устройство.
- Решить, хранит ли устройство только файлы или также подписывает пользовательские операции.
- Описать протокол загрузки, скачивания и удаления файлов.
- Определить правила шифрования файлов до отправки на устройство.
- Продумать индексацию файлов для личных и общих переписок.
- Решить, как устройство авторизуется на основном сервере SHiNE.
## Вопросы перед реализацией
- ESP32S3 должен работать как полностью локальное устройство или как публично доступный мини-сервер?
- Нужен ли внешний relay, если устройство находится за NAT?
- Какие ограничения по размеру файла считаем допустимыми?
- Хранит ли устройство метаданные переписок или только зашифрованные blob-файлы?
- Как восстанавливать доступ, если устройство потеряно или заменено?
## Что уже сделано
Код не реализован. Идея зафиксирована как будущая задача после описания модели ключей.
## Документы, которые нужно обновить при возврате
- `Dev_Docs/Keys/README.md`
- `Dev_Docs/Personal_Messages/README.md`
- `Dev_Docs/API/`
- `Dev_Docs/Blockchain/`, если появятся новые блоки или команды для файлов.
## С какого места продолжать
Начать с короткого протокольного документа: роли устройства, авторизация, шифрование файлов, минимальные API-операции и сценарии восстановления.
@@ -1,105 +0,0 @@
# Сессионные homeserver-ы в PDA пользователя
- Статус:
`future`
- Горизонт:
`medium`
- Ориентир:
после завершения первого этапа по пользовательским сессиям
- Основание:
Идея зафиксирована после обсуждения архитектуры пользовательских сессий и внутренних homeserver-ов. Сейчас задача сознательно отложена: сначала нужно аккуратно ввести базовую модель сессий, а затем возвращаться к расширенной серверной роли.
## Зачем нужна фича
У одного пользователя может быть несколько доверенных внутренних homeserver-ов, и каждый из них должен жить как отдельная пользовательская сессия, а не как отдельная особая сущность вне общей модели.
Это нужно, чтобы:
- хранить несколько homeserver-ов у одного пользователя одновременно;
- различать обычные клиентские сессии и серверные сессии по явному типу;
- дать расширяемый формат записи с версией;
- использовать единый подход для DM, звонков и внутренних команд между сессиями.
## Целевая идея
В пользовательском PDA должен появиться список записей сессий, где каждая запись содержит как минимум:
- `sessionType` (`u8`);
- `sessionVersion` (`u8`);
- `sessionName`;
- `sessionPubKey`.
Предварительные значения:
- тип `1` - обычная пользовательская сессия;
- тип `100` - homeserver пользователя;
- версия `1` - первая рабочая версия формата записи сессии.
На текущем этапе под это уже зарезервирован отдельный блок `SessionsBlock` с `block_type = 55`, а `TrustedStateBlock` остаётся на `50`.
Важно: homeserver-ов у одного пользователя может быть несколько.
## Архитектурный принцип
Внутренний протокол взаимодействия должен оставаться транспортным.
То есть SHiNE-сервер не должен разбирать прикладной смысл внутренней нагрузки homeserver-а, а должен:
- доставлять сообщения между сессиями;
- доставлять сигналы звонков между сессиями;
- хранить и маршрутизировать адресацию;
- не принимать на себя бизнес-логику содержимого внутренних команд.
## Что уже подтверждается текущим кодом
- Личные сообщения уже доставляются по всем сессиям целевого пользователя с отдельным учётом доставки на каждую сессию.
- Подтверждение доставки DM уже идёт отдельно по каждой сессии.
- Вызов звонка уже рассылается по нескольким активным сессиям пользователя.
- Сигналы звонка уже адресуются конкретной сессии, а stop-сигналы дублируются на остальные сессии того же пользователя.
Иными словами, текущая серверная логика ближе к модели "сервер доставляет между сессиями", чем к модели "сервер понимает внутренний протокол homeserver-а".
## Что нужно сделать при возврате к задаче
1. Согласовать финальный бинарный формат записи сессии в PDA пользователя.
2. Проверить, не меняет ли это уже опубликованный формат пользовательской PDA-записи.
3. Если формат PDA меняется, заранее предупредить пользователя и получить отдельное подтверждение.
4. Решить, где именно хранится массив сессий:
- в основной записи пользователя;
- в отдельной PDA-структуре расширения;
- или в смешанной схеме с базовой записью и внешними индексами.
5. Зафиксировать ограничения:
- максимальное число сессий;
- максимальную длину `sessionName`;
- правила удаления и обновления записи;
- правила ротации `sessionPubKey`.
6. Продумать, как UI и сервер будут отличать тип `1` и тип `100`.
7. Определить, какие внутренние сообщения homeserver-а останутся полностью прозрачными для SHiNE-сервера, а какие потребуют только технической маршрутизации.
8. Добавить API/операции чтения и обновления списка сессий, если для этого не хватит существующих механизмов.
9. После реализации обязательно обновить документацию.
## Что нужно обновить при реализации
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`
- `Dev_Docs/Solana_Architecture/README.md`
- `Dev_Docs/Инициализация_Solana_регистрации/README.md`
- `Dev_Docs/Keys/README.md`
- `Dev_Docs/Personal_Messages/README.md`, если изменится адресация DM по типам сессий
- `Dev_Docs/API/`, если появятся новые серверные операции или изменятся ответы
## Что пока не делать
- Не включать это автоматически в основной deploy сервера.
- Не менять сейчас Solana PDA-формат без отдельного подтверждения.
- Не добавлять временные поля в публичный API "на всякий случай".
## С какого места продолжать
Продолжать после завершения первой части:
1. описать минимальный формат записи пользовательской сессии;
2. отдельно решить, живут ли homeserver-ы в том же списке, что и обычные сессии;
3. затем уже проектировать операции регистрации, обновления и отключения таких сессий.
@@ -1,45 +0,0 @@
# Подключение других устройств через QR
- Горизонт:
`medium`
- Ориентир:
позже, не сейчас
- Статус:
`future`
## Зачем нужна фича
Нужно нормально довести подключение другого устройства через QR-код. Сейчас есть полуготовая заготовка, но сценарий работает нестабильно и требует отдельной доработки.
## Что уже есть
- В UI уже есть экраны:
- `shine-UI/js/pages/connect-device-view.js`
- `shine-UI/js/pages/device-qr-view.js`
- Есть сервис переноса ключей через QR:
- `shine-UI/js/services/qr-key-transfer-service.js`
- Логика частично собрана, но её нельзя считать завершённой или надёжной.
## Что нужно будет сделать потом
1. Проверить и довести формат QR-передачи.
2. Проверить сканирование и ручной ввод QR-текста.
3. Проверить перенос `device`, `blockchain`, `root` ключей только по реальному наличию на исходном устройстве.
4. Проверить, что после переноса очищается старая история нужного логина и не ломается вход.
5. Отдельно проверить сценарий без `BarcodeDetector`.
6. Довести экран подтверждения на втором устройстве.
## Что сейчас важно
- Не считать эту часть готовой.
- Не возвращать её в активную разработку без отдельной команды пользователя.
- Если вернёмся к задаче, сначала нужно понять, что именно уже работает, а что нет, и потом починить целиком.
## Что обновить при возврате
- `Dev_Docs/Pending_Features/README.md`
- `shine-UI/js/pages/connect-device-view.js`
- `shine-UI/js/pages/device-qr-view.js`
- `shine-UI/js/services/qr-key-transfer-service.js`
- документацию по ключам, если формат переноса меняется
@@ -1,94 +0,0 @@
# Telegram-агент для разрешённых игроков
- Горизонт:
`near`
- Ориентир:
сегодня/завтра
- Статус:
`proposal`
## Кратко
Нужно расширить `SHiNE-agent-bot-coder`, чтобы агент мог принимать личные сообщения от заранее разрешённых пользователей, вести по каждому отдельную рабочую папку и историю, помогать им с обсуждениями/документами без изменения кода, а краткий результат публиковать в общий канал.
## Пользовательский сценарий
1. Разрешённый пользователь пишет агенту в личные сообщения текстом или голосом.
2. Голосовое сообщение распознаётся так же, как сейчас распознаются voice/audio-задачи.
3. Сервис определяет пользователя по разрешённому списку логинов.
4. Для пользователя используется отдельная папка в `Players/`.
5. Codex запускается с системным контекстом: от имени какого человека он работает, где лежит его папка, какие у него локальные инструкции.
6. Агент может читать код и документацию проекта, но писать должен только в папку этого пользователя, если нет отдельного согласования на изменение общего проекта.
7. После ответа пользователю агент отправляет в общий канал короткую сводку двумя сообщениями или двумя блоками: вопрос пользователя и полученный ответ.
8. Команда `/new` или `New` сбрасывает только сессию этого пользователя.
## Предлагаемая структура
- `Players/`
- `Ivan/`
- `AGENTS.md`
- `history/`
- `files/`
- `Sergey/`
- `AGENTS.md`
- `history/`
- `files/`
- `Milana/`
- `AGENTS.md`
- `history/`
- `files/`
Имена папок можно уточнить после получения точных Telegram-логинов.
## Что нужно сделать
1. Добавить конфигурацию разрешённых Telegram-пользователей.
2. Описать соответствие `telegram username -> имя игрока -> папка`.
3. Создавать или использовать отдельную историю диалога для каждого игрока.
4. Поддержать личные сообщения от разрешённых пользователей.
5. Запретить постановку задач от неизвестных пользователей.
6. Для групп/каналов оставить текущую логику: команды Айдара имеют приоритет.
7. При запуске Codex для игрока добавлять отдельный системный контекст:
- имя пользователя;
- путь к его папке;
- правило записи только в эту папку;
- путь к персональному `AGENTS.md`.
8. После ответа игроку отправлять краткую сводку в общий канал.
9. Поддержать `/new`/`New` как сброс только персональной сессии игрока.
10. Добавить защиту от случайного изменения общего кода в режиме игрока.
## Вопросы перед реализацией
1. Точные Telegram-логины Ивана, Сергея и Миланы.
2. Какой общий канал использовать для сводок: текущий `@shine_writing` или отдельный чат.
3. Нужно ли отправлять в общий канал полный текст вопроса/ответа или краткую выжимку.
4. Нужно ли пересылать вложения игроков в общий канал или только текстовые сводки.
5. Разрешить ли игрокам читать все документы проекта, включая технические заметки деплоя.
6. Что делать, если пользователь просит изменить код: отказать, создать предложение в своей папке или просить подтверждение Айдара.
7. Нужны ли русские имена папок (`Иван`, `Сергей`, `Милана`) или ASCII-имена (`Ivan`, `Sergey`, `Milana`).
8. Нужно ли хранить истории игроков в общей папке сервиса или внутри `Players/<name>/history/`.
## Риски и ограничения
- Нужно аккуратно разделить режим Айдара и режим игрока, чтобы игроки не могли случайно запустить изменение общего кода.
- Нужно не смешать истории разных пользователей.
- Нужно ограничить публикацию в общий канал, чтобы не утекали личные или слишком длинные ответы.
- Нужна проверка Telegram-идентификации: username может меняться, поэтому желательно хранить и `user_id`.
## Документы, которые обновить при реализации
- `SHiNE-agent-bot-coder/AGENTS.md`
- `SHiNE-agent-bot-coder/AGENT.md`
- `SHiNE-agent-bot-coder/README.md`
- `Dev_Docs/deploy/agent-bot-coder-local-systemd.md`, если появятся новые переменные окружения или настройки сервиса.
## Минимальная проверка
1. Айдар по-прежнему может ставить задачи из `@shine_writing`.
2. Неизвестный пользователь не ставит задачу в очередь.
3. Разрешённый игрок пишет личное текстовое сообщение и получает ответ.
4. Разрешённый игрок отправляет voice, оно распознаётся и обрабатывается.
5. История одного игрока не попадает в историю другого.
6. `/new` сбрасывает только историю текущего игрока.
7. Сводка вопрос/ответ появляется в общем канале.
8. В режиме игрока агент не пишет за пределы `Players/<name>/` без отдельного подтверждения.
@@ -1,71 +0,0 @@
# Пополнение Solana и Arweave через внешний сервис покупки
- Горизонт:
`near`
- Ориентир:
сегодня/завтра
- Статус:
`proposal`
## Кратко
Нужно добавить удобное пополнение кошельков на экране регистрации/кошелька: для Solana и Arweave дать отдельные действия `Пополнить`, которые ведут на международный сервис покупки криптовалюты с карты и помогают пользователю скопировать адрес кошелька.
## Пользовательский сценарий
1. Пользователь видит адрес кошелька Solana или Arweave.
2. Нажимает `Пополнить`.
3. Открывается промежуточное окно с инструкцией:
- сейчас пользователь перейдёт на страницу покупки/пополнения;
- нужно указать или проверить адрес кошелька;
- после оплаты нужно закрыть внешнюю страницу и вернуться назад;
- Solana обычно приходит быстро, ориентир 10-15 секунд после подтверждения сети;
- Arweave может идти дольше, точное время нужно уточнить по выбранному сервису.
4. В окне есть кнопки:
- `Скопировать адрес и перейти`;
- `Перейти без копирования`.
5. Для Solana и Arweave используются разные окна/инструкции и, возможно, разные внешние ссылки.
## Что нужно сделать
1. Найти текущий экран, где показываются кошельки при регистрации и пополнении.
2. Найти текущую ссылку покупки Arweave, если она уже есть в UI.
3. Выбрать международный сервис покупки Solana с карты, не российский.
4. Проверить, поддерживает ли сервис deep link с предзаполненным адресом кошелька.
5. Если deep link невозможен, реализовать промежуточное окно с копированием адреса.
6. Добавить отдельные действия для Solana и Arweave.
7. Сделать текст инструкции коротким и понятным.
8. Проверить, что адрес копируется в буфер обмена в браузере.
9. Проверить мобильный сценарий и desktop-сценарий.
## Вопросы перед реализацией
1. Какой сервис покупки Solana использовать: тот же провайдер, что для Arweave, или другой международный on-ramp.
2. Нужно ли разрешать покупку только SOL или также USDC/SPL-токены на Solana.
3. Где именно показывать кнопку `Пополнить`: только регистрация, настройки кошелька или оба места.
4. Нужно ли показывать предупреждение о комиссиях и стороннем сервисе.
5. Нужно ли открывать внешнюю страницу в новой вкладке или в текущем окне.
6. Нужно ли логировать факт нажатия `Пополнить` на сервере.
7. Какой точный текст использовать для времени прихода Arweave.
## Риски и ограничения
- On-ramp-сервисы меняют ссылки и параметры, поэтому deep link нужно проверять перед реализацией.
- Clipboard API может требовать HTTPS и пользовательский жест.
- Нельзя обещать точное время поступления средств: лучше писать ориентир и зависимость от сети/провайдера.
- Внешний сервис может быть недоступен в отдельных странах или для отдельных карт.
## Документы, которые обновить при реализации
- Документацию UI/кошельков, если такая есть.
- `Dev_Docs/Pending_Features/` - добавить файл ручной проверки после реализации.
- `Dev_Docs/API/`, только если появится новый серверный API или логирование.
## Минимальная проверка
1. На Solana-кошельке открывается правильное окно пополнения.
2. Кнопка `Скопировать адрес и перейти` копирует Solana-адрес и открывает внешний сервис.
3. Кнопка `Перейти без копирования` открывает внешний сервис без копирования.
4. Аналогичный сценарий работает для Arweave.
5. На мобильном экране текст и кнопки не перекрываются.
6. Возврат назад в приложение не ломает состояние регистрации/кошелька.
-20
View File
@@ -1,20 +0,0 @@
# Недопроверенные фичи
Эта папка хранит список доработок, которые уже реализованы, но ещё не подтверждены ручной проверкой.
## Как использовать
1. При каждом коммите с новыми пользовательскими фичами (если нужна ручная проверка) добавить новый файл:
- формат: `YYYY-MM-DD_HHMM_<short-feature-name>.md`
- название `<short-feature-name>` и текст файла по возможности писать на русском языке
2. В файле указать:
- что сделано;
- как проверять;
- ожидаемый результат;
- текущий статус (`pending` / `in_progress` / `done`).
3. После подтверждения работоспособности — удалить файл фичи из этой папки.
## Важно
- `README.md` не удаляется.
- Количество недопроверенных фич = число файлов `*.md` в этой папке, кроме `README.md`.
-203
View File
@@ -1,203 +0,0 @@
# Личные сообщения (DM)
## Текущее состояние
Сейчас в проекте реализованы:
- новый формат контентных личных сообщений `SHiNE_DM`;
- ревизии сообщений через `revisionTimeMs`;
- редактирование сообщения через повторную отправку той же логической пары;
- удаление сообщения через пустую ревизию;
- `upsert` последней версии сообщения на сервере.
Сейчас в проекте **не реализованы**:
- вложения в DM;
- upload/download файлов для DM;
- UI-кнопка прикрепления файла;
- серверное хранение файловых связей для DM.
Черновик будущих вложений вынесен отдельно:
- `Dev_Docs/Personal_Messages/Черновик_будущих_DM_вложений.md`
## Общая схема
Личное сообщение по-прежнему отправляется парой signed-блоков:
- `type=1` — входящий блок для получателя;
- `type=2` — исходящая копия для отправителя.
Read-receipt пока остаются в legacy-формате:
- `type=3` — входящее подтверждение прочтения;
- `type=4` — исходящая копия подтверждения.
Ключи сообщения:
- `baseKey = fromLogin|toLogin|timeMs|nonce`
- `messageKey = baseKey|messageType`
Логический идентификатор письма задаётся парой:
- `timeMs`
- `nonce`
Эти поля не меняются при редактировании или удалении. Меняется только:
- `revisionTimeMs`
- содержимое `encryptedBody`
Сервер хранит только последнюю версию записи для каждого `messageKey`.
## Формат контентного DM: `SHiNE_DM`
Префикс бинарного блока:
- `SHiNE_DM`
Поля идут в big-endian порядке:
1. `formatVersionMajor` (`u8`) = `1`
2. `formatVersionMinor` (`u8`) = `0`
3. `toLoginLen` (`u8`) + `toLogin` (ASCII, `1..60`)
4. `fromLoginLen` (`u8`) + `fromLogin` (ASCII, `1..60`)
5. `timeMs` (`u64`)
6. `nonce` (`u32`)
7. `messageType` (`u16`) — только `1` или `2`
8. `revisionTimeMs` (`u64`)
9. `attachmentsCount` (`u8`)
10. `encryptedBodyLen` (`u32`)
11. `encryptedBody` (`bytes`)
12. `signature` (`64 bytes`, Ed25519)
### Ограничения
- `attachmentsCount` сейчас всегда должен быть `0`
- `encryptedBodyLen` сейчас ограничен сервером до `16384` байт
- `revisionTimeMs` не может быть отрицательным
Если приходит `attachmentsCount != 0`, сервер отклоняет такой DM как:
- `ATTACHMENTS_DISABLED`
## Legacy read-receipt: `SHiNE_dm2`
Подтверждения прочтения `type=3/4` пока используют старый контейнер `SHiNE_dm2`:
1. `toLoginLen` (`u8`) + `toLogin`
2. `fromLoginLen` (`u8`) + `fromLogin`
3. `timeMs` (`u64`)
4. `nonce` (`u32`)
5. `messageType` (`u16`) — `3` или `4`
6. `payloadLen` (`u16`)
7. `payloadBytes`
8. `signature`
## Редактирование
Редактирование делается новой отправкой той же логической пары сообщения:
- `timeMs` и `nonce` остаются теми же;
- `messageType` остаётся `1/2`;
- `revisionTimeMs` становится больше;
- `encryptedBody` содержит новую версию текста.
Если на сервер приходит более старая ревизия, она игнорируется.
Если приходит та же ревизия и тот же бинарный блок, сервер тоже её не применяет повторно.
## Удаление
Удаление личного сообщения делается как новая ревизия того же сообщения:
- `timeMs` и `nonce` остаются прежними;
- `revisionTimeMs` увеличивается;
- `attachmentsCount = 0`;
- `encryptedBodyLen = 0`;
- `encryptedBody` пустой.
В UI такое сообщение не показывается.
На сервере это не отдельный тип сообщения, а просто последняя пустая ревизия того же `messageKey`.
## Поведение сервера
Для контентных DM сервер:
1. принимает пару signed-блоков `type=1/2`;
2. валидирует формат, подпись и совпадение ключевых полей пары;
3. проверяет, что для обеих сторон пары совпадают:
- `fromLogin`
- `toLogin`
- `timeMs`
- `nonce`
- `revisionTimeMs`
- `encryptedBody`
4. делает `upsert` последней версии в `signed_messages_v2`;
5. сбрасывает pending-доставку по сессиям для новой ревизии;
6. рассылает актуальную версию адресатам через `SignedMessageArrived`.
История старых ревизий сейчас не хранится отдельно: в таблице остаётся только последняя версия по каждому `messageKey`.
## Хранение в БД
Основная таблица:
- `signed_messages_v2`
Для контентных DM в ней используются:
- `message_key`
- `base_key`
- `target_login`
- `from_login`
- `to_login`
- `time_ms`
- `nonce`
- `message_type`
- `revision_time_ms`
- `raw_block`
- `created_at_ms`
Отдельных таблиц файлов для DM сейчас нет.
## События и доставка
Запрос на отправку по WebSocket остаётся прежним:
- `SendMessagePair`
- `ReceiveOutcomingMessage` как алиас
Клиент отправляет:
- `incomingBlobB64`
- `outgoingBlobB64`
Событие в активные сессии:
- `SignedMessageArrived`
Если пришла новая ревизия того же сообщения, `messageKey` остаётся прежним, а внутри `blobB64` будет более новый `revisionTimeMs`.
Подтверждение доставки в сессию:
- `AckSessionDelivery`
## Правила UI
UI сейчас работает так:
- показывает только текст `encryptedBody`;
- умеет обновлять уже существующее сообщение по тому же `messageKey`;
- не показывает удалённые сообщения;
- позволяет владельцу сообщения вызвать меню `Скопировать как текст / Прочесть / Изменить / Удалить`;
- при редактировании показывает над полем ввода полоску `Редактируем сообщение: ...` с кнопкой отмены;
- после редактирования показывает под временем отдельную строку `изменено: <дата время>`;
- не показывает и не принимает вложения.
## Что обязательно помнить
- вложения в DM сейчас отключены на уровне протокола и UI;
- любые старые описания `/f/...`, `/upload` и файловых таблиц для DM больше не актуальны;
- если позже вложения вернутся, их формат и серверная логика могут быть другими.
@@ -1,42 +0,0 @@
# TODO: доработка персональных сообщений для агентов
Статус: отложено.
## Что хотели сделать
Добавить упрощённую маршрутизацию персональных сообщений через служебную инструкцию в начале текстового payload (внутри подписанного DM-блока), чтобы:
- отличать сообщения человеку от сообщений агенту;
- отличать сообщения от человека и от агента;
- скрывать в обычном UI сообщения, адресованные агенту (`target=agent`);
- поддержать сценарий «сообщения самому себе между своими клиентами/устройствами», где один клиент/агент пишет другому в рамках одного логина.
## Базовая идея формата (черновик)
Пример префикса:
```text
@shine:pm:v1 {"target":"agent","agentId":"assistant","author":"human"}
Текст сообщения...
```
Пример ответа агента:
```text
@shine:pm:v1 {"target":"user","author":"agent","agentId":"assistant","agentLabel":"My Bot"}
Ответ агента...
```
## Почему отложено
- нужно отдельно согласовать финальный формат инструкции;
- нужно определить строгие правила UI-фильтрации и fallback;
- нужно определить, нужен ли позднее отдельный серверный роутинг для agent-сессий.
## Что сделать при возвращении к задаче
1. Зафиксировать окончательный формат префикса и JSON-полей.
2. Описать правила парсинга/валидации (включая битые/неполные префиксы).
3. Добавить UI-логику показа/скрытия agent-сообщений.
4. Добавить маркировку «ответ агента» в диалоге.
5. Продумать режим self-chat (между своими клиентами/агентом) в рамках одного логина.
@@ -1,73 +0,0 @@
# Черновик будущих вложений в DM
## Важно
Этот документ описывает только ранний черновик идеи.
Сейчас в проекте **нет** поддержки вложений в личных сообщениях:
- в реализованном формате `SHiNE_DM` поле `attachmentsCount` пока всегда должно быть `0`;
- UI не показывает кнопку прикрепления файлов;
- сервер не принимает upload файлов для DM;
- сервер не раздаёт специальные DM-файлы по отдельным endpoints;
- сервер не хранит отдельные файловые связи для личных сообщений.
Этот документ нужен только для того, чтобы рядом с актуальной документацией было явно видно:
- какие идеи обсуждались;
- что это **не реализовано**;
- что формат, хранение и способ загрузки потом могут сильно измениться.
## Что обсуждалось
Рассматривался такой общий подход:
- у контентного DM есть внешний список вложений;
- во внешнем формате лежат только технические данные;
- человекочитаемые данные о файле живут внутри зашифрованного тела сообщения;
- один и тот же blob-файл теоретически мог бы переиспользоваться в нескольких сообщениях.
Черновой вариант внешнего списка:
- `attachmentsCount`
- далее для каждого вложения:
- `encFileHashSHA256` (`32 bytes`)
- `encFileSize` (`u64`)
Черновой вариант внутреннего маркера в тексте:
```text
<<file:file-format(1.0):type|fileName|origSize|origHashB64u|encHashB64u|encSize|keyB64u|nonceB64u>>
```
Где обсуждались поля:
- `type`
- `fileName`
- `origSize`
- `origHashB64u`
- `encHashB64u`
- `encSize`
- `keyB64u`
- `nonceB64u`
## Что может измениться
В будущем могут измениться любые части идеи:
- сам бинарный формат;
- способ привязки файлов к сообщению;
- момент загрузки файла относительно отправки сообщения;
- серверное хранение blob-файлов;
- права доступа к скачиванию;
- способ рендера вложения в UI.
Именно поэтому этот файл не надо воспринимать как актуальную спецификацию.
## Источник истины на сейчас
Актуальное состояние личных сообщений описано только в:
- `Dev_Docs/Personal_Messages/README.md`
Если между этим черновиком и основным README есть расхождение, верным считается `README.md`.
-59
View File
@@ -1,59 +0,0 @@
# Деплой SHiNE (шаблон)
Этот раздел хранит актуальные инструкции по деплою.
## Базовый сервер
- SSH: `player@shineup.me`
- Домен: `shineup.me`
- Базовый путь: `/home/player`
Для всех рабочих инструкций и скриптов использовать доменное имя `shineup.me`, а не фиксированный IP:
- актуальный IP должен браться через DNS-резолв на момент подключения;
- ручное дублирование IP в документации и deploy-скриптах не поддерживать.
## Локальные команды
- Деплой сервера: `./gradlew deployServer`
- Деплой UI: `./gradlew deployUI`
- Локальный запуск: `./gradlew startLocal`
## UI-деплой и Caddy (обязательно)
- Целевая директория UI-деплоя: `/home/player/SHiNE/shine-ui`.
- `Caddyfile` на сервере должен смотреть в ту же директорию через `root * /home/player/SHiNE/shine-ui`.
- В `deploy_shine-PWA.sh` добавлена проверка: скрипт ищет блок `shineup.me { ... }` (или значение `EXPECTED_CADDY_SITE`) и проверяет `root` внутри этого блока.
- Если `root` внутри целевого блока не совпадает, деплой прерывается с ошибкой.
- Для ручного обхода проверки (только осознанно): `ALLOW_CADDY_MISMATCH=1 ./gradlew deployUI`.
- При необходимости можно явно переопределить путь деплоя:
- `REMOTE_UI_DIR=/нужный/путь ./gradlew deployUI`
- `EXPECTED_CADDY_UI_ROOT=/нужный/путь ./gradlew deployUI`
- `EXPECTED_CADDY_SITE=example.com ./gradlew deployUI`
## Временные тестовые сайты Solana tickets
- Для HTML UI программы `shine_payments` используется отдельный временный тестовый сайт.
- Основной каталог публикации:
- `/home/player/sites/test-solana-tickets.shineup.me`
- Рабочие домены:
- `https://test-solana-tickets.shineup.me`
- `https://test-solana-tickets.shiningpeople.ru`
- Назначение:
- ручная проверка сценариев покупки билетов;
- проверка DAO-инструментов и лимитов менеджеров;
- проверка ручного добавления билетов и `step_payout`.
- Эти сайты не считать основным UI SHiNE; это отдельная тестовая публикация под Solana-часть.
### Важно для локального UI (history-router / Ctrl+F5)
- Локальный UI **обязательно** поднимать только через `./gradlew startLocal`.
- Эта задача запускает `scripts/local_spa_server.py`, который делает SPA fallback: любой неизвестный путь (`/m/...`, `/channel/...`) возвращает `index.html`.
- Это обязательно для корректной работы `Ctrl+F5` на внутренних роутов без `404`.
- Рабочий URL выводится задачей в консоль в формате: `http://localhost:<WEB_PORT>/?localWsPort=<WS_PORT>`.
## Обязательные правила
1. Перед серверным деплоем проверить локально.
2. При нестандартном деплое (другой хост, другая структура, ручные шаги) обязательно уточнить у пользователя, нужно ли обновить этот шаблон.
3. Если деплой-процесс изменился, этот файл и файлы в `servers/` обновлять в том же коммите.
@@ -1,29 +0,0 @@
# Сервер `93.170.12.154` — резервный
- Пользователь: `player`
- Каталог SHiNE: `/home/player/SHiNE`
- UI исходник (после rsync): `/home/player/SHiNE/SHiNE-UI`
- UI публикация для Caddy: `/var/www/shine-ui`
- Сервер: `/home/player/SHiNE/SHiNE-server/shine-server.jar`
- Данные: `/home/player/SHiNE/SHiNE-server/data/`
- `shine.sqlite`
- `*.bch`
- Логи сервера: `/home/player/SHiNE/SHiNE-server/logs/app.log`
## Сервисы
- `shine-server.service` (systemd)
- `caddy.service` (systemd)
## Статус
- Резервный сервер для SHiNE.
- Основной прод-сервер: `shineup.me` (подключение через `player@shineup.me`, IP определяется через DNS).
## Caddy
- Конфиг: `/etc/caddy/Caddyfile`
- Настройки:
- `no-store/no-cache` заголовки;
- `try_files {path} /index.html` (SPA fallback);
- `reverse_proxy /ws* -> 127.0.0.1:7070`.
@@ -1,35 +0,0 @@
# Сервер `shineup.me` — основной
- SSH: `player@shineup.me`
- Определение IP: через DNS-резолв домена `shineup.me` на момент подключения
- Пользователь: `player`
- Базовый путь: `/home/player`
- Каталог SHiNE: `/home/player/SHiNE`
- UI публикация: `/home/player/SHiNE/shine-ui`
- Сервер: `/home/player/SHiNE/shine-server/shine-server.jar`
- Данные: `/home/player/SHiNE/shine-server/data/`
- Логи сервера: `/home/player/SHiNE/shine-server/logs/app.log`
## Сервисы
- `shine-server.service` (systemd)
- `caddy.service` (systemd)
## Caddy
- Активный конфиг (через systemd `ExecStart`): `/home/player/SHiNE/caddy/Caddyfile`
- Для UI:
- `root * /home/player/SHiNE/shine-ui`
- `try_files {path} /index.html` (SPA fallback)
- no-cache заголовки
- `reverse_proxy /ws* -> 127.0.0.1:7070`
## Дополнительно
- Для отдельной админки `shine_payments` используется каталог:
- `/home/player/sites/test-solana-tickets.shineup.me`
- Эта публикация используется как временный тестовый сайт для сценариев покупки билетов и выплат `shine_payments`.
- Домены этой публикации:
- `https://test-solana-tickets.shineup.me`
- `https://test-solana-tickets.shiningpeople.ru`
- Для всех deploy-скриптов и инструкций использовать именно `player@shineup.me`, без жёсткой фиксации IP.
@@ -1,98 +0,0 @@
# Деплой и инициализация Solana-регистрации (две обязательные программы)
## Коротко
Для рабочей регистрации пользователя нужны **обе** программы:
1. `shine_users` — хранение и обновление `user_pda`, economy-конфиг, логика регистрации.
2. `shine_login_guard` — проверка/классификация логина (CPI из `shine_users`).
Если задеплоена только одна из них — регистрация неработоспособна.
## Актуальные адреса (devnet)
- `shine_users` (регистрация пользователей):
`FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm`
- `shine_login_guard`:
`3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo`
- `shine_payments`:
`c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW`
## Подтверждение деплоя
- Сеть: `https://api.devnet.solana.com`
- `shine_users`:
- `Program ID`: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm`
- TX deploy: `5VzfpSirFCRqPUZfvAt3eADY9KnowW79PKZ1pCQAa2DJGiztj4dUYYXrSQNmWEhPVu6mPSDfcuHzFyEVmoKLa9DM`
- `shine_login_guard`:
- `Program ID`: `3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo`
- TX deploy: `5iptngPYrLLjPE3Xby24zyNW3edVUnBNLBx785vjojMoq5JNLFNQvLNAm3jNYHbpf2B36qtbpTNzcvUNyRDqm1Mf`
## Порядок деплоя (devnet)
1. Убедиться, что CLI смотрит в devnet и у кошелька есть SOL.
2. Собрать и задеплоить `shine_login_guard`.
3. Собрать и задеплоить `shine_users`.
4. Проверить, что адреса совпадают между:
- `Anchor.toml`
- `declare_id!` в `programs/*/src/lib.rs`
- UI/серверными константами.
5. Выполнить `init_users_economy_config` (один раз на программу `shine_users`).
Пример команд:
```bash
cd shine-solana/shine
solana config get
solana balance
anchor build -p shine_login_guard
anchor deploy -p shine_login_guard
anchor build -p shine_users
anchor deploy -p shine_users
```
## Куда вписаны адреса в проекте
### UI
- Общие Solana-константы:
- `shine-UI/js/solana-programs.js`
- Страница инициализации:
- `shine-UI/js/pages/solana-users-init-view.js`
- Переход на страницу:
- `shine-UI/js/pages/developer-settings-view.js`
### Сервер
- Серверные константы Solana:
- `shine-server-config/src/main/java/utils/config/SolanaProgramsConfig.java`
## Как запустить инициализацию economy PDA
1. Открыть UI.
2. Перейти: `Профиль -> Настройки -> Настройки разработчика -> Solana: init регистрации`.
3. Подключить кошелёк (Phantom, devnet).
4. Нажать `Запустить init_users_economy_config`.
5. Дождаться статуса `Успешно`.
Страница сама вычисляет PDA `users_economy_config` по seed:
- seed: `shine_users_economy_config`
- program: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm`
## Кто оплачивает create/update user_pda
- И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с `deviceKey`.
- В UI это означает, что Solana fee payer всегда берётся из `device`-ключа пользователя/сервера.
- `rootKey` нужен для подписи unsigned PDA-записи, но не оплачивает транзакцию.
- Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес `deviceKey`.
## Важно
- `init_users_economy_config` выполняется один раз на программу.
Если PDA уже создан, повторный вызов вернёт ошибку "already initialized" (это нормальное поведение).
- Серверные приватные ключи для Solana не используются как отдельный backend-wallet: в UI/server UI транзакцию оплачивает именно `deviceKey`, а содержимое записи подписывает `rootKey`.
- `shine_users` внутри `create_user_pda` требует корректный адрес `shine_login_guard` для CPI-классификации логина.
Несовпадение адреса приведёт к ошибке регистрации.
+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 и может пережить перезагрузку устройства.
@@ -11,7 +11,7 @@
* legacy(empty password):
* secret = SHA256(base64(SHA256(password)) + "master.secret")
* keyPair_i = Ed25519(SHA256(base64(secret) + "|" + suffix_i))
* suffixes = ["root.key", "bch.key", "dev.key"]
* suffixes = ["root.key", "blockchain.key", "client.key"]
*
* Плата: Waveshare ESP32-S3-Touch-AMOLED-2.16
* SD : SDMMC 1-bit CLK=GPIO2, CMD=GPIO1, D0=GPIO3
@@ -116,8 +116,8 @@ static bool gKbNums = false;
// ═══════════════════════════════════════════════════════════
struct KeyPair { uint8_t pub[32]; uint8_t priv[32]; };
static KeyPair gKeys[3];
static const char * KEY_SUFFIXES[3] = {"root.key", "bch.key", "dev.key"};
static const char * KEY_LABELS[3] = {"root.key", "bch.key", "dev.key"};
static const char * KEY_SUFFIXES[3] = {"root.key", "blockchain.key", "client.key"};
static const char * KEY_LABELS[3] = {"root.key", "blockchain.key", "client.key"};
static uint32_t gElapsedSec = 0;
// Base58 представления (43-44 символа для 32 байт + \0)
@@ -1,7 +1,6 @@
#include "shine_secret_generation.h"
#include <SD_MMC.h>
#include <Preferences.h>
#include <mbedtls/sha256.h>
#include <mbedtls/base64.h>
#include <string.h>
@@ -80,6 +79,19 @@ 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];
@@ -449,7 +461,6 @@ bool shineSecretInitSd(String &error) {
bool shineSecretStart(const char *normalizedLogin, const char *password, String &error) {
error = "";
if (!shineSecretInitSd(error)) return false;
if (!normalizedLogin || !*normalizedLogin) {
error = "login not set";
return false;
@@ -463,6 +474,8 @@ bool shineSecretStart(const char *normalizedLogin, const char *password, String
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+");
@@ -224,12 +224,14 @@ static int16_t gTouchLastY = 0;
struct DerivedKeyState {
bool ready;
uint8_t masterSecret[32];
uint8_t recoveryPub[32];
uint8_t recoverySk[64];
uint8_t rootPub[32];
uint8_t rootSk[64];
uint8_t blockchainPub[32];
uint8_t blockchainSk[64];
uint8_t devicePub[32];
uint8_t deviceSk[64];
uint8_t clientPub[32];
uint8_t clientSk[64];
};
static DerivedKeyState gDerivedKeys = {};
@@ -237,16 +239,16 @@ static DerivedKeyState gDerivedKeys = {};
static const char *kSystemProgramId = "11111111111111111111111111111111";
static const char *kEd25519ProgramId = "Ed25519SigVerify111111111111111111111111111";
static const char *kSysvarInstructionsId = "Sysvar1nstructions1111111111111111111111111";
static const char *kShineUsersProgramId = "FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm";
static const char *kShineLoginGuardProgramId = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo";
static const char *kShinePaymentsProgramId = "c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW";
static const char *kShineUsersProgramId = "SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6";
static const char *kShineLoginGuardProgramId = "SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh";
static const char *kShinePaymentsProgramId = "SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v";
static const char *kUsersSeedPrefix = "user_login=";
static const char *kUsersEconomyConfigSeed = "shine_users_economy_config";
static const char *kPaymentsInflowSeed = "shine_payments_inflow_vault";
static const char *kProgramDerivedAddressMarker = "ProgramDerivedAddress";
static const char *kLastBlockPrefix = "SHiNE_LAST_BLOCK";
static const uint8_t kBlockTypeRootKey = 1;
static const uint8_t kBlockTypeDeviceKey = 2;
static const uint8_t kBlockTypeClientKey = 2;
static const uint8_t kBlockTypeBlockchainRegistry = 3;
static const uint8_t kBlockTypeServerProfile = 30;
static const uint8_t kBlockTypeAccessServers = 40;
@@ -782,19 +784,20 @@ static void pushFixed(std::vector<uint8_t> &out, const uint8_t *data, size_t len
static bool deriveKeysFromMasterSecret(const uint8_t masterSecret[32]) {
memset(&gDerivedKeys, 0, sizeof(gDerivedKeys));
memcpy(gDerivedKeys.masterSecret, masterSecret, 32);
String secretB64 = base64Encode(masterSecret, 32);
if (secretB64.length() == 0) {
return false;
}
const char *suffixes[3] = {"root.key", "bch.key", "dev.key"};
uint8_t *pubs[3] = {gDerivedKeys.rootPub, gDerivedKeys.blockchainPub, gDerivedKeys.devicePub};
uint8_t *sks[3] = {gDerivedKeys.rootSk, gDerivedKeys.blockchainSk, gDerivedKeys.deviceSk};
for (int i = 0; i < 3; i++) {
String material = secretB64 + "|" + suffixes[i];
const char *prefix = "SHiNE-key";
const char *suffixes[4] = {"recovery.key", "root.key", "blockchain.key", "client.key"};
uint8_t *pubs[4] = {gDerivedKeys.recoveryPub, gDerivedKeys.rootPub, gDerivedKeys.blockchainPub, gDerivedKeys.clientPub};
uint8_t *sks[4] = {gDerivedKeys.recoverySk, gDerivedKeys.rootSk, gDerivedKeys.blockchainSk, gDerivedKeys.clientSk};
for (int i = 0; i < 4; i++) {
std::vector<uint8_t> material;
material.reserve(strlen(prefix) + 1 + 32 + 1 + strlen(suffixes[i]));
material.insert(material.end(), prefix, prefix + strlen(prefix));
material.push_back(0);
material.insert(material.end(), masterSecret, masterSecret + 32);
material.push_back(0);
material.insert(material.end(), suffixes[i], suffixes[i] + strlen(suffixes[i]));
uint8_t seed[32];
if (!sha256String(material, seed)) {
return false;
}
sha256Raw(material.data(), material.size(), seed);
if (crypto_sign_seed_keypair(pubs[i], sks[i], seed) != 0) {
return false;
}
@@ -822,7 +825,7 @@ static bool restoreDerivedKeysFromSecret() {
return false;
}
gData.secretReady = true;
gData.walletAddress = bytesToBase58(gDerivedKeys.devicePub, 32);
gData.walletAddress = bytesToBase58(gDerivedKeys.clientPub, 32);
return true;
}
@@ -835,7 +838,7 @@ static bool deriveFreshSecretAndWallet() {
return false;
}
gData.secret = bytesToBase58(secret, sizeof(secret));
gData.walletAddress = bytesToBase58(gDerivedKeys.devicePub, 32);
gData.walletAddress = bytesToBase58(gDerivedKeys.clientPub, 32);
gData.secretReady = true;
return true;
}
@@ -889,7 +892,7 @@ static std::vector<uint8_t> buildUnsignedCreateRecord(
const String &blockchainName,
const String &serverAddress,
const uint8_t rootPub[32],
const uint8_t devicePub[32],
const uint8_t clientPub[32],
const uint8_t blockchainPub[32],
const uint8_t lastBlockSignature[64],
uint64_t createdAtMs) {
@@ -911,9 +914,9 @@ static std::vector<uint8_t> buildUnsignedCreateRecord(
out.push_back(0);
pushFixed(out, rootPub, 32);
out.push_back(kBlockTypeDeviceKey);
out.push_back(kBlockTypeClientKey);
out.push_back(0);
pushFixed(out, devicePub, 32);
pushFixed(out, clientPub, 32);
out.push_back(kBlockTypeBlockchainRegistry);
out.push_back(0);
@@ -960,7 +963,7 @@ static std::vector<uint8_t> buildCreateInstructionData(
const String &blockchainName,
const String &serverAddress,
const uint8_t rootPub[32],
const uint8_t devicePub[32],
const uint8_t clientPub[32],
const uint8_t blockchainPub[32],
const uint8_t lastBlockSignature[64],
const uint8_t rootSignature[64],
@@ -972,7 +975,7 @@ static std::vector<uint8_t> buildCreateInstructionData(
pushFixed(out, rootPub, 32);
pushU64LE(out, createdAtMs);
pushU64LE(out, 0);
pushFixed(out, devicePub, 32);
pushFixed(out, clientPub, 32);
pushFixed(out, blockchainPub, 32);
pushStrU8(out, blockchainName);
pushU64LE(out, 0);
@@ -1079,7 +1082,7 @@ static bool pdaAlreadyExists(const String &login, String &pdaAddress, String &me
static std::vector<uint8_t> buildLegacyMessage(
const uint8_t recentBlockhash[32],
const uint8_t devicePub[32],
const uint8_t clientPub[32],
const uint8_t userPda[32],
const uint8_t inflowVault[32],
const uint8_t economyConfig[32],
@@ -1098,7 +1101,7 @@ static std::vector<uint8_t> buildLegacyMessage(
base58ToFixed32(kShineLoginGuardProgramId, loginGuardProgram);
std::vector<std::vector<uint8_t>> accountKeys;
accountKeys.emplace_back(devicePub, devicePub + 32);
accountKeys.emplace_back(clientPub, clientPub + 32);
accountKeys.emplace_back(userPda, userPda + 32);
accountKeys.emplace_back(inflowVault, inflowVault + 32);
accountKeys.emplace_back(systemProgram, systemProgram + 32);
@@ -1244,7 +1247,7 @@ static bool registerHomeserverOnSolana(String &messageOut) {
uint64_t createdAtMs = (uint64_t)millis() + 1704067200000ULL;
std::vector<uint8_t> unsignedRecord = buildUnsignedCreateRecord(
gData.login, blockchainName, gData.wsUrl,
gDerivedKeys.rootPub, gDerivedKeys.devicePub, gDerivedKeys.blockchainPub,
gDerivedKeys.rootPub, gDerivedKeys.clientPub, gDerivedKeys.blockchainPub,
lastBlockSignature, createdAtMs);
uint8_t unsignedHash[32];
uint8_t rootSignature[64];
@@ -1256,7 +1259,7 @@ static bool registerHomeserverOnSolana(String &messageOut) {
std::vector<uint8_t> createData = buildCreateInstructionData(
gData.login, blockchainName, gData.wsUrl,
gDerivedKeys.rootPub, gDerivedKeys.devicePub, gDerivedKeys.blockchainPub,
gDerivedKeys.rootPub, gDerivedKeys.clientPub, gDerivedKeys.blockchainPub,
lastBlockSignature, rootSignature, createdAtMs);
std::vector<uint8_t> edRootData = buildEd25519InstructionData(rootSignature, gDerivedKeys.rootPub, unsignedHash);
std::vector<uint8_t> edBchData = buildEd25519InstructionData(lastBlockSignature, gDerivedKeys.blockchainPub, lastBlockHash);
@@ -1269,7 +1272,7 @@ static bool registerHomeserverOnSolana(String &messageOut) {
std::vector<uint8_t> message = buildLegacyMessage(
recentBlockhash,
gDerivedKeys.devicePub,
gDerivedKeys.clientPub,
userPda,
inflowVault,
economyConfig,
@@ -1277,7 +1280,7 @@ static bool registerHomeserverOnSolana(String &messageOut) {
edBchData,
createData);
uint8_t txSignature[64];
if (!signMessageEd25519(message, gDerivedKeys.deviceSk, txSignature)) {
if (!signMessageEd25519(message, gDerivedKeys.clientSk, txSignature)) {
messageOut = "Не удалось подписать Solana-транзакцию";
return false;
}
@@ -1311,7 +1314,7 @@ static String defaultApiUrl() {
}
static String defaultRpcUrl() {
return "https://api.devnet.solana.com";
return "https://api.mainnet-beta.solana.com";
}
static String defaultWsUrl() {
@@ -2107,7 +2110,7 @@ static void drawConfirmScreen() {
String text = "Выполнить действие?";
if (gConfirmTarget == CONFIRM_REGISTER) {
title = "Регистрация";
text = "Отправить create_user_pda в Solana через device key этого устройства?";
text = "Отправить create_user_pda в Solana через client key этого устройства?";
} else if (gConfirmTarget == CONFIRM_CLEAR_ACCOUNT) {
title = "Очистка";
text = "Удалить секрет, кошелёк и статус регистрации?";
@@ -70,11 +70,11 @@
Фоновая логика:
- пока открыт `HOME`, экран сам обновляется примерно раз в секунду;
- при наличии `login + secret + homeserver` и Wi-Fi устройство читает Solana `user_pda` напрямую через RPC;
- сравниваются `root key`, `blockchain key`, `device key` и `homeserver` session-запись типа `100`;
- сравниваются `root key`, `blockchain key`, `client key` и `homeserver` session-запись типа `100`;
- для строки `SHiNE:` устройство держит отдельную WebSocket-сессию с сервером SHiNE:
- авторизация через `AuthChallenge/CreateAuthSession` или `SessionChallenge/SessionLogin`;
- session key = публичный `homeserver key`;
- подтверждение создания сессии подписывается `device key`;
- подтверждение создания сессии подписывается `client key`;
- heartbeat выполняется `Ping` раз в минуту.
## SETTINGS_MENU
@@ -150,12 +150,12 @@
- статусное сообщение;
- текущий `Solana RPC` адрес;
- кнопку `SOLANA RPC`;
- текущий `Shine server` адрес;
- кнопку `SHINE SERVER`.
- текущий `SHiNE server login` или уже резолвленный адрес;
- кнопку `SHiNE SERVER LOGIN`, если обычный `user PDA` ещё не зарегистрирован.
Значения по умолчанию:
- Solana RPC: `https://api.devnet.solana.com`
- Shine server: `https://shineup.me`
- Solana RPC: `https://api.mainnet-beta.solana.com`
- SHiNE server login: `shineupme`
Нажатие на любую из двух кнопок открывает `TEXT_EDIT_SCREEN`.
@@ -214,8 +214,8 @@
- `Root key priv (base58)`;
- `Blockchain key (base58)`;
- `Blockchain key priv (base58)`;
- `Device key (base58)`;
- `Device key priv (base58)`;
- `Client key (base58)`;
- `Client key priv (base58)`;
- `Homeserver key (base58)`;
- `Homeserver key priv (base58)`;
- для каждого поля показывается формула derivation;
@@ -229,7 +229,7 @@
Используется для:
- пароля Wi-Fi;
- Solana RPC;
- Shine server.
- SHiNE server login.
Показывает:
- заголовок;
@@ -259,6 +259,7 @@
- переключение страниц выполняется свайпом влево/вправо внутри `TEXT_EDIT_SCREEN`;
- страница 1 содержит в основном буквы и базовые URL-символы;
- страница 2 содержит цифры и дополнительные URL/символьные кнопки;
- на символьных страницах должны быть доступны как минимум `!` и отдельная кнопка `SPACE`;
- переключение `ABC/123` и `SHIFT` не должно очищать уже введённый текст;
- переключение страниц клавиатуры свайпом тоже не должно очищать уже введённый текст, включая цифры, символы и уже набранные заглавные буквы;
- есть специальные действия `DEL` и `CLR`.
@@ -291,7 +292,7 @@
Используется `Preferences` (NVS памяти ESP32):
- `solana_rpc`
- `shine_server`
- `shine_server_login`
## Хранение аккаунта
@@ -308,6 +309,7 @@
- половина букв находится на левой странице, половина на правой;
- на правой странице кнопки тоже стоят в ровных колонках, без сдвига рядов вправо;
- отдельный режим `symbols` тоже разделён на 2 страницы;
- в режиме `symbols` на правой странице есть отдельная кнопка `SPACE` и отдельная кнопка `!`;
- 3 основных ряда клавиш и нижний служебный ряд увеличены по высоте;
- клавиши дополнительно увеличены по высоте по сравнению с предыдущим промежуточным вариантом;
- четвёртый ряд содержит:
@@ -19,14 +19,14 @@
- локальный UI на тач-экране;
- хранение настроек и секретов во внутренней памяти `ESP32` через `NVS`;
- русский текст в логике интерфейса; в текущей временной сборке отображение на экране идёт через ASCII-транслитерацию, потому что `U8g2`-шрифты на устройстве временно не рисуются;
- экранный 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` после успешной регистрации.
@@ -34,8 +34,8 @@
Что пока считается именно прототипом, а не финальной интеграцией:
- приём реальных входящих запросов на вход/подпись пока не подключён к живой сети;
- входящие запросы пока демонстрационные, чтобы можно было проверить UX и логику подтверждения.
- пока реализован только сценарий заявок на подключение устройств через доверенную сессию;
- другие типы входящих запросов на подпись и произвольные approval-flow на этом устройстве ещё не подключены.
## Основная идея устройства
@@ -46,7 +46,7 @@
- показывает адрес кошелька устройства;
- позволяет пополнить баланс перед регистрацией;
- после выполнения условий даёт зарегистрировать устройство как homeserver;
- после регистрации может принимать входящие запросы на вход и на подпись.
- после авторизации в SHiNE может подтверждать заявки на подключение новых устройств пользователя.
`SD`-карта не нужна для постоянного хранения секрета в этом прототипе.
Основное сохранение идёт во внутреннюю flash-память через `NVS`.
@@ -65,19 +65,20 @@
- `user pda address`;
- `registration signature`;
- `balance`;
- `server api url`;
- `server rpc url`;
- `server ws url`;
- `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"`
- `clientInfo = "ESP32 homeserver:<homeserverName>"`
Это относится и к `CreateAuthSession`, и к `SessionLogin`.
@@ -86,7 +87,7 @@
Кнопка регистрации доступна только если одновременно выполнены условия:
1. настроен и подтверждён `Wi-Fi`;
2. заполнены и подтверждены серверные адреса;
2. задан и подтверждён `SHiNE server login`;
3. задан логин;
4. сгенерирован или введён секрет;
5. баланс кошелька не меньше `0.20 SOL`;
@@ -107,22 +108,28 @@
7. `ACCOUNT`
8. `WALLET`
9. `WALLET_QR`
10. `REQUESTS`
11. `REQUEST_DETAIL`
12. `SETTINGS`
13. `PIN_EDIT`
14. `TEXT_INPUT`
15. `CONFIRM`
16. `REGISTER_ACCOUNT_CONFIRM`
17. `REGISTER_ACCOUNT_RESULT`
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`.
- Русские строки на экране временно показываются в ASCII-транслитерации.
- Русские строки в UI устройства пока не использовать.
- Кнопки крупные, с тач-ориентированным размером.
- Опасные действия подтверждаются отдельным диалогом.
- После изменения данных конфигурация сразу сохраняется в `NVS`.
@@ -167,20 +174,23 @@
- блок `STATUS` поднят выше относительно предыдущей версии;
- если состояние хорошее, слово `connected` в строках `Wi-Fi` и `SHiNE` показывается зелёным.
В зоне баланса:
В зоне кошелька:
- основная кнопка показа/обновления баланса занимает примерно 80% строки;
- текст на кнопке баланса выровнен левее центра;
- справа от неё стоит отдельная кнопка `QR`;
- после старта устройства баланс пытается загрузиться автоматически, если уже есть секрет и `Wi-Fi`;
- нажатие на кнопку `QR` открывает экран `WALLET_QR`.
- вместо старых двух кнопок `баланс` и `QR` показывается одна широкая кнопка;
- текст кнопки: `Wallet: <selected wallet name>`;
- доступные имена:
- `ClientKey`
- `RootKey`
- либо сохранённое имя `custom`-кошелька;
- после старта устройства баланс активного выбранного кошелька пытается загрузиться автоматически, если уже есть секрет и `Wi-Fi`;
- нажатие на эту кнопку открывает экран `WALLET`.
Нижние кнопки:
- `Статус`
- `Подключение`
- `Аккаунт`
- `Кошелёк`
- `Wallet`
- `Запросы`
- `Настройки`
@@ -405,26 +415,43 @@
Показывает:
- адрес кошелька устройства;
- баланс в `SOL`;
- минимально рекомендуемую сумму для регистрации;
- статус `Хватает / Не хватает`.
- кнопку `Wallet: <selected wallet name>`;
- строку текущего статуса/баланса активного кошелька;
- сокращённый публичный ключ активного кошелька.
Кнопки:
- `QR и URI`
- `+0.10 SOL`
- `+0.25 SOL`
- `-0.10 SOL`
- `Проверить`
- `Назад`
- `Wallet: <selected wallet name>`
- `SHOW BALANCE`
- `SHOW WALLET QR`
Поведение:
- кнопки пополнения/уменьшения нужны для теста сценариев;
- роверить` читает реальный баланс из `Solana RPC`;
- адрес кошелька должен совпадать с `device key`, вычисленным из сохранённого `master secret`;
- отрицательный баланс не допускается.
- верхняя кнопка открывает экран выбора кошелька `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
@@ -447,65 +474,106 @@
Поведение:
- QR должен быть сканируемым, а не декоративным;
- адрес кошелька берётся из `device key`, вычисленного из сохранённого `master secret`;
- нажатие в любую точку экрана возвращает пользователя на `HOME`.
- адрес кошелька берётся из текущего активного выбранного кошелька;
- нажатие в любую точку экрана возвращает пользователя на `WALLET`.
## Экран REQUESTS
## Экран WALLET_SIGN_REQUEST
Показывает список демонстрационных запросов:
- `Вход в сессию`
- `Подпись сообщения`
Для каждого запроса:
- тип;
- источник;
- короткий статус.
Кнопки:
- `Открыть запрос 1`
- `Открыть запрос 2`
- `Назад`
## Экран REQUEST_DETAIL
Показывает детали выбранного запроса:
- тип запроса;
- кто запросил;
- время;
- описание;
- отпечаток/идентификатор.
Кнопки:
- `Разрешить`
- `Отклонить`
- `Назад`
Поведение:
- после разрешения или отклонения запрос помечается обработанным;
- экран списка отражает новый статус.
## Экран SETTINGS
Экран показывается, когда браузерное wallet-расширение присылает на ESP32 запрос `sign_transaction`.
Показывает:
- текущий PIN;
- базовые флаги безопасности;
- технические действия прототипа.
- заголовок `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.`
Кнопки:
- `Сменить PIN`
- `Сбросить онлайн`
- `Полный сброс`
- `Назад`
- `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
@@ -561,7 +629,7 @@
2. открыть `Подключение -> Wi-Fi`;
3. ввести `SSID` и пароль, нажать `Проверить`;
4. открыть `Подключение -> Серверы`;
5. проверить или задать серверные адреса;
5. проверить или задать `SHiNE server login` (по умолчанию `shineupme`);
6. открыть `Аккаунт`;
7. ввести логин;
8. задать имя homeserver;
@@ -570,14 +638,15 @@
11. при необходимости пополнить баланс;
12. вернуться на `HOME`;
13. нажать `REGISTER ACCOUNT`;
14. на экране проверки ещё раз увидеть `login`, статус свободного `PDA`, баланс, `homeserver1` и при необходимости сообщение о неподключённом `Wi-Fi`;
14. на экране проверки ещё раз увидеть `login`, статус свободного `PDA`, баланс, `homeserver1`, серверный login и при необходимости сообщение о неподключённом `Wi-Fi`;
15. нажать `ЗАРЕГИСТРИРОВАТЬ В СИЯНИИ`;
16. после завершения увидеть либо экран успеха с `user_pda` и `tx signature`, либо подробную ошибку;
17. после успешной регистрации увидеть статус `Homeserver активен`.
Примечание:
- устройство реально отправляет `create_user_pda` в `shine_users`, а после подтверждения сохраняет `PDA` и `tx signature`.
- устройство реально отправляет `create_user_pda` в `shine_users`, а после подтверждения сохраняет `PDA` и `tx signature`;
- при первой регистрации для обычного `user PDA` не заполняется `serverAddress`, а `accessServers` получает `shineupme` или другой выбранный `SHiNE server login`.
## Сценарий входящего запроса
@@ -591,8 +660,8 @@
В текущей диагностической версии:
- строковые литералы в коде остаются русскими и в `UTF-8`;
- перед выводом на экран они временно транслитерируются в ASCII;
- строковые литералы экранного UI должны оставаться английскими ASCII-совместимыми;
- возвращение кириллицы допустимо только после отдельной доработки шрифтов и реальной проверки на устройстве;
- рендер выполняется стандартным шрифтом `Arduino_GFX`;
- это обходной режим, пока `U8g2`-шрифты на устройстве не начнут рисоваться стабильно.
@@ -601,7 +670,7 @@
Минимально нужно проверить:
1. устройство загружается и сразу открывает `HOME`; экран блокировки временно отключён;
2. текст отображается читаемо хотя бы в ASCII-транслитерации;
2. весь экранный текст отображается читаемо на английском без замены символов;
3. ввод по экранной клавиатуре работает;
4. после перезагрузки сохранённые поля остаются в памяти;
5. секрет и адрес кошелька сохраняются на устройстве;
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
@@ -1,71 +0,0 @@
# Задание для Айдара: навести порядок в инструкциях агентов SHiNE
## Кратко
Нужно согласовать и оформить единый порядок инструкций для Codex/Telegram-агентов в проекте SHiNE, чтобы агенты стабильно понимали структуру проекта, границы ответственности и правила работы с сервером, UI, Solana-модулем, Telegram-ботом и игроками.
## Зачем это нужно
Сейчас проект состоит из нескольких связанных, но разных частей:
- основной сервер `SHiNE-server/`;
- UI `shine-UI/`;
- Solana/Anchor-модуль `shine-solana/shine/`;
- Telegram-агент-кодер `SHiNE-agent-bot-coder/`;
- TURN-сервер;
- документация `Dev_Docs/`;
- отдельные рабочие папки игроков `Players/`.
Без явных инструкций агент может путать эти зоны: например, смешать деплой Solana с деплоем сервера, изменить код от имени игрока, не обновить документацию API/DM/блокчейна или неправильно трактовать файл инструкций.
## Что предлагается сделать
1. Утвердить корневой `AGENTS.md` как главный набор правил проекта.
2. Проверить и при необходимости уточнить локальный `AGENTS.md` внутри `shine-solana/shine/`.
3. Оставить отдельные служебные инструкции Telegram-агента в `SHiNE-agent-bot-coder/AGENT.md`.
4. Оставить автоматически читаемые инструкции Telegram-агента в `SHiNE-agent-bot-coder/AGENTS.md`.
5. Явно закрепить режим игроков:
- игроки могут задавать вопросы, просить анализ, идеи и ТЗ;
- игроки не меняют код проекта напрямую;
- материалы игроков сохраняются только в `Players/<username>/`.
6. Зафиксировать правило: если пользователь говорит «агент MD» или похожую формулировку, считать, что речь про автоматически читаемый `AGENTS.md`.
7. Добавить простой процесс согласования изменений инструкций:
- Дима или другой участник готовит предложение;
- Айдар получает уведомление/заявку;
- Айдар отвечает: одобрить, отклонить или попросить доработать;
- только после одобрения агент вносит изменения в проектные инструкции.
## Предлагаемая логика уведомления Айдару
Минимальный вариант без сложной разработки:
1. Агент готовит текст заявки.
2. Текст отправляется Айдару в Telegram или в общий рабочий чат.
3. В заявке явно указаны варианты ответа:
- `одобрить`;
- `отклонить`;
- `доработать: ...`.
4. После ответа Айдара агент либо выполняет согласованные правки, либо фиксирует, что задача отклонена/нужна доработка.
Более удобный вариант на будущее:
- добавить в Telegram-бота команду или сценарий согласования задач, например:
- `/approve <id>`;
- `/reject <id> причина`;
- `/revise <id> комментарий`.
Но для начала достаточно простого текстового согласования через Telegram.
## Что нужно от Айдара
Подтвердить, что такой порядок подходит:
1. Корневой `AGENTS.md` остается главным правилом проекта.
2. Для Solana, Telegram-агента и игроков сохраняются отдельные локальные правила.
3. Игроки не меняют код напрямую, а готовят материалы и предложения.
4. Изменения инструкций выполняются только после явного одобрения Айдара.
5. Уведомления Айдару на первом этапе можно делать простым текстом в Telegram, без отдельной сложной системы заявок.
## Ожидаемый результат
После одобрения:
- агенты будут стабильнее понимать границы проекта;
- снизится риск случайных изменений не в той части системы;
- появится понятный порядок согласования задач от игроков;
- Айдар будет явно контролировать изменения в инструкциях и правилах работы агентов.
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
@@ -1 +0,0 @@
-19
View File
@@ -1,19 +0,0 @@
TELEGRAM_BOT_TOKEN=replace_me
OPENAI_API_KEY=replace_me
ALLOWED_TELEGRAM_USERNAME=AidarKC
ALLOWED_TELEGRAM_PLAYERS=malvviiina:Милана,zodiaktechnika32:Сергей,oidasyda:Иван,blackbyrd1:Ворон,dimasol1:Дима
ALLOWED_TELEGRAM_CHANNEL_USERNAME=shine_writing
BOT_USERNAME=aidar_su_bot
OPENAI_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe
TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS=300
OPENAI_TRANSCRIBE_TIMEOUT_SECONDS=900
OPENAI_TTS_MODEL=gpt-4o-mini-tts
OPENAI_TTS_VOICE=alloy
OPENAI_TTS_RESPONSE_FORMAT=opus
OPENAI_TTS_TIMEOUT_SECONDS=180
OPENAI_TTS_CHUNK_CHARS=3500
CODEX_BIN=/home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl
CODEX_WORKDIR=/home/ai/work/SHiNE/SHiNE-server-sha256
CODEX_TIMEOUT_SECONDS=900
MAX_RETRIES=3
DATA_DIR=./data
-5
View File
@@ -1,5 +0,0 @@
.env
data/
logs/
run/
__pycache__/
-81
View File
@@ -1,81 +0,0 @@
# AGENT.md для SHiNE-agent-bot-coder
Ты запущен как обработчик входящего Telegram-сообщения от пользователя.
## Контекст
- `SHiNE-agent-bot-coder` — локальный Telegram-бот-сервис агента-кодера для работы с этим проектом.
- Сервис принимает входящие сообщения от пользователя Telegram, сохраняет историю, ставит задачи в очередь и последовательно запускает Codex CLI в рабочем проекте.
- Текстовые сообщения обрабатываются напрямую, voice/audio сначала распознаются через OpenAI transcription, затем передаются как текстовая задача.
- История диалога хранится в JSONL-файле, путь передаётся в промпте.
- Сообщение может быть текстом или результатом распознавания голосового.
- Ответ пойдёт пользователю в Telegram как обычное текстовое сообщение.
- Единственная рабочая реализация сервиса — Python-скрипт `py_bot_service.py`; старая Java-реализация удалена как нерабочая и не должна восстанавливаться без отдельного решения Айдара.
- В репозитории также есть отдельный Solana/Anchor-модуль `shine-solana/shine/`; он логически связан с SHiNE, но не должен автоматически подключаться к основному серверному deploy без отдельной команды.
- Перед изменениями внутри `shine-solana/shine/` читать локальные инструкции `shine-solana/shine/AGENTS.md`; в git не добавлять локальные ключи, `.git`, `.idea`, `.gradle`, `target`, `node_modules`, `test-ledger`, логи, временные run-отчёты и `.env`-конфиги.
## Авторитет команд и история
- Основной пользователь и источник команд — Айдар: `@AidarKC` / `@aidarkc`.
- Дополнительно разрешены игроки из whitelist (`ALLOWED_TELEGRAM_PLAYERS`), каждый со своей отдельной историей и рабочей папкой `Players/<username>/`.
- Игроки работают в режиме вопросов/анализа/подготовки материалов: в промпте явно задано правило не менять код проекта и писать материалы только в своей папке.
- Для неизвестных пользователей в личном чате сервис отвечает вежливым отказом.
- В Telegram-канале/группе `@shine_writing` сервис выполняет сообщения только от Айдара, а ответы отправляет в тот же чат.
- Если Telegram сообщает о миграции обычной группы в supergroup, сервис должен запомнить новый `chat_id` и отправлять ответы уже туда.
- На события подключения/отключения пользователей (join/leave) сервис не отвечает и ничего не отправляет.
## Очередь и состояние
- Входящие задачи записываются в файловую очередь и обрабатываются строго по одной, чтобы не смешивать изменения в проекте.
- Сервис ведёт состояние активной задачи и текущего файла истории, а после рестарта продолжает незавершённую обработку с учётом сохранённого состояния.
- Истории диалогов хранятся в JSONL по каждому разрешённому username отдельно: `data/history/<username>/`.
- Архив истории после `/new`: `data/history/<username>/archive/`.
- После `/new` для этого же пользователя должен сбрасываться и контекст продолжения Codex-сессии; следующий запрос запускается как новая сессия, не через resume.
- Для просмотра истории игрока открывать файлы в его папке истории по username.
- Дедупликация входящих Telegram update нужна, чтобы одно сообщение не попало в обработку повторно.
- Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус с общим временем работы задачи; при дальнейшем молчании повторяет статус каждые 2 минуты.
- После успешной обработки задачи из личного чата Айдара сервис должен отправить публичный итоговый отчёт в группу `@shine_writing`: первым сообщением исходный запрос, вторым сообщением-ответом итоговый ответ Codex. Промежуточные статусы в группу не дублировать.
- Для приватных voice/audio-запросов в публичном отчёте первым сообщением отправлять исходный Telegram voice/audio-файл с подписью, где указан распознанный текст. В пользовательском тексте отчёта не показывать Telegram `file_id`.
- Озвучивание финальных ответов настраивается персонально для каждого Telegram-пользователя командами `/voice_on`, `/voice_off`; для новых пользователей оно включено по умолчанию.
- Адаптация текста перед озвучкой настраивается персонально командами `/voice_rewrite_on`, `/voice_rewrite_off`. Если она включена, сервис перед TTS вызывает дешёвую текстовую модель OpenAI и делает голосовую версию без длинных хэшей, путей, команд и технического шума, сохраняя смысл и порядок исходного ответа.
- Режим личных ответов настраивается персонально командами `/single_message_on`, `/single_message_off`: либо одно редактируемое сообщение по этапам, либо отдельные сообщения как раньше.
- Команда `/settings` должна сразу показывать текущее состояние всех персональных настроек пользователя и список команд для их изменения.
- Если озвучивание включено, после полного текстового финального ответа сервис дополнительно отправляет voice-файл с синтезированной речью через OpenAI TTS даже для текстовых запросов. Voice отправляется в исходный чат, а также в известный личный чат пользователя и в общий чат `@shine_writing`, если они отличаются и доступны. Промежуточные статусы не озвучивать.
- Команда `/status` должна показывать состояние очереди и персональные настройки: voice-ответы, адаптацию текста перед озвучкой и режим одного сообщения в личке.
## Правила голосовой версии ответа
- Текстовый финальный ответ должен оставаться полноценным: в нём можно указывать команды, пути, хэши коммитов, номера версий, результаты проверок и другие технические детали.
- Голосовую версию финального ответа нужно делать короче и проще для восприятия на слух. Основной механизм — персонально включаемая адаптация текста через дополнительный OpenAI-вызов перед TTS.
- В голосовой версии не зачитывать длинные хэши коммитов, токены, file_id, длинные команды, полные пути и другие строки, которые человек всё равно не сможет надёжно запомнить на слух.
- Для commit/push в голосовой версии достаточно сказать краткий итог: что коммит сделан, что именно изменено, проверки прошли без ошибок, push выполнен, рабочее дерево чистое.
- Если пользователю нужны точные команды, хэши или подробности, они должны оставаться в текстовом ответе.
## Планы и отложенные фичи
- Планы проекта по отложенным фичам хранятся в `Dev_Docs/Future_Features/`.
- Внутри есть три горизонта:
- `near/` - ближайшие планы, обычно сегодня/завтра;
- `medium/` - среднесрочные планы, обычно недели или 1-2 месяца;
- `far/` - дальнее будущее без понятного срока.
- Если пользователь спрашивает, какие есть планы или что можно продолжить, нужно смотреть эти три папки и отвечать кратким списком по горизонтам.
- Файлы из `Dev_Docs/Future_Features/` не начинать реализовывать без явной команды пользователя.
- После реализации фичи, требующей ручной проверки, нужно добавить отдельный файл в `Dev_Docs/Pending_Features/`.
## Центр задач и предложений
- Сервис хранит простые задачи и предложения в `data/task_center/items.json`.
- Айдар может смотреть список через `/tasks` или естественные фразы вроде «покажи мои задачи», «покажи задачи Миланы».
- Айдар может ставить задачи игрокам фразой вида «поставь задачу Милане: ...».
- Игроки могут отправлять предложения Айдару фразой вида `предложение: ...`, `идея: ...` или `заявка: ...`.
- Статусы меняются фразами с ID: `одобрить TC-0001`, `отклонить TC-0001`, `доработать TC-0001`, `закрыть TC-0001`.
- После финального ответа в личном чате сервис добавляет короткое напоминание, если у пользователя есть активные задачи или предложения.
## Локальный запуск и systemd
- Основной запуск сервиса выполняется Python-скриптом `py_bot_service.py` из папки `SHiNE-agent-bot-coder/`.
- Локальные секреты и параметры должны храниться в `.env`, этот файл не коммитится.
- Для проверки Codex без Telegram можно использовать self-test режим сервиса.
- Для постоянного локального запуска используется user-level systemd service `shine-agent-bot-coder`; скрипты установки лежат в `SHiNE-agent-bot-coder/scripts/systemd/`.
- Если меняется логика сервиса, после изменений нужно проверить запуск локально и при необходимости перезапустить user systemd service.
- Команда Telegram `/restart` (`/restart_service`) доступна только Айдару и выполняет отложенный рестарт после текущей задачи, до взятия следующей. Аварийный жёсткий рестарт доступен только Айдару командами `/restart_hard`, `/restart_now`, `/restart_force`.
## Правила ответа
- Пиши содержательно и коротко.
- Не упоминай внутренние служебные детали, файловую систему и технические логи.
- Если запрос требует действий с кодом/проектом, выполняй их в рабочей директории.
- Если для ответа данных недостаточно, задай ровно один уточняющий вопрос.
- Если была ошибка предыдущего запуска, в промпте будет пометка retry — учти это и продолжи с учётом текущего состояния проекта.
-25
View File
@@ -1,25 +0,0 @@
# AGENTS
## Назначение
- Это автоматически читаемые инструкции Codex для папки `SHiNE-agent-bot-coder/`.
- `SHiNE-agent-bot-coder` — локальный Telegram-бот-сервис агента-кодера для работы с проектом SHiNE.
- Если пользователь говорит «агент MD», «агент с MD» или похожим образом про файл инструкций Codex, считать, что имеется в виду `AGENTS.md`.
## Связанные инструкции
- Подробные служебные правила Telegram-обработчика лежат в `AGENT.md`.
- `AGENT.md` используется самим сервисом как файл инструкций, который передаётся в промпт обработчика входящих Telegram-сообщений.
- При изменении логики сервиса сначала читать `AGENT.md`, затем код `py_bot_service.py`.
## Планы и задачи
- Отложенные задачи проекта лежат в `../Dev_Docs/Future_Features/`.
- Точка входа по планам: `../Dev_Docs/Future_Features/README.md`.
- Горизонты планов:
- `near/` - ближайшие планы;
- `medium/` - среднесрочные планы;
- `far/` - дальнее будущее.
- Если пользователь спрашивает, какие есть планы или что можно продолжить, кратко перечислять задачи по этим горизонтам.
- Не начинать реализацию задач из `Future_Features` без явной команды пользователя.
## Проверка после изменений
- Если меняется логика Telegram-бота, проверить локальный запуск или self-test, когда это уместно.
- Если меняется только документация или инструкции, достаточно проверить, что ссылки на документы актуальны.
-2
View File
@@ -1,2 +0,0 @@
@AGENTS.md
@AGENT.md
@@ -1,26 +0,0 @@
# Промпты для режима игроков (на согласование)
## 1) Базовый служебный промпт (добавка к задаче игрока)
```text
Режим игрока (обязательно):
- Пользователь: <Имя> (@<username>).
- Рабочая папка игрока: <project>/Players/<username>
- Код проекта не изменять.
- Можно отвечать на вопросы по проекту, предлагать идеи и готовить ТЗ.
- Если нужны правки кода, описывать предложение текстом и сохранять материалы только в папке игрока.
```
## 2) Приветственное сообщение игроку (один раз)
```text
Привет, <Имя>.
Можно задавать вопросы по проекту, просить анализ, идеи и подготовку готового ТЗ.
Команда /new начинает новую сессию и архивирует текущую историю.
```
## 3) Отказ неизвестному пользователю
```text
Извините, доступ к этому агенту пока не выдан. Обратитесь к Айдару.
```
-100
View File
@@ -1,100 +0,0 @@
# SHiNE-agent-bot-coder
Локальный Telegram-бот-сервис для пользователя `ai`:
- принимает сообщения от `@AidarKC`;
- поддерживает whitelist игроков (`ALLOWED_TELEGRAM_PLAYERS`) с отдельными историями;
- ведёт историю диалога в `JSONL`;
- ставит задачи в файловую очередь;
- обрабатывает задачи строго последовательно;
- поддерживает текстовые и голосовые сообщения (voice/audio через OpenAI transcription);
- вызывает Codex CLI и отправляет ответ в Telegram;
- в личном чате умеет работать в двух персонально переключаемых режимах: через одно редактируемое статусное сообщение или через отдельные сообщения по этапам;
- умеет персонально для каждого пользователя озвучивать финальный ответ через OpenAI TTS;
- при рестарте восстанавливает незавершённые задачи;
- отправляет аварийный статус только если Codex молчит 2 минуты подряд во время активной задачи;
- принимает сообщения из канала/группы `@shine_writing`, выполняет команды только от `@AidarKC`;
- учитывает миграцию обычной Telegram-группы в supergroup и перенаправляет ответы на новый `chat_id`.
Рабочая реализация сервиса — только `py_bot_service.py`. Старая Java-реализация удалена, потому что не заработала и больше не используется.
## Структура
- `.env` — локальные секреты и параметры запуска (не коммитится);
- `data/py_queue.jsonl` — очередь Python-сервиса;
- `data/py_state.json` — текущее состояние Python-сервиса;
- `data/py_processed_updates.log` — дедуп входящих update;
- `data/history/<username>/*.jsonl` — активные истории по пользователям;
- `data/history/<username>/archive/*.jsonl` — архивы после `/new`.
## Локальный запуск
1. Скопировать пример:
- `cp .env.example .env`
2. Заполнить секреты в `.env`.
- `TELEGRAM_BOT_TOKEN` — токен рабочего Telegram-бота.
- `ALLOWED_TELEGRAM_USERNAME` — пользователь, чьи сообщения выполняются как команды.
- `ALLOWED_TELEGRAM_PLAYERS` — whitelist игроков в формате `username:Имя,username2:Имя2`.
- `ALLOWED_TELEGRAM_CHANNEL_USERNAME` — канал, из которого принимаются `channel_post`; обычные group/supergroup-сообщения обрабатываются как `message`.
- `TELEGRAM_API_BASE_URL` — базовый URL Bot API; по умолчанию `https://api.telegram.org`. Для очень больших voice/audio можно поднять локальный `telegram-bot-api` и направить бота туда.
- `TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS` — тайм-аут скачивания voice/audio из Telegram, по умолчанию 300 секунд.
- `OPENAI_TRANSCRIBE_TIMEOUT_SECONDS` — тайм-аут распознавания voice/audio в OpenAI, по умолчанию 900 секунд.
- `OPENAI_TRANSCRIBE_MAX_UPLOAD_BYTES` — безопасный лимит размера одного куска для OpenAI transcription, по умолчанию `24 MiB`.
- `OPENAI_TRANSCRIBE_MAX_CHUNK_SECONDS` — максимальная длина одного куска при длинном аудио, по умолчанию `900` секунд.
- `OPENAI_TRANSCRIBE_OVERLAP_SECONDS` — перекрытие соседних кусков для более ровной склейки текста, по умолчанию `2` секунды.
- `OPENAI_TRANSCRIBE_REENCODE_BITRATE_KBPS` — битрейт локального пережатия длинного аудио через `ffmpeg`, по умолчанию `24`.
- `OPENAI_TRANSCRIBE_FFMPEG_TIMEOUT_SECONDS` — тайм-аут локальной обработки длинного аудио через `ffmpeg`/`ffprobe`, по умолчанию `1800`.
- `FFMPEG_BIN` и `FFPROBE_BIN` — пути к локальным бинарям `ffmpeg`/`ffprobe`, если они не лежат в `PATH`.
- `OPENAI_TTS_MODEL` — модель синтеза речи, по умолчанию `gpt-4o-mini-tts`.
- `OPENAI_TTS_VOICE` — голос синтеза речи, по умолчанию `alloy`.
- `OPENAI_TTS_RESPONSE_FORMAT` — аудиоформат для Telegram voice, по умолчанию `opus`.
- `OPENAI_TTS_TIMEOUT_SECONDS` — тайм-аут генерации одного фрагмента речи, по умолчанию 180 секунд.
- `OPENAI_TTS_CHUNK_CHARS` — максимальный размер одного фрагмента озвучки, по умолчанию 3500 символов.
3. Запуск:
- `python3 SHiNE-agent-bot-coder/py_bot_service.py`
## Быстрый self-test Codex (без Telegram)
```bash
python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь одной строкой: Codex работает"
```
## Длинные voice/audio
- Если аудио короткое, бот отправляет его в OpenAI как раньше.
- Если аудио большое или длинное, бот локально пережимает его через `ffmpeg`, при необходимости режет на куски и распознаёт последовательно.
- Если Telegram заранее сообщает большой размер файла, бот больше не отказывается сразу: сначала явно пишет, что пробует скачать файл, затем отдельно сообщает, удалось ли скачивание, и только после успешной загрузки переходит к подготовке аудио и OpenAI.
- Для очень больших файлов упираемся не только в OpenAI, но и в лимит обычного облачного Telegram Bot API на скачивание файла ботом. Для таких случаев нужно использовать локальный `telegram-bot-api` сервер и указать его через `TELEGRAM_API_BASE_URL`.
## Статусы в личке
- Для `private`-чата бот поддерживает персональную настройку режима ответа.
- По умолчанию он старается не засорять переписку промежуточными сообщениями: создаёт одно статусное сообщение и редактирует его по этапам.
- Если включить `/single_message_off`, бот возвращается к старому режиму и отправляет отдельные сообщения по этапам и финальный ответ отдельно.
- Если финальный текст в режиме одного сообщения не помещается целиком, бот оставляет первую часть в отредактированном статусном сообщении и отправляет максимум ещё одно дополнительное текстовое сообщение с хвостом ответа.
- Голосовой ответ, если он включён, всегда приходит отдельным новым сообщением.
## Запуск как systemd-сервис
Файлы для установки:
- `scripts/systemd/shine-agent-bot-coder.service`
- `scripts/systemd/install-local-systemd.sh`
Установка:
- `bash SHiNE-agent-bot-coder/scripts/systemd/install-local-systemd.sh`
Проверка:
- `systemctl --user status shine-agent-bot-coder --no-pager`
- `journalctl --user -u shine-agent-bot-coder -f`
Перезапуск после изменений:
- `systemctl --user restart shine-agent-bot-coder`
## Telegram-команды
- `/status` — активная задача и размер очереди.
- `/settings` — текущие пользовательские настройки и команды для их изменения.
- `/queue` — список задач в очереди.
- `/stop` — остановить текущую задачу.
- `/cancel <id|all>` — удалить задачу по id/префиксу или очистить очередь.
- `/new` — архивировать текущую историю, сбросить продолжение Codex-сессии для этого пользователя и начать новый диалог.
- `/voice_on` — включить озвучивание финальных ответов для текущего пользователя.
- `/voice_off` — выключить озвучивание финальных ответов для текущего пользователя.
- `/voice_rewrite_on` — включить адаптацию текста перед озвучкой.
- `/voice_rewrite_off` — выключить адаптацию текста перед озвучкой.
- `/single_message_on` — вести ответ в личке через одно редактируемое сообщение.
- `/single_message_off` — слать отдельные сообщения по этапам и отдельный финальный ответ.
- `/restart` или `/restart_service` — отложенный рестарт после текущей задачи, до взятия следующей (только для Айдара).
- `/restart_hard` — жёсткий рестарт прямо сейчас (только для Айдара).
File diff suppressed because it is too large Load Diff
@@ -1,28 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="/home/ai/work/SHiNE/SHiNE-server-sha256"
SERVICE_DIR="${ROOT_DIR}/SHiNE-agent-bot-coder"
UNIT_SRC="${SERVICE_DIR}/scripts/systemd/shine-agent-bot-coder.service"
UNIT_DST="${HOME}/.config/systemd/user/shine-agent-bot-coder.service"
echo "[1/6] Проверка python3..."
command -v python3 >/dev/null 2>&1 || { echo "python3 не найден"; exit 1; }
echo "[2/6] Подготовка папки логов..."
mkdir -p "${SERVICE_DIR}/logs"
echo "[3/6] Копирование user systemd unit..."
mkdir -p "$(dirname "${UNIT_DST}")"
cp "${UNIT_SRC}" "${UNIT_DST}"
echo "[4/6] daemon-reload..."
systemctl --user daemon-reload
echo "[5/6] enable + start..."
systemctl --user enable --now shine-agent-bot-coder
echo "[6/6] Статус:"
systemctl --user status shine-agent-bot-coder --no-pager
echo "Готово. Логи: journalctl --user -u shine-agent-bot-coder -f"
@@ -1,19 +0,0 @@
[Unit]
Description=SHiNE Agent Bot Coder (Telegram + Codex queue worker)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-agent-bot-coder
EnvironmentFile=/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-agent-bot-coder/.env
ExecStart=/usr/bin/python3 /home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-agent-bot-coder/py_bot_service.py
Restart=always
RestartSec=5
TimeoutStopSec=20
SuccessExitStatus=143 0
StandardOutput=append:/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-agent-bot-coder/logs/service.log
StandardError=append:/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-agent-bot-coder/logs/service.log
[Install]
WantedBy=default.target
+8 -3
View File
@@ -7,11 +7,13 @@ Chrome-compatible Manifest V3 plugin for SHiNE wallet-session login.
- создать `wallet-session` через `StartTrustedDeviceLogin`;
- показать код подключения;
- дождаться подтверждения на доверенном устройстве;
- принять `session-only` payload без передачи `deviceKey/rootKey/blockchainKey`;
- принять `session-only` payload без передачи `clientKey/rootKey/blockchainKey`;
- сохранить `sessionPriv/sessionKey/sessionId` в локальном хранилище plugin;
- восстанавливать session через `SessionChallenge -> SessionLogin`;
- держать wallet-state в `background service worker`, а popup использовать как UI.
- держать wallet-state в `background service worker`, а side panel использовать как UI.
- принимать не адрес сервера, а логин серверного аккаунта SHiNE и находить точный `https://...` / `wss://...` адрес через его PDA.
- внедрять legacy `window.solana` / `window.phantom.solana` provider для сайтов.
- регистрировать кошелёк как `Wallet Standard` wallet для dapp, которые ищут стандартные кошельки.
## Как загрузить локально
@@ -19,13 +21,16 @@ Chrome-compatible Manifest V3 plugin for SHiNE wallet-session login.
2. Включи `Developer mode`
3. Нажми `Load unpacked`
4. Выбери папку `SHiNE-browser-plugin-wallet/`
5. Нажми на иконку расширения в toolbar: откроется side panel SHiNE Wallet
## Ограничения текущего этапа
- plugin пока не держит постоянный фоновый WS-канал после закрытия popup, но хранит wallet-state в `background`;
- 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
+448 -57
View File
@@ -26,19 +26,101 @@ const state = {
connectionOnline: false,
walletProfile: null,
signing: {
selectedKeyId: 'device',
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);
@@ -71,14 +153,18 @@ async function loadStateFromStorage() {
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) : [],
};
state.activeSession = await loadSessionMaterial();
const storedSession = await loadSessionMaterial();
if (storedSession || !state.sessionAttachInProgress) {
state.activeSession = storedSession;
}
state.walletProfile = state.activeSession?.walletProfile || null;
state.signing = {
selectedKeyId: String(state.activeSession?.selectedKeyId || 'device'),
selectedDeviceName: String(state.activeSession?.selectedDeviceName || ''),
devicesResolvedAtMs: Number(state.activeSession?.devicesResolvedAtMs || 0),
};
state.currentWallet = state.activeSession?.currentWallet || null;
}
async function persistSettings(nextSettings = {}) {
@@ -86,10 +172,29 @@ async function persistSettings(nextSettings = {}) {
...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) {
@@ -119,9 +224,9 @@ async function saveActiveSessionRecord() {
const nextRecord = {
...state.activeSession,
walletProfile: state.walletProfile,
selectedKeyId: state.signing.selectedKeyId,
selectedDeviceName: state.signing.selectedDeviceName,
devicesResolvedAtMs: state.signing.devicesResolvedAtMs,
currentWallet: state.currentWallet,
};
state.activeSession = nextRecord;
await saveSessionMaterial(nextRecord);
@@ -152,27 +257,10 @@ function toWalletErrorMessage(error, fallback = 'Не удалось выпол
return raw || fallback;
}
function buildSigningKeyOptions(walletProfile) {
const rootKey = String(walletProfile?.publicKeys?.rootKeyBase58 || '').trim();
const deviceKey = String(walletProfile?.publicKeys?.deviceKeyBase58 || '').trim();
const options = [];
if (rootKey) {
options.push({
id: 'root',
label: `rootKey (ed25519, ${shortKey(rootKey)})`,
keyType: 'ed25519',
publicKeyBase58: rootKey,
});
}
if (deviceKey) {
options.push({
id: 'device',
label: `deviceKey (ed25519, ${shortKey(deviceKey)})`,
keyType: 'ed25519',
publicKeyBase58: deviceKey,
});
}
return options;
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 = []) {
@@ -181,18 +269,25 @@ function mergeHomeserverStatuses(publishedHomeservers = [], 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) => {
let onlineState = 'unknown';
if (published.length === 1) {
onlineState = onlineHomeservers.length > 0 ? 'online' : 'offline';
} else if (onlineHomeservers.length === 0) {
onlineState = 'offline';
} else if (onlineHomeservers.length === published.length) {
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',
};
@@ -203,25 +298,20 @@ 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 signingKeyOptions = buildSigningKeyOptions(profile);
const selectedKeyId = signingKeyOptions.some((item) => item.id === state.signing.selectedKeyId)
? state.signing.selectedKeyId
: (signingKeyOptions[0]?.id || '');
const selectedDeviceName = state.signing.selectedDeviceName
|| String(profile?.homeserverSessions?.[0]?.sessionName || '');
state.walletProfile = {
...profile,
signingKeyOptions,
homeserverSessions: Array.isArray(profile.homeserverSessions) ? profile.homeserverSessions.map((item) => ({
...item,
onlineState: 'unknown',
onlineLabel: 'unknown',
activeSessionId: '',
})) : [],
};
state.signing = {
...state.signing,
selectedKeyId,
selectedDeviceName,
};
await saveActiveSessionRecord();
@@ -282,8 +372,17 @@ async function attachApprovedSession(payload) {
throw new Error('Получен неполный session-only payload');
}
await clearSessionMaterial();
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({
@@ -293,6 +392,10 @@ async function attachApprovedSession(payload) {
serverUrl: sessionRecord.serverUrl,
});
state.connectionOnline = false;
state.currentWallet = null;
} finally {
state.sessionAttachInProgress = false;
}
}
async function pollPairingStatus() {
@@ -362,7 +465,7 @@ async function startPairing({ login, usePassword, password }) {
state.pairingId = String(payload?.pairingId || '').trim();
state.expiresAtMs = Number(payload?.expiresAtMs || 0);
state.shortCode = String(payload?.shortCode || '0000000');
state.shortCode = String(payload?.shortCode || '');
state.trustedSessionOnline = !!payload?.trustedSessionOnline;
if (!state.pairingId) {
throw new Error('Сервер не вернул pairingId.');
@@ -375,7 +478,7 @@ async function startPairing({ login, usePassword, password }) {
setStatus('Wallet-session заявка создана. Ожидаем подтверждение на доверенном устройстве.', 'info');
return {
pairingId: state.pairingId,
shortCode: String(payload?.shortCode || '0000000'),
shortCode: String(payload?.shortCode || ''),
expiresAtMs: state.expiresAtMs,
trustedSessionOnline: !!payload?.trustedSessionOnline,
};
@@ -400,10 +503,10 @@ async function disconnectSession() {
state.connectionOnline = false;
state.walletProfile = null;
state.signing = {
selectedKeyId: 'device',
selectedDeviceName: '',
devicesResolvedAtMs: 0,
};
state.currentWallet = null;
setStatus('Сохранённая wallet-session удалена из plugin.', 'info');
return { ok: true };
}
@@ -440,40 +543,296 @@ async function refreshWalletDevices() {
}
}
async function updateSigningSelection({ selectedKeyId, selectedDeviceName } = {}) {
async function updateSigningSelection({ selectedDeviceName } = {}) {
state.signing = {
...state.signing,
selectedKeyId: String(selectedKeyId || state.signing.selectedKeyId || ''),
selectedDeviceName: String(selectedDeviceName || state.signing.selectedDeviceName || ''),
};
await saveActiveSessionRecord();
return { ok: true };
}
async function prepareSignSignal() {
async function resolveSelectedHomeserverSession() {
if (!state.activeSession?.login) {
throw new Error('Сначала подключите wallet-session.');
}
if (!state.signing.selectedKeyId) {
throw new Error('Не выбран ключ подписи.');
}
if (!state.signing.selectedDeviceName) {
throw new Error('Не выбрано устройство homeserver.');
}
const selectedDevice = (state.walletProfile?.homeserverSessions || []).find((item) => item.sessionName === state.signing.selectedDeviceName);
let selectedDevice = (state.walletProfile?.homeserverSessions || []).find((item) => item.sessionName === state.signing.selectedDeviceName);
if (!selectedDevice) {
throw new Error('Выбранное устройство не найдено в PDA аккаунта.');
}
setStatus(
`Каркас готов: запрос подписи должен идти через ${selectedDevice.sessionName}. Сам signaling подписи ещё не доделан.`,
'info',
);
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,
pending: 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 },
@@ -485,8 +844,10 @@ function snapshot() {
trustedSessionOnline: state.trustedSessionOnline,
},
session: state.activeSession ? { ...state.activeSession } : null,
connectionOnline: state.connectionOnline,
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,
@@ -538,8 +899,8 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:prepareSignSignal') {
const result = await prepareSignSignal();
if (type === 'wallet:requestCurrentWallet') {
const result = await requestCurrentWallet();
sendResponse({ ok: true, result, state: snapshot() });
return;
}
@@ -548,15 +909,45 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
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, state: snapshot() });
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(() => {});
@@ -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();
@@ -59,6 +59,15 @@ export async function createRequesterPairingMaterial() {
};
}
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 ?? '')}`;
@@ -75,6 +75,22 @@ export class ShineApiClient {
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();
@@ -1,8 +1,8 @@
import { base64ToBytes } from './crypto-utils.js';
import { PublicKey } from './vendor/solana-publickey-bundle.js';
const SOLANA_ENDPOINT_DEFAULT = 'https://api.devnet.solana.com';
const SHINE_USERS_PROGRAM_ID = 'FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm';
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';
@@ -69,8 +69,9 @@ function parseServerFieldsFromUserPda(dataBytes) {
let isServer = false;
let serverAddress = '';
let accessServers = [];
let recoveryKey32 = null;
let rootKey32 = null;
let deviceKey32 = null;
let clientKey32 = null;
let blockchainKey32 = null;
let blockchainName = '';
let homeserverSessions = [];
@@ -79,10 +80,11 @@ function parseServerFieldsFromUserPda(dataBytes) {
const blockType = readU8(bytes, cursorRef);
cursorRef.value += 1; // block_version
if (blockType === 1 || blockType === 2) {
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) deviceKey32 = key32;
if (blockType === 2) clientKey32 = key32;
continue;
}
if (blockType === 3) {
@@ -150,8 +152,9 @@ function parseServerFieldsFromUserPda(dataBytes) {
serverAddress: normalizeHostLike(serverAddress),
accessServers: accessServers.map((value) => normalizeServerLogin(value)).filter(Boolean),
publicKeys: {
recoveryKeyBase58: recoveryKey32 ? new PublicKey(recoveryKey32).toBase58() : '',
rootKeyBase58: rootKey32 ? new PublicKey(rootKey32).toBase58() : '',
deviceKeyBase58: deviceKey32 ? new PublicKey(deviceKey32).toBase58() : '',
clientKeyBase58: clientKey32 ? new PublicKey(clientKey32).toBase58() : '',
blockchainKeyBase58: blockchainKey32 ? new PublicKey(blockchainKey32).toBase58() : '',
blockchainName,
},
@@ -12136,8 +12136,8 @@ function weierstrass(curveDef) {
return drbg(seed, k2sig);
}
Point2.BASE._setWindowSize(8);
function verify2(signature, msgHash, publicKey2, opts = defaultVerOpts) {
const sg = signature;
function verify2(signature2, msgHash, publicKey2, opts = defaultVerOpts) {
const sg = signature2;
msgHash = ensureBytes("msgHash", msgHash);
publicKey2 = ensureBytes("publicKey", publicKey2);
if ("strict" in opts)
@@ -12515,30 +12515,30 @@ var PACKET_DATA_SIZE = 1280 - 40 - 8;
var VERSION_PREFIX_MASK = 127;
var SIGNATURE_LENGTH_IN_BYTES = 64;
var TransactionExpiredBlockheightExceededError = class extends Error {
constructor(signature) {
super(`Signature ${signature} has expired: block height exceeded.`);
constructor(signature2) {
super(`Signature ${signature2} has expired: block height exceeded.`);
this.signature = void 0;
this.signature = signature;
this.signature = signature2;
}
};
Object.defineProperty(TransactionExpiredBlockheightExceededError.prototype, "name", {
value: "TransactionExpiredBlockheightExceededError"
});
var TransactionExpiredTimeoutError = class extends Error {
constructor(signature, timeoutSeconds) {
super(`Transaction was not confirmed in ${timeoutSeconds.toFixed(2)} seconds. It is unknown if it succeeded or failed. Check signature ${signature} using the Solana Explorer or CLI tools.`);
constructor(signature2, timeoutSeconds) {
super(`Transaction was not confirmed in ${timeoutSeconds.toFixed(2)} seconds. It is unknown if it succeeded or failed. Check signature ${signature2} using the Solana Explorer or CLI tools.`);
this.signature = void 0;
this.signature = signature;
this.signature = signature2;
}
};
Object.defineProperty(TransactionExpiredTimeoutError.prototype, "name", {
value: "TransactionExpiredTimeoutError"
});
var TransactionExpiredNonceInvalidError = class extends Error {
constructor(signature) {
super(`Signature ${signature} has expired: the nonce is no longer valid.`);
constructor(signature2) {
super(`Signature ${signature2} has expired: the nonce is no longer valid.`);
this.signature = void 0;
this.signature = signature;
this.signature = signature2;
}
};
Object.defineProperty(TransactionExpiredNonceInvalidError.prototype, "name", {
@@ -12598,6 +12598,9 @@ var MessageAccountKeys = class {
var publicKey = (property = "publicKey") => {
return BufferLayout.blob(32, property);
};
var signature = (property = "signature") => {
return BufferLayout.blob(64, property);
};
var rustString = (property = "string") => {
const rsl = BufferLayout.struct([BufferLayout.u32("length"), BufferLayout.u32("lengthPadding"), BufferLayout.blob(BufferLayout.offset(BufferLayout.u32(), -8), "chars")], property);
const _decode = rsl.decode.bind(rsl);
@@ -12954,6 +12957,260 @@ var Message = class _Message {
return new _Message(messageArgs);
}
};
var MessageV0 = class _MessageV0 {
constructor(args) {
this.header = void 0;
this.staticAccountKeys = void 0;
this.recentBlockhash = void 0;
this.compiledInstructions = void 0;
this.addressTableLookups = void 0;
this.header = args.header;
this.staticAccountKeys = args.staticAccountKeys;
this.recentBlockhash = args.recentBlockhash;
this.compiledInstructions = args.compiledInstructions;
this.addressTableLookups = args.addressTableLookups;
}
get version() {
return 0;
}
get numAccountKeysFromLookups() {
let count = 0;
for (const lookup of this.addressTableLookups) {
count += lookup.readonlyIndexes.length + lookup.writableIndexes.length;
}
return count;
}
getAccountKeys(args) {
let accountKeysFromLookups;
if (args && "accountKeysFromLookups" in args && args.accountKeysFromLookups) {
if (this.numAccountKeysFromLookups != args.accountKeysFromLookups.writable.length + args.accountKeysFromLookups.readonly.length) {
throw new Error("Failed to get account keys because of a mismatch in the number of account keys from lookups");
}
accountKeysFromLookups = args.accountKeysFromLookups;
} else if (args && "addressLookupTableAccounts" in args && args.addressLookupTableAccounts) {
accountKeysFromLookups = this.resolveAddressTableLookups(args.addressLookupTableAccounts);
} else if (this.addressTableLookups.length > 0) {
throw new Error("Failed to get account keys because address table lookups were not resolved");
}
return new MessageAccountKeys(this.staticAccountKeys, accountKeysFromLookups);
}
isAccountSigner(index) {
return index < this.header.numRequiredSignatures;
}
isAccountWritable(index) {
const numSignedAccounts = this.header.numRequiredSignatures;
const numStaticAccountKeys = this.staticAccountKeys.length;
if (index >= numStaticAccountKeys) {
const lookupAccountKeysIndex = index - numStaticAccountKeys;
const numWritableLookupAccountKeys = this.addressTableLookups.reduce((count, lookup) => count + lookup.writableIndexes.length, 0);
return lookupAccountKeysIndex < numWritableLookupAccountKeys;
} else if (index >= this.header.numRequiredSignatures) {
const unsignedAccountIndex = index - numSignedAccounts;
const numUnsignedAccounts = numStaticAccountKeys - numSignedAccounts;
const numWritableUnsignedAccounts = numUnsignedAccounts - this.header.numReadonlyUnsignedAccounts;
return unsignedAccountIndex < numWritableUnsignedAccounts;
} else {
const numWritableSignedAccounts = numSignedAccounts - this.header.numReadonlySignedAccounts;
return index < numWritableSignedAccounts;
}
}
resolveAddressTableLookups(addressLookupTableAccounts) {
const accountKeysFromLookups = {
writable: [],
readonly: []
};
for (const tableLookup of this.addressTableLookups) {
const tableAccount = addressLookupTableAccounts.find((account) => account.key.equals(tableLookup.accountKey));
if (!tableAccount) {
throw new Error(`Failed to find address lookup table account for table key ${tableLookup.accountKey.toBase58()}`);
}
for (const index of tableLookup.writableIndexes) {
if (index < tableAccount.state.addresses.length) {
accountKeysFromLookups.writable.push(tableAccount.state.addresses[index]);
} else {
throw new Error(`Failed to find address for index ${index} in address lookup table ${tableLookup.accountKey.toBase58()}`);
}
}
for (const index of tableLookup.readonlyIndexes) {
if (index < tableAccount.state.addresses.length) {
accountKeysFromLookups.readonly.push(tableAccount.state.addresses[index]);
} else {
throw new Error(`Failed to find address for index ${index} in address lookup table ${tableLookup.accountKey.toBase58()}`);
}
}
}
return accountKeysFromLookups;
}
static compile(args) {
const compiledKeys = CompiledKeys.compile(args.instructions, args.payerKey);
const addressTableLookups = new Array();
const accountKeysFromLookups = {
writable: new Array(),
readonly: new Array()
};
const lookupTableAccounts = args.addressLookupTableAccounts || [];
for (const lookupTable of lookupTableAccounts) {
const extractResult = compiledKeys.extractTableLookup(lookupTable);
if (extractResult !== void 0) {
const [addressTableLookup, {
writable,
readonly
}] = extractResult;
addressTableLookups.push(addressTableLookup);
accountKeysFromLookups.writable.push(...writable);
accountKeysFromLookups.readonly.push(...readonly);
}
}
const [header, staticAccountKeys] = compiledKeys.getMessageComponents();
const accountKeys = new MessageAccountKeys(staticAccountKeys, accountKeysFromLookups);
const compiledInstructions = accountKeys.compileInstructions(args.instructions);
return new _MessageV0({
header,
staticAccountKeys,
recentBlockhash: args.recentBlockhash,
compiledInstructions,
addressTableLookups
});
}
serialize() {
const encodedStaticAccountKeysLength = Array();
encodeLength(encodedStaticAccountKeysLength, this.staticAccountKeys.length);
const serializedInstructions = this.serializeInstructions();
const encodedInstructionsLength = Array();
encodeLength(encodedInstructionsLength, this.compiledInstructions.length);
const serializedAddressTableLookups = this.serializeAddressTableLookups();
const encodedAddressTableLookupsLength = Array();
encodeLength(encodedAddressTableLookupsLength, this.addressTableLookups.length);
const messageLayout = BufferLayout.struct([BufferLayout.u8("prefix"), BufferLayout.struct([BufferLayout.u8("numRequiredSignatures"), BufferLayout.u8("numReadonlySignedAccounts"), BufferLayout.u8("numReadonlyUnsignedAccounts")], "header"), BufferLayout.blob(encodedStaticAccountKeysLength.length, "staticAccountKeysLength"), BufferLayout.seq(publicKey(), this.staticAccountKeys.length, "staticAccountKeys"), publicKey("recentBlockhash"), BufferLayout.blob(encodedInstructionsLength.length, "instructionsLength"), BufferLayout.blob(serializedInstructions.length, "serializedInstructions"), BufferLayout.blob(encodedAddressTableLookupsLength.length, "addressTableLookupsLength"), BufferLayout.blob(serializedAddressTableLookups.length, "serializedAddressTableLookups")]);
const serializedMessage = new Uint8Array(PACKET_DATA_SIZE);
const MESSAGE_VERSION_0_PREFIX = 1 << 7;
const serializedMessageLength = messageLayout.encode({
prefix: MESSAGE_VERSION_0_PREFIX,
header: this.header,
staticAccountKeysLength: new Uint8Array(encodedStaticAccountKeysLength),
staticAccountKeys: this.staticAccountKeys.map((key) => key.toBytes()),
recentBlockhash: import_bs58.default.decode(this.recentBlockhash),
instructionsLength: new Uint8Array(encodedInstructionsLength),
serializedInstructions,
addressTableLookupsLength: new Uint8Array(encodedAddressTableLookupsLength),
serializedAddressTableLookups
}, serializedMessage);
return serializedMessage.slice(0, serializedMessageLength);
}
serializeInstructions() {
let serializedLength = 0;
const serializedInstructions = new Uint8Array(PACKET_DATA_SIZE);
for (const instruction of this.compiledInstructions) {
const encodedAccountKeyIndexesLength = Array();
encodeLength(encodedAccountKeyIndexesLength, instruction.accountKeyIndexes.length);
const encodedDataLength = Array();
encodeLength(encodedDataLength, instruction.data.length);
const instructionLayout = BufferLayout.struct([BufferLayout.u8("programIdIndex"), BufferLayout.blob(encodedAccountKeyIndexesLength.length, "encodedAccountKeyIndexesLength"), BufferLayout.seq(BufferLayout.u8(), instruction.accountKeyIndexes.length, "accountKeyIndexes"), BufferLayout.blob(encodedDataLength.length, "encodedDataLength"), BufferLayout.blob(instruction.data.length, "data")]);
serializedLength += instructionLayout.encode({
programIdIndex: instruction.programIdIndex,
encodedAccountKeyIndexesLength: new Uint8Array(encodedAccountKeyIndexesLength),
accountKeyIndexes: instruction.accountKeyIndexes,
encodedDataLength: new Uint8Array(encodedDataLength),
data: instruction.data
}, serializedInstructions, serializedLength);
}
return serializedInstructions.slice(0, serializedLength);
}
serializeAddressTableLookups() {
let serializedLength = 0;
const serializedAddressTableLookups = new Uint8Array(PACKET_DATA_SIZE);
for (const lookup of this.addressTableLookups) {
const encodedWritableIndexesLength = Array();
encodeLength(encodedWritableIndexesLength, lookup.writableIndexes.length);
const encodedReadonlyIndexesLength = Array();
encodeLength(encodedReadonlyIndexesLength, lookup.readonlyIndexes.length);
const addressTableLookupLayout = BufferLayout.struct([publicKey("accountKey"), BufferLayout.blob(encodedWritableIndexesLength.length, "encodedWritableIndexesLength"), BufferLayout.seq(BufferLayout.u8(), lookup.writableIndexes.length, "writableIndexes"), BufferLayout.blob(encodedReadonlyIndexesLength.length, "encodedReadonlyIndexesLength"), BufferLayout.seq(BufferLayout.u8(), lookup.readonlyIndexes.length, "readonlyIndexes")]);
serializedLength += addressTableLookupLayout.encode({
accountKey: lookup.accountKey.toBytes(),
encodedWritableIndexesLength: new Uint8Array(encodedWritableIndexesLength),
writableIndexes: lookup.writableIndexes,
encodedReadonlyIndexesLength: new Uint8Array(encodedReadonlyIndexesLength),
readonlyIndexes: lookup.readonlyIndexes
}, serializedAddressTableLookups, serializedLength);
}
return serializedAddressTableLookups.slice(0, serializedLength);
}
static deserialize(serializedMessage) {
let byteArray = [...serializedMessage];
const prefix = guardedShift(byteArray);
const maskedPrefix = prefix & VERSION_PREFIX_MASK;
assert2(prefix !== maskedPrefix, `Expected versioned message but received legacy message`);
const version2 = maskedPrefix;
assert2(version2 === 0, `Expected versioned message with version 0 but found version ${version2}`);
const header = {
numRequiredSignatures: guardedShift(byteArray),
numReadonlySignedAccounts: guardedShift(byteArray),
numReadonlyUnsignedAccounts: guardedShift(byteArray)
};
const staticAccountKeys = [];
const staticAccountKeysLength = decodeLength(byteArray);
for (let i2 = 0; i2 < staticAccountKeysLength; i2++) {
staticAccountKeys.push(new PublicKey(guardedSplice(byteArray, 0, PUBLIC_KEY_LENGTH)));
}
const recentBlockhash = import_bs58.default.encode(guardedSplice(byteArray, 0, PUBLIC_KEY_LENGTH));
const instructionCount = decodeLength(byteArray);
const compiledInstructions = [];
for (let i2 = 0; i2 < instructionCount; i2++) {
const programIdIndex = guardedShift(byteArray);
const accountKeyIndexesLength = decodeLength(byteArray);
const accountKeyIndexes = guardedSplice(byteArray, 0, accountKeyIndexesLength);
const dataLength = decodeLength(byteArray);
const data = new Uint8Array(guardedSplice(byteArray, 0, dataLength));
compiledInstructions.push({
programIdIndex,
accountKeyIndexes,
data
});
}
const addressTableLookupsCount = decodeLength(byteArray);
const addressTableLookups = [];
for (let i2 = 0; i2 < addressTableLookupsCount; i2++) {
const accountKey = new PublicKey(guardedSplice(byteArray, 0, PUBLIC_KEY_LENGTH));
const writableIndexesLength = decodeLength(byteArray);
const writableIndexes = guardedSplice(byteArray, 0, writableIndexesLength);
const readonlyIndexesLength = decodeLength(byteArray);
const readonlyIndexes = guardedSplice(byteArray, 0, readonlyIndexesLength);
addressTableLookups.push({
accountKey,
writableIndexes,
readonlyIndexes
});
}
return new _MessageV0({
header,
staticAccountKeys,
recentBlockhash,
compiledInstructions,
addressTableLookups
});
}
};
var VersionedMessage = {
deserializeMessageVersion(serializedMessage) {
const prefix = serializedMessage[0];
const maskedPrefix = prefix & VERSION_PREFIX_MASK;
if (maskedPrefix === prefix) {
return "legacy";
}
return maskedPrefix;
},
deserialize: (serializedMessage) => {
const version2 = VersionedMessage.deserializeMessageVersion(serializedMessage);
if (version2 === "legacy") {
return Message.from(serializedMessage);
}
if (version2 === 0) {
return MessageV0.deserialize(serializedMessage);
} else {
throw new Error(`Transaction message version ${version2} deserialization is not supported`);
}
}
};
var DEFAULT_SIGNATURE = import_buffer2.Buffer.alloc(SIGNATURE_LENGTH_IN_BYTES).fill(0);
var TransactionInstruction = class {
constructor(opts) {
@@ -13196,9 +13453,9 @@ var Transaction = class _Transaction {
isWritable: true
});
}
for (const signature of this.signatures) {
for (const signature2 of this.signatures) {
const uniqueIndex = uniqueMetas.findIndex((x) => {
return x.pubkey.equals(signature.publicKey);
return x.pubkey.equals(signature2.publicKey);
});
if (uniqueIndex > -1) {
if (!uniqueMetas[uniqueIndex].isSigner) {
@@ -13206,7 +13463,7 @@ var Transaction = class _Transaction {
console.warn("Transaction references a signature that is unnecessary, only the fee payer and instruction signer accounts should sign a transaction. This behavior is deprecated and will throw an error in the next major version release.");
}
} else {
throw new Error(`unknown signer: ${signature.publicKey.toString()}`);
throw new Error(`unknown signer: ${signature2.publicKey.toString()}`);
}
}
let numRequiredSignatures = 0;
@@ -13392,8 +13649,8 @@ var Transaction = class _Transaction {
_partialSign(message, ...signers) {
const signData = message.serialize();
signers.forEach((signer) => {
const signature = sign(signData, signer.secretKey);
this._addSignature(signer.publicKey, toBuffer(signature));
const signature2 = sign(signData, signer.secretKey);
this._addSignature(signer.publicKey, toBuffer(signature2));
});
}
/**
@@ -13404,20 +13661,20 @@ var Transaction = class _Transaction {
* @param {PublicKey} pubkey Public key that will be added to the transaction.
* @param {Buffer} signature An externally created signature to add to the transaction.
*/
addSignature(pubkey, signature) {
addSignature(pubkey, signature2) {
this._compile();
this._addSignature(pubkey, signature);
this._addSignature(pubkey, signature2);
}
/**
* @internal
*/
_addSignature(pubkey, signature) {
assert2(signature.length === 64);
_addSignature(pubkey, signature2) {
assert2(signature2.length === 64);
const index = this.signatures.findIndex((sigpair) => pubkey.equals(sigpair.publicKey));
if (index < 0) {
throw new Error(`unknown signer: ${pubkey.toString()}`);
}
this.signatures[index].signature = import_buffer2.Buffer.from(signature);
this.signatures[index].signature = import_buffer2.Buffer.from(signature2);
}
/**
* Verify signatures of a Transaction
@@ -13436,15 +13693,15 @@ var Transaction = class _Transaction {
_getMessageSignednessErrors(message, requireAllSignatures) {
const errors = {};
for (const {
signature,
signature: signature2,
publicKey: publicKey2
} of this.signatures) {
if (signature === null) {
if (signature2 === null) {
if (requireAllSignatures) {
(errors.missing ||= []).push(publicKey2);
}
} else {
if (!verify(signature, message, publicKey2.toBytes())) {
if (!verify(signature2, message, publicKey2.toBytes())) {
(errors.invalid ||= []).push(publicKey2);
}
}
@@ -13498,11 +13755,11 @@ Missing signature for public key${sigErrors.missing.length === 1 ? "" : "(s)"} [
assert2(signatures.length < 256);
import_buffer2.Buffer.from(signatureCount).copy(wireTransaction, 0);
signatures.forEach(({
signature
signature: signature2
}, index) => {
if (signature !== null) {
assert2(signature.length === 64, `signature has invalid length`);
import_buffer2.Buffer.from(signature).copy(wireTransaction, signatureCount.length + index * 64);
if (signature2 !== null) {
assert2(signature2.length === 64, `signature has invalid length`);
import_buffer2.Buffer.from(signature2).copy(wireTransaction, signatureCount.length + index * 64);
}
});
signData.copy(wireTransaction, signatureCount.length + signatures.length * 64);
@@ -13545,8 +13802,8 @@ Missing signature for public key${sigErrors.missing.length === 1 ? "" : "(s)"} [
const signatureCount = decodeLength(byteArray);
let signatures = [];
for (let i2 = 0; i2 < signatureCount; i2++) {
const signature = guardedSplice(byteArray, 0, SIGNATURE_LENGTH_IN_BYTES);
signatures.push(import_bs58.default.encode(import_buffer2.Buffer.from(signature)));
const signature2 = guardedSplice(byteArray, 0, SIGNATURE_LENGTH_IN_BYTES);
signatures.push(import_bs58.default.encode(import_buffer2.Buffer.from(signature2)));
}
return _Transaction.populate(Message.from(byteArray), signatures);
}
@@ -13564,9 +13821,9 @@ Missing signature for public key${sigErrors.missing.length === 1 ? "" : "(s)"} [
if (message.header.numRequiredSignatures > 0) {
transaction.feePayer = message.accountKeys[0];
}
signatures.forEach((signature, index) => {
signatures.forEach((signature2, index) => {
const sigPubkeyPair = {
signature: signature == import_bs58.default.encode(DEFAULT_SIGNATURE) ? null : import_bs58.default.decode(signature),
signature: signature2 == import_bs58.default.encode(DEFAULT_SIGNATURE) ? null : import_bs58.default.decode(signature2),
publicKey: message.accountKeys[index]
};
transaction.signatures.push(sigPubkeyPair);
@@ -13591,6 +13848,65 @@ Missing signature for public key${sigErrors.missing.length === 1 ? "" : "(s)"} [
return transaction;
}
};
var VersionedTransaction = class _VersionedTransaction {
get version() {
return this.message.version;
}
constructor(message, signatures) {
this.signatures = void 0;
this.message = void 0;
if (signatures !== void 0) {
assert2(signatures.length === message.header.numRequiredSignatures, "Expected signatures length to be equal to the number of required signatures");
this.signatures = signatures;
} else {
const defaultSignatures = [];
for (let i2 = 0; i2 < message.header.numRequiredSignatures; i2++) {
defaultSignatures.push(new Uint8Array(SIGNATURE_LENGTH_IN_BYTES));
}
this.signatures = defaultSignatures;
}
this.message = message;
}
serialize() {
const serializedMessage = this.message.serialize();
const encodedSignaturesLength = Array();
encodeLength(encodedSignaturesLength, this.signatures.length);
const transactionLayout = BufferLayout.struct([BufferLayout.blob(encodedSignaturesLength.length, "encodedSignaturesLength"), BufferLayout.seq(signature(), this.signatures.length, "signatures"), BufferLayout.blob(serializedMessage.length, "serializedMessage")]);
const serializedTransaction = new Uint8Array(2048);
const serializedTransactionLength = transactionLayout.encode({
encodedSignaturesLength: new Uint8Array(encodedSignaturesLength),
signatures: this.signatures,
serializedMessage
}, serializedTransaction);
return serializedTransaction.slice(0, serializedTransactionLength);
}
static deserialize(serializedTransaction) {
let byteArray = [...serializedTransaction];
const signatures = [];
const signaturesLength = decodeLength(byteArray);
for (let i2 = 0; i2 < signaturesLength; i2++) {
signatures.push(new Uint8Array(guardedSplice(byteArray, 0, SIGNATURE_LENGTH_IN_BYTES)));
}
const message = VersionedMessage.deserialize(new Uint8Array(byteArray));
return new _VersionedTransaction(message, signatures);
}
sign(signers) {
const messageData = this.message.serialize();
const signerPubkeys = this.message.staticAccountKeys.slice(0, this.message.header.numRequiredSignatures);
for (const signer of signers) {
const signerIndex = signerPubkeys.findIndex((pubkey) => pubkey.equals(signer.publicKey));
assert2(signerIndex >= 0, `Cannot sign with non signer key ${signer.publicKey.toBase58()}`);
this.signatures[signerIndex] = sign(messageData, signer.secretKey);
}
}
addSignature(publicKey2, signature2) {
assert2(signature2.byteLength === 64, "Signature must be 64 bytes long");
const signerPubkeys = this.message.staticAccountKeys.slice(0, this.message.header.numRequiredSignatures);
const signerIndex = signerPubkeys.findIndex((pubkey) => pubkey.equals(publicKey2));
assert2(signerIndex >= 0, `Can not add signature; \`${publicKey2.toBase58()}\` is not required to sign this transaction`);
this.signatures[signerIndex] = signature2;
}
};
var NUM_TICKS_PER_SECOND = 160;
var DEFAULT_TICKS_PER_SLOT = 64;
var NUM_SLOTS_PER_SECOND = NUM_TICKS_PER_SECOND / DEFAULT_TICKS_PER_SLOT;
@@ -13607,7 +13923,7 @@ var SYSVAR_STAKE_HISTORY_PUBKEY = new PublicKey("SysvarStakeHistory1111111111111
var SendTransactionError = class extends Error {
constructor({
action,
signature,
signature: signature2,
transactionMessage,
logs
}) {
@@ -13617,7 +13933,7 @@ ${JSON.stringify(logs.slice(-10), null, 2)}. ` : "";
let message;
switch (action) {
case "send":
message = `Transaction ${signature} resulted in an error.
message = `Transaction ${signature2} resulted in an error.
${transactionMessage}. ` + maybeLogsOutput + guideText;
break;
case "simulate":
@@ -13633,7 +13949,7 @@ Message: ${transactionMessage}.
this.signature = void 0;
this.transactionMessage = void 0;
this.transactionLogs = void 0;
this.signature = signature;
this.signature = signature2;
this.transactionMessage = transactionMessage;
this.transactionLogs = logs ? logs : void 0;
}
@@ -13675,12 +13991,12 @@ async function sendAndConfirmTransaction(connection, transaction, signers, optio
maxRetries: options.maxRetries,
minContextSlot: options.minContextSlot
};
const signature = await connection.sendTransaction(transaction, signers, sendOptions);
const signature2 = await connection.sendTransaction(transaction, signers, sendOptions);
let status;
if (transaction.recentBlockhash != null && transaction.lastValidBlockHeight != null) {
status = (await connection.confirmTransaction({
abortSignal: options?.abortSignal,
signature,
signature: signature2,
blockhash: transaction.recentBlockhash,
lastValidBlockHeight: transaction.lastValidBlockHeight
}, options && options.commitment)).value;
@@ -13694,25 +14010,25 @@ async function sendAndConfirmTransaction(connection, transaction, signers, optio
minContextSlot: transaction.minNonceContextSlot,
nonceAccountPubkey,
nonceValue: transaction.nonceInfo.nonce,
signature
signature: signature2
}, options && options.commitment)).value;
} else {
if (options?.abortSignal != null) {
console.warn("sendAndConfirmTransaction(): A transaction with a deprecated confirmation strategy was supplied along with an `abortSignal`. Only transactions having `lastValidBlockHeight` or a combination of `nonceInfo` and `minNonceContextSlot` are abortable.");
}
status = (await connection.confirmTransaction(signature, options && options.commitment)).value;
status = (await connection.confirmTransaction(signature2, options && options.commitment)).value;
}
if (status.err) {
if (signature != null) {
if (signature2 != null) {
throw new SendTransactionError({
action: "send",
signature,
signature: signature2,
transactionMessage: `Status: (${JSON.stringify(status)})`
});
}
throw new Error(`Transaction ${signature} failed (${JSON.stringify(status)})`);
throw new Error(`Transaction ${signature2} failed (${JSON.stringify(status)})`);
}
return signature;
return signature2;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -15218,14 +15534,14 @@ var Ed25519Program = class _Ed25519Program {
const {
publicKey: publicKey2,
message,
signature,
signature: signature2,
instructionIndex
} = params;
assert2(publicKey2.length === PUBLIC_KEY_BYTES$1, `Public Key must be ${PUBLIC_KEY_BYTES$1} bytes but received ${publicKey2.length} bytes`);
assert2(signature.length === SIGNATURE_BYTES, `Signature must be ${SIGNATURE_BYTES} bytes but received ${signature.length} bytes`);
assert2(signature2.length === SIGNATURE_BYTES, `Signature must be ${SIGNATURE_BYTES} bytes but received ${signature2.length} bytes`);
const publicKeyOffset = ED25519_INSTRUCTION_LAYOUT.span;
const signatureOffset = publicKeyOffset + publicKey2.length;
const messageDataOffset = signatureOffset + signature.length;
const messageDataOffset = signatureOffset + signature2.length;
const numSignatures = 1;
const instructionData = import_buffer2.Buffer.alloc(messageDataOffset + message.length);
const index = instructionIndex == null ? 65535 : instructionIndex;
@@ -15241,7 +15557,7 @@ var Ed25519Program = class _Ed25519Program {
messageInstructionIndex: index
}, instructionData);
instructionData.fill(publicKey2, publicKeyOffset);
instructionData.fill(signature, signatureOffset);
instructionData.fill(signature2, signatureOffset);
instructionData.fill(message, messageDataOffset);
return new TransactionInstruction({
keys: [],
@@ -15263,11 +15579,11 @@ var Ed25519Program = class _Ed25519Program {
try {
const keypair = Keypair.fromSecretKey(privateKey);
const publicKey2 = keypair.publicKey.toBytes();
const signature = sign(message, keypair.secretKey);
const signature2 = sign(message, keypair.secretKey);
return this.createInstructionWithPublicKey({
publicKey: publicKey2,
message,
signature,
signature: signature2,
instructionIndex
});
} catch (error) {
@@ -15277,8 +15593,8 @@ var Ed25519Program = class _Ed25519Program {
};
Ed25519Program.programId = new PublicKey("Ed25519SigVerify111111111111111111111111111");
var ecdsaSign = (msgHash, privKey) => {
const signature = secp256k1.sign(msgHash, privKey);
return [signature.toCompactRawBytes(), signature.recovery];
const signature2 = secp256k1.sign(msgHash, privKey);
return [signature2.toCompactRawBytes(), signature2.recovery];
};
secp256k1.utils.isValidPrivateKey;
var publicKeyCreate = secp256k1.getPublicKey;
@@ -15316,14 +15632,14 @@ var Secp256k1Program = class _Secp256k1Program {
const {
publicKey: publicKey2,
message,
signature,
signature: signature2,
recoveryId,
instructionIndex
} = params;
return _Secp256k1Program.createInstructionWithEthAddress({
ethAddress: _Secp256k1Program.publicKeyToEthAddress(publicKey2),
message,
signature,
signature: signature2,
recoveryId,
instructionIndex
});
@@ -15336,7 +15652,7 @@ var Secp256k1Program = class _Secp256k1Program {
const {
ethAddress: rawAddress,
message,
signature,
signature: signature2,
recoveryId,
instructionIndex = 0
} = params;
@@ -15354,7 +15670,7 @@ var Secp256k1Program = class _Secp256k1Program {
const dataStart = 1 + SIGNATURE_OFFSETS_SERIALIZED_SIZE;
const ethAddressOffset = dataStart;
const signatureOffset = dataStart + ethAddress.length;
const messageDataOffset = signatureOffset + signature.length + 1;
const messageDataOffset = signatureOffset + signature2.length + 1;
const numSignatures = 1;
const instructionData = import_buffer2.Buffer.alloc(SECP256K1_INSTRUCTION_LAYOUT.span + message.length);
SECP256K1_INSTRUCTION_LAYOUT.encode({
@@ -15366,7 +15682,7 @@ var Secp256k1Program = class _Secp256k1Program {
messageDataOffset,
messageDataSize: message.length,
messageInstructionIndex: instructionIndex,
signature: toBuffer(signature),
signature: toBuffer(signature2),
ethAddress: toBuffer(ethAddress),
recoveryId
}, instructionData);
@@ -15396,11 +15712,11 @@ var Secp256k1Program = class _Secp256k1Program {
/* isCompressed */
).slice(1);
const messageHash = import_buffer2.Buffer.from(keccak_256(toBuffer(message)));
const [signature, recoveryId] = ecdsaSign(messageHash, privateKey);
const [signature2, recoveryId] = ecdsaSign(messageHash, privateKey);
return this.createInstructionWithPublicKey({
publicKey: publicKey2,
message,
signature,
signature: signature2,
recoveryId,
instructionIndex
});
@@ -16179,7 +16495,9 @@ var VoteAccountLayout = BufferLayout.struct([
BufferLayout.struct([BufferLayout.nu64("slot"), BufferLayout.nu64("timestamp")], "lastTimestamp")
]);
export {
PublicKey
PublicKey,
Transaction,
VersionedTransaction
};
/*! Bundled license information:
@@ -1,3 +1,3 @@
import { PublicKey } from '@solana/web3.js';
import { PublicKey, Transaction, VersionedTransaction } from '@solana/web3.js';
export { PublicKey };
export { PublicKey, Transaction, VersionedTransaction };
@@ -24,6 +24,7 @@ export class WsJsonClient {
this.ws = null;
this.openPromise = null;
this.pending = new Map();
this.listeners = new Map();
}
async open() {
@@ -78,14 +79,53 @@ export class WsJsonClient {
} catch {
return;
}
if (data?.event) {
this.emit(String(data?.op || ''), data);
return;
}
const requestId = data?.requestId;
if (!requestId) return;
if (!requestId) {
this.emit(String(data?.op || ''), data);
return;
}
const slot = this.pending.get(requestId);
if (!slot) return;
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);
+28 -3
View File
@@ -4,7 +4,8 @@
"version": "0.1.0",
"description": "Wallet-session plugin for SHiNE with session-only login via trusted device.",
"permissions": [
"storage"
"storage",
"sidePanel"
],
"host_permissions": [
"<all_urls>"
@@ -13,8 +14,32 @@
"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": "SHiNE Wallet",
"default_popup": "popup.html"
"default_title": "Open SHiNE Wallet"
},
"side_panel": {
"default_path": "popup.html"
}
}
+58 -4
View File
@@ -2,22 +2,34 @@
box-sizing: border-box;
}
html {
min-height: 100%;
}
body {
margin: 0;
min-width: 360px;
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 {
@@ -140,9 +152,9 @@ select {
}
.code {
font-size: 34px;
font-size: 30px;
font-weight: 700;
letter-spacing: 0.18em;
letter-spacing: 0.12em;
}
.summary-row {
@@ -153,7 +165,7 @@ select {
}
.summary-row code {
max-width: 180px;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -186,6 +198,12 @@ select {
gap: 8px;
}
.detail-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.device-row {
padding: 8px 10px;
border: 1px solid #243446;
@@ -193,6 +211,30 @@ select {
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;
@@ -209,3 +251,15 @@ select {
.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;
}
+53 -50
View File
@@ -6,7 +6,7 @@
<title>SHiNE Wallet</title>
<link rel="stylesheet" href="./popup.css" />
</head>
<body>
<body class="side-panel-body">
<main class="layout">
<section class="panel">
<div class="panel-header">
@@ -14,49 +14,13 @@
<h1>SHiNE Wallet</h1>
<p class="muted">Session-only wallet plugin</p>
</div>
<span id="connection-pill" class="pill pill-offline">offline</span>
<span id="connection-pill" class="pill pill-offline">не подключено</span>
</div>
<p id="server-login-info" class="muted small">Сервер SHiNE: —</p>
<p id="server-address" class="muted small">Адрес: —</p>
<div id="session-card" class="card hidden">
<div class="card-title">Подключённая wallet-session</div>
<div class="summary-row"><span>Логин</span><strong id="session-login"></strong></div>
<div class="summary-row"><span>Session ID</span><code id="session-id"></code></div>
<div class="summary-row"><span>Тип</span><strong id="session-type">wallet</strong></div>
<div class="summary-row"><span>deviceKey</span><code id="device-key-short"></code></div>
<div class="actions">
<button id="resume-btn" class="btn secondary" type="button">Проверить session</button>
<button id="refresh-devices-btn" class="btn secondary" type="button">Обновить устройства</button>
<button id="disconnect-btn" class="btn danger" type="button">Отключить</button>
</div>
</div>
<div id="signing-card" class="card hidden">
<div class="card-title">Подготовка подписи</div>
<label class="field">
<span>Ключ подписи</span>
<select id="sign-key-select"></select>
</label>
<label class="field">
<span>Устройство homeserver</span>
<select id="device-select"></select>
</label>
<div id="homeserver-list" class="device-list"></div>
<p class="muted small">
Для выбора доступны homeserver-сессии, опубликованные в PDA аккаунта. Online-статус определяется без постоянного удержания соединения.
</p>
<div class="actions">
<button id="prepare-sign-btn" class="btn primary" type="button">Запросить подпись</button>
</div>
<p class="muted small">
Сам signaling подтверждения подписи ещё не доделан. Сейчас доступен только каркас выбора ключа и устройства.
</p>
</div>
<div class="card">
<div class="card-title">Войти через другое устройство</div>
<div id="connect-card" class="card">
<div class="card-title">Подключение</div>
<label class="field">
<span>Логин</span>
<input id="login-input" type="text" autocomplete="username" />
@@ -69,29 +33,68 @@
<span>Пароль подключения</span>
<input id="password-input" type="password" autocomplete="current-password" />
</label>
<button id="start-btn" class="btn primary" type="button">Получить код</button>
<p class="muted small">
Wallet plugin создаёт временный requester keypair, ждёт подтверждение на доверенном устройстве
и получает только wallet-session без передачи постоянных ключей.
</p>
<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">0000000</div>
<p id="pairing-hint" class="muted small">
Покажите код на доверенном устройстве в разделе «Подключить по коду».
</p>
<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>
</html>
+132 -67
View File
@@ -1,11 +1,13 @@
import { formatPairingShortCode } from './js/lib/device-pairing.js';
const els = {
serverLoginInfo: document.querySelector('#server-login-info'),
serverAddress: document.querySelector('#server-address'),
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'),
@@ -14,17 +16,22 @@ const els = {
status: document.querySelector('#status'),
sessionCard: document.querySelector('#session-card'),
sessionLogin: document.querySelector('#session-login'),
sessionId: document.querySelector('#session-id'),
sessionType: document.querySelector('#session-type'),
deviceKeyShort: document.querySelector('#device-key-short'),
resumeBtn: document.querySelector('#resume-btn'),
refreshDevicesBtn: document.querySelector('#refresh-devices-btn'),
disconnectBtn: document.querySelector('#disconnect-btn'),
signingCard: document.querySelector('#signing-card'),
signKeySelect: document.querySelector('#sign-key-select'),
walletCard: document.querySelector('#wallet-card'),
deviceSelect: document.querySelector('#device-select'),
homeserverList: document.querySelector('#homeserver-list'),
prepareSignBtn: document.querySelector('#prepare-sign-btn'),
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'),
};
@@ -32,16 +39,20 @@ let state = {
settings: {
serverLogin: 'shineupme',
serverHttp: 'https://shineup.me',
serverUrl: 'wss://shineup.me/ws',
login: '',
},
pairing: {
active: false,
pairingId: '',
expiresAtMs: 0,
shortCode: '',
},
session: null,
connectionOnline: false,
walletProfile: null,
signing: {
selectedDeviceName: '',
},
currentWallet: null,
pendingApproval: null,
status: {
text: '',
kind: 'info',
@@ -58,7 +69,7 @@ function setStatus(message, kind = 'info') {
}
function setConnectedPill(connected) {
els.connectionPill.textContent = connected ? 'online' : 'offline';
els.connectionPill.textContent = connected ? 'подключено' : 'не подключено';
els.connectionPill.className = connected ? 'pill pill-online' : 'pill pill-offline';
}
@@ -97,81 +108,119 @@ function renderHomeserverList(items = []) {
});
}
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();
if (loginValue && resolvedServerLogin && resolvedServerAddress) {
els.serverLoginInfo.textContent = `Сервер SHiNE: ${resolvedServerLogin}`;
els.serverAddress.textContent = `Адрес: ${resolvedServerAddress}`;
} else {
els.serverLoginInfo.textContent = 'Сервер SHiNE: —';
els.serverAddress.textContent = 'Адрес: —';
}
els.serverLoginInfo.textContent = resolvedServerLogin && resolvedServerAddress
? `Сервер SHiNE: ${resolvedServerLogin} (${resolvedServerAddress})`
: 'Сервер SHiNE: —';
if (document.activeElement !== els.loginInput) {
els.loginInput.value = loginValue;
}
setConnectedPill(!!state?.connectionOnline);
setConnectedPill(!!state?.session);
setStatus(state?.status?.text || '', state?.status?.kind || 'info');
const session = state?.session;
const walletProfile = state?.walletProfile;
const signing = state?.signing || {};
if (session) {
els.sessionCard.classList.remove('hidden');
els.sessionLogin.textContent = session.login || '—';
els.sessionId.textContent = session.sessionId || '—';
els.sessionType.textContent = String(session.sessionType || 50) === '50' ? 'wallet' : String(session.sessionType || '—');
els.deviceKeyShort.textContent = shortKey(walletProfile?.publicKeys?.deviceKeyBase58 || '');
els.signingCard.classList.remove('hidden');
} else {
els.sessionCard.classList.add('hidden');
els.sessionLogin.textContent = '—';
els.sessionId.textContent = '—';
els.sessionType.textContent = 'wallet';
els.deviceKeyShort.textContent = '—';
els.signingCard.classList.add('hidden');
}
const currentWallet = state?.currentWallet || null;
const pendingApproval = state?.pendingApproval || null;
const signKeyOptions = Array.isArray(walletProfile?.signingKeyOptions) ? walletProfile.signingKeyOptions : [];
els.signKeySelect.innerHTML = '';
signKeyOptions.forEach((item) => {
const option = document.createElement('option');
option.value = item.id;
option.textContent = item.label;
option.selected = item.id === signing.selectedKeyId;
els.signKeySelect.append(option);
});
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 || 'unknown'}]`;
option.textContent = `${item.sessionName} [${item.onlineState || 'offline'}]`;
option.selected = item.sessionName === signing.selectedDeviceName;
els.deviceSelect.append(option);
});
renderHomeserverList(homeservers);
els.prepareSignBtn.disabled = !session || !signing.selectedKeyId || !signing.selectedDeviceName;
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');
const shortCode = String(pairing.shortCode || els.shortCode.dataset.shortCode || els.shortCode.textContent || '0000000');
els.shortCode.dataset.shortCode = shortCode;
els.shortCode.textContent = shortCode;
els.pairingHint.textContent = pairing.trustedSessionOnline
? 'Покажите код на доверенном устройстве и подтвердите выпуск wallet-session.'
: 'Сейчас нет онлайн доверенной сессии. Откройте другое устройство и подтвердите заявку.';
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 = '0000000';
delete els.shortCode.dataset.shortCode;
els.shortCode.textContent = formatPairingShortCode('');
els.pairingExpire.textContent = '';
els.startBtn.disabled = false;
}
@@ -239,16 +288,13 @@ async function startPairing() {
return;
}
setStatus('Создаём wallet-session заявку...', 'info');
els.startBtn.disabled = true;
try {
const response = await sendMessage('wallet:startPairing', {
await sendMessage('wallet:startPairing', {
login,
usePassword: !!els.usePassword.checked,
password: String(els.passwordInput.value || ''),
});
applyState(response.state);
} catch (error) {
els.startBtn.disabled = false;
setStatus(error.message || 'Не удалось начать pairing.', 'error');
}
}
@@ -287,23 +333,41 @@ async function refreshDevices() {
}
}
async function updateSigningSelection() {
async function updateDeviceSelection() {
try {
await sendMessage('wallet:updateSigningSelection', {
selectedKeyId: String(els.signKeySelect.value || '').trim(),
selectedDeviceName: String(els.deviceSelect.value || '').trim(),
});
} catch (error) {
setStatus(error.message || 'Не удалось обновить выбор для подписи.', 'error');
setStatus(error.message || 'Не удалось обновить выбор homeserver.', 'error');
}
}
async function prepareSignSignal() {
setStatus('Готовим каркас запроса подписи...', 'info');
async function requestCurrentWallet() {
setStatus('Запрашиваем текущий кошелёк с ESP32...', 'info');
try {
await sendMessage('wallet:prepareSignSignal');
await sendMessage('wallet:requestCurrentWallet');
} catch (error) {
setStatus(error.message || 'Не удалось подготовить запрос подписи.', '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');
}
}
@@ -338,9 +402,10 @@ function bindUi() {
els.resumeBtn.addEventListener('click', () => { void resumeSession(); });
els.refreshDevicesBtn.addEventListener('click', () => { void refreshDevices(); });
els.disconnectBtn.addEventListener('click', () => { void disconnectSession(); });
els.signKeySelect.addEventListener('change', () => { void updateSigningSelection(); });
els.deviceSelect.addEventListener('change', () => { void updateSigningSelection(); });
els.prepareSignBtn.addEventListener('click', () => { void prepareSignSignal(); });
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() {
@@ -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'));
}
+10 -10
View File
@@ -9,7 +9,7 @@ SHiNE-server — серверная часть мессенджера SHiNE: Web
- `shine-server-net-server/` — точка входа, запуск HTTP/WS сервера
- `shine-server-net-protocol/` — обработчики операций (RPC и события WS)
- `shine-server-db/` — DAO, SQL-схема, SQLite
- `shine-server-db/` — DAO, SQL-схема, PostgreSQL runtime
- `shine-server-blockchain/` — логика хранения и проверки блоков блокчейна
- `shine-server-crypto/` — криптографические утилиты
- `shine-server-config/` — конфигурация сервера
@@ -42,23 +42,23 @@ shine-UI/server-ui.html
Для обновления — только root + device (blockchain-ключ не нужен).
Актуальные адреса программ Solana (devnet):
- `shine_users`: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm`
- `shine_payments`: `c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW`
- `shine_users`: `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6`
- `shine_payments`: `SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v`
Подробнее: `Dev_Docs/Инициализация_Solana_регистрации/README.md`
Подробнее: `docs/Инициализация_Solana_регистрации/README.md`
## Синхронизация с партнёрскими серверами
Сервер должен синхронизировать блоки блокчейна и DM с серверами-партнёрами из `sync_servers`.
Детали: `Dev_Docs/Blockchain/sync-between-servers.md`
Детали: `docs/Blockchain/sync-between-servers.md`
## Деплой
```
./gradlew deployServer
```
Хост по умолчанию: `player@93.170.12.154` (shineup.me).
- Основные инструкции по деплою находятся в `../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`
File diff suppressed because it is too large Load Diff
@@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"
@@ -36,7 +36,12 @@ public final class BodyRecordParser {
case TextBody.KEY -> {
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|| st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|| st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF)
|| st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF)
|| st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
yield new TextLineBody(subType, version, bodyBytes);
}
@@ -45,12 +50,17 @@ public final class BodyRecordParser {
yield new TextReplyBody(subType, version, bodyBytes);
}
if (st == (MsgSubType.TEXT_RATING & 0xFFFF)) {
yield new TextRatingBody(subType, version, bodyBytes);
}
throw new IllegalArgumentException("Unknown TEXT subType for type=1 ver=1: subType=" + st);
}
case ReactionBody.KEY -> new ReactionBody(subType, version, bodyBytes);
case ConnectionBody.KEY -> new ConnectionBody(subType, version, bodyBytes);
case UserParamBody.KEY -> new UserParamBody(subType, version, bodyBytes);
case StatusActionBody.KEY -> new StatusActionBody(subType, version, bodyBytes);
default -> throw new IllegalArgumentException(String.format(
"Unknown body type/version from header: type=%d ver=%d subType=%d",
@@ -64,11 +64,29 @@ public final class MsgSubType {
*/
public static final short TEXT_EDIT_REPLY = 21;
/** RATING — target-based отзыв на конкретный блок. */
public static final short TEXT_RATING = 30;
/**
* REPOST репост сообщения в линии канала.
* REPOST отложенная будущая заготовка репоста сообщения в линии канала.
* Имеет hasLine + target (toBlockchainName + toBlockGlobalNumber + toBlockHash32) + текст комментария.
*/
public static final short TEXT_REPOST = 30;
public static final short TEXT_REPOST = 50;
/**
* CHANNEL_META скрытый технический снимок профиля канала.
* Имеет hasLine, использует body как POST: line-поля + UTF-8 текст.
*/
public static final short TEXT_CHANNEL_META = 90;
/** ENTRYPOINT — входная страница канала (line-based). */
public static final short TEXT_ENTRYPOINT = 100;
/** EXERCISE — упражнение/комплекс (line-based). */
public static final short TEXT_EXERCISE = 110;
/** SERVICE — услуга/процедура (line-based). */
public static final short TEXT_SERVICE = 120;
/** COURSE — курс (line-based). */
public static final short TEXT_COURSE = 130;
/* ===================== REACTION (msg_type=2) ===================== */
@@ -80,14 +98,9 @@ public final class MsgSubType {
/* ===================== CONNECTION (msg_type=3) ===================== */
/** Добавить в близкие друзья (close friend). */
public static final short CONNECTION_FRIEND = 10;
public static final short CONNECTION_CLOSE_FRIEND = 10;
/** Удалить из близких друзей (close friend). */
public static final short CONNECTION_UNFRIEND = 11;
/** Alias: добавить в close friend (то же значение, что CONNECTION_FRIEND). */
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
/** Alias: удалить из close friend (то же значение, что CONNECTION_UNFRIEND). */
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
/** Добавить в контакты. */
public static final short CONNECTION_CONTACT = 20;
@@ -138,4 +151,16 @@ public final class MsgSubType {
/** Параметр профиля key/value (обе строки). */
public static final short USER_PARAM_TEXT_TEXT = 1;
/* ===================== STATUS_ACTION (msg_type=5) ===================== */
public static final short STATUS_DONE_ONCE = 10;
public static final short STATUS_LEARNED = 20;
public static final short STATUS_SERVICE_PASSED = 30;
public static final short STATUS_CONFIRMED = 100;
public static final short STATUS_INTERESTED = 110;
public static final short STATUS_STARTED = 120;
public static final short STATUS_IN_STUDY = 130;
public static final short STATUS_ABANDONED = 140;
public static final short STATUS_COMPLETED = 150;
}
@@ -66,7 +66,7 @@ import java.util.Objects;
* toBlockHash32=hash32(CREATE_CHANNEL)
*
* 3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД):
* - CONNECTION_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
* - CONNECTION_CLOSE_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
* - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала:
* разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL).
*
@@ -183,8 +183,8 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
private static boolean isValidSubType(short st) {
int v = st & 0xFFFF;
return v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF)
|| v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF)
|| v == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF)
@@ -19,7 +19,7 @@ import java.util.Objects;
* [1] channelNameLen
* [N] channelName UTF-8
* [2] channelDescriptionLen
* [M] channelDescription UTF-8 (0..200 bytes)
* [M] channelDescription UTF-8 (0..2048 bytes)
* [2] channelTypeCode (uint16)
* [2] channelTypeVersion (uint16)
*/
@@ -38,7 +38,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
private static final byte[] ZERO32 = new byte[32];
private static final int MAX_NAME_LENGTH = 32;
private static final int MAX_DESCRIPTION_UTF8_LEN = 200;
public static final int MAX_DESCRIPTION_UTF8_LEN = 2048;
public final short subType;
public final short version;
@@ -88,7 +88,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
int descriptionLen = Short.toUnsignedInt(bb.getShort());
if (descriptionLen > MAX_DESCRIPTION_UTF8_LEN) {
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
throw new IllegalArgumentException("channelDescription utf8 len must be <=2048");
}
if (bb.remaining() != descriptionLen + 4) {
throw new IllegalArgumentException("CreateChannelBody tail mismatch: remaining=" + bb.remaining() + " descriptionLen=" + descriptionLen);
@@ -145,7 +145,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
String normalizedDescription = normalizeDescription(channelDescription);
byte[] descUtf8 = normalizedDescription.getBytes(StandardCharsets.UTF_8);
if (descUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
throw new IllegalArgumentException("channelDescription utf8 len must be <=2048");
}
int typeCode = Short.toUnsignedInt(channelTypeCode);
@@ -166,7 +166,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
private static String normalizeDescription(String value) {
if (value == null) return "";
return value.trim().replaceAll("\\s+", " ");
return value.trim().replace("\r\n", "\n").replace('\r', '\n');
}
@Override
@@ -177,7 +177,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
}
byte[] descriptionUtf8 = normalizeDescription(channelDescription).getBytes(StandardCharsets.UTF_8);
if (descriptionUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
throw new IllegalArgumentException("channelDescription utf8 len must be <=2048");
}
int cap = 4 + (4 + 32 + 4) + 1 + nameUtf8.length + 2 + descriptionUtf8.length + 4;
@@ -204,4 +204,3 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
@Override
public int lineSeq() { return thisLineNumber; }
}
@@ -0,0 +1,166 @@
package blockchain.body;
import blockchain.MsgSubType;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
/**
* StatusActionBody type=5, ver=1.
*
* Все STATUS_ACTION имеют target на конкретный блок и опциональный текст-пояснение.
*
* Формат bodyBytes (BigEndian):
* [1] toBlockchainNameLen (uint8)
* [N] toBlockchainName UTF-8
* [4] toBlockGlobalNumber
* [32] toBlockHash32
* [2] textLenBytes (uint16)
* [M] text UTF-8
*/
public final class StatusActionBody implements BodyRecord, BodyHasTarget {
public static final short TYPE = 5;
public static final short VER = 1;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public final short subType;
public final short version;
public final String toBlockchainName;
public final int toBlockGlobalNumber;
public final byte[] toBlockHash32;
public final String message;
public StatusActionBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType;
this.version = version;
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("StatusActionBody version must be 1, got=" + (version & 0xFFFF));
}
if (!isSupportedSubType(subType)) {
throw new IllegalArgumentException("Unsupported STATUS_ACTION subType: " + (subType & 0xFFFF));
}
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "STATUS_ACTION too short");
int nameLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toBlockchainNameLen is 0");
ensureMin(bb, nameLen + 4 + 32 + 2, "STATUS_ACTION payload too short");
byte[] nameBytes = new byte[nameLen];
bb.get(nameBytes);
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
this.message = readStrictUtf8Len16AllowEmpty(bb, "StatusActionBody text");
ensureNoTail(bb, "StatusActionBody");
}
public StatusActionBody(short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
Objects.requireNonNull(message, "message == null");
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Unsupported STATUS_ACTION subType");
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.subType = subType;
this.version = VER;
this.toBlockchainName = toBlockchainName;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
this.message = message;
}
@Override
public StatusActionBody check() {
if (!isSupportedSubType(subType)) {
throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF));
}
if (toBlockchainName == null || toBlockchainName.isBlank()) {
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName is blank");
}
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32) {
throw new IllegalArgumentException("toBlockHash32 invalid");
}
if (message == null) throw new IllegalArgumentException("message is null");
return this;
}
@Override
public byte[] toBytes() {
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName utf8 len must be 1..255");
}
ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length)
.order(ByteOrder.BIG_ENDIAN);
bb.put((byte) nameUtf8.length);
bb.put(nameUtf8);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length);
bb.put(msgUtf8);
return bb.array();
}
@Override public String toBchName() { return toBlockchainName; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
private static boolean isSupportedSubType(short subType) {
int st = subType & 0xFFFF;
return st == (MsgSubType.STATUS_DONE_ONCE & 0xFFFF)
|| st == (MsgSubType.STATUS_LEARNED & 0xFFFF)
|| st == (MsgSubType.STATUS_SERVICE_PASSED & 0xFFFF)
|| st == (MsgSubType.STATUS_CONFIRMED & 0xFFFF)
|| st == (MsgSubType.STATUS_INTERESTED & 0xFFFF)
|| st == (MsgSubType.STATUS_STARTED & 0xFFFF)
|| st == (MsgSubType.STATUS_IN_STUDY & 0xFFFF)
|| st == (MsgSubType.STATUS_ABANDONED & 0xFFFF)
|| st == (MsgSubType.STATUS_COMPLETED & 0xFFFF);
}
private static String readStrictUtf8Len16AllowEmpty(ByteBuffer bb, String fieldName) {
int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) return "";
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
byte[] bytes = new byte[len];
bb.get(bytes);
var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try {
return decoder.decode(ByteBuffer.wrap(bytes)).toString();
} catch (CharacterCodingException e) {
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
}
}
private static void ensureMin(ByteBuffer bb, int need, String msg) {
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
}
private static void ensureNoTail(ByteBuffer bb, String ctx) {
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
}
}
@@ -16,11 +16,16 @@ import java.util.Objects;
* subType:
* - POST (10)
* - EDIT_POST (11)
* - REPOST (30)
* - REPOST (50)
* - CHANNEL_META (90)
* - ENTRYPOINT (100)
* - EXERCISE (110)
* - SERVICE (120)
* - COURSE (130)
*
* Формат bodyBytes (BigEndian):
*
* POST:
* POST / CHANNEL_META / ENTRYPOINT / EXERCISE / SERVICE / COURSE:
* [4] lineCode
* [4] prevLineNumber
* [32] prevLineHash32
@@ -89,8 +94,13 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
int st = this.subType & 0xFFFF;
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)) {
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST, got subType=" + st);
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
&& st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
&& st != (MsgSubType.TEXT_EXERCISE & 0xFFFF)
&& st != (MsgSubType.TEXT_SERVICE & 0xFFFF)
&& st != (MsgSubType.TEXT_COURSE & 0xFFFF)) {
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META, got subType=" + st);
}
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
@@ -133,7 +143,8 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
this.toBlockHash32 = null;
}
this.message = readStrictUtf8Len16(bb, "TextLineBody text", st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF));
this.message = readStrictUtf8Len16(bb, "TextLineBody text",
st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF));
ensureNoTail(bb, "TextLineBody");
}
@@ -155,12 +166,17 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
int st = subType & 0xFFFF;
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)) {
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST");
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
&& st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
&& st != (MsgSubType.TEXT_EXERCISE & 0xFFFF)
&& st != (MsgSubType.TEXT_SERVICE & 0xFFFF)
&& st != (MsgSubType.TEXT_COURSE & 0xFFFF)) {
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META");
}
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
if (st == (MsgSubType.TEXT_POST & 0xFFFF) && message.isBlank()) {
if (requiresNonBlankMessage(st) && message.isBlank()) {
throw new IllegalArgumentException("message is blank");
}
@@ -206,7 +222,12 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
int st = subType & 0xFFFF;
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF))
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
&& st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
&& st != (MsgSubType.TEXT_EXERCISE & 0xFFFF)
&& st != (MsgSubType.TEXT_SERVICE & 0xFFFF)
&& st != (MsgSubType.TEXT_COURSE & 0xFFFF))
throw new IllegalArgumentException("Bad TextLineBody subType: " + st);
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
@@ -231,10 +252,15 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("REPOST toBlockHash32 invalid");
} else {
if (message == null || message.isBlank())
if (st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
if (message == null) throw new IllegalArgumentException("CHANNEL_META message is null");
} else if (requiresNonBlankMessage(st) && (message == null || message.isBlank())) {
throw new IllegalArgumentException("Text message is blank");
} else if (message == null) {
throw new IllegalArgumentException("Text message is null");
}
if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null)
throw new IllegalArgumentException("POST must not contain target fields");
throw new IllegalArgumentException("POST/CHANNEL_META must not contain target fields");
}
return this;
@@ -246,12 +272,17 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
int st = subType & 0xFFFF;
if (st == (MsgSubType.TEXT_POST & 0xFFFF) && msgUtf8.length == 0) {
if (requiresNonBlankMessage(st) && msgUtf8.length == 0) {
throw new IllegalArgumentException("Text payload is empty");
}
int cap;
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|| st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|| st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|| st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF)) {
cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
// EDIT_POST
@@ -310,6 +341,15 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF);
}
private static boolean requiresNonBlankMessage(int st) {
return st == (MsgSubType.TEXT_POST & 0xFFFF)
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF)
|| st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|| st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF);
}
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) {
@@ -0,0 +1,157 @@
package blockchain.body;
import blockchain.MsgSubType;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
/**
* TextRatingBody type=1, ver=1.
*
* subType:
* - RATING (30)
*
* Формат bodyBytes (BigEndian):
* [1] toBlockchainNameLen (uint8)
* [N] toBlockchainName UTF-8
* [4] toBlockGlobalNumber
* [32] toBlockHash32
* [2] textLenBytes (uint16)
* [M] text UTF-8
*/
public final class TextRatingBody implements BodyRecord, BodyHasTarget {
public static final short TYPE = 1;
public static final short VER = 1;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public final short subType;
public final short version;
public final String toBlockchainName;
public final int toBlockGlobalNumber;
public final byte[] toBlockHash32;
public final String message;
public TextRatingBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType;
this.version = version;
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("TextRatingBody version must be 1, got=" + (version & 0xFFFF));
}
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) {
throw new IllegalArgumentException("TextRatingBody supports only TEXT_RATING");
}
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "RATING too short");
int nameLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("RATING toBlockchainNameLen is 0");
ensureMin(bb, nameLen + 4 + 32 + 2, "RATING payload too short");
byte[] nameBytes = new byte[nameLen];
bb.get(nameBytes);
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
this.message = readStrictUtf8Len16(bb, "TextRatingBody text");
ensureNoTail(bb, "TextRatingBody");
}
public TextRatingBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
Objects.requireNonNull(message, "message == null");
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
this.subType = MsgSubType.TEXT_RATING;
this.version = VER;
this.toBlockchainName = toBlockchainName;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
this.message = message;
}
@Override
public TextRatingBody check() {
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) {
throw new IllegalArgumentException("Bad TextRatingBody subType: " + (subType & 0xFFFF));
}
if (toBlockchainName == null || toBlockchainName.isBlank()) {
throw new IllegalArgumentException("RATING toBlockchainName is blank");
}
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32) {
throw new IllegalArgumentException("toBlockHash32 invalid");
}
if (message == null || message.isBlank()) throw new IllegalArgumentException("message is blank");
return this;
}
@Override
public byte[] toBytes() {
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
throw new IllegalArgumentException("RATING toBlockchainName utf8 len must be 1..255");
}
ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length)
.order(ByteOrder.BIG_ENDIAN);
bb.put((byte) nameUtf8.length);
bb.put(nameUtf8);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length);
bb.put(msgUtf8);
return bb.array();
}
@Override public String toBchName() { return toBlockchainName; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) {
int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) throw new IllegalArgumentException(fieldName + " is empty");
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
byte[] bytes = new byte[len];
bb.get(bytes);
var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try {
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
return s;
} catch (CharacterCodingException e) {
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
}
}
private static void ensureMin(ByteBuffer bb, int need, String msg) {
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
}
private static void ensureNoTail(ByteBuffer bb, String ctx) {
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
}
}
@@ -11,6 +11,8 @@ import java.util.Objects;
* Теперь поддерживает:
* - основной файл блокчейна: <blockchainName>.bch
* - временный файл блокчейна: <blockchainName>.tmp_bch
* - sidecar-файл проверки записи: <blockchainName>.write_check
* - marker-файл записи: <blockchainName>.write_pending
*
* Важное:
* - validateSimpleFileName() запрещает path traversal.
@@ -29,6 +31,15 @@ public final class FileStoreUtil {
/** Расширение временного файла (старое+новое). */
public static final String BLOCKCHAIN_TMP_EXTENSION = ".tmp_bch";
/** Маркер того, что chain сейчас в процессе полного resync. */
public static final String BLOCKCHAIN_RESYNC_MARKER_EXTENSION = ".resync_pending";
/** Marker того, что обычный AddBlock находится в опасной фазе записи. */
public static final String BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION = ".write_pending";
/** Sidecar-файл с blockNumber/blockHash для обычного AddBlock. */
public static final String BLOCKCHAIN_WRITE_CHECK_EXTENSION = ".write_check";
private static final FileStoreUtil INSTANCE = new FileStoreUtil();
private final Path dataDirPath;
@@ -130,6 +141,87 @@ public final class FileStoreUtil {
newFile(buildBlockchainTmpFileName(blockchainName), data);
}
/** <blockchainName>.write_check */
public String buildBlockchainWriteCheckFileName(String blockchainName) {
validateSimpleFileName(blockchainName);
return blockchainName + BLOCKCHAIN_WRITE_CHECK_EXTENSION;
}
public Path resolveBlockchainWriteCheckPath(String blockchainName) {
return resolveSafe(buildBlockchainWriteCheckFileName(blockchainName));
}
public void writeBlockchainWriteCheck(String blockchainName, int blockNumber, String blockHashHex) {
StringBuilder sb = new StringBuilder(128);
sb.append("blockNumber=").append(blockNumber).append('\n');
sb.append("blockHash=").append(blockHashHex == null ? "" : blockHashHex).append('\n');
newFile(buildBlockchainWriteCheckFileName(blockchainName), sb.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
/** <blockchainName>.write_pending */
public String buildBlockchainWritePendingMarkerFileName(String blockchainName) {
validateSimpleFileName(blockchainName);
return blockchainName + BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION;
}
public Path resolveBlockchainWritePendingMarkerPath(String blockchainName) {
return resolveSafe(buildBlockchainWritePendingMarkerFileName(blockchainName));
}
public void writeBlockchainWritePendingMarker(String blockchainName) {
newFile(buildBlockchainWritePendingMarkerFileName(blockchainName), new byte[0]);
}
/** <blockchainName>.resync_pending */
public String buildBlockchainResyncMarkerFileName(String blockchainName) {
validateSimpleFileName(blockchainName);
return blockchainName + BLOCKCHAIN_RESYNC_MARKER_EXTENSION;
}
public Path resolveBlockchainResyncMarkerPath(String blockchainName) {
return resolveSafe(buildBlockchainResyncMarkerFileName(blockchainName));
}
public void writeBlockchainResyncMarker(String blockchainName, String markerContent) {
byte[] data = markerContent == null ? new byte[0] : markerContent.getBytes(java.nio.charset.StandardCharsets.UTF_8);
newFile(buildBlockchainResyncMarkerFileName(blockchainName), data);
}
public void deleteIfExists(Path path) {
if (path == null) {
return;
}
try {
Files.deleteIfExists(path);
} catch (IOException e) {
throw new IllegalStateException("Не удалось удалить файл: " + path, e);
}
}
public void deleteBlockchainFileIfExists(String blockchainName) {
deleteIfExists(resolveBlockchainPath(blockchainName));
}
public void deleteBlockchainTmpFileIfExists(String blockchainName) {
deleteIfExists(resolveBlockchainTmpPath(blockchainName));
}
public void deleteBlockchainWriteCheckIfExists(String blockchainName) {
deleteIfExists(resolveBlockchainWriteCheckPath(blockchainName));
}
public void deleteBlockchainWritePendingMarkerIfExists(String blockchainName) {
deleteIfExists(resolveBlockchainWritePendingMarkerPath(blockchainName));
}
public void deleteBlockchainResyncMarkerIfExists(String blockchainName) {
deleteIfExists(resolveBlockchainResyncMarkerPath(blockchainName));
}
public boolean existsBlockchainResyncMarker(String blockchainName) {
return exists(buildBlockchainResyncMarkerFileName(blockchainName));
}
/**
* Атомарно заменить основной файл блокчейна временным:
* <name>.tmp_bch -> <name>.bch
@@ -3,6 +3,9 @@ package utils.config;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Properties;
public final class AppConfig {
@@ -26,24 +29,38 @@ public final class AppConfig {
}
private void load() {
try (InputStream in = getClass().getClassLoader()
.getResourceAsStream("application.properties")) {
try (InputStream in = getClass().getClassLoader().getResourceAsStream("application.properties")) {
if (in == null) {
throw new RuntimeException("Config file application.properties not found");
}
properties.load(in);
} catch (IOException e) {
throw new RuntimeException("Failed to load application.properties", e);
}
Path externalConfig = Paths.get("application.properties");
if (!Files.isRegularFile(externalConfig)) {
return;
}
Properties override = new Properties();
try (InputStream in = Files.newInputStream(externalConfig)) {
override.load(in);
} catch (IOException e) {
throw new RuntimeException("Failed to load external application.properties from " + externalConfig.toAbsolutePath(), e);
}
for (String name : override.stringPropertyNames()) {
properties.setProperty(name, override.getProperty(name));
}
}
/** Вернёт значение строки или null, если параметр не найден */
public String getParam(String name) {
String fromSystem = System.getProperty(name);
if (fromSystem != null) return fromSystem;
String fromEnv = System.getenv(toEnvName(name));
if (fromEnv != null && !fromEnv.isBlank()) return fromEnv.trim();
return properties.getProperty(name);
}
@@ -63,4 +80,11 @@ public final class AppConfig {
String v = properties.getProperty(name);
return v == null ? defaultValue : Boolean.parseBoolean(v);
}
private static String toEnvName(String name) {
return name
.replace('.', '_')
.replace('-', '_')
.toUpperCase();
}
}
@@ -14,6 +14,9 @@ public final class ShineSignatureConstants {
/** Подписываемые данные параметра пользователя: prefix + login + param + time_ms + value */
public static final String USER_PARAMETER_PREFIX = "SHiNe/UserParameter:";
/** Подписываемые данные пользовательских настроек: prefix + login + type + key + time_ms + value_text + value_num */
public static final String USER_SETTINGS_PREFIX = "SHiNe/UserSettings:";
/** TAG в HeaderBody (genesis). ASCII "SHiNe". */
public static final String BLOCKCHAIN_HEADER_TAG = "SHiNe";
@@ -1,21 +1,31 @@
package utils.config;
/**
* Публичные адреса Solana-программ SHiNE (жёстко зафиксированы для devnet).
* Секреты на сервере не хранятся.
* Публичные адреса Solana-программ SHiNE.
* Program ID одинаковые для mainnet/devnet, а cluster/RPC должны
* переопределяться через application.properties конкретного инстанса.
*/
public final class SolanaProgramsConfig {
private SolanaProgramsConfig() {}
public static final String SOLANA_CLUSTER = "devnet";
public static final String SOLANA_RPC_URL = "https://api.devnet.solana.com";
private static final AppConfig CONFIG = AppConfig.getInstance();
// Программа регистрации пользователей (shine_users), задеплоена в devnet.
public static final String SHINE_USERS_PROGRAM_ID = "FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm";
public static final String SOLANA_CLUSTER = getParam("solana.cluster", "mainnet-beta");
public static final String SOLANA_RPC_URL = getParam("solana.rpcUrl", "https://api.mainnet-beta.solana.com");
// Программа регистрации пользователей (shine_users), задеплоена в mainnet.
public static final String SHINE_USERS_PROGRAM_ID = "SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6";
// Отдельно фиксируем адреса связанной инфраструктуры, чтобы UI/сервер ссылались одинаково.
public static final String SHINE_LOGIN_GUARD_PROGRAM_ID = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo";
public static final String SHINE_PAYMENTS_PROGRAM_ID = "c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW";
}
public static final String SHINE_LOGIN_GUARD_PROGRAM_ID = "SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh";
public static final String SHINE_PAYMENTS_PROGRAM_ID = "SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v";
private static String getParam(String key, String defaultValue) {
String value = CONFIG.getParam(key);
if (value == null || value.isBlank()) {
return defaultValue;
}
return value.trim();
}
}

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