From cf6a2830c8761b6d9772f987b6c2fb63dd4107405d9755dca5aab6b4bc4defa1 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Thu, 11 Jun 2026 04:10:31 +0400 Subject: [PATCH 01/56] =?UTF-8?q?solana:=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D1=82=D1=8C=20griefing=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20PDA=20=D0=B8=20=D0=B7=D0=B0=D0=BC=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B7=D0=BA=D1=83=20=D0=B2=D1=8B=D0=BF=D0=BB=D0=B0=D1=82?= =?UTF-8?q?,=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D0=B0?= =?UTF-8?q?=D1=83=D0=B4=D0=B8=D1=82=20=E2=84=962?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shine_payments + shine_users: - create_pda_account переведён на «создание поверх предзаполненного» (allocate+assign+добор ренты), чтобы подсев лампортов на детерминированный адрес PDA (тикет/логин) не блокировал создание — закрыт LOW из аудита №1; в shine_payments is_uninitialized_account перестала зависеть от баланса. shine_payments (HIGH из аудита №2): - запрещён recipient == inflow_vault в buy_ticket*, manager_add_ticket и change_ticket_recipient; добавлена защита по умолчанию в transfer_from_vault (require vault.key != recipient.key). Это убирает алиасинг аккаунта в step_payout, который навсегда замораживал очередь выплат и средства вольта. Документация и учёт: - doc/programs/shine_payments.md §3.4, §10.1; doc/programs/shine_users.md §3.3; - Dev_Docs/audit: добавлен аудит №2, обе закрытые находки помечены ИСПРАВЛЕНО; - Dev_Docs/Pending_Features: две записи на ручную e2e-проверку на devnet; - VERSION.properties: client 1.2.161, server 1.2.150. Co-Authored-By: Claude Fable 5 --- ...2026-06-11_payments-recipient-not-vault.md | 33 ++++ .../2026-06-11_pda-anti-griefing-create.md | 32 ++++ .../Solana-audit-2-by-Claude-11июня2026.md | 177 ++++++++++++++++++ .../Solana-audit-by-Claude-File5-9июня2026.md | 14 +- VERSION.properties | 4 +- .../shine/doc/programs/shine_payments.md | 39 +++- .../shine/doc/programs/shine_users.md | 17 ++ .../shine/programs/shine_payments/src/lib.rs | 59 +++++- .../shine/programs/shine_users/src/lib.rs | 30 ++- 9 files changed, 389 insertions(+), 16 deletions(-) create mode 100644 Dev_Docs/Pending_Features/2026-06-11_payments-recipient-not-vault.md create mode 100644 Dev_Docs/Pending_Features/2026-06-11_pda-anti-griefing-create.md create mode 100644 Dev_Docs/audit/Solana-audit-2-by-Claude-11июня2026.md diff --git a/Dev_Docs/Pending_Features/2026-06-11_payments-recipient-not-vault.md b/Dev_Docs/Pending_Features/2026-06-11_payments-recipient-not-vault.md new file mode 100644 index 0000000..caaf849 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-11_payments-recipient-not-vault.md @@ -0,0 +1,33 @@ +# Запрет получателя тикета, равного inflow-вольту (защита от заморозки очереди) + +## Краткое описание +Закрыта HIGH-находка второго аудита (`Dev_Docs/audit/Solana-audit-2-by-Claude-11июня2026.md`). +Тикет с `recipient_wallet == адрес inflow_vault` приводил к алиасингу аккаунта в +`step_payout` (вольт одновременно источник и получатель), второй mutable-займ +лампортов в `transfer_from_vault` падал, и такой тикет навсегда блокировал +обслуживание очереди и замораживал средства вольта. + +Исправление в `shine_payments`: +- `buy_ticket_by_purchase_usd` — `require!(recipient_wallet != config.inflow_vault)`; +- `process_manager_add_ticket` — запрет через `find_single_pda(INFLOW_VAULT_SEED)`; +- `process_change_ticket_recipient` — тот же запрет для `new_recipient_wallet`; +- `transfer_from_vault` — защита по умолчанию `require!(vault.key != recipient.key)`. + +Ошибка во всех случаях — `InvalidTicketRecipient`. + +## Что проверять (devnet/localnet) +1. Покупка тикета с `recipient_wallet = <адрес inflow_vault PDA>` → отклоняется + (`InvalidTicketRecipient`), тикет не создаётся. +2. `manager_add_ticket` с тем же recipient → отклоняется. +3. `change_ticket_recipient` с `new_recipient = inflow_vault` → отклоняется. +4. Обычные покупки/выплаты с нормальным получателем → работают как раньше, очередь + обслуживается, `step_payout` выплачивает корректно. +5. Регресс не затронул выплаты в `dao_wallet` и `call_reward` подписанту (их адреса + не совпадают с вольтом). + +## Ожидаемый результат +Тикет с получателем-вольтом невозможно создать; ранее существовавший вектор +перманентной заморозки очереди закрыт. Прочая логика выплат без изменений. + +## Статус +pending diff --git a/Dev_Docs/Pending_Features/2026-06-11_pda-anti-griefing-create.md b/Dev_Docs/Pending_Features/2026-06-11_pda-anti-griefing-create.md new file mode 100644 index 0000000..247f4ec --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-11_pda-anti-griefing-create.md @@ -0,0 +1,32 @@ +# Устойчивое создание PDA (защита от «минирования» адреса) + +## Краткое описание +В `shine_payments` и `shine_users` функция `create_pda_account` переведена с жёсткого +`system_instruction::create_account` на паттерн «создание поверх предзаполненного»: +- если на детерминированном адресе будущего PDA нет лампортов — обычный `create_account`; +- если лампорты уже есть («подсев» атакующим) — добор ренты переводом, затем + `allocate` + `assign` под подписью PDA. + +Дополнительно в `shine_payments` `is_uninitialized_account` перестала требовать нулевой +баланс (проверяет только пустые данные + владельца System Program). + +Это закрывает последний (LOW) пункт аудита: griefing-DoS на покупку тикетов и сквоттинг +логинов через предсказуемые адреса PDA. + +## Что проверять (devnet/localnet) +1. Обычная покупка тикета без «подсева» — создаётся как раньше (быстрый путь). +2. На адрес следующего тикета заранее переведены лампорты обычным system-переводом → + покупка/`manager_add_ticket` всё равно проходит, тикет создаётся корректно, данные + и владелец = program id. +3. На адрес `user_pda` будущего логина заранее переведены лампорты → регистрация логина + (`create_user_pda`) всё равно проходит; запись и комиссия корректны. +4. Повторная инициализация уже существующего тикета/пользователя по-прежнему отклоняется + (`PdaAlreadyExists` / `UserAlreadyExists`). +5. Singleton-PDA (`init`, economy config) и manager allowance создаются без регрессий. + +## Ожидаемый результат +Подсев лампортов на заранее известный адрес PDA не блокирует создание; вся остальная +экономика и проверки повторной инициализации работают как прежде. + +## Статус +pending diff --git a/Dev_Docs/audit/Solana-audit-2-by-Claude-11июня2026.md b/Dev_Docs/audit/Solana-audit-2-by-Claude-11июня2026.md new file mode 100644 index 0000000..efb4406 --- /dev/null +++ b/Dev_Docs/audit/Solana-audit-2-by-Claude-11июня2026.md @@ -0,0 +1,177 @@ +# Аудит безопасности Solana-программ SHiNE — выпуск 2 (11.06.2026) + +Повторный независимый аудит после исправления всех 4 находок первого отчёта +(`Solana-audit-by-Claude-File5-9июня2026.md`). Код перечитан целиком: + +- `shine_login_guard` (183 строки) — stateless-классификатор логинов; +- `shine_users` (1069 строк) — реестр пользователей, PDA-записи, подписи, экономика лимитов; +- `shine_payments` (1381 строка) — очереди тикетов, выплаты из вольта, оракул Pyth. + +Перебраны классы атак: подмена аккаунтов/PDA, авторизация и подписи, арифметика и +переполнения, валидация оракула, экономика, реентранси, griefing/DoS, **алиасинг +аккаунтов (передача одного аккаунта в несколько слотов инструкции)**. + +## Статус прошлых находок (все закрыты) + +- 🔴 Critical #1 (economy-config PDA в `shine_users`) — закрыто: `validate_users_economy_config_pda` проверяет и адрес, и `owner == program_id`, и вызывается перед чтением и в create, и в update. +- 🔴 Critical #2 (singleton-PDA в `shine_payments`) — закрыто: `validate_singleton_state_pda` проверяет точный адрес + `owner == id()` во всех инструкциях (`update_coef_limit`, `grant_manager_limits`, `buy_ticket*`, `manager_add_ticket`, `step_payout`, `change_ticket_recipient`). +- 🟠 Medium (валидация Pyth) — закрыто: пин адреса аккаунта `PYTH_SOL_USD_ACCOUNT`, проверка `owner == pyth_receiver`, разбор официальным `PriceUpdateV2`, `get_price_no_older_than` с проверкой `feed_id`, проверка возраста и доверительного интервала (`ORACLE_MAX_CONFIDENCE_PPM`). +- 🟡 Low (griefing на предсказуемых адресах) — закрыто: `create_pda_account` в обеих программах переведён на «создание поверх предзаполненного» (allocate + assign + добор ренты). + +--- + +## 🔴 HIGH (НОВОЕ) — `shine_payments`: тикет с `recipient_wallet == inflow_vault` навсегда замораживает все выплаты — ✅ ИСПРАВЛЕНО (11.06.2026) + +Закрыто: равенство `recipient == inflow_vault` запрещено во всех точках задания +получателя — `buy_ticket_by_purchase_usd` (через `config.inflow_vault`), +`process_manager_add_ticket` и `process_change_ticket_recipient` (через +`find_single_pda(INFLOW_VAULT_SEED)`). Дополнительно в `transfer_from_vault` добавлена +защита по умолчанию `require!(vault.key != recipient.key)`. Документация — +`doc/programs/shine_payments.md` §10.1. Историческое описание находки ниже. + + + +### Где +`transfer_from_vault` (строки 1258–1268) переводит лампорты из вольта прямой +манипуляцией балансами (вольт — PDA без приватного ключа, обычный system-перевод +невозможен): + +```rust +fn transfer_from_vault(vault: &AccountInfo, recipient: &AccountInfo, amount: u64) -> ProgramResult { + if amount == 0 { return Ok(()); } + let mut vault_lamports = vault.try_borrow_mut_lamports()?; // займ #1 + let mut recipient_lamports = recipient.try_borrow_mut_lamports()?; // займ #2 + ... +} +``` + +В `step_payout` (строка 849) получатель — это `ticket.recipient_wallet`: + +```rust +transfer_from_vault(inflow_vault_pda, ticket_recipient_wallet, ticket_lamports)?; +``` + +А `recipient_wallet` нигде не валидируется при создании тикета: +`buy_ticket*` (строки 696/711/725 → 1031), `manager_add_ticket` (строка 765), +`change_ticket_recipient` (строка 900) — берут его «как есть» из аргументов. + +### Суть атаки (алиасинг аккаунта) +В Solana, если один и тот же аккаунт передан в инструкцию в нескольких слотах, +рантайм отдаёт для всех слотов **один и тот же** `RefCell` (механизм дублей). +Поэтому если `ticket.recipient_wallet` равен адресу `inflow_vault` PDA, то в +`step_payout` аккаунт вольта попадает и в слот `inflow_vault_pda`, и в слот +`ticket_recipient_wallet`. Тогда внутри `transfer_from_vault`: + +- `vault.try_borrow_mut_lamports()` — берёт mutable-займ (успех); +- `recipient.try_borrow_mut_lamports()` — это **тот же** аккаунт → второй + mutable-займ → `Err(AccountBorrowFailed)` → `?` возвращает ошибку → инструкция + падает. + +### Почему это «заморозка всего», а не один тикет +Выплаты идут строго по возрастанию индекса. `step_payout` всегда обслуживает +сначала очередь Q1 (если в ней есть pending), затем Q2, затем Q3, и в каждой — +ровно «следующий неоплаченный» тикет (`paid + 1`). Тикет с `recipient == vault`: + +- не может быть оплачен (`step_payout` всегда падает на нём); +- не может быть пропущен (нет механизма «skip»); +- блокирует все тикеты после него в своей очереди; +- если он в Q1 — блокирует обслуживание Q2 и Q3 (до них очередь не доходит); +- лампорты вольта (накопленные регистрационные комиссии) перестают выплачиваться + и не уходят в DAO (слив в DAO происходит только когда `pending == 0` по всем + очередям, а это состояние недостижимо). + +### Эксплуатация (тривиальная, перестановочная) +Q1 — публичная очередь (`buy_ticket` доступен любому). Атакующий покупает **один** +дешёвый тикет Q1, указав `recipient_wallet = <адрес inflow_vault PDA>`. Адрес вольта +детерминирован и публичен (`find_single_pda(INFLOW_VAULT_SEED)`). С этого момента вся +подсистема выплат и средства вольта заморожены за стоимость одного тикета + ренты. + +Дополнительно: даже при защите на этапе покупки остаётся вектор через +`change_ticket_recipient` (строка 900) — владелец любого своего неоплаченного тикета +может выставить `new_recipient_wallet = vault` позже. + +### Класс и серьёзность +Класс: «account aliasing / duplicate-account mutable borrow» + отсутствие +валидации адреса получателя. Прямой кражи средств нет, но это перманентный +отказ в обслуживании (availability) с блокировкой средств вольта, триггер — +копеечный и доступен анонимно. Оценка: **HIGH**. + +### Рекомендуемый фикс +Запретить `recipient`, равный адресу вольта, во всех точках, где он задаётся, чтобы +тикет с таким получателем вообще не мог появиться: + +1. в `buy_ticket_by_purchase_usd` — `require!(recipient_wallet != config.inflow_vault, …)` + (config уже прочитан); +2. в `process_manager_add_ticket` — сверять с `find_single_pda(INFLOW_VAULT_SEED).0`; +3. в `process_change_ticket_recipient` — то же для `new_recipient_wallet`. + +Дополнительно (defense-in-depth) — в `transfer_from_vault` явно +`require!(vault.key != recipient.key, …)` с понятной ошибкой, чтобы любой будущий +вызов был защищён от алиасинга. Этого `require` недостаточно как единственной меры +(тикет всё равно застрял бы), поэтому основная защита — на входе. + +--- + +## 🟡 LOW / INFO — наблюдения без прямой эксплуатации + +### L1. `change_ticket_recipient` и `buy_ticket` не проверяют получателя на «опасные» адреса +Связано с HIGH выше; после фикса основной проблемы стоит заодно зафиксировать +правило «получатель не должен совпадать с системными PDA программы». + +### L2. Гонка за логином (first-come) в `shine_users` +Адрес `user_pda` выводится из логина. После закрытия griefing-подсева остаётся +обычное состязание: увидев в мемпуле регистрацию `alice`, атакующий может +зарегистрировать `alice` со своим `root_key` первым. On-chain это решается только +commit-reveal; для текущей модели — приемлемый риск, отметить как известный. + +### L3. `step_payout` без slippage-параметра +Выплата считается по текущей цене оракула без верхней границы лампортов. Цена +ограничена возрастом (120с) и доверительным интервалом (10%), аккаунт оракула +запинен — манипуляция маловероятна, но при резком движении цены SOL объём выплаты +в лампортах плавает. Риск низкий; при желании добавить верхнюю границу на шаг. + +### L4. Экономическая устойчивость вольта (дизайн, не баг) +Деньги за покупку тикетов (`buy_ticket`) уходят на `dao_wallet`, а выплаты в +`step_payout` идут из `inflow_vault`, который наполняется **регистрационными +комиссиями** `shine_users`. Если поток регистраций меньше обязательств по выплатам, +вольт истощается и выплаты останавливаются (без потери средств, но с остановкой +сервиса). Это свойство экономической модели — стоит явно держать в уме и +мониторить баланс вольта/обязательств. + +### L5. Заполнение Q1 до лимита как мягкий DoS +`buy_ticket` блокируется при `q1_sum_total >= limit_usd_cents`. Атакующий может +наполнить Q1 своими тикетами и приостановить покупки. Дорого (тратит SOL в DAO и +ренту) и его же тикеты потом оплачиваются из вольта, поэтому это скорее +экономический, а не дешёвый griefing. Риск низкий. + +--- + +## ✅ Проверено и подтверждено как корректное + +- **Подмена singleton-PDA** невозможна: везде сверяется точный адрес и владелец. +- **Авторизация**: `update_coef_limit`/`grant_manager_limits` требуют `signer == config.dao_wallet`; `manager_add_ticket` — `signer == allowance.manager_wallet`; `change_ticket_recipient` — `signer == ticket.recipient_wallet`; обновление economy-config — `signer == DAO_AUTHORITY`. +- **Ed25519 в `shine_users`**: строгие относительные индексы (−1/−2), `num_signatures == 1`, все три `ix_index == u16::MAX` (данные внутри самой ed25519-инструкции), сверка pubkey/signature/message по хэшу. Подмена и указание на чужую инструкцию исключены. +- **Цепочка версий записи** (`version == record_number+1`, `prev_hash == hash(old)`) — корректная защита от replay; сигнатура записи завязана на `root_key`, а не на плательщика. +- **Монотонность** `used_bytes`/`last_block_number` и `used_bytes <= paid_limit_bytes`. +- **Арифметика**: повсеместные `checked_*`, `overflow-checks = true`, расчёты оракула в `u128` с `u64::try_from` на сужении. +- **Оракул Pyth**: пин аккаунта + owner + feed_id + возраст + confidence через официальный SDK. +- **Рент-экземпт вольта** сохраняется: `available_vault_lamports` вычитает `minimum_balance`, а суммарная проверка `available >= needed` гарантирует, что после выплат вольт не опустится ниже ренты. +- **Двойная оплата тикета** исключена: `is_paid` + инкремент `*_tickets_paid`, следующий шаг адресует следующий индекс. +- **Реентранси отсутствует**: CPI только в System Program (transfer/allocate/assign) и в stateless `shine_login_guard` (с проверкой возвращённого `program_id`); обратных вызовов в наши программы нет. +- **create_pda_account (новый)**: устойчив к подсеву лампортов; атакующий не может ни выделить данные, ни сменить владельца PDA (нет ключа/seeds), поэтому ветка allocate+assign безопасна. +- **shine_login_guard**: stateless, без аккаунтов и средств; DFS-классификация ограничена (`MAX_WORDS_PER_LOGIN = 3`, длина ≤ 20) — без compute-DoS. + +--- + +## Приоритет действий + +1. **HIGH** — запретить `recipient == inflow_vault` в `buy_ticket*`, `manager_add_ticket`, + `change_ticket_recipient`; добавить `require!(vault.key != recipient.key)` в + `transfer_from_vault` как защиту по умолчанию. Закрыть до mainnet. +2. **LOW** — зафиксировать правило «получатель ≠ системные PDA» (L1), оценить + добавление верхней границы выплаты на шаг (L3). +3. **INFO** — формально задокументировать экономику вольта (L4) и known-issue + гонки за логином (L5/L2). + +Изменений в код в рамках этого аудита не вносил — это анализ. Готов подготовить патч +по пункту 1, если подтвердите. diff --git a/Dev_Docs/audit/Solana-audit-by-Claude-File5-9июня2026.md b/Dev_Docs/audit/Solana-audit-by-Claude-File5-9июня2026.md index 82b280f..3cf5ca9 100644 --- a/Dev_Docs/audit/Solana-audit-by-Claude-File5-9июня2026.md +++ b/Dev_Docs/audit/Solana-audit-by-Claude-File5-9июня2026.md @@ -73,7 +73,19 @@ read_sol_usd_price / parse_pyth_price_update_v2 (строки 1038–1075): Проверка возраста цены (ORACLE_MAX_AGE_SECS = 120) есть и сделана корректно. Рекомендация: проверять владельца аккаунта, сверять feed_id с константой и валидировать verification_level == Full (или парсить через официальный pyth_solana_receiver_sdk, который уже завендорен в .vendor/). --- -🟡 LOW — DoS через предсказуемые адреса тикетов +🟡 LOW — DoS через предсказуемые адреса тикетов — ✅ ИСПРАВЛЕНО (11.06.2026) + +Закрыто: `create_pda_account` в `shine_payments` и `shine_users` переведён на паттерн +«создание поверх предзаполненного» (allocate + assign + добор ренты вместо строгого +`system_instruction::create_account`). «Подсев» лампортов на заранее известный адрес +тикета или пользовательской записи больше не блокирует создание PDA. Проверка +`is_uninitialized_account` в payments перестала зависеть от нулевого баланса. Тот же фикс +закрывает аналогичный сквоттинг логинов в `shine_users` (адрес выводится из логина). +Подробности — в `doc/programs/shine_payments.md` §3.4 и `doc/programs/shine_users.md` §3.3. + +Историческое описание находки ниже. + + is_uninitialized_account (строка 1195) считает аккаунт неинициализированным только если lamports() == 0. Адреса тикетов детерминированы (queue_seed + index), а индекс последователен и предсказуем. Любой может заранее перевести немного лампортов на адрес следующего тикета — тогда create_pda_account упадёт (PdaAlreadyExists / ошибка create_account), заблокировав покупку/добавление тикета. Это griefing-DoS, не кража. Митигировать можно паттерном «create поверх предзаполненного» (allocate + assign + добор ренты) вместо system_instruction::create_account. diff --git a/VERSION.properties b/VERSION.properties index 003bbd1..38e42da 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.159 -server.version=1.2.148 +client.version=1.2.161 +server.version=1.2.150 diff --git a/shine-solana/shine/doc/programs/shine_payments.md b/shine-solana/shine/doc/programs/shine_payments.md index 3c471a9..607aa0b 100644 --- a/shine-solana/shine/doc/programs/shine_payments.md +++ b/shine-solana/shine/doc/programs/shine_payments.md @@ -58,6 +58,24 @@ - seed prefix: `shine_p_manager_allow` - второй seed: `manager_wallet.as_ref()` +### 3.4. Правило создания PDA (защита от «минирования» адреса) + +Все адреса PDA детерминированы (особенно тикеты: seed + последовательный `ticket_index`), +поэтому злоумышленник может заранее вычислить адрес будущего тикета и перевести на него +немного лампортов обычным system-переводом. Если бы создание шло строго через +`system_instruction::create_account`, такой «подсев» приводил бы к ошибке +«account already in use» и навсегда блокировал бы покупку/добавление тикета (griefing-DoS). + +Поэтому `create_pda_account` создаёт аккаунт устойчиво к предзаполненному балансу: + +- если на адресе нет лампортов — обычный `create_account` (быстрый путь); +- если лампорты уже есть — «создание поверх предзаполненного»: добор ренты переводом, + затем `allocate` + `assign` под подписью PDA. + +Признак «инициализируемого» аккаунта (`is_uninitialized_account`) — пустые данные и +владелец System Program; условие нулевого баланса намеренно не проверяется. Уже созданный +программой PDA имеет данные/владельца = program id и повторно инициализирован не будет. + ## 4. Состояния программы ### 4.1. `ConfigState` @@ -218,7 +236,8 @@ - ticket создаётся только в `Q1`; - очередь `Q1` временно блокируется, если достигнут её суммарный лимит; - `payout_usd_cents = purchase_usd_cents * coef_ppm / COEF_SCALE_PPM`; -- `recipient_wallet` записывается в ticket. +- `recipient_wallet` записывается в ticket; +- `recipient_wallet` **не должен совпадать с адресом inflow-вольта** (см. §10.1). ### 9.2. Важная деталь денежного потока @@ -248,6 +267,7 @@ Signer должен совпадать с `manager_wallet` в `manager_allowance - `queue_id` только `1`, `2` или `3`; - `payout_usd_cents > 0`; +- `recipient_wallet` не равен адресу inflow-вольта (см. §10.1); - доступный allowance по очереди не меньше суммы тикета; - ticket PDA ещё не существует. @@ -257,6 +277,21 @@ Signer должен совпадать с `manager_wallet` в `manager_allowance - allowance уменьшается; - агрегаты соответствующей очереди увеличиваются. +### 10.1. Запрет получателя, равного inflow-вольту (важно для безопасности) + +`recipient_wallet` тикета не должен совпадать с адресом `inflow_vault` PDA. +Причина: в `step_payout` выплата получателю исполняется прямой манипуляцией +балансами (`transfer_from_vault`), где источником служит сам вольт. Если получатель +равен вольту, один и тот же аккаунт попадает в инструкцию дважды (в Solana дубли +делят общий `RefCell`), и второй mutable-займ лампортов завершается ошибкой. Такой +тикет невозможно ни оплатить, ни пропустить — он навсегда блокирует обслуживание +очереди (а если он в `Q1`, то и `Q2`/`Q3`) и замораживает средства вольта. + +Поэтому равенство `recipient == inflow_vault` запрещено во всех точках, где +получатель задаётся: `buy_ticket*`, `manager_add_ticket`, `change_ticket_recipient`. +Дополнительно `transfer_from_vault` содержит защиту по умолчанию +`require!(vault.key != recipient.key)`. + ## 11. Инструкция `step_payout` ### Назначение @@ -334,6 +369,8 @@ next_index = tickets_paid + 1 ### Ограничения +`new_recipient_wallet` не должен совпадать с адресом inflow-вольта (см. §10.1). + Нельзя менять получателя у следующего тикета на выплату в активной очереди. Логика: diff --git a/shine-solana/shine/doc/programs/shine_users.md b/shine-solana/shine/doc/programs/shine_users.md index 57e362a..46a6c3d 100644 --- a/shine-solana/shine/doc/programs/shine_users.md +++ b/shine-solana/shine/doc/programs/shine_users.md @@ -74,6 +74,23 @@ PDA экономических настроек: users_economy_config_pda = PDA(["shine_users_economy_config"], shine_users_program_id) ``` +### 3.3. Правило создания PDA (защита от «минирования» адреса) + +Адрес пользовательской PDA выводится из логина и публично предсказуем: зная желаемый логин, +любой может заранее вычислить адрес записи и перевести на него немного лампортов обычным +system-переводом. Если бы создание шло строго через `system_instruction::create_account`, +такой «подсев» приводил бы к ошибке «account already in use» и навсегда блокировал бы +регистрацию этого логина (targeted-DoS / сквоттинг логинов), причём без оплаты комиссии. + +Поэтому `create_pda_account` создаёт аккаунт устойчиво к предзаполненному балансу: + +- если на адресе нет лампортов — обычный `create_account` (быстрый путь); +- если лампорты уже есть — «создание поверх предзаполненного»: добор ренты переводом, + затем `allocate` + `assign` под подписью PDA. + +Проверки повторной инициализации (`owner == System Program` и пустые данные) остаются и +не зависят от баланса аккаунта. + ## 4. Состояния программы ### 4.1. `UsersEconomyConfigState` diff --git a/shine-solana/shine/programs/shine_payments/src/lib.rs b/shine-solana/shine/programs/shine_payments/src/lib.rs index 056e8ef..481a854 100644 --- a/shine-solana/shine/programs/shine_payments/src/lib.rs +++ b/shine-solana/shine/programs/shine_payments/src/lib.rs @@ -741,6 +741,10 @@ fn process_manager_add_ticket( require!(args.payout_usd_cents > 0, PaymentsError::InvalidPayoutAmount); require!(is_valid_queue_id(args.queue_id), PaymentsError::InvalidTicketQueue); + // Получатель не должен совпадать с inflow-вольтом (см. подробности в + // buy_ticket_by_purchase_usd): иначе тикет навсегда застрянет в step_payout. + let (inflow_vault_addr, _) = find_single_pda(program_id, settings::INFLOW_VAULT_SEED); + require!(args.recipient_wallet != inflow_vault_addr, PaymentsError::InvalidTicketRecipient); let (expected_manager_pda, _) = find_manager_allowance_pda(program_id, signer.key); require_keys_eq!(expected_manager_pda, *manager_allowance_pda.key, PaymentsError::InvalidPdaAddress); @@ -868,6 +872,11 @@ fn process_change_ticket_recipient( require!(account_iter.next().is_none(), PaymentsError::InvalidInstruction); validate_singleton_state_pda(program_id, queues_pda, settings::QUEUES_SEED)?; + // Новый получатель не должен совпадать с inflow-вольтом (см. подробности в + // buy_ticket_by_purchase_usd): иначе тикет навсегда застрянет в step_payout. + let (inflow_vault_addr, _) = find_single_pda(program_id, settings::INFLOW_VAULT_SEED); + require!(args.new_recipient_wallet != inflow_vault_addr, PaymentsError::InvalidTicketRecipient); + let queues = read_state::(queues_pda)?; let mut ticket = read_state::(ticket_pda)?; require!(!ticket.is_paid, PaymentsError::TicketAlreadyPaid); @@ -1010,6 +1019,11 @@ fn buy_ticket_by_purchase_usd( let mut queues = read_state::(ctx.queues_pda)?; require_keys_eq!(*ctx.dao_wallet.key, config.dao_wallet, PaymentsError::InvalidDaoWallet); + // Получатель тикета не должен совпадать с inflow-вольтом: иначе в step_payout + // вольт окажется и источником, и получателем перевода (алиасинг одного аккаунта), + // второй mutable-займ в transfer_from_vault упадёт, и такой тикет навсегда + // заморозит обслуживание очереди. Запрещаем такой recipient на входе. + require!(recipient_wallet != config.inflow_vault, PaymentsError::InvalidTicketRecipient); let queue1_sum_total_before = queues.q1_sum_total_usd_cents; require!(queue1_sum_total_before < coef_limit.limit_usd_cents, PaymentsError::QueueTemporarilyPaused); @@ -1192,13 +1206,31 @@ fn create_pda_account<'info>( space: u64, ) -> ProgramResult { require!(is_uninitialized_account(pda), PaymentsError::PdaAlreadyExists); - let lamports = Rent::get()?.minimum_balance(space as usize); - let create_ix = system_instruction::create_account(payer.key, pda.key, lamports, space, program_id); - invoke_signed( - &create_ix, - &[payer.clone(), pda.clone(), system_program_ai.clone()], - &[seeds], - ) + let required_lamports = Rent::get()?.minimum_balance(space as usize); + let current_lamports = pda.lamports(); + + if current_lamports == 0 { + // Быстрый путь: адрес пуст — обычное создание аккаунта одной инструкцией. + let create_ix = system_instruction::create_account(payer.key, pda.key, required_lamports, space, program_id); + return invoke_signed( + &create_ix, + &[payer.clone(), pda.clone(), system_program_ai.clone()], + &[seeds], + ); + } + + // На адресе уже лежат лампорты — вероятно, «подсев» атакующим на заранее + // известный детерминированный адрес тикета/PDA. Создаём «поверх предзаполненного»: + // доводим ренту переводом, затем allocate + assign под подписью PDA. + let top_up = required_lamports.saturating_sub(current_lamports); + if top_up > 0 { + let transfer_ix = system_instruction::transfer(payer.key, pda.key, top_up); + invoke(&transfer_ix, &[payer.clone(), pda.clone(), system_program_ai.clone()])?; + } + let allocate_ix = system_instruction::allocate(pda.key, space); + invoke_signed(&allocate_ix, &[pda.clone(), system_program_ai.clone()], &[seeds])?; + let assign_ix = system_instruction::assign(pda.key, program_id); + invoke_signed(&assign_ix, &[pda.clone(), system_program_ai.clone()], &[seeds]) } fn write_state(pda: &AccountInfo, state: &T) -> ProgramResult { @@ -1219,9 +1251,15 @@ fn read_state(pda: &AccountInfo) -> Result { T::decode(&data[..encoded_len]) } +// «Инициализируемым» считаем аккаунт без данных, всё ещё принадлежащий System +// Program. Условие lamports() == 0 сознательно убрано: адреса тикетов и прочих +// PDA детерминированы, и любой может заранее перевести на них немного лампортов, +// чтобы заблокировать создание (griefing-DoS). Наличие лампортов больше не должно +// мешать инициализации — за безопасное создание «поверх предзаполненного» отвечает +// create_pda_account. Уже созданный нашей программой PDA имеет данные/владельца id() +// и сюда не пройдёт. fn is_uninitialized_account(account: &AccountInfo) -> bool { - account.lamports() == 0 - && account.data_len() == 0 + account.data_len() == 0 && (*account.owner == system_program::ID || *account.owner == Pubkey::default()) } @@ -1235,6 +1273,9 @@ fn transfer_from_vault(vault: &AccountInfo, recipient: &AccountInfo, amount: u64 if amount == 0 { return Ok(()); } + // Защита по умолчанию от алиасинга: источник и получатель не должны быть одним + // и тем же аккаунтом, иначе второй mutable-займ лампортов ниже завершится ошибкой. + require!(vault.key != recipient.key, PaymentsError::InvalidTicketRecipient); let mut vault_lamports = vault.try_borrow_mut_lamports()?; let mut recipient_lamports = recipient.try_borrow_mut_lamports()?; require!(**vault_lamports >= amount, PaymentsError::NotEnoughInflowForStep); diff --git a/shine-solana/shine/programs/shine_users/src/lib.rs b/shine-solana/shine/programs/shine_users/src/lib.rs index a4907f8..b59709e 100644 --- a/shine-solana/shine/programs/shine_users/src/lib.rs +++ b/shine-solana/shine/programs/shine_users/src/lib.rs @@ -1007,11 +1007,35 @@ fn transfer_lamports<'a>(payer: &AccountInfo<'a>, recipient: &AccountInfo<'a>, s invoke(&ix, &[payer.clone(), recipient.clone(), system_program_ai.clone()]) } +// Создание PDA, устойчивое к «минированию» детерминированного адреса. +// Адрес будущей записи логина выводится из самого логина, поэтому злоумышленник +// может заранее вычислить адрес и перевести на него немного лампортов обычным +// system-переводом. Тогда обычный system_instruction::create_account упал бы +// («account already in use») и заблокировал бы регистрацию этого логина навсегда. +// Чтобы это исключить, при уже существующих на адресе лампортах создаём аккаунт +// «поверх предзаполненного»: доводим ренту переводом, затем allocate + assign +// под подписью PDA. Подсев чужих лампортов больше ничего не ломает. fn create_pda_account<'a>(payer: &AccountInfo<'a>, pda: &AccountInfo<'a>, system_program_ai: &AccountInfo<'a>, owner: &Pubkey, seeds: &[&[u8]], space: usize) -> ProgramResult { let rent = Rent::get()?; - let lamports = rent.minimum_balance(space); - let ix = system_instruction::create_account(payer.key, pda.key, lamports, space as u64, owner); - invoke_signed(&ix, &[payer.clone(), pda.clone(), system_program_ai.clone()], &[seeds]) + let required_lamports = rent.minimum_balance(space); + let current_lamports = pda.lamports(); + + if current_lamports == 0 { + // Быстрый путь: адрес пуст — обычное создание аккаунта одной инструкцией. + let ix = system_instruction::create_account(payer.key, pda.key, required_lamports, space as u64, owner); + return invoke_signed(&ix, &[payer.clone(), pda.clone(), system_program_ai.clone()], &[seeds]); + } + + // На адресе уже лежат лампорты (вероятно, «подсев» атакующим). Доводим баланс + // до рент-экземпта, выделяем место и назначаем владельцем нашу программу. + let top_up = required_lamports.saturating_sub(current_lamports); + transfer_lamports(payer, pda, system_program_ai, top_up)?; + + let allocate_ix = system_instruction::allocate(pda.key, space as u64); + invoke_signed(&allocate_ix, &[pda.clone(), system_program_ai.clone()], &[seeds])?; + + let assign_ix = system_instruction::assign(pda.key, owner); + invoke_signed(&assign_ix, &[pda.clone(), system_program_ai.clone()], &[seeds]) } fn ensure_pda_size_and_rent<'a>(pda: &AccountInfo<'a>, payer: &AccountInfo<'a>, system_program_ai: &AccountInfo<'a>, required_len: usize) -> ProgramResult { From 42dcf6970dd8040dd5e85add87d5b8789920efe77573dfa3c70ca4177de56fd5 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Fri, 12 Jun 2026 21:16:12 +0400 Subject: [PATCH 02/56] =?UTF-8?q?homeserver:=20=D1=80=D0=B5=D0=BD=D0=B4?= =?UTF-8?q?=D0=B5=D0=B9=D0=BC=20subserver=E2=86=92homeserver,=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=20=D0=B4=D0=B5=D1=80?= =?UTF-8?q?=D0=B8=D0=B2=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=D0=B5=D0=B9,=20=D0=B7=D0=B0=D0=BF=D1=80=D0=B5=D1=82=20=D0=BF?= =?UTF-8?q?=D1=83=D1=81=D1=82=D0=BE=D0=B3=D0=BE=20=D0=BF=D0=B0=D1=80=D0=BE?= =?UTF-8?q?=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Основное (наша работа в этой сессии): - Переименование «subserver» → «homeserver» по всему проекту: основной ESP32-скетч (папка shine_subserver_ui → shine_homeserver_ui, .ino, flash-скрипт, режим burn.sh homeserver-ui), скетч lvgl_nav_minimal_test (ключ homeserver.key:<имя>), spec-доки reference/*, формат PDA (терминология session_type=100 «Homeserver пользователя»), константа SESSION_TYPE_HOMESERVER в JS и Rust (значение 100 не менялось, формат не затронут), pending/future доки, AGENTS.md, DAO-док. Сохранены отдельный lvgl_subserver_touch_test и историческая пометка о рендейме в DERIVATION.md. - Новый источник истины по деривации ключей: Dev_Docs/Keys/DERIVATION.md (Argon2id-секрет из пароля, формула Ed25519(SHA-256(base64(secret)|suffix)), суффиксы root/bch/dev/homeserver.key, Solana-ключ = dev.key). Уточнены роли root (главный/master) и dev (пополняемый кошелёк) в Dev_Docs/Keys/README.md. - UI: убран легаси-путь пустого пароля (derivePasswordSeed и др.), deriveMasterSecretFromPassword бросает ошибку на пустом пароле, register-view блокирует пустой пароль; экран пополнения переведён на канонический device-адрес из preGeneratedKeyBundle (удалён расходящийся deriveWalletFromPassword). Включены также параллельные правки Solana-аудита №3 (были в рабочем дереве, переплетены в lib.rs): - shine_users: defense-in-depth «строгий список аккаунтов» (require!(it.next().is_none())) в init/update economy config и create/update user PDA, плюс описание в doc/programs/shine_users.md; - Dev_Docs/audit/Solana-audit-3-by-Claude-12июня2026.md. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 4 +- DAO_запуск/README.md | 4 +- Dev_Docs/Future_Features/README.md | 2 +- ...еры_в_pda.md => 2026-06-02_сессионные_homeserver_в_pda.md} | 20 +-- Dev_Docs/Keys/DERIVATION.md | 154 ++++++++++++++++++ Dev_Docs/Keys/README.md | 7 +- ...> 2026-06-07_1650_esp32_homeserver_ui_прототип.md} | 12 +- ...2026-06-08_1150_esp32_auto_flash_script.md | 8 +- ...2026-06-08_1245_esp32_pin_button_labels.md | 2 +- ...6-06-08_1315_esp32_test_sketches_folder.md | 4 +- .../2026-06-08_1940_esp32_nav_minimal_test.md | 24 +-- Dev_Docs/Solana/user_pda/README.md | 4 +- .../Solana-audit-3-by-Claude-12июня2026.md | 134 +++++++++++++++ ...> shine_homeserver_ui_nav_minimal_spec.md} | 38 ++--- ...ui_spec.md => shine_homeserver_ui_spec.md} | 28 ++-- .../test-device/README.md | 10 +- .../test-device/burn.sh | 4 +- ...ver_ui.sh => flash_shine_homeserver_ui.sh} | 4 +- .../qrcode_bridge.c | 0 .../shine_homeserver_ui.ino} | 42 ++--- .../test-device/test_sketches/README.md | 4 +- .../lvgl_nav_minimal_test.ino | 134 +++++++-------- .../lvgl_subserver_touch_test.ino | 4 +- VERSION.properties | 4 +- shine-UI/js/pages/register-view.js | 5 + shine-UI/js/pages/topup-view.js | 24 ++- shine-UI/js/services/crypto-utils.js | 50 +----- .../js/services/shine-user-pda-service.js | 2 +- shine-UI/js/services/solana-wallet-service.js | 12 -- .../formats/shine-user-pda-format-v.1.0.md | 4 +- .../shine/doc/programs/shine_users.md | 12 ++ .../shine/programs/shine_users/src/lib.rs | 8 +- 32 files changed, 517 insertions(+), 251 deletions(-) rename Dev_Docs/Future_Features/medium/{2026-06-02_сессионные_саб_серверы_в_pda.md => 2026-06-02_сессионные_homeserver_в_pda.md} (81%) create mode 100644 Dev_Docs/Keys/DERIVATION.md rename Dev_Docs/Pending_Features/{2026-06-07_1650_esp32_subserver_ui_прототип.md => 2026-06-07_1650_esp32_homeserver_ui_прототип.md} (71%) create mode 100644 Dev_Docs/audit/Solana-audit-3-by-Claude-12июня2026.md rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/{shine_subserver_ui_nav_minimal_spec.md => shine_homeserver_ui_nav_minimal_spec.md} (93%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/{shine_subserver_ui_spec.md => shine_homeserver_ui_spec.md} (96%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/{flash_shine_subserver_ui.sh => flash_shine_homeserver_ui.sh} (92%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/{shine_subserver_ui => shine_homeserver_ui}/qrcode_bridge.c (100%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/{shine_subserver_ui/shine_subserver_ui.ino => shine_homeserver_ui/shine_homeserver_ui.ino} (98%) diff --git a/AGENTS.md b/AGENTS.md index c1bc31e..8bb574b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,12 +24,12 @@ - Подробные служебные правила Telegram-обработчика, его очередь, история, systemd-запуск и особенности ответов описывать в `SHiNE-agent-bot-coder/AGENT.md`. - Если в сообщениях пользователя встречается «агент MD» или похожая формулировка про файл инструкций Codex, считать, что имеется в виду автоматически читаемый `AGENTS.md`. -## ESP32 UI сабсервера +## ESP32 UI homeserver - Для UI-скетча устройства `ESP32-S3-Touch-AMOLED-2.16` документ-спецификация и Arduino-скетч должны всегда оставаться синхронными. - Актуальный документ по экранной логике, состояниям, кнопкам, полям, статусам и ограничениям UI считать источником истины для скетча. - При изменении документа обязательно в том же наборе изменений приводить в соответствие скетч. - При изменении скетча обязательно в том же наборе изменений обновлять документ, если поменялись экраны, тексты, переходы, статусы, кнопки, поля или поведение. -- Для нового ESP32 UI-прототипа сабсервера использовать русский язык как основной и отдельно следить, чтобы текст реально отображался на устройстве, а не только логически присутствовал в коде. +- Для нового ESP32 UI-прототипа homeserver использовать русский язык как основной и отдельно следить, чтобы текст реально отображался на устройстве, а не только логически присутствовал в коде. ## Solana-модуль - В проекте есть отдельный Solana/Anchor-модуль в папке `shine-solana/shine/`. diff --git a/DAO_запуск/README.md b/DAO_запуск/README.md index c739f28..ac7fd93 100644 --- a/DAO_запуск/README.md +++ b/DAO_запуск/README.md @@ -155,11 +155,11 @@ - это обязательный шаг перед переходом от "собрали" к "доверяем". -### 3. Устройство на ESP32 как сабсервер с ключами +### 3. Устройство на ESP32 как homeserver с ключами Что сделать: -- дописать прошивку, чтобы устройство могло выступать сабсервером с ключами; +- дописать прошивку, чтобы устройство могло выступать homeserver с ключами; - дать ему возможность регистрироваться и подключаться к серверу; - определить, какие операции устройство подписывает и где хранит ключевой материал. diff --git a/Dev_Docs/Future_Features/README.md b/Dev_Docs/Future_Features/README.md index e81b0e3..f841e89 100644 --- a/Dev_Docs/Future_Features/README.md +++ b/Dev_Docs/Future_Features/README.md @@ -37,7 +37,7 @@ - `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_сессионные_саб_серверы_в_pda.md` - несколько саб-серверов пользователя как типизированные сессии в PDA с версией записи. +- `medium/2026-06-02_сессионные_homeserver_в_pda.md` - несколько homeserver-ов пользователя как типизированные сессии в PDA с версией записи. ### DAO-запуск diff --git a/Dev_Docs/Future_Features/medium/2026-06-02_сессионные_саб_серверы_в_pda.md b/Dev_Docs/Future_Features/medium/2026-06-02_сессионные_homeserver_в_pda.md similarity index 81% rename from Dev_Docs/Future_Features/medium/2026-06-02_сессионные_саб_серверы_в_pda.md rename to Dev_Docs/Future_Features/medium/2026-06-02_сессионные_homeserver_в_pda.md index 46bd937..b8f92d3 100644 --- a/Dev_Docs/Future_Features/medium/2026-06-02_сессионные_саб_серверы_в_pda.md +++ b/Dev_Docs/Future_Features/medium/2026-06-02_сессионные_homeserver_в_pda.md @@ -1,4 +1,4 @@ -# Сессионные саб-серверы в PDA пользователя +# Сессионные homeserver-ы в PDA пользователя - Статус: `future` @@ -10,15 +10,15 @@ после завершения первого этапа по пользовательским сессиям - Основание: - Идея зафиксирована после обсуждения архитектуры пользовательских сессий и внутренних саб-серверов. Сейчас задача сознательно отложена: сначала нужно аккуратно ввести базовую модель сессий, а затем возвращаться к расширенной серверной роли. + Идея зафиксирована после обсуждения архитектуры пользовательских сессий и внутренних homeserver-ов. Сейчас задача сознательно отложена: сначала нужно аккуратно ввести базовую модель сессий, а затем возвращаться к расширенной серверной роли. ## Зачем нужна фича -У одного пользователя может быть несколько доверенных внутренних саб-серверов, и каждый из них должен жить как отдельная пользовательская сессия, а не как отдельная особая сущность вне общей модели. +У одного пользователя может быть несколько доверенных внутренних homeserver-ов, и каждый из них должен жить как отдельная пользовательская сессия, а не как отдельная особая сущность вне общей модели. Это нужно, чтобы: -- хранить несколько саб-серверов у одного пользователя одновременно; +- хранить несколько homeserver-ов у одного пользователя одновременно; - различать обычные клиентские сессии и серверные сессии по явному типу; - дать расширяемый формат записи с версией; - использовать единый подход для DM, звонков и внутренних команд между сессиями. @@ -35,18 +35,18 @@ Предварительные значения: - тип `1` - обычная пользовательская сессия; -- тип `100` - саб-сервер пользователя; +- тип `100` - homeserver пользователя; - версия `1` - первая рабочая версия формата записи сессии. На текущем этапе под это уже зарезервирован отдельный блок `SessionsBlock` с `block_type = 55`, а `TrustedStateBlock` остаётся на `50`. -Важно: саб-серверов у одного пользователя может быть несколько. +Важно: homeserver-ов у одного пользователя может быть несколько. ## Архитектурный принцип Внутренний протокол взаимодействия должен оставаться транспортным. -То есть SHiNE-сервер не должен разбирать прикладной смысл внутренней нагрузки саб-сервера, а должен: +То есть SHiNE-сервер не должен разбирать прикладной смысл внутренней нагрузки homeserver-а, а должен: - доставлять сообщения между сессиями; - доставлять сигналы звонков между сессиями; @@ -60,7 +60,7 @@ - Вызов звонка уже рассылается по нескольким активным сессиям пользователя. - Сигналы звонка уже адресуются конкретной сессии, а stop-сигналы дублируются на остальные сессии того же пользователя. -Иными словами, текущая серверная логика ближе к модели "сервер доставляет между сессиями", чем к модели "сервер понимает внутренний протокол саб-сервера". +Иными словами, текущая серверная логика ближе к модели "сервер доставляет между сессиями", чем к модели "сервер понимает внутренний протокол homeserver-а". ## Что нужно сделать при возврате к задаче @@ -77,7 +77,7 @@ - правила удаления и обновления записи; - правила ротации `sessionPubKey`. 6. Продумать, как UI и сервер будут отличать тип `1` и тип `100`. -7. Определить, какие внутренние сообщения саб-сервера останутся полностью прозрачными для SHiNE-сервера, а какие потребуют только технической маршрутизации. +7. Определить, какие внутренние сообщения homeserver-а останутся полностью прозрачными для SHiNE-сервера, а какие потребуют только технической маршрутизации. 8. Добавить API/операции чтения и обновления списка сессий, если для этого не хватит существующих механизмов. 9. После реализации обязательно обновить документацию. @@ -101,5 +101,5 @@ Продолжать после завершения первой части: 1. описать минимальный формат записи пользовательской сессии; -2. отдельно решить, живут ли саб-серверы в том же списке, что и обычные сессии; +2. отдельно решить, живут ли homeserver-ы в том же списке, что и обычные сессии; 3. затем уже проектировать операции регистрации, обновления и отключения таких сессий. diff --git a/Dev_Docs/Keys/DERIVATION.md b/Dev_Docs/Keys/DERIVATION.md new file mode 100644 index 0000000..3b66907 --- /dev/null +++ b/Dev_Docs/Keys/DERIVATION.md @@ -0,0 +1,154 @@ +# Деривация секрета и ключей SHiNE (формулы) + +> **Статус: ИСТОЧНИК ИСТИНЫ (single source of truth) по конкретной деривации.** +> Этот файл описывает, как из пароля получается секрет и как из секрета выводятся +> все ключи (root, blockchain, device/Solana, homeserver) — формулами, байт-в-байт. +> Если в коде меняется деривация (формула секрета, параметры Argon2id, соль, формула +> ключа, разделитель `|`, набор/имена суффиксов, формат homeserver-ключа, связь +> dev-ключ ↔ Solana-адрес) — **в том же изменении обязательно править этот документ**. +> Роли и назначение ключей описаны отдельно в `Dev_Docs/Keys/README.md` (архитектура). +> Здесь — только механика. Документ намеренно краткий. + +--- + +## 1. Секрет (masterSecret) + +`masterSecret` — 32 байта. Два источника: + +**А. Из пароля пользователя (основной путь, UI).** + +``` +login = trim(lowercase(login)) +salt = SHA-256("shine-auth-v2|login=" + login + "|suffix=master.secret")[0..16) // первые 16 байт +material = utf8(login + "\n" + password) +masterSecret(32) = Argon2id(material, salt, t=2, m=65536 KiB, p=1, dkLen=32) +``` + +- Параметры Argon2id фиксированы: `t=2`, `m=65536` (64 МиБ), `p=1`, `dkLen=32`. +- Логин входит и в соль, и в начало `material` (склейка через `\n`). +- Пустой пароль **запрещён**: легаси-fallback без Argon2 удалён, `deriveMasterSecretFromPassword` бросает ошибку на пустом пароле, а форма регистрации в UI блокирует пустой пароль (`register-view.js`). + +**Б. Случайный (прошивка ESP32, новый аккаунт без пароля).** + +``` +masterSecret(32) = 32 случайных байта (esp_random) // хранится на устройстве как base58 +``` + +Дальше деривация ключей одинакова независимо от источника секрета. + +--- + +## 2. Производные ключи + +Все ключи выводятся из `masterSecret` по **одной формуле**, отличается только суффикс: + +``` +material = base64_std(masterSecret) + "|" + <суффикс> +seed(32) = SHA-256(material) +(pub, priv) = Ed25519_keypair_from_seed(seed) +``` + +- `base64_std` — стандартный base64 (не url-safe). +- Разделитель — символ `|`. +- Суффиксы значимы байт-в-байт (регистр и точки важны). + +| Ключ | Суффикс | Назначение (кратко) | +|------|---------|---------------------| +| root | `root.key` | Личность. Подписывает unsigned-часть PDA-записи (`RootKeyBlock`). | +| blockchain | `bch.key` | Подписывает `LastBlockState` персонального блокчейна (`blockchain_public_key`). | +| device / **Solana** | `dev.key` | Ключ устройства = Solana-ключ. Fee payer и подпись Solana-транзакций; адрес кошелька = `base58(devicePub)`. См. §3. | +| homeserver | `homeserver.key:<имя>` | Ключ homeserver-устройства, по одному на каждый homeserver (различитель — имя). См. §4. | + +Полные роли каждого ключа — в `Dev_Docs/Keys/README.md`. + +--- + +## 3. Solana-ключ + +Отдельного «солана-ключа» нет. На Solana работают два ключа: + +- **`dev.key` (device) — пополняемый кошелёк и fee payer.** Solana-адрес = `base58(devicePub)`. + Этим ключом оплачиваются и подписываются `create_user_pda` / `update_user_pda`. + Пополнять SOL нужно именно на этот адрес. +- **`root.key` — авторитет записи**, подписывает unsigned-часть PDA через Ed25519-инструкцию, но **не** является fee payer. + +Соответствует формату PDA `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` §2.1 +(«create/update оплачиваются с `device_key`», «root_key — не fee payer»). + +Кратко про роли на Solana: `root.key` — это **главный (master) ключ**: им управляют PDA-записью +(`create/update`) и через это можно заменить все остальные ключи; `dev.key` — это **пополняемый +кошелёк и плательщик комиссий**. Полное описание ролей — `Dev_Docs/Keys/README.md`. + +--- + +## 4. Ключи homeserver + +У пользователя может быть несколько homeserver-ов. Каждый имеет **своё имя** и **свой приватный ключ**, +выведенный из секрета по той же формуле с именованным суффиксом: + +``` +suffix = "homeserver.key:" + <имя homeserver> // имя по умолчанию: "homeserver1" +material = base64_std(masterSecret) + "|" + suffix +seed(32) = SHA-256(material) +(pub, priv) = Ed25519_keypair_from_seed(seed) +``` + +Пример для двух homeserver-ов: + +``` +homeserver.key:home-a -> ключ A +homeserver.key:home-b -> ключ B +``` + +Публичный ключ homeserver-а публикуется в `SessionsBlock` пользовательской PDA как +`session_pub_key` с `session_type = 100`, имя — в `session_name` (формат PDA §13). + +> Это переименование прежней схемы `subserver.key:<имя>` → `homeserver.key:<имя>`. +> Термин «саб-сервер» по проекту заменяется на «homeserver». + +--- + +## 5. Где это в коде + +### Деривация секрета и ключей (UI, каноническая) +- `shine-UI/js/services/crypto-utils.js` + - секрет из пароля: `makeArgon2Salt`, `deriveMasterSecretArgon2id`, `deriveMasterSecretFromPassword` (~129–218); + - ключ из секрета: `deriveEd25519FromMasterSecret` (~220). +- `shine-UI/js/services/auth-service.js` — набор root/bch/dev из `masterSecret` (~732–758). +- `shine-UI/server-ui/js/server-ui-shared.js` — те же root/bch/dev для серверного UI (~147–160). + +### Solana-ключ / адрес кошелька (UI) +- `shine-UI/js/pages/registration-payment-view.js` — `deriveUserWalletAddress`: адрес = `base58(devicePub)` (~113). +- `shine-UI/js/pages/topup-view.js` — `deviceWalletAddressFromBundle`: тот же канонический адрес из `preGeneratedKeyBundle.devicePair`. + Прежний расходящийся путь `deriveWalletFromPassword` (прямой Argon2 по `dev.key`, мимо `masterSecret`) удалён. + +### Деривация ключей (прошивка ESP32) +- `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/shine_homeserver_ui/shine_homeserver_ui.ino` + - `deriveKeysFromMasterSecret` (~782), `restoreDerivedKeysFromSecret` (~806), `deriveFreshSecretAndWallet` (~829); + - регистрация/подпись Solana: `registerHomeserverOnSolana` (~1182), `signMessageEd25519` (~1147). +- `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_nav_minimal_test/lvgl_nav_minimal_test.ino` + - homeserver-ключ: `homeserverKeySuffix` (~690), `deriveKeyPairFromSecretSuffix` (~699), `refreshDerivedKeys` (~725); суффикс `homeserver.key:<имя>`. + +### Формат PDA (куда попадают ключи) +- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` + — `RootKeyBlock` §6, `DeviceKeyBlock` §7, `blockchain_public_key` §9, `SessionsBlock`/`session_type=100` §13, оплата §2.1. + +### Сервер (тестовый seed) +- `SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java` `deriveKeysFromPassword` (~246) — + выводит ключи как `Ed25519(SHA-256(base64(SHA-256(password)) + suffix))`, **без** Argon2 и **без** разделителя `|`. + Это **не баг**, а точное повторение легаси-пути UI `derivePasswordSeed` (для пустого пароля), у которого тоже нет `|`. + С современным путём `masterSecret`-bundle (Argon2 + `base64(secret)|suffix`) он **не совпадает** by design. + Если потребуется, чтобы seed совпадал с реальными клиентами на Argon2 — нужно отдельно портировать + Argon2id+masterSecret в Java (на сервере Argon2 сейчас нет). Простое добавление `|` было бы **неверным**: + сломало бы совпадение с легаси-путём и всё равно не дало бы совпадения с Argon2-путём. + +--- + +## 6. Правило синхронизации (обязательно) + +1. Этот документ — источник истины по деривации секрета и ключей. +2. Любое изменение кода, затрагивающее формулу секрета, параметры Argon2id, соль, формулу ключа, + разделитель `|`, набор/имена суффиксов, формат homeserver-ключа или связь dev-ключ ↔ Solana-адрес — + **обязательно** отражать здесь в том же изменении. +3. Пункты, помеченные ⚠️, — это долг к устранению, а не норма. +4. Нельзя сознательно оставлять код и этот документ в рассинхроне без отдельной явной договорённости. diff --git a/Dev_Docs/Keys/README.md b/Dev_Docs/Keys/README.md index 0e12835..863a9da 100644 --- a/Dev_Docs/Keys/README.md +++ b/Dev_Docs/Keys/README.md @@ -8,7 +8,7 @@ В SHiNE у пользователя есть несколько уровней ключей: -- `root key` - главный корневой ключ пользователя, он же основной Solana-ключ. +- `root key` - главный (master) ключ пользователя: тот, кто им владеет, управляет пользовательской PDA в Solana и может заменить все остальные ключи. Это не пополняемый кошелёк (комиссии платит `device key`). - `blockchain key` - ключ записи в персональный SHiNE-блокчейн пользователя. - `device key` - общий ключ пользовательских устройств для повседневной работы, звонков, DM и мелких платежей. - `session key` - ключ конкретной сессии или конкретного устройства для авторизации на сервере. @@ -28,9 +28,9 @@ - управление остальными ключами; - подтверждение операций, которые должны иметь максимальный уровень доверия. -В текущей модели `root key` совпадает по смыслу с главным Solana-ключом пользователя. +`root key` — это **главный (master) ключ** в следующем смысле: зная `root key`, можно управлять пользовательской PDA-записью в Solana (`create_user_pda` / `update_user_pda`) и тем самым **заменить все остальные ключи** пользователя (device, blockchain, homeserver). Поэтому компрометация `root key` равносильна компрометации всей личности пользователя. -На `root key` могут храниться значимые средства, если пользователь сознательно выбирает такую модель. Для мелких текущих расходов предпочтительнее использовать `device key`. +Важно не путать авторитет и кошелёк: `root key` — это авторитет над PDA-записью, а **SOL-комиссии за create/update платит `device key`** (он же fee payer и адрес для пополнения). Подробнее о том, какой ключ за что отвечает на Solana, — в `Dev_Docs/Keys/DERIVATION.md`, §3. ## `blockchain key` @@ -158,6 +158,7 @@ Self-message - это сообщение пользователя самому ## Связанные документы +- `Dev_Docs/Keys/DERIVATION.md` - **источник истины по конкретной деривации** секрета и ключей (формулы Argon2id, `base64|suffix→SHA-256→Ed25519`, суффиксы `root.key`/`bch.key`/`dev.key`/`homeserver.key:<имя>`, Solana-ключ, ссылки на код). - `Dev_Docs/Personal_Messages/README.md` - текущая документация личных сообщений. - `Dev_Docs/Blockchain/README.md` - точка входа по форматам SHiNE-блокчейна. - `Dev_Docs/Solana_Architecture/README.md` - архитектура Solana-программ, PDA-счетов, DAO и движения средств. diff --git a/Dev_Docs/Pending_Features/2026-06-07_1650_esp32_subserver_ui_прототип.md b/Dev_Docs/Pending_Features/2026-06-07_1650_esp32_homeserver_ui_прототип.md similarity index 71% rename from Dev_Docs/Pending_Features/2026-06-07_1650_esp32_subserver_ui_прототип.md rename to Dev_Docs/Pending_Features/2026-06-07_1650_esp32_homeserver_ui_прототип.md index e3b8f5a..f3356da 100644 --- a/Dev_Docs/Pending_Features/2026-06-07_1650_esp32_subserver_ui_прототип.md +++ b/Dev_Docs/Pending_Features/2026-06-07_1650_esp32_homeserver_ui_прототип.md @@ -1,26 +1,26 @@ -# ESP32 UI-прототип сабсервера SHiNE +# ESP32 UI-прототип homeserver SHiNE - краткое описание фичи: - для `Waveshare ESP32-S3-Touch-AMOLED-2.16` добавлен новый интерактивный UI-скетч сабсервера `SHiNE` с хранением данных в `NVS`, настройками `Wi-Fi`, настройками серверов, кошельком, экраном `QR/URI`, живой Solana-регистрацией и экраном входящих запросов. Логика PIN в коде сохранена, но вход по PIN во временной сборке отключён, чтобы не блокировать проверку остальных экранов. В текущей версии `Wi-Fi` подключается реально, адреса `API/RPC/WS` проверяются реально, баланс кошелька читается из `Solana RPC`, а регистрация отправляет `create_user_pda` в `shine_users`. + для `Waveshare ESP32-S3-Touch-AMOLED-2.16` добавлен новый интерактивный UI-скетч homeserver `SHiNE` с хранением данных в `NVS`, настройками `Wi-Fi`, настройками серверов, кошельком, экраном `QR/URI`, живой Solana-регистрацией и экраном входящих запросов. Логика PIN в коде сохранена, но вход по PIN во временной сборке отключён, чтобы не блокировать проверку остальных экранов. В текущей версии `Wi-Fi` подключается реально, адреса `API/RPC/WS` проверяются реально, баланс кошелька читается из `Solana RPC`, а регистрация отправляет `create_user_pda` в `shine_users`. - что именно проверять: - 1. Прошить режим `subserver-ui` и дождаться открытия главного экрана без PIN. + 1. Прошить режим `homeserver-ui` и дождаться открытия главного экрана без PIN. 2. Проверить, что текст в заголовках, кнопках и статусах отображается читаемо; в текущей временной сборке допускается ASCII-транслитерация русского текста. 3. Открыть `Настройки` и убедиться, что показывается пометка о временно отключённом входе по PIN. 4. Открыть `Подключение -> Wi-Fi`, ввести `SSID` и пароль, нажать `Проверить`, дождаться реального подключения, затем перезагрузить устройство и проверить, что значения сохранились. 5. Открыть `Подключение -> Серверы`, проверить или изменить `API/RPC/WS`, нажать `Проверить` и убедиться, что показываются реальные статусы доступности, затем перезагрузить устройство и проверить сохранение значений. - 6. Открыть `Аккаунт`, ввести логин, имя сабсервера и нажать `Сгенерировать`; проверить, что появились секрет и адрес кошелька, а после перезагрузки они не исчезают. + 6. Открыть `Аккаунт`, ввести логин, имя homeserver и нажать `Сгенерировать`; проверить, что появились секрет и адрес кошелька, а после перезагрузки они не исчезают. 7. Открыть `Кошелёк`, нажать `Проверить` и убедиться, что баланс реально читается из `Solana RPC`; затем открыть `QR и URI` и проверить, что QR-код отрисовывается и сканируется как `solana:`-ссылка. 8. При необходимости отдельно проверить тестовые кнопки `+/- SOL`: они меняют локальный баланс для UX-сценариев, но после следующей реальной RPC-проверки баланс должен вернуться к сетевому значению. 9. Вернуться на главный экран и проверить, что до выполнения всех условий кнопка регистрации недоступна, а после выполнения становится доступной. - 10. Выполнить регистрацию и убедиться, что статус меняется на `Сабсервер активен`, онлайн-статус становится активным, а на экране появляются краткие отпечатки `PDA/TX`. + 10. Выполнить регистрацию и убедиться, что статус меняется на `Homeserver активен`, онлайн-статус становится активным, а на экране появляются краткие отпечатки `PDA/TX`. 11. После регистрации проверить через `Solana`/UI проекта, что `user_pda` для этого логина реально создана и соответствует `device`-адресу устройства. 12. Открыть `Запросы`, поочерёдно открыть оба демонстрационных запроса и проверить, что кнопки `Разрешить` и `Отклонить` меняют их статус. 13. При необходимости открыть `Настройки -> Сменить PIN` и убедиться, что новый PIN сохраняется, хотя вход по PIN временно не используется на старте. 14. Выполнить `Полный сброс` и убедиться, что все поля, секрет, баланс, онлайн и регистрация очищаются. - ожидаемый результат: - новый `ESP32`-скетч стабильно запускается, показывает читаемый интерфейс хотя бы в ASCII-транслитерации, сохраняет данные во внутренней памяти устройства, реально подключается к `Wi-Fi`, реально проверяет `API/RPC/WS`, реально читает баланс из `Solana RPC`, рисует рабочий `QR` для `solana:`-URI и позволяет вручную пройти полный сценарий on-chain регистрации сабсервера. + новый `ESP32`-скетч стабильно запускается, показывает читаемый интерфейс хотя бы в ASCII-транслитерации, сохраняет данные во внутренней памяти устройства, реально подключается к `Wi-Fi`, реально проверяет `API/RPC/WS`, реально читает баланс из `Solana RPC`, рисует рабочий `QR` для `solana:`-URI и позволяет вручную пройти полный сценарий on-chain регистрации homeserver. - статус: pending diff --git a/Dev_Docs/Pending_Features/2026-06-08_1150_esp32_auto_flash_script.md b/Dev_Docs/Pending_Features/2026-06-08_1150_esp32_auto_flash_script.md index 66e4021..68f9af6 100644 --- a/Dev_Docs/Pending_Features/2026-06-08_1150_esp32_auto_flash_script.md +++ b/Dev_Docs/Pending_Features/2026-06-08_1150_esp32_auto_flash_script.md @@ -1,13 +1,13 @@ -# ESP32 авто-прошивка shine_subserver_ui +# ESP32 авто-прошивка shine_homeserver_ui - краткое описание фичи: - добавлен исполняемый скрипт `flash_shine_subserver_ui.sh`, который автоматически ищет USB-порт `ESP32` и запускает заливку прошивки `shine_subserver_ui` без ручного указания `PORT`. + добавлен исполняемый скрипт `flash_shine_homeserver_ui.sh`, который автоматически ищет USB-порт `ESP32` и запускает заливку прошивки `shine_homeserver_ui` без ручного указания `PORT`. - что именно проверять: 1. Подключить плату `ESP32` по USB. 2. Перейти в папку `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/`. - 3. Запустить `./flash_shine_subserver_ui.sh`. + 3. Запустить `./flash_shine_homeserver_ui.sh`. 4. Убедиться, что скрипт сам показывает найденный порт и успешно запускает compile/upload. - ожидаемый результат: - скрипт без ручного ввода порта находит `ESP32`, печатает найденный `/dev/ttyACM*` или `/dev/ttyUSB*` и заливает `shine_subserver_ui`. + скрипт без ручного ввода порта находит `ESP32`, печатает найденный `/dev/ttyACM*` или `/dev/ttyUSB*` и заливает `shine_homeserver_ui`. - статус: pending diff --git a/Dev_Docs/Pending_Features/2026-06-08_1245_esp32_pin_button_labels.md b/Dev_Docs/Pending_Features/2026-06-08_1245_esp32_pin_button_labels.md index f29c8d7..786a26e 100644 --- a/Dev_Docs/Pending_Features/2026-06-08_1245_esp32_pin_button_labels.md +++ b/Dev_Docs/Pending_Features/2026-06-08_1245_esp32_pin_button_labels.md @@ -1,7 +1,7 @@ # ESP32 PIN-клавиатура: подписи кнопок - краткое описание фичи: - в UI-скетче `shine_subserver_ui` изменена отрисовка подписей кнопок. Вместо малого шрифта теперь используется более стабильный шрифт с явным центрированием текста внутри кнопок, чтобы на экране ввода PIN и других экранах не пропадали цифры и надписи. + в UI-скетче `shine_homeserver_ui` изменена отрисовка подписей кнопок. Вместо малого шрифта теперь используется более стабильный шрифт с явным центрированием текста внутри кнопок, чтобы на экране ввода PIN и других экранах не пропадали цифры и надписи. - что именно проверять: 1. Включить устройство и дождаться экрана ввода PIN. 2. Убедиться, что на всех серых кнопках видны цифры `0-9`, `Отмена` и `OK`. diff --git a/Dev_Docs/Pending_Features/2026-06-08_1315_esp32_test_sketches_folder.md b/Dev_Docs/Pending_Features/2026-06-08_1315_esp32_test_sketches_folder.md index 222ebf9..80e7579 100644 --- a/Dev_Docs/Pending_Features/2026-06-08_1315_esp32_test_sketches_folder.md +++ b/Dev_Docs/Pending_Features/2026-06-08_1315_esp32_test_sketches_folder.md @@ -6,8 +6,8 @@ 1. Запустить `./burn.sh gfx-text-test` и убедиться, что прошивается тест текста из новой папки. 2. Запустить `./burn.sh gfx-layout-test` и проверить нижние ряды кнопок. 3. Запустить `./burn.sh lvgl-basic-test` и проверить, что `LVGL` показывает текст и кнопки. - 4. Убедиться, что новая папка не мешает сборке `subserver-ui`. + 4. Убедиться, что новая папка не мешает сборке `homeserver-ui`. - ожидаемый результат: - тестовые скетчи лежат отдельно от основного UI, шьются отдельными режимами и позволяют быстро проверять разные гипотезы по экрану без правок в `shine_subserver_ui`. + тестовые скетчи лежат отдельно от основного UI, шьются отдельными режимами и позволяют быстро проверять разные гипотезы по экрану без правок в `shine_homeserver_ui`. - статус: pending diff --git a/Dev_Docs/Pending_Features/2026-06-08_1940_esp32_nav_minimal_test.md b/Dev_Docs/Pending_Features/2026-06-08_1940_esp32_nav_minimal_test.md index 6984a83..577565a 100644 --- a/Dev_Docs/Pending_Features/2026-06-08_1940_esp32_nav_minimal_test.md +++ b/Dev_Docs/Pending_Features/2026-06-08_1940_esp32_nav_minimal_test.md @@ -1,9 +1,9 @@ # ESP32 nav minimal test -- Краткое описание: минимальный UI-прототип для сабсервера на базе `LVGL + subserver touch`, с Wi-Fi flow, серверными адресами и общим экраном редактирования текста. +- Краткое описание: минимальный UI-прототип для homeserver на базе `LVGL + subserver touch`, с Wi-Fi flow, серверными адресами и общим экраном редактирования текста. - Что проверять: - стартует экран `HOME`; - - на `HOME` видны реальное значение сабсервера или `subserver not set`, реальное значение логина или `login not set`, при отсутствии секрета строка `secret not set`, а также `STATUS`, верхний правый блок с процентом батареи, иконкой батареи и индикатором Wi-Fi, кнопка баланса, строка `SHiNE: ...`, кнопка `SETTINGS` уменьшенной ширины у правого края и нижняя подпись `SHiNE subserver (v.0.18)`; + - на `HOME` видны реальное значение homeserver или `homeserver not set`, реальное значение логина или `login not set`, при отсутствии секрета строка `secret not set`, а также `STATUS`, верхний правый блок с процентом батареи, иконкой батареи и индикатором Wi-Fi, кнопка баланса, строка `SHiNE: ...`, кнопка `SETTINGS` уменьшенной ширины у правого края и нижняя подпись `SHiNE homeserver (v.0.18)`; - справа от строки логина виден индикатор статуса Solana-аккаунта: - зелёный, если ключи совпали; - красный, если mismatch; @@ -19,9 +19,9 @@ - `unavailable` - пока открыт `HOME`, статус сам обновляется без перехода на другие экраны; - баланс обновляется кнопкой по нажатию; - - если логин зарегистрирован и секрет/сабсервер заданы, устройство: + - если логин зарегистрирован и секрет/homeserver заданы, устройство: - читает `user_pda` через Solana RPC; - - сверяет `root`, `blockchain`, `device` и `subserver` session type `100`; + - сверяет `root`, `blockchain`, `device` и `homeserver` session type `100`; - поднимает WebSocket-сессию с сервером SHiNE; - шлёт `Ping` раз в минуту; - кнопка `SETTINGS` открывает `SETTINGS_MENU`; @@ -54,7 +54,7 @@ - визуальный курсор в поле ввода не показывается; - новые символы всегда дописываются только в конец строки; - основные 3 ряда клавиш и нижний служебный ряд стали выше; - - внизу остаётся отдельная тёмная полоса с версией `SHiNE subserver (v.0.18)`, а рамка клавиатурного блока заканчивается выше неё; + - внизу остаётся отдельная тёмная полоса с версией `SHiNE homeserver (v.0.18)`, а рамка клавиатурного блока заканчивается выше неё; - одно непрерывное касание вызывает не более одного действия кнопки; - скольжение пальцем по клавиатуре не нажимает подряд несколько клавиш; - медленный свайп по экрану не должен превращаться в случайное нажатие кнопки; @@ -75,11 +75,11 @@ - нажатие `Account` открывает `ACCOUNT_SCREEN`; - `ACCOUNT_SCREEN` показывает 3 кнопки: - `Login ()` - - `Subserver ()` + - `Homeserver ()` - `Secret (<*****|not set>)` - `Login` открывает общий экран редактирования и сохраняется в NVS; - - `Subserver` открывает промежуточный экран с `USE SUBSERVER1` и `EDIT MANUALLY`; - - `USE SUBSERVER1` возвращает стандартное значение `subserver1`; + - `Homeserver` открывает промежуточный экран с `USE HOMESERVER1` и `EDIT MANUALLY`; + - `USE HOMESERVER1` возвращает стандартное значение `homeserver1`; - `EDIT MANUALLY` открывает общий экран редактирования и сохраняет значение в NVS; - `Secret` теперь открывает меню секрета с показом секрета, ручным вводом и генерацией; - в `SHOW SECRET` показывается прокручиваемый список всех ключей: @@ -90,15 +90,15 @@ - `Blockchain key priv (base58)` - `Device key (base58)` - `Device key priv (base58)` - - `Subserver key (base58)` - - `Subserver key priv (base58)` + - `Homeserver key (base58)` + - `Homeserver key priv (base58)` - значения ключей показываются полными строками увеличенным шрифтом; - при смене `login` сохранённый секрет сбрасывается в `not set`; - во время генерации секрета есть `CANCEL` и подтверждение остановки; - при отмене генерации старый секрет, если он был, не должен теряться; - свайп вправо из внутренних экранов возвращает в `SETTINGS_MENU`; - - свайп вправо из `ACCOUNT_SUBSERVER_SCREEN` и `ACCOUNT_SECRET_SCREEN` возвращает в `ACCOUNT_SCREEN`; + - свайп вправо из `ACCOUNT_HOMESERVER_SCREEN` и `ACCOUNT_SECRET_SCREEN` возвращает в `ACCOUNT_SCREEN`; - если во время реального свайпа палец проходит по кнопке, это не должно открывать кнопку как обычный `click`. - Ожидаемый результат: новый скетч даёт чистый навигационный каркас и уже умеет настраивать Wi-Fi и серверные адреса на самой ESP32. -- Дополнительно ожидается: `HOME` уже показывает реальный Solana/WS-статус сабсервера, а отсутствие пользователя в Solana заметно сразу без перехода в настройки. +- Дополнительно ожидается: `HOME` уже показывает реальный Solana/WS-статус homeserver, а отсутствие пользователя в Solana заметно сразу без перехода в настройки. - Статус: pending diff --git a/Dev_Docs/Solana/user_pda/README.md b/Dev_Docs/Solana/user_pda/README.md index e520ff9..1039e38 100644 --- a/Dev_Docs/Solana/user_pda/README.md +++ b/Dev_Docs/Solana/user_pda/README.md @@ -90,7 +90,7 @@ UserPdaRecordV1 | `3` | `BlockchainRegistryBlock` | Один или несколько блокчейнов пользователя. | | `30` | `ServerProfileBlock` | Серверные данные пользователя. | | `40` | `AccessServersBlock` | Серверы доступа/relay. | -| `50` | `SessionsBlock` | Опубликованные пользовательские сессии и саб-серверы. | +| `50` | `SessionsBlock` | Опубликованные пользовательские сессии и homeserver-ы. | | `70` | `TrustedStateBlock` | Счетчик trusted-связей. | | `255` | `ReservedBlock` | Зарезервировано, пока не используется. | @@ -309,7 +309,7 @@ SessionRecord | Значение | Смысл | |----------|-------| | `1` | Обычная пользовательская сессия. | -| `100` | Саб-сервер пользователя. | +| `100` | Homeserver пользователя. | Правила: diff --git a/Dev_Docs/audit/Solana-audit-3-by-Claude-12июня2026.md b/Dev_Docs/audit/Solana-audit-3-by-Claude-12июня2026.md new file mode 100644 index 0000000..e5a93f6 --- /dev/null +++ b/Dev_Docs/audit/Solana-audit-3-by-Claude-12июня2026.md @@ -0,0 +1,134 @@ +# Аудит безопасности Solana-программ SHiNE — выпуск 3 (12.06.2026) + +Тематический аудит с фокусом на **полноту проверок входных аккаунтов** +(signer / owner / каноничный PDA-адрес / system-program / sysvar инструкций / +аккаунт оракула) — отвечает на вопрос «точно ли хватает всех проверок входных +аккаунтов». Код перечитан целиком после исправлений аудита №2 +(`Solana-audit-2-by-Claude-11июня2026.md`): + +- `shine_login_guard` (183 строки) — stateless-классификатор логинов, аккаунтами не пользуется; +- `shine_users` (1068 строк) — реестр пользователей, PDA-записи, ed25519-подписи, экономика лимитов; +- `shine_payments` (1398 строк) — очереди тикетов, выплаты из вольта, оракул Pyth. + +Это ручная (не-Anchor `#[derive(Accounts)]`) реализация на `solana_program`, поэтому +каждая проверка аккаунта выполняется явно в коде handler-а. Перебраны: подмена +аккаунтов/PDA, подмена владельца, bump-seed атаки, отсутствие signer/authority, +подмена system-program и sysvar, подмена аккаунта оракула, неинициализированные/ +повторно инициализируемые PDA, «лишние» аккаунты. + +## Итоговый вердикт + +**Проверок входных аккаунтов достаточно во всех трёх программах.** По каждому +handler присутствуют все требуемые классы проверок; грубых дыр (подмена PDA на +чужой аккаунт, отсутствие owner/signer-проверки, использование пользовательского +bump, подмена аккаунта оракула) не найдено. Все Critical/HIGH из аудитов №1 и №2 +закрыты и в этом проходе подтверждены в коде. Новых эксплуатируемых пробелов в +валидации аккаунтов нет; есть несколько LOW/INFO-замечаний «by design». + +## Статус прошлых находок (подтверждено в коде на 12.06.2026) + +- 🔴 Critical #1 (economy-config PDA, `shine_users`) — закрыто: `validate_users_economy_config_pda` (адрес + `owner == program_id`) вызывается и в create, и в update перед чтением. +- 🔴 Critical #2 (singleton-PDA, `shine_payments`) — закрыто: `validate_singleton_state_pda` (адрес + `owner == id()`) во всех инструкциях. +- 🟠 Medium (валидация Pyth) — закрыто: пин адреса `PYTH_SOL_USD_ACCOUNT`, `owner == pyth_receiver`, `PriceUpdateV2`, `feed_id`, возраст, доверительный интервал. +- 🟡 Low (griefing на предсказуемых адресах) — закрыто: `create_pda_account` создаёт «поверх предзаполненного» в обеих программах. +- 🔴 HIGH аудита №2 (`recipient_wallet == inflow_vault` замораживает выплаты) — закрыто: запрет `recipient == inflow_vault` в `buy_ticket_by_purchase_usd` (стр. 1026), `process_manager_add_ticket` (стр. 747), `process_change_ticket_recipient` (стр. 878) + защита по умолчанию `require!(vault.key != recipient.key)` в `transfer_from_vault` (стр. 1278). + +--- + +## Матрица проверок входных аккаунтов + +### shine_users + +| Инструкция | signer | owner PDA | адрес/seed PDA | system | sysvar / подпись | прочее | +|---|---|---|---|---|---|---| +| `init_users_economy_config` | ✓ | `owner == system` + `data_is_empty` (анти-reinit) | деривация + сверка | ✓ | — | значения из `settings`, не из ввода | +| `update_users_economy_config` | ✓ + `signer == DAO_AUTHORITY` | `owner == program_id` | деривация + сверка | — | — | `lamports_per_limit_step > 0` | +| `create_user_pda` | ✓ + `signer == device_key` | user_pda `owner == system` + empty; econ_config `owner == program_id` | user_pda, econ_config, inflow_vault, login_guard — все сверены | ✓ | ed25519 (record sig idx −2, last_block idx −1) | `inflow_vault` сверен с PDA `shine_payments`; login_guard сверен дважды | +| `update_user_pda` | ✓ + `signer == device_key` | user_pda `owner == program_id`; econ_config `owner == program_id` | деривация + сверка | ✓ | ed25519 + `version == old+1` + `prev_hash == hash(old)` | immutable-поля сверены с прежней записью | + +### shine_payments + +| Инструкция | signer | owner / валидация PDA | адрес PDA | system | прочее | +|---|---|---|---|---|---| +| `init` | ✓ payer | все 4 PDA `is_uninitialized` | деривация + сверка | ✓ | `dao_wallet` из `settings`, нет лишних аккаунтов | +| `update_coef_limit` | ✓ + `signer == config.dao_wallet` | config/coef `owner == id()` | деривация + сверка | — | границы coef/limit/reward; нет лишних аккаунтов | +| `grant_manager_limits` | ✓ + `signer == config.dao_wallet` | config `owner == id()`; allowance create/read | allowance из `manager_wallet` | ✓ | `state.manager_wallet == args.manager_wallet` | +| `buy_ticket` / `_usd` / `_sol` | ✓ | config/coef/queues `owner == id()` | ticket деривация + сверка + `is_uninitialized` | ✓ | oracle (key+owner+возраст+confidence), `dao_wallet == config.dao_wallet`, `recipient != inflow_vault`, slippage | +| `manager_add_ticket` | ✓ | allowance/queues `owner == id()` | allowance из `signer`; ticket деривация + сверка + uninit | ✓ | `allowance.manager_wallet == signer`, `queue_id ∈ {1,2,3}`, `recipient != inflow_vault` | +| `step_payout` | ✓ | все singleton-PDA `owner == id()` | ticket деривация + сверка | — | `dao_wallet == config.dao_wallet`, `inflow == config.inflow_vault`, ticket `queue/index/!is_paid/recipient`, oracle | +| `change_ticket_recipient` | ✓ + `signer == ticket.recipient_wallet` | queues + ticket `owner == id()` (через `read_state`) | ticket деривация из своих `queue_id/index` + сверка | — | `!is_paid`, запрет менять «следующий к выплате», `recipient != inflow_vault` | + +### shine_login_guard + +Аккаунты не используются (`_accounts`); программа stateless, средствами не владеет. +Защита со стороны вызова реализована в `shine_users`: сверяется и адрес вызываемой +программы (`login_guard_program.key == SHINE_LOGIN_GUARD_PROGRAM_ID`), и `program_id` +в `get_return_data`. Подмена/подделка ответа исключены. Отдельных проверок входных +аккаунтов внутри программы не требуется. + +--- + +## 🟡 LOW / INFO — наблюдения без прямой эксплуатации + +### L1. Permissionless `init` в обеих программах +`shine_payments::init` и `shine_users::init_users_economy_config` может вызвать кто +угодно первым. Практического эксплойта нет: все значения (включая `dao_wallet` и +`DAO_AUTHORITY`) берутся из констант `settings`, а не из ввода, повторная +инициализация заблокирована проверками `is_uninitialized` / `data_is_empty`. Риск +низкий; при желании привязать init к ожидаемому деплой-кошельку. Совпадает с моделью +«первый init = деплой». + +### L2. В `shine_users` нет явной проверки «лишних аккаунтов» — ✅ ИСПРАВЛЕНО (12.06.2026) +`shine_payments` в каждом handler делает `require!(account_iter.next().is_none())`. +В `shine_users` такой проверки не было — лишние аккаунты в конце списка просто +игнорировались (читается строго нужное количество через `next_account_info`). Это +безвредно (на безопасность не влияло), но для симметрии и явности добавлено. +Класс: гигиена, не уязвимость. + +Закрыто: во все 4 инструкции `shine_users` (`init_users_economy_config`, +`update_users_economy_config`, `create_user_pda`, `update_user_pda`) после чтения +фиксированного набора аккаунтов добавлено `require!(it.next().is_none(), +ShineUsersError::InvalidInstruction)`. Документация — `doc/programs/shine_users.md` §3.4. + +### L3. Гонка за логином (first-come) в `shine_users` — known issue +Адрес `user_pda` детерминирован из логина; после закрытия griefing-подсева остаётся +обычное состязание за регистрацию (front-run в мемпуле). On-chain решается только +commit-reveal; для текущей модели — приемлемый риск, ранее зафиксирован в аудите №2 +(L2). К проверкам аккаунтов не относится. + +### L4. Экономическая устойчивость вольта (дизайн, не баг) +Деньги за покупку тикетов уходят на `dao_wallet`, а выплаты `step_payout` идут из +`inflow_vault`, наполняемого регистрационными комиссиями `shine_users` (коэффициент +по умолчанию `START_COEF_PPM = 5x`). При недостаточном притоке регистраций вольт +истощается и выплаты останавливаются (без потери средств). Это свойство +экономической модели «очередь/билеты», а не дефект валидации аккаунтов — отмечено +для полноты (ранее L4 в аудите №2). Мониторить баланс вольта vs обязательств. + +--- + +## ✅ Проверено и подтверждено как корректное (по входным аккаунтам) + +- **Подмена PDA** невозможна нигде: всюду пара «деривация `find_program_address` + сверка полного адреса». Пользовательский bump не принимается, `create_program_address` с внешним bump не используется — bump-seed атаки исключены. +- **Проверка владельца** при каждом чтении PDA: `read_state` и `validate_singleton_state_pda` (`shine_payments`) требуют `owner == id()`; `validate_users_economy_config_pda` и проверка `user_pda.owner == program_id` (`shine_users`) — перед десериализацией данных. +- **Создаваемые PDA**: проверка `is_uninitialized` / `owner == system && data_is_empty` исключает повторную инициализацию и перезапись чужого аккаунта. +- **signer / authority**: все handler начинают с обязательного `is_signer`; привилегированные операции дополнительно сверяют ключ с авторитетом (`config.dao_wallet`, `DAO_AUTHORITY`, `allowance.manager_wallet`, `ticket.recipient_wallet`, `device_key`). +- **system-program** сверяется с `system_program::ID` там, где идёт создание аккаунта/перевод; **sysvar инструкций** сверяется с `sysvar::instructions::id()` перед ed25519-интроспекцией. +- **Аккаунт оракула**: пин адреса `PYTH_SOL_USD_ACCOUNT` + `owner == pyth_receiver` + `feed_id` + возраст (120 с) + доверительный интервал (10%). +- **Ed25519 в `shine_users`**: относительные индексы −1/−2, `num_signatures == 1`, все три `ix_index == u16::MAX` (offset-данные внутри самой ed25519-инструкции), сверка `program_id == ed25519_program` и pubkey/signature/message по хэшу — указать на чужую инструкцию нельзя. +- **Алиасинг аккаунтов**: `recipient != inflow_vault` запрещён на входе во всех точках задания получателя + `vault.key != recipient.key` в `transfer_from_vault`. +- **`inflow_vault` в `shine_users`** сверяется с PDA, выведенным из `SHINE_PAYMENTS_PROGRAM_ID` и `SHINE_PAYMENTS_INFLOW_VAULT_SEED` — комиссия не может уйти на чужой адрес. +- **Реентранси** отсутствует: CPI только в System Program и в stateless `shine_login_guard` (с проверкой возвращённого `program_id`); обратных вызовов в наши программы нет. + +--- + +## Приоритет действий + +1. **LOW** — ✅ выполнено 12.06.2026: добавлено `require!(it.next().is_none(), …)` во + все инструкции `shine_users` для симметрии с `shine_payments` (L2). +2. **INFO** — зафиксировать в эксплуатационной документации known-issue гонки за + логином (L3) и экономику вольта (L4); рассмотреть привязку `init` к ожидаемому + деплой-кошельку (L1). + +Критичных и высоких находок по полноте проверок входных аккаунтов в этом проходе +нет. Единственная LOW-правка (L2) применена в рамках этого же изменения; код +`shine_users` собирается успешно (`cargo build -p shine_users`). diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_subserver_ui_nav_minimal_spec.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_nav_minimal_spec.md similarity index 93% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_subserver_ui_nav_minimal_spec.md rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_nav_minimal_spec.md index 5261000..252e6d0 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_subserver_ui_nav_minimal_spec.md +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_nav_minimal_spec.md @@ -1,4 +1,4 @@ -# SHiNE ESP32 Subserver UI Nav Minimal Spec +# SHiNE ESP32 Homeserver UI Nav Minimal Spec Минимальный навигационный прототип для `Waveshare ESP32-S3-Touch-AMOLED-2.16`. @@ -23,7 +23,7 @@ - `WIFI_SCREEN` - `SERVER_SCREEN` - `ACCOUNT_SCREEN` -- `ACCOUNT_SUBSERVER_SCREEN` +- `ACCOUNT_HOMESERVER_SCREEN` - `ACCOUNT_SECRET_SCREEN` - `SECRET_SHOW_SCREEN` - `SECRET_GENERATE_*` @@ -33,7 +33,7 @@ ## HOME Показывает: -- сверху слева значение сабсервера или `subserver not set`; +- сверху слева значение homeserver или `homeserver not set`; - ниже значение логина или `login not set`; - справа от строки логина индикатор статуса Solana-аккаунта: - зелёный — все ключи совпадают; @@ -51,7 +51,7 @@ - строка `SHiNE: connected/account not configured/unavailable`; - при отсутствии пользователя в Solana PDA слева снизу появляется кнопка `REGISTER ACCOUNT`; - снизу кнопку `SETTINGS`, уменьшенную примерно до половины ширины экрана и сдвинутую к правому краю. -- внизу на тёмной полосе подпись `SHiNE subserver (v.0.18)`. +- внизу на тёмной полосе подпись `SHiNE homeserver (v.0.18)`. Строка Wi-Fi на `HOME`: - `Wi-Fi (not configured) not configured` @@ -65,11 +65,11 @@ Фоновая логика: - пока открыт `HOME`, экран сам обновляется примерно раз в секунду; -- при наличии `login + secret + subserver` и Wi-Fi устройство читает Solana `user_pda` напрямую через RPC; -- сравниваются `root key`, `blockchain key`, `device key` и `subserver` session-запись типа `100`; +- при наличии `login + secret + homeserver` и Wi-Fi устройство читает Solana `user_pda` напрямую через RPC; +- сравниваются `root key`, `blockchain key`, `device key` и `homeserver` session-запись типа `100`; - для строки `SHiNE:` устройство держит отдельную WebSocket-сессию с сервером SHiNE: - авторизация через `AuthChallenge/CreateAuthSession` или `SessionChallenge/SessionLogin`; - - session key = публичный `subserver key`; + - session key = публичный `homeserver key`; - подтверждение создания сессии подписывается `device key`; - heartbeat выполняется `Ping` раз в минуту. @@ -164,26 +164,26 @@ - заголовок `ACCOUNT`; - статусное сообщение; - кнопку `Login ()`; -- кнопку `Subserver ()`; +- кнопку `Homeserver ()`; - кнопку `Secret (<*****|not set>)`. Переходы: - свайп вправо -> `SETTINGS_MENU` - `Login` -> `TEXT_EDIT_SCREEN` -- `Subserver` -> `ACCOUNT_SUBSERVER_SCREEN` +- `Homeserver` -> `ACCOUNT_HOMESERVER_SCREEN` - `Secret` -> `ACCOUNT_SECRET_SCREEN` -## ACCOUNT_SUBSERVER_SCREEN +## ACCOUNT_HOMESERVER_SCREEN Показывает: -- текущий `subserver`; -- рекомендацию оставить `subserver1`, если устройство одно; -- кнопку `USE SUBSERVER1`; +- текущий `homeserver`; +- рекомендацию оставить `homeserver1`, если устройство одно; +- кнопку `USE HOMESERVER1`; - кнопку `EDIT MANUALLY`; - кнопку `BACK`. Переходы: -- `USE SUBSERVER1` -> сохраняет `subserver1` и возвращает в `ACCOUNT_SCREEN` +- `USE HOMESERVER1` -> сохраняет `homeserver1` и возвращает в `ACCOUNT_SCREEN` - `EDIT MANUALLY` -> `TEXT_EDIT_SCREEN` - свайп вправо -> `ACCOUNT_SCREEN` @@ -212,8 +212,8 @@ - `Blockchain key priv (base58)`; - `Device key (base58)`; - `Device key priv (base58)`; -- `Subserver key (base58)`; -- `Subserver key priv (base58)`; +- `Homeserver key (base58)`; +- `Homeserver key priv (base58)`; - для каждого поля показывается формула derivation; - значения ключей показываются полными строками увеличенным шрифтом; - кнопку `BACK`. @@ -293,7 +293,7 @@ Используется `Preferences` (NVS памяти ESP32): - `login` -- `subserver` +- `homeserver` - `secret_set` ## Детали клавиатуры @@ -312,7 +312,7 @@ - `DEL` - `SAVE` - `CANCEL` -- ниже рамки клавиатурного блока остаётся отдельная тёмная полоса с версией `SHiNE subserver (v.0.18)`. +- ниже рамки клавиатурного блока остаётся отдельная тёмная полоса с версией `SHiNE homeserver (v.0.18)`. ## Жесты @@ -329,7 +329,7 @@ - `WIFI_SCREEN`: свайп вправо -> `SETTINGS_MENU` - `SERVER_SCREEN`: свайп вправо -> `SETTINGS_MENU` - `ACCOUNT_SCREEN`: свайп вправо -> `SETTINGS_MENU` -- `ACCOUNT_SUBSERVER_SCREEN`: свайп вправо -> `ACCOUNT_SCREEN` +- `ACCOUNT_HOMESERVER_SCREEN`: свайп вправо -> `ACCOUNT_SCREEN` - `ACCOUNT_SECRET_SCREEN`: свайп вправо -> `ACCOUNT_SCREEN` - `TEXT_EDIT_SCREEN`: свайп влево/вправо -> переключение страниц клавиатуры - переключение страниц клавиатуры срабатывает только если свайп начался в зоне самой клавиатуры, а не по всему экрану редактора diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_subserver_ui_spec.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md similarity index 96% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_subserver_ui_spec.md rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md index 36f33de..2a97bc1 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_subserver_ui_spec.md +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md @@ -1,8 +1,8 @@ -# SHiNE ESP32 Subserver UI Spec +# SHiNE ESP32 Homeserver UI Spec ## Назначение -Этот документ описывает актуальный UI-прототип сабсервера `SHiNE` для платы `Waveshare ESP32-S3-Touch-AMOLED-2.16`. +Этот документ описывает актуальный UI-прототип homeserver `SHiNE` для платы `Waveshare ESP32-S3-Touch-AMOLED-2.16`. Документ является источником истины для Arduino-скетча: @@ -37,13 +37,13 @@ ## Основная идея устройства -Устройство работает как отдельный сабсервер: +Устройство работает как отдельный homeserver: - хранит секрет на самом устройстве; -- позволяет ввести логин, секрет и имя сабсервера; +- позволяет ввести логин, секрет и имя homeserver; - показывает адрес кошелька устройства; - позволяет пополнить баланс перед регистрацией; -- после выполнения условий даёт зарегистрировать устройство как сабсервер; +- после выполнения условий даёт зарегистрировать устройство как homeserver; - после регистрации может принимать входящие запросы на вход и на подпись. `SD`-карта не нужна для постоянного хранения секрета в этом прототипе. @@ -57,7 +57,7 @@ - `Wi-Fi SSID`; - `Wi-Fi password`; - `login`; -- `session/subserver name`; +- `session/homeserver name`; - `master secret`; - `wallet address`; - `user pda address`; @@ -142,7 +142,7 @@ - крупный статус регистрации; - имя логина; -- имя сабсервера; +- имя homeserver; - короткий статус Wi-Fi; - короткий статус сервера; - короткий статус баланса. @@ -162,14 +162,14 @@ Если регистрация уже сделана: -- вместо призыва к регистрации показывается статус `Сабсервер активен`. +- вместо призыва к регистрации показывается статус `Homeserver активен`. ## Экран STATUS Показывает сводку: - логин; -- сабсервер; +- homeserver; - есть ли секрет; - зарегистрировано ли устройство; - подключён ли Wi-Fi; @@ -256,7 +256,7 @@ Показывает: - логин; -- имя сабсервера; +- имя homeserver; - статус секрета; - короткий отпечаток секрета; - статус регистрации; @@ -266,7 +266,7 @@ - `Изменить логин` - `Секрет` -- `Имя сабсервера` +- `Имя homeserver` - `Сгенерировать` - `Очистить` - `Назад` @@ -394,7 +394,7 @@ QR должен быть сканируемым, а не декоративны - `SSID` - `Пароль Wi-Fi` - `Логин` -- `Имя сабсервера` +- `Имя homeserver` - `API URL` - `RPC URL` - `WS URL` @@ -432,13 +432,13 @@ QR должен быть сканируемым, а не декоративны 5. проверить или задать серверные адреса; 6. открыть `Аккаунт`; 7. ввести логин; -8. задать имя сабсервера; +8. задать имя homeserver; 9. сгенерировать секрет; 10. открыть `Кошелёк`; 11. при необходимости пополнить баланс; 12. вернуться на `HOME`; 13. нажать `Зарегистрировать`; -14. после подтверждения увидеть статус `Сабсервер активен`. +14. после подтверждения увидеть статус `Homeserver активен`. Примечание: diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/README.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/README.md index 9ce964a..b2a3a28 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/README.md +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/README.md @@ -14,7 +14,7 @@ - `hello` — базовый тест экрана (пример `01_HelloWorld`) - `simple` — простой кастомный тест: экран + touch + запись/проигрывание + наклон (IMU) - `argon2` — генерация masterSecret через Argon2id с SD-картой как памятью (тест скорости) -- `subserver-ui` — основной UI-прототип сабсервера SHiNE: NVS, PIN, Wi-Fi, серверы, кошелёк, QR, запросы +- `homeserver-ui` — основной UI-прототип homeserver SHiNE: NVS, PIN, Wi-Fi, серверы, кошелёк, QR, запросы - `text-test` — диагностический экран рендера текста: default font, U8g2 ASCII, U8g2 кириллица, кнопки с подписями - `gfx-text-test` — тот же тест рендера текста, но уже внутри новой папки `test_sketches/` - `gfx-layout-test` — тест геометрии и нижних рядов кнопок @@ -22,9 +22,9 @@ - `lvgl-interaction-test` — экран на `LVGL` с большим числом кнопок и сообщением о нажатой кнопке - `lvgl-touch-debug-test` — точечная диагностика touch: сырые координаты, маркер точки и большая тест-кнопка `LVGL` - `lvgl-official-based-test` — наш минимальный экран, но на максимально близкой к официальному `LVGL_Widgets` инициализации -- `lvgl-subserver-touch-test` — гибрид: `LVGL`-интерфейс, но display/touch init и raw touch-read взяты из `shine_subserver_ui`; подтверждено на устройстве, touch работает, зелёных линий по краям нет +- `lvgl-subserver-touch-test` — гибрид: `LVGL`-интерфейс, но display/touch init и raw touch-read взяты из `shine_homeserver_ui`; подтверждено на устройстве, touch работает, зелёных линий по краям нет - `lvgl-russian-font-test` — тест кастомного `LVGL`-шрифта с кириллицей: русские кнопки, длинные подписи и статусы -- `lvgl-nav-minimal-test` — новый минимальный UI-каркас сабсервера: `HOME`, `SETTINGS_MENU`, `Wi-Fi`, `Server`, `Account`, свайпы, крупные кнопки и реальная настройка Wi-Fi с сохранением в NVS +- `lvgl-nav-minimal-test` — новый минимальный UI-каркас homeserver: `HOME`, `SETTINGS_MENU`, `Wi-Fi`, `Server`, `Account`, свайпы, крупные кнопки и реальная настройка Wi-Fi с сохранением в NVS Запуск: @@ -32,7 +32,7 @@ - `./burn.sh audio` - `./burn.sh hello` - `./burn.sh simple` -- `./burn.sh subserver-ui` +- `./burn.sh homeserver-ui` - `./burn.sh text-test` - `./burn.sh gfx-text-test` - `./burn.sh gfx-layout-test` @@ -43,4 +43,4 @@ - `./burn.sh lvgl-subserver-touch-test` - `./burn.sh lvgl-russian-font-test` - `./burn.sh lvgl-nav-minimal-test` -- `./flash_shine_subserver_ui.sh` - автоматически находит USB-порт и заливает `shine_subserver_ui` +- `./flash_shine_homeserver_ui.sh` - автоматически находит USB-порт и заливает `shine_homeserver_ui` diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/burn.sh b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/burn.sh index 4783e44..763e059 100755 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/burn.sh +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/burn.sh @@ -34,7 +34,7 @@ case "${MODE}" in audio) SKETCH_DIR="${DEMO_BASE}/examples/07_ES8311" ;; simple) SKETCH_DIR="${ROOT_DIR}/simple_av_test" ;; argon2) SKETCH_DIR="${ROOT_DIR}/argon2_sd_test" ;; - subserver-ui) SKETCH_DIR="${ROOT_DIR}/shine_subserver_ui" ;; + homeserver-ui) SKETCH_DIR="${ROOT_DIR}/shine_homeserver_ui" ;; text-test) SKETCH_DIR="${ROOT_DIR}/text_render_test" ;; gfx-text-test) SKETCH_DIR="${ROOT_DIR}/test_sketches/gfx_text_render_test" ;; gfx-layout-test) SKETCH_DIR="${ROOT_DIR}/test_sketches/gfx_button_layout_test" ;; @@ -47,7 +47,7 @@ case "${MODE}" in lvgl-nav-minimal-test) SKETCH_DIR="${ROOT_DIR}/test_sketches/lvgl_nav_minimal_test" ;; *) echo "Unknown mode: ${MODE}" >&2 - echo "Use one of: hello, widgets, audio, simple, argon2, subserver-ui, text-test, gfx-text-test, gfx-layout-test, lvgl-basic-test, lvgl-interaction-test, lvgl-touch-debug-test, lvgl-official-based-test, lvgl-subserver-touch-test, lvgl-russian-font-test, lvgl-nav-minimal-test" >&2 + echo "Use one of: hello, widgets, audio, simple, argon2, homeserver-ui, text-test, gfx-text-test, gfx-layout-test, lvgl-basic-test, lvgl-interaction-test, lvgl-touch-debug-test, lvgl-official-based-test, lvgl-subserver-touch-test, lvgl-russian-font-test, lvgl-nav-minimal-test" >&2 exit 2 ;; esac diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/flash_shine_subserver_ui.sh b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/flash_shine_homeserver_ui.sh similarity index 92% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/flash_shine_subserver_ui.sh rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/flash_shine_homeserver_ui.sh index 609b664..56ab206 100755 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/flash_shine_subserver_ui.sh +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/flash_shine_homeserver_ui.sh @@ -43,9 +43,9 @@ fi if [[ -z "${PORT}" ]]; then echo "Не удалось автоматически найти USB-порт ESP32." >&2 echo "Подключите плату и проверьте 'arduino-cli board list'." >&2 - echo "Либо укажите порт вручную: PORT=/dev/ttyACM0 ./flash_shine_subserver_ui.sh" >&2 + echo "Либо укажите порт вручную: PORT=/dev/ttyACM0 ./flash_shine_homeserver_ui.sh" >&2 exit 1 fi echo "== Найден порт: ${PORT}" -PORT="${PORT}" "${ROOT_DIR}/burn.sh" subserver-ui +PORT="${PORT}" "${ROOT_DIR}/burn.sh" homeserver-ui diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/shine_subserver_ui/qrcode_bridge.c b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/shine_homeserver_ui/qrcode_bridge.c similarity index 100% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/shine_subserver_ui/qrcode_bridge.c rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/shine_homeserver_ui/qrcode_bridge.c diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/shine_subserver_ui/shine_subserver_ui.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/shine_homeserver_ui/shine_homeserver_ui.ino similarity index 98% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/shine_subserver_ui/shine_subserver_ui.ino rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/shine_homeserver_ui/shine_homeserver_ui.ino index be6eeb6..e071a95 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/shine_subserver_ui/shine_subserver_ui.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/shine_homeserver_ui/shine_homeserver_ui.ino @@ -97,7 +97,7 @@ enum ActionId { ACT_VERIFY_SERVERS, ACT_SET_TEST_SERVERS, ACT_EDIT_LOGIN, - ACT_EDIT_SUBSERVER, + ACT_EDIT_HOMESERVER, ACT_GENERATE_SECRET, ACT_CLEAR_ACCOUNT, ACT_SHOW_QR, @@ -137,7 +137,7 @@ enum EditTarget { EDIT_SSID, EDIT_WIFI_PASSWORD, EDIT_LOGIN, - EDIT_SUBSERVER, + EDIT_HOMESERVER, EDIT_API, EDIT_RPC, EDIT_WS, @@ -174,7 +174,7 @@ struct AppData { String wifiSsid; String wifiPassword; String login; - String subserverName; + String homeserverName; String secret; String walletAddress; String userPdaAddress; @@ -551,7 +551,7 @@ static bool canRegister() { static String registrationSummary() { if (gData.registered) { - return "Сабсервер активен"; + return "Homeserver активен"; } if (!gData.wifiReady) { return "Нужен Wi-Fi"; @@ -1179,7 +1179,7 @@ static bool awaitTransactionConfirmation(const String &signatureB58, String &mes return false; } -static bool registerSubserverOnSolana(String &messageOut) { +static bool registerHomeserverOnSolana(String &messageOut) { messageOut = ""; if (!gDerivedKeys.ready) { if (!restoreDerivedKeysFromSecret()) { @@ -1656,7 +1656,7 @@ static bool refreshWalletBalance(String &messageOut) { static void seedRequests() { gRequests[0].type = "Вход в сессию"; gRequests[0].actor = "Chrome / aidarkc"; - gRequests[0].details = "Клиент просит подключиться к сабсерверу и открыть сессию без ввода пароля."; + gRequests[0].details = "Клиент просит подключиться к homeserverу и открыть сессию без ввода пароля."; gRequests[0].status = "Ожидает"; gRequests[1].type = "Подпись сообщения"; @@ -1670,7 +1670,7 @@ static void loadDefaults() { gData.wifiSsid = ""; gData.wifiPassword = ""; gData.login = ""; - gData.subserverName = "subserver1"; + gData.homeserverName = "homeserver1"; gData.secret = ""; gData.walletAddress = ""; gData.userPdaAddress = ""; @@ -1692,7 +1692,7 @@ static void saveData() { gPrefs.putString("wifi_ssid", gData.wifiSsid); gPrefs.putString("wifi_pass", gData.wifiPassword); gPrefs.putString("login", gData.login); - gPrefs.putString("subserver", gData.subserverName); + gPrefs.putString("homeserver", gData.homeserverName); gPrefs.putString("secret", gData.secret); gPrefs.putString("wallet", gData.walletAddress); gPrefs.putString("user_pda", gData.userPdaAddress); @@ -1714,7 +1714,7 @@ static void loadData() { gData.wifiSsid = gPrefs.getString("wifi_ssid", gData.wifiSsid); gData.wifiPassword = gPrefs.getString("wifi_pass", gData.wifiPassword); gData.login = gPrefs.getString("login", gData.login); - gData.subserverName = gPrefs.getString("subserver", gData.subserverName); + gData.homeserverName = gPrefs.getString("homeserver", gData.homeserverName); gData.secret = gPrefs.getString("secret", gData.secret); gData.walletAddress = gPrefs.getString("wallet", gData.walletAddress); gData.userPdaAddress = gPrefs.getString("user_pda", gData.userPdaAddress); @@ -1758,8 +1758,8 @@ static void generateSecretAndWallet() { gData.registrationSignature = ""; gData.registered = false; gData.online = false; - if (gData.subserverName.length() == 0) { - gData.subserverName = "subserver1"; + if (gData.homeserverName.length() == 0) { + gData.homeserverName = "homeserver1"; } saveData(); } @@ -1815,7 +1815,7 @@ static String editTargetLabel() { case EDIT_SSID: return "SSID"; case EDIT_WIFI_PASSWORD: return "Пароль Wi-Fi"; case EDIT_LOGIN: return "Логин"; - case EDIT_SUBSERVER: return "Имя сабсервера"; + case EDIT_HOMESERVER: return "Имя homeserver"; case EDIT_API: return "API URL"; case EDIT_RPC: return "RPC URL"; case EDIT_WS: return "WS URL"; @@ -1846,7 +1846,7 @@ static void drawHomeScreen() { drawPanel(20, 92, 440, 98, C_PANEL, C_BORDER, 16); drawText(36, 122, registrationSummary(), canRegister() || gData.registered ? C_ACCENT : C_WARN, (const uint8_t *)FONT_HEAD); drawText(36, 152, "Логин: " + (gData.login.length() ? gData.login : "не задан"), C_TEXT, (const uint8_t *)FONT_BODY); - drawText(36, 174, "Сабсервер: " + gData.subserverName, C_MUTE, (const uint8_t *)FONT_BODY); + drawText(36, 174, "Homeserver: " + gData.homeserverName, C_MUTE, (const uint8_t *)FONT_BODY); drawPanel(20, 204, 210, 82, C_CARD, C_BORDER, 12); drawText(34, 232, "Wi-Fi", C_TEXT, (const uint8_t *)FONT_BODY); @@ -1871,7 +1871,7 @@ static void drawStatusScreen() { drawTopBar("Статус"); drawPanel(20, 92, 440, 286, C_PANEL, C_BORDER, 16); drawText(36, 122, "Логин: " + (gData.login.length() ? gData.login : "не задан"), C_TEXT); - drawText(36, 148, "Сабсервер: " + gData.subserverName, C_TEXT); + drawText(36, 148, "Homeserver: " + gData.homeserverName, C_TEXT); drawText(36, 174, "Секрет: " + boolText(gData.secretReady, "сохранён", "не задан"), gData.secretReady ? C_ACCENT : C_WARN); drawText(36, 200, "Отпечаток: " + (gData.secretReady ? shortenValue(gData.secret) : "-"), C_MUTE, (const uint8_t *)FONT_SMALL); drawText(36, 226, "Wi-Fi: " + boolText(gData.wifiReady, "готов", "не готов"), gData.wifiReady ? C_ACCENT : C_WARN); @@ -1947,13 +1947,13 @@ static void drawAccountScreen() { drawTopBar("Аккаунт"); drawPanel(20, 92, 440, 188, C_PANEL, C_BORDER, 16); drawText(36, 122, "Логин: " + (gData.login.length() ? gData.login : "не задан"), C_TEXT); - drawText(36, 152, "Сабсервер: " + gData.subserverName, C_TEXT); + drawText(36, 152, "Homeserver: " + gData.homeserverName, C_TEXT); drawText(36, 182, "Секрет: " + boolText(gData.secretReady, "сохранён", "не задан"), gData.secretReady ? C_ACCENT : C_WARN); drawText(36, 212, "Кошелёк: " + (gData.walletAddress.length() ? shortenValue(gData.walletAddress, 10, 8) : "не создан"), C_MUTE, (const uint8_t *)FONT_SMALL); drawText(36, 236, "Регистрация: " + boolText(gData.registered, "выполнена", "не выполнена"), gData.registered ? C_ACCENT : C_WARN); drawText(36, 260, "PDA: " + registrationDetailsShort(), C_MUTE, (const uint8_t *)FONT_SMALL); addButton(20, 300, 212, 48, ACT_EDIT_LOGIN, "Изменить логин"); - addButton(248, 300, 212, 48, ACT_EDIT_SUBSERVER, "Имя сабсервера"); + addButton(248, 300, 212, 48, ACT_EDIT_HOMESERVER, "Имя homeserver"); addButton(20, 360, 212, 48, ACT_GENERATE_SECRET, "Сгенерировать", true, C_OK); addButton(248, 360, 212, 48, ACT_CLEAR_ACCOUNT, "Очистить", true, C_BUTTON2); addButton(20, 420, 440, 36, ACT_BACK, "Назад"); @@ -2193,9 +2193,9 @@ static void applyEditValue() { gData.registrationSignature = ""; gNotice = "Логин сохранён"; break; - case EDIT_SUBSERVER: - gData.subserverName = value.length() ? value : "subserver1"; - gNotice = "Имя сабсервера сохранено"; + case EDIT_HOMESERVER: + gData.homeserverName = value.length() ? value : "homeserver1"; + gNotice = "Имя homeserver сохранено"; break; case EDIT_API: gData.apiUrl = value; @@ -2351,7 +2351,7 @@ static void handleAction(ActionId action) { } if (action == ACT_CONFIRM_YES) { if (gConfirmTarget == CONFIRM_REGISTER) { - registerSubserverOnSolana(gNotice); + registerHomeserverOnSolana(gNotice); } else if (gConfirmTarget == CONFIRM_CLEAR_ACCOUNT) { gData.secret = ""; gData.walletAddress = ""; @@ -2445,7 +2445,7 @@ static void handleAction(ActionId action) { gNeedRedraw = true; break; case ACT_EDIT_LOGIN: openEdit(EDIT_LOGIN, gData.login, false); break; - case ACT_EDIT_SUBSERVER: openEdit(EDIT_SUBSERVER, gData.subserverName, false); break; + case ACT_EDIT_HOMESERVER: openEdit(EDIT_HOMESERVER, gData.homeserverName, false); break; case ACT_GENERATE_SECRET: generateSecretAndWallet(); gNotice = gData.secretReady ? "Секрет сгенерирован, device-кошелёк выведен из него" : "Не удалось сгенерировать секрет"; diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/README.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/README.md index 19c379e..841dee6 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/README.md +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/README.md @@ -2,7 +2,7 @@ Набор отдельных диагностических скетчей для `Waveshare ESP32-S3-Touch-AMOLED-2.16`. -Скетчи в этой папке нужны для быстрой проверки конкретных гипотез без влияния на основной `shine_subserver_ui`. +Скетчи в этой папке нужны для быстрой проверки конкретных гипотез без влияния на основной `shine_homeserver_ui`. ## Список @@ -12,7 +12,7 @@ - `lvgl_interaction_test/` - расширенный тест `LVGL` с 9 кнопками, touch-вводом и статусом нажатия - `lvgl_touch_debug_test/` - диагностика touch: сырые координаты, точка касания и одна большая кнопка `LVGL` - `lvgl_official_based_test/` - минимальный наш экран поверх максимально близкой к официальному `05_LVGL_Widgets` инициализации -- `lvgl_subserver_touch_test/` - гибридный тест: `LVGL`-экран с инициализацией дисплея и чтением touch из `shine_subserver_ui`; подтверждён на реальном устройстве +- `lvgl_subserver_touch_test/` - гибридный тест: `LVGL`-экран с инициализацией дисплея и чтением touch из `shine_homeserver_ui`; подтверждён на реальном устройстве - `lvgl_russian_font_test/` - тест кастомного кириллического `LVGL`-шрифта с русскими кнопками, длинными строками и рабочим touch - `lvgl_nav_minimal_test/` - новый минимальный навигационный каркас сабсервера на рабочем `LVGL + subserver touch`, расширенный настройкой Wi-Fi и сохранением в NVS diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_nav_minimal_test/lvgl_nav_minimal_test.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_nav_minimal_test/lvgl_nav_minimal_test.ino index 1640db1..ae9bc23 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_nav_minimal_test/lvgl_nav_minimal_test.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_nav_minimal_test/lvgl_nav_minimal_test.ino @@ -48,7 +48,7 @@ #define TEXT_EDIT_PANEL_Y 112 #define TEXT_EDIT_PANEL_W 460 #define TEXT_EDIT_PANEL_H 330 -#define TEST_VERSION "SHiNE subserver (v.0.18)" +#define TEST_VERSION "SHiNE homeserver (v.0.18)" static const char *kShineUsersProgramId = "FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm"; static const char *kShineUsersUserPdaSeedPrefix = "user_login="; @@ -60,7 +60,7 @@ static const uint8_t kBlockTypeServerProfile = 30; static const uint8_t kBlockTypeAccessServers = 40; static const uint8_t kBlockTypeSessions = 50; static const uint8_t kBlockTypeTrustedState = 70; -static const uint8_t kSessionTypeSubserver = 100; +static const uint8_t kSessionTypeHomeserver = 100; enum Screen { SCREEN_HOME, @@ -68,7 +68,7 @@ enum Screen { SCREEN_WIFI, SCREEN_SERVER, SCREEN_ACCOUNT, - SCREEN_ACCOUNT_SUBSERVER, + SCREEN_ACCOUNT_HOMESERVER, SCREEN_ACCOUNT_SECRET, SCREEN_SECRET_SHOW, SCREEN_SECRET_GENERATE_INFO, @@ -99,10 +99,10 @@ enum ActionId { ACTION_SERVER_EDIT_SOLANA, ACTION_SERVER_EDIT_SHINE, ACTION_ACCOUNT_EDIT_LOGIN, - ACTION_ACCOUNT_EDIT_SUBSERVER, + ACTION_ACCOUNT_EDIT_HOMESERVER, ACTION_ACCOUNT_EDIT_SECRET, - ACTION_ACCOUNT_SUBSERVER_USE_DEFAULT, - ACTION_ACCOUNT_SUBSERVER_EDIT_MANUAL, + ACTION_ACCOUNT_HOMESERVER_USE_DEFAULT, + ACTION_ACCOUNT_HOMESERVER_EDIT_MANUAL, ACTION_SECRET_SHOW, ACTION_SECRET_MANUAL, ACTION_SECRET_GENERATE, @@ -129,7 +129,7 @@ enum EditContext { EDIT_CONTEXT_SOLANA_RPC, EDIT_CONTEXT_SHINE_SERVER, EDIT_CONTEXT_LOGIN, - EDIT_CONTEXT_SUBSERVER, + EDIT_CONTEXT_HOMESERVER, EDIT_CONTEXT_SECRET_MANUAL, EDIT_CONTEXT_SECRET_GENERATE_PASSWORD, }; @@ -216,7 +216,7 @@ static String gSolanaRpcUrl = "https://api.devnet.solana.com"; static String gShineServerUrl = "https://shineup.me"; static String gServerStatusMessage = "Edit RPC or shine host"; static String gLoginValue; -static String gSubserverValue = "subserver1"; +static String gHomeserverValue = "homeserver1"; static bool gSecretConfigured = false; static String gSecretBase58; static uint8_t gSecretBytes[32] = {}; @@ -257,8 +257,8 @@ static String gBlockchainPubB58; static String gBlockchainPrivB58; static String gDevicePubB58; static String gDevicePrivB58; -static String gSubserverPubB58; -static String gSubserverPrivB58; +static String gHomeserverPubB58; +static String gHomeserverPrivB58; static EditContext gEditContext = EDIT_CONTEXT_NONE; static Screen gEditReturnScreen = SCREEN_HOME; @@ -305,7 +305,7 @@ static void restoreTextareaFromEditValue(); static void refreshDerivedKeys(); static void clearDerivedKeys(); static String loginDisplayValue(); -static String subserverDisplayValue(); +static String homeserverDisplayValue(); static String homeSecretStatus(); static String secretButtonValue(); static void clearSecretValue(); @@ -687,13 +687,13 @@ static bool parseUrlHostPortPath(const String &url, String &hostOut, uint16_t &p return hostOut.length() > 0 && portOut > 0; } -static String subserverKeySuffix() { - String name = gSubserverValue; +static String homeserverKeySuffix() { + String name = gHomeserverValue; name.trim(); if (name.isEmpty()) { - name = "subserver1"; + name = "homeserver1"; } - return String("subserver.key:") + name; + return String("homeserver.key:") + name; } static void deriveKeyPairFromSecretSuffix(const uint8_t *secret32, const String &suffix, String &pubB58, String &privB58) { @@ -718,8 +718,8 @@ static void clearDerivedKeys() { gBlockchainPrivB58 = ""; gDevicePubB58 = ""; gDevicePrivB58 = ""; - gSubserverPubB58 = ""; - gSubserverPrivB58 = ""; + gHomeserverPubB58 = ""; + gHomeserverPrivB58 = ""; } static void refreshDerivedKeys() { @@ -730,7 +730,7 @@ static void refreshDerivedKeys() { deriveKeyPairFromSecretSuffix(gSecretBytes, "root.key", gRootPubB58, gRootPrivB58); deriveKeyPairFromSecretSuffix(gSecretBytes, "bch.key", gBlockchainPubB58, gBlockchainPrivB58); deriveKeyPairFromSecretSuffix(gSecretBytes, "dev.key", gDevicePubB58, gDevicePrivB58); - deriveKeyPairFromSecretSuffix(gSecretBytes, subserverKeySuffix(), gSubserverPubB58, gSubserverPrivB58); + deriveKeyPairFromSecretSuffix(gSecretBytes, homeserverKeySuffix(), gHomeserverPubB58, gHomeserverPrivB58); } static bool deriveKeypairFromSeed32(const uint8_t seed32[32], uint8_t pub32[32], uint8_t sec64[64]) { @@ -1302,20 +1302,20 @@ static void refreshAccountPdaStatus() { mismatch = "blockchain key mismatch"; } else if (memcmp(devicePub, pdaState.deviceKey32, 32) != 0) { mismatch = "device key mismatch"; - } else if (gSubserverValue.isEmpty()) { - mismatch = "subserver not set"; + } else if (gHomeserverValue.isEmpty()) { + mismatch = "homeserver not set"; } else { bool foundSession = false; bool sessionMismatch = false; for (const auto &session : pdaState.sessions) { - if (session.sessionType == kSessionTypeSubserver && session.sessionName == gSubserverValue) { + if (session.sessionType == kSessionTypeHomeserver && session.sessionName == gHomeserverValue) { foundSession = true; - if (gSubserverPubB58.isEmpty()) { + if (gHomeserverPubB58.isEmpty()) { sessionMismatch = true; } else { - uint8_t subserverPub[32] = {}; - if (!base58ToFixed32(gSubserverPubB58, subserverPub) - || memcmp(subserverPub, session.sessionPubKey32, 32) != 0) { + uint8_t homeserverPub[32] = {}; + if (!base58ToFixed32(gHomeserverPubB58, homeserverPub) + || memcmp(homeserverPub, session.sessionPubKey32, 32) != 0) { sessionMismatch = true; } } @@ -1323,9 +1323,9 @@ static void refreshAccountPdaStatus() { } } if (!foundSession) { - mismatch = "subserver not in PDA"; + mismatch = "homeserver not in PDA"; } else if (sessionMismatch) { - mismatch = "subserver key mismatch"; + mismatch = "homeserver key mismatch"; } } @@ -1565,7 +1565,7 @@ static bool ensureShineSessionAuthenticated(String &errorOut) { errorOut = "Wi-Fi disconnected"; return false; } - if (gLoginValue.isEmpty() || !gSecretConfigured || gSubserverValue.isEmpty()) { + if (gLoginValue.isEmpty() || !gSecretConfigured || gHomeserverValue.isEmpty()) { errorOut = "account not configured"; return false; } @@ -1605,7 +1605,7 @@ static bool ensureShineSessionAuthenticated(String &errorOut) { uint8_t subPub[32] = {}; uint8_t subSec[64] = {}; if (!deriveSeedKeypairFromBase58(gDevicePrivB58, deviceSeed, devicePub, deviceSec) - || !deriveSeedKeypairFromBase58(gSubserverPrivB58, subSeed, subPub, subSec)) { + || !deriveSeedKeypairFromBase58(gHomeserverPrivB58, subSeed, subPub, subSec)) { errorOut = "local key derive failed"; return false; } @@ -1634,7 +1634,7 @@ static bool ensureShineSessionAuthenticated(String &errorOut) { + "\",\"sessionKey\":\"" + jsonEscape(sessionKey) + "\",\"timeMs\":" + String((unsigned long long)timeMs) + ",\"signatureB64\":\"" + jsonEscape(bytesToBase64String(signature, 64)) - + "\",\"clientInfo\":\"ESP32 subserver\"}"; + + "\",\"clientInfo\":\"ESP32 homeserver\"}"; String loginResp; if (shineWsRequest(gShineWs, "SessionLogin", loginReq, loginResp)) { if (jsonInt64Field(loginResp, "status", statusCode) && statusCode == 200) { @@ -1691,7 +1691,7 @@ static bool ensureShineSessionAuthenticated(String &errorOut) { + ",\"authNonce\":\"" + jsonEscape(authNonce) + "\",\"deviceKey\":\"" + jsonEscape(bytesToBase64String(devicePub, 32)) + "\",\"signatureB64\":\"" + jsonEscape(bytesToBase64String(signature, 64)) - + "\",\"clientInfo\":\"ESP32 subserver\"}"; + + "\",\"clientInfo\":\"ESP32 homeserver\"}"; String createResp; if (!shineWsRequest(gShineWs, "CreateAuthSession", createReq, createResp)) { errorOut = "CreateAuthSession failed"; @@ -1710,7 +1710,7 @@ static bool ensureShineSessionAuthenticated(String &errorOut) { static void manageShineConnection() { String serverLabel = gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl; - if (gLoginValue.isEmpty() || !gSecretConfigured || gSubserverValue.isEmpty()) { + if (gLoginValue.isEmpty() || !gSecretConfigured || gHomeserverValue.isEmpty()) { gShineStatusLine = String("SHiNE: ") + serverLabel + " account not configured"; clearShineSessionState(false); return; @@ -1802,7 +1802,7 @@ static void loadPrefs() { gSolanaRpcUrl = gPrefs.getString("solana_rpc", "https://api.devnet.solana.com"); gShineServerUrl = gPrefs.getString("shine_server", "https://shineup.me"); gLoginValue = gPrefs.getString("login", ""); - gSubserverValue = gPrefs.getString("subserver", "subserver1"); + gHomeserverValue = gPrefs.getString("homeserver", "homeserver1"); gSecretConfigured = gPrefs.getBool("secret_set", false); gSecretBase58 = gPrefs.getString("secret_b58", ""); if (gSecretConfigured && gPrefs.getBytesLength("secret_bytes") == 32) { @@ -1850,7 +1850,7 @@ static void saveServerPrefs() { static void saveAccountPrefs() { gPrefs.putString("login", gLoginValue); - gPrefs.putString("subserver", gSubserverValue); + gPrefs.putString("homeserver", gHomeserverValue); gPrefs.putBool("secret_set", gSecretConfigured); gPrefs.putString("secret_b58", gSecretBase58); if (gSecretConfigured) { @@ -1925,8 +1925,8 @@ static String loginDisplayValue() { return gLoginValue.isEmpty() ? "login not set" : gLoginValue; } -static String subserverDisplayValue() { - return gSubserverValue.isEmpty() ? "subserver not set" : gSubserverValue; +static String homeserverDisplayValue() { + return gHomeserverValue.isEmpty() ? "homeserver not set" : gHomeserverValue; } static String homeSecretStatus() { @@ -2234,13 +2234,13 @@ static void applyEditorValue() { return; } - if (gEditContext == EDIT_CONTEXT_SUBSERVER) { + if (gEditContext == EDIT_CONTEXT_HOMESERVER) { value.trim(); - gSubserverValue = value; + gHomeserverValue = value; refreshDerivedKeys(); saveAccountPrefs(); markAccountStateDirty(); - gAccountStatusMessage = gSubserverValue.isEmpty() ? "Subserver cleared" : "Subserver saved"; + gAccountStatusMessage = gHomeserverValue.isEmpty() ? "Homeserver cleared" : "Homeserver saved"; showScreen(SCREEN_ACCOUNT); return; } @@ -2465,8 +2465,8 @@ static void actionButtonCb(lv_event_t *event) { gLoginValue, false); break; - case ACTION_ACCOUNT_EDIT_SUBSERVER: - showScreen(SCREEN_ACCOUNT_SUBSERVER); + case ACTION_ACCOUNT_EDIT_HOMESERVER: + showScreen(SCREEN_ACCOUNT_HOMESERVER); break; case ACTION_ACCOUNT_EDIT_SECRET: showScreen(SCREEN_ACCOUNT_SECRET); @@ -2506,20 +2506,20 @@ static void actionButtonCb(lv_event_t *event) { case ACTION_SECRET_GENERATE_CANCEL_NO: showScreen(SCREEN_SECRET_GENERATE_RUNNING); break; - case ACTION_ACCOUNT_SUBSERVER_USE_DEFAULT: - gSubserverValue = "subserver1"; + case ACTION_ACCOUNT_HOMESERVER_USE_DEFAULT: + gHomeserverValue = "homeserver1"; refreshDerivedKeys(); saveAccountPrefs(); markAccountStateDirty(); - gAccountStatusMessage = "Subserver set to subserver1"; + gAccountStatusMessage = "Homeserver set to homeserver1"; showScreen(SCREEN_ACCOUNT); break; - case ACTION_ACCOUNT_SUBSERVER_EDIT_MANUAL: - openEditor(EDIT_CONTEXT_SUBSERVER, + case ACTION_ACCOUNT_HOMESERVER_EDIT_MANUAL: + openEditor(EDIT_CONTEXT_HOMESERVER, SCREEN_ACCOUNT, - "EDIT SUBSERVER", + "EDIT HOMESERVER", "", - gSubserverValue, + gHomeserverValue, false); break; case ACTION_BACK_SECRET_MENU: @@ -2616,17 +2616,17 @@ static void makeVersionTag() { static void drawHome() { setRootStyle(); - lv_obj_t *subserver = lv_label_create(gRoot); - lv_label_set_text(subserver, subserverDisplayValue().c_str()); - lv_obj_set_style_text_font(subserver, &lv_font_montserrat_18, 0); - lv_obj_set_style_text_color(subserver, lv_color_hex(0xFFFFFF), 0); - lv_obj_align(subserver, LV_ALIGN_TOP_LEFT, 24, 18); + lv_obj_t *homeserver = lv_label_create(gRoot); + lv_label_set_text(homeserver, homeserverDisplayValue().c_str()); + lv_obj_set_style_text_font(homeserver, &lv_font_montserrat_18, 0); + lv_obj_set_style_text_color(homeserver, lv_color_hex(0xFFFFFF), 0); + lv_obj_align(homeserver, LV_ALIGN_TOP_LEFT, 24, 18); lv_obj_t *login = lv_label_create(gRoot); lv_label_set_text(login, loginDisplayValue().c_str()); lv_obj_set_style_text_font(login, &lv_font_montserrat_22, 0); lv_obj_set_style_text_color(login, lv_color_hex(0xD5DEE7), 0); - lv_obj_align_to(login, subserver, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 6); + lv_obj_align_to(login, homeserver, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 6); lv_obj_t *accountDot = lv_obj_create(gRoot); lv_obj_set_size(accountDot, 14, 14); @@ -2793,23 +2793,23 @@ static void drawAccountScreen() { showMessageAt(gAccountStatusMessage, 56); String loginButton = String("Login (") + (gLoginValue.isEmpty() ? "not set" : gLoginValue) + ")"; - String subserverButton = String("Subserver (") + (gSubserverValue.isEmpty() ? "not set" : gSubserverValue) + ")"; + String homeserverButton = String("Homeserver (") + (gHomeserverValue.isEmpty() ? "not set" : gHomeserverValue) + ")"; String secretButton = String("Secret (") + secretButtonValue() + ")"; makeButton(loginButton.c_str(), 22, 118, 436, 84, 0x355C7D, ACTION_ACCOUNT_EDIT_LOGIN, &lv_font_montserrat_20); - makeButton(subserverButton.c_str(), 22, 222, 436, 84, 0x355C7D, ACTION_ACCOUNT_EDIT_SUBSERVER, &lv_font_montserrat_20); + makeButton(homeserverButton.c_str(), 22, 222, 436, 84, 0x355C7D, ACTION_ACCOUNT_EDIT_HOMESERVER, &lv_font_montserrat_20); makeButton(secretButton.c_str(), 22, 326, 436, 84, 0x355C7D, ACTION_ACCOUNT_EDIT_SECRET, &lv_font_montserrat_20); makeBody("Swipe right to return to Settings.", 420, 420); makeVersionTag(); } -static void drawAccountSubserverScreen() { +static void drawAccountHomeserverScreen() { setRootStyle(); - makeTitle("SUBSERVER", 18, &lv_font_montserrat_24); - showMessageAt(String("Current: ") + subserverDisplayValue(), 56); - makeBody("If you only use one subserver, keep the default name subserver1.", 98, 420); - makeButton("USE SUBSERVER1", 22, 202, 436, 84, 0x2A9D8F, ACTION_ACCOUNT_SUBSERVER_USE_DEFAULT, &lv_font_montserrat_22); - makeButton("EDIT MANUALLY", 22, 306, 436, 84, 0x355C7D, ACTION_ACCOUNT_SUBSERVER_EDIT_MANUAL, &lv_font_montserrat_22); + makeTitle("HOMESERVER", 18, &lv_font_montserrat_24); + showMessageAt(String("Current: ") + homeserverDisplayValue(), 56); + makeBody("If you only use one homeserver, keep the default name homeserver1.", 98, 420); + makeButton("USE HOMESERVER1", 22, 202, 436, 84, 0x2A9D8F, ACTION_ACCOUNT_HOMESERVER_USE_DEFAULT, &lv_font_montserrat_22); + makeButton("EDIT MANUALLY", 22, 306, 436, 84, 0x355C7D, ACTION_ACCOUNT_HOMESERVER_EDIT_MANUAL, &lv_font_montserrat_22); makeButton("BACK", 140, 402, 200, 54, 0x5A6570, ACTION_BACK_ACCOUNT, &lv_font_montserrat_20); makeVersionTag(); } @@ -2876,8 +2876,8 @@ static void drawSecretShowScreen() { addKeyBlock("Blockchain key priv (base58)", "sha256(base64(secret)|bch.key)", gBlockchainPrivB58); addKeyBlock("Device key (base58)", "pub from sha256(base64(secret)|dev.key)", gDevicePubB58); addKeyBlock("Device key priv (base58)", "sha256(base64(secret)|dev.key)", gDevicePrivB58); - addKeyBlock("Subserver key (base58)", String("pub from sha256(base64(secret)|") + subserverKeySuffix() + ")", gSubserverPubB58); - addKeyBlock("Subserver key priv (base58)", String("sha256(base64(secret)|") + subserverKeySuffix() + ")", gSubserverPrivB58); + addKeyBlock("Homeserver key (base58)", String("pub from sha256(base64(secret)|") + homeserverKeySuffix() + ")", gHomeserverPubB58); + addKeyBlock("Homeserver key priv (base58)", String("sha256(base64(secret)|") + homeserverKeySuffix() + ")", gHomeserverPrivB58); } else { showMessageAt("Secret not set", 96); } @@ -3084,8 +3084,8 @@ static void rebuildScreen() { case SCREEN_ACCOUNT: drawAccountScreen(); break; - case SCREEN_ACCOUNT_SUBSERVER: - drawAccountSubserverScreen(); + case SCREEN_ACCOUNT_HOMESERVER: + drawAccountHomeserverScreen(); break; case SCREEN_ACCOUNT_SECRET: drawAccountSecretScreen(); @@ -3219,7 +3219,7 @@ static void handleSwipe(SwipeDirection swipe) { case SCREEN_ACCOUNT: handleAccountSwipe(swipe); break; - case SCREEN_ACCOUNT_SUBSERVER: + case SCREEN_ACCOUNT_HOMESERVER: case SCREEN_ACCOUNT_SECRET: handleAccountSubscreenSwipe(swipe); break; diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_subserver_touch_test/lvgl_subserver_touch_test.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_subserver_touch_test/lvgl_subserver_touch_test.ino index d76b1e9..8308cca 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_subserver_touch_test/lvgl_subserver_touch_test.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_subserver_touch_test/lvgl_subserver_touch_test.ino @@ -4,7 +4,7 @@ #include #include -// Подтверждено на устройстве: LVGL-рендер работает вместе с touch-путём из shine_subserver_ui. +// Подтверждено на устройстве: LVGL-рендер работает вместе с touch-путём из shine_homeserver_ui. #define PIN_LCD_CS 12 #define PIN_LCD_SCLK 38 @@ -146,7 +146,7 @@ static void createUi() { lv_obj_align(gVersionLabel, LV_ALIGN_TOP_MID, 0, 12); lv_obj_t *subtitle = lv_label_create(screen); - lv_label_set_text(subtitle, "Touch path comes from shine_subserver_ui. Tap buttons and watch status."); + lv_label_set_text(subtitle, "Touch path comes from shine_homeserver_ui. Tap buttons and watch status."); lv_obj_set_width(subtitle, 436); lv_label_set_long_mode(subtitle, LV_LABEL_LONG_WRAP); lv_obj_set_style_text_font(subtitle, &lv_font_montserrat_14, 0); diff --git a/VERSION.properties b/VERSION.properties index 38e42da..6fff200 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.161 -server.version=1.2.150 +client.version=1.2.162 +server.version=1.2.151 diff --git a/shine-UI/js/pages/register-view.js b/shine-UI/js/pages/register-view.js index 4492b0a..3038f76 100644 --- a/shine-UI/js/pages/register-view.js +++ b/shine-UI/js/pages/register-view.js @@ -176,6 +176,11 @@ export function render({ navigate }) { const prevPassword = String(state.registrationDraft.password || ''); const nextLogin = String(loginInput.value.trim()); const nextPassword = String(passwordInput.value || ''); + if (nextPassword.length === 0) { + formError.textContent = 'Пустой пароль запрещён. Введите непустой пароль для регистрации.'; + formError.style.display = ''; + return; + } const credsChanged = prevLogin !== nextLogin || prevPassword !== nextPassword; state.registrationDraft.login = nextLogin; diff --git a/shine-UI/js/pages/topup-view.js b/shine-UI/js/pages/topup-view.js index 2c4a2eb..bfc87fd 100644 --- a/shine-UI/js/pages/topup-view.js +++ b/shine-UI/js/pages/topup-view.js @@ -1,7 +1,6 @@ import { renderHeader } from '../components/header.js'; import { state } from '../state.js'; import { - deriveWalletFromPassword, formatSol, getBalanceSol, getTopupSiteUrl, @@ -10,6 +9,21 @@ import { export const pageMeta = { id: 'topup-view', title: 'Пополнение счета', showAppChrome: false }; +// Канонический Solana-адрес пополнения = публичный device-ключ из сгенерированного набора ключей. +// Тот же путь, что в registration-payment-view (deriveUserWalletAddress); не выводим адрес +// напрямую из пароля, иначе он расходится с device-ключом регистрации. +async function deviceWalletAddressFromBundle() { + const keyBundle = state.registrationDraft.preGeneratedKeyBundle; + if (!keyBundle || !keyBundle.devicePair) { + throw new Error('Ключи ещё не сгенерированы. Вернитесь на экран регистрации.'); + } + const raw = atob(keyBundle.devicePair.publicKeyB64); + const bytes = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i += 1) bytes[i] = raw.charCodeAt(i); + const { PublicKey } = await import('https://esm.sh/@solana/web3.js@1.98.4'); + return new PublicKey(bytes).toBase58(); +} + export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack'; @@ -55,7 +69,7 @@ export function render({ navigate }) { Открыть сайт пополнения
-
Кошелёк для пополнения (wallet.key)
+
Кошелёк для пополнения (device key = Solana wallet)
`; card.children[3].append(walletRow); @@ -103,9 +117,9 @@ export function render({ navigate }) { (async () => { try { if (!walletValue.value) { - const wallet = await deriveWalletFromPassword(String(state.registrationDraft.password ?? '')); - state.registrationPayment.walletAddress = wallet.address; - walletValue.value = wallet.address; + const address = await deviceWalletAddressFromBundle(); + state.registrationPayment.walletAddress = address; + walletValue.value = address; } const topupSiteLink = card.querySelector('#topup-site-link'); if (topupSiteLink instanceof HTMLAnchorElement) { diff --git a/shine-UI/js/services/crypto-utils.js b/shine-UI/js/services/crypto-utils.js index f9b5ff5..d15f291 100644 --- a/shine-UI/js/services/crypto-utils.js +++ b/shine-UI/js/services/crypto-utils.js @@ -116,12 +116,6 @@ export async function sha256Text(text) { return sha256Bytes(utf8Bytes(text)); } -export async function derivePasswordSeed(password, suffix) { - const base = await sha256Text(password || ''); - const concat = `${bytesToBase64(base)}${suffix}`; - return sha256Text(concat); -} - function normalizeLoginForKdf(login) { return String(login || '').trim().toLowerCase(); } @@ -134,21 +128,6 @@ async function makeArgon2Salt(login, suffix) { return digest.slice(0, 16); } -async function derivePasswordSeedArgon2id({ login, password, suffix }) { - const normalizedLogin = normalizeLoginForKdf(login); - const normalizedPassword = String(password ?? ''); - const normalizedSuffix = String(suffix || '').trim(); - const salt = await makeArgon2Salt(normalizedLogin, normalizedSuffix); - const passBytes = utf8Bytes(`${normalizedLogin}\n${normalizedPassword}`); - const out = await argon2idAsync(passBytes, salt, { - t: 2, - m: 65536, - p: 1, - dkLen: 32, - }); - return new Uint8Array(out); -} - async function deriveMasterSecretArgon2id({ login, password, onProgress }) { const normalizedLogin = normalizeLoginForKdf(login); const normalizedPassword = String(password ?? ''); @@ -177,38 +156,13 @@ function ed25519Pkcs8FromSeed(seed32) { return out; } -export async function deriveEd25519FromPassword(password, suffix, options = {}) { - const normalizedPassword = String(password ?? ''); - const normalizedLogin = String(options?.login ?? ''); - const useLegacyEmptyPassword = normalizedPassword.length === 0; - const seed = useLegacyEmptyPassword - ? await derivePasswordSeed(normalizedPassword, suffix) - : await derivePasswordSeedArgon2id({ - login: normalizedLogin, - password: normalizedPassword, - suffix, - }); - const pkcs8 = ed25519Pkcs8FromSeed(seed); - const subtle = getSubtleApi(); - const privateKey = await subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']); - const jwk = await subtle.exportKey('jwk', privateKey); - if (!jwk.x) throw new Error('Не удалось получить публичный ключ Ed25519'); - - return { - privateKey, - publicKeyB64: bytesToBase64(base64ToBytes(base64UrlToBase64(jwk.x))), - privatePkcs8B64: bytesToBase64(pkcs8), - }; -} - export async function deriveMasterSecretFromPassword(password, options = {}) { const normalizedPassword = String(password ?? ''); const normalizedLogin = String(options?.login ?? ''); const onProgress = typeof options?.onProgress === 'function' ? options.onProgress : undefined; if (normalizedPassword.length === 0) { - const legacy = await derivePasswordSeed(normalizedPassword, 'master.secret'); - if (onProgress) onProgress(1); - return legacy; + // Пустой пароль запрещён: упрощённый легаси-путь убран, регистрация/вход требуют непустой пароль. + throw new Error('Пустой пароль запрещён: регистрация и вход требуют непустой пароль.'); } return deriveMasterSecretArgon2id({ login: normalizedLogin, diff --git a/shine-UI/js/services/shine-user-pda-service.js b/shine-UI/js/services/shine-user-pda-service.js index d4491b8..4d41627 100644 --- a/shine-UI/js/services/shine-user-pda-service.js +++ b/shine-UI/js/services/shine-user-pda-service.js @@ -25,7 +25,7 @@ const BLOCK_TYPE_SESSIONS = 50; const BLOCK_TYPE_TRUSTED_STATE = 70; const SESSIONS_MODE_MIXED = 1; const SESSION_TYPE_USER = 1; -const SESSION_TYPE_SUBSERVER = 100; +const SESSION_TYPE_HOMESERVER = 100; let solanaLibPromise = null; function loadSolanaLib() { diff --git a/shine-UI/js/services/solana-wallet-service.js b/shine-UI/js/services/solana-wallet-service.js index 4c5a882..4582a18 100644 --- a/shine-UI/js/services/solana-wallet-service.js +++ b/shine-UI/js/services/solana-wallet-service.js @@ -1,4 +1,3 @@ -import { deriveEd25519FromPassword } from './crypto-utils.js'; import { extractDeviceKey32FromStoredValue } from './device-key-utils.js'; import { loadEncryptedUserSecrets } from './key-vault.js'; import { SOLANA_ENDPOINT_DEFAULT } from '../solana-programs.js'; @@ -68,17 +67,6 @@ async function keypairFromPkcs8(pkcs8B64) { return solana.Keypair.fromSeed(seed32); } -export async function deriveWalletFromPassword(password) { - const keyBundle = await deriveEd25519FromPassword(String(password ?? ''), 'dev.key'); - const keypair = await keypairFromPkcs8(keyBundle.privatePkcs8B64); - return { - address: keypair.publicKey.toBase58(), - keypair, - devicePublicKeyB64: keyBundle.publicKeyB64, - devicePrivatePkcs8B64: keyBundle.privatePkcs8B64, - }; -} - export async function createRandomSolanaWallet() { const solana = await loadSolanaLib(); const keypair = solana.Keypair.generate(); diff --git a/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md b/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md index e520ff9..1039e38 100644 --- a/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md +++ b/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md @@ -90,7 +90,7 @@ UserPdaRecordV1 | `3` | `BlockchainRegistryBlock` | Один или несколько блокчейнов пользователя. | | `30` | `ServerProfileBlock` | Серверные данные пользователя. | | `40` | `AccessServersBlock` | Серверы доступа/relay. | -| `50` | `SessionsBlock` | Опубликованные пользовательские сессии и саб-серверы. | +| `50` | `SessionsBlock` | Опубликованные пользовательские сессии и homeserver-ы. | | `70` | `TrustedStateBlock` | Счетчик trusted-связей. | | `255` | `ReservedBlock` | Зарезервировано, пока не используется. | @@ -309,7 +309,7 @@ SessionRecord | Значение | Смысл | |----------|-------| | `1` | Обычная пользовательская сессия. | -| `100` | Саб-сервер пользователя. | +| `100` | Homeserver пользователя. | Правила: diff --git a/shine-solana/shine/doc/programs/shine_users.md b/shine-solana/shine/doc/programs/shine_users.md index 46a6c3d..7f01b48 100644 --- a/shine-solana/shine/doc/programs/shine_users.md +++ b/shine-solana/shine/doc/programs/shine_users.md @@ -91,6 +91,18 @@ system-переводом. Если бы создание шло строго ч Проверки повторной инициализации (`owner == System Program` и пустые данные) остаются и не зависят от баланса аккаунта. +### 3.4. Строгий список аккаунтов (нет «лишних» аккаунтов) + +Все инструкции `shine_users` читают строго фиксированный набор аккаунтов и после этого +явно требуют, чтобы в переданном списке больше ничего не было +(`require!(it.next().is_none(), InvalidInstruction)`). Если вызывающий добавит лишние +аккаунты в хвост, инструкция завершится ошибкой `InvalidInstruction (1)`. + +Это не закрывает отдельной уязвимости (каждый используемый аккаунт и так строго +валидируется по signer/owner/адресу PDA), а является defense-in-depth и приводит поведение +к единому виду с `shine_payments`, где такая же проверка стоит во всех инструкциях. +Списки аккаунтов в разделах ниже надо считать исчерпывающими и точными по количеству. + ## 4. Состояния программы ### 4.1. `UsersEconomyConfigState` diff --git a/shine-solana/shine/programs/shine_users/src/lib.rs b/shine-solana/shine/programs/shine_users/src/lib.rs index b59709e..eeb869e 100644 --- a/shine-solana/shine/programs/shine_users/src/lib.rs +++ b/shine-solana/shine/programs/shine_users/src/lib.rs @@ -40,7 +40,7 @@ const BLOCKCHAIN_TYPE_MAIN_USER: u8 = 1; const SESSIONS_MODE_MIXED: u8 = 1; const SESSIONS_MODE_PDA_ONLY: u8 = 10; const SESSION_TYPE_USER: u8 = 1; -const SESSION_TYPE_SUBSERVER: u8 = 100; +const SESSION_TYPE_HOMESERVER: u8 = 100; const LAST_BLOCK_STATE_PREFIX: &[u8] = b"SHiNE_LAST_BLOCK"; const IX_INIT_USERS_ECONOMY_CONFIG: u8 = 1; @@ -375,6 +375,7 @@ fn process_init_users_economy_config(program_id: &Pubkey, accounts: &[AccountInf let signer = next_account_info(&mut it)?; let users_economy_config_pda = next_account_info(&mut it)?; let system_program_ai = next_account_info(&mut it)?; + require!(it.next().is_none(), ShineUsersError::InvalidInstruction); require!(signer.is_signer, ShineUsersError::InvalidSigner); require_keys_eq!(*system_program_ai.key, system_program::id(), ShineUsersError::InvalidSystemProgram); @@ -407,6 +408,7 @@ fn process_update_users_economy_config(program_id: &Pubkey, accounts: &[AccountI let mut it = accounts.iter(); let signer = next_account_info(&mut it)?; let users_economy_config_pda = next_account_info(&mut it)?; + require!(it.next().is_none(), ShineUsersError::InvalidInstruction); require!(signer.is_signer, ShineUsersError::InvalidSigner); let dao_authority = Pubkey::from_str(settings::DAO_AUTHORITY).map_err(|_| ProgramError::from(ShineUsersError::InvalidSigner))?; @@ -433,6 +435,7 @@ fn process_create_user_pda(program_id: &Pubkey, accounts: &[AccountInfo], args: let instructions_sysvar = next_account_info(&mut it)?; let users_economy_config_pda = next_account_info(&mut it)?; let login_guard_program = next_account_info(&mut it)?; + require!(it.next().is_none(), ShineUsersError::InvalidInstruction); require!(signer.is_signer, ShineUsersError::InvalidSigner); require_keys_eq!(*signer.key, args.fields.device_key, ShineUsersError::InvalidSigner); @@ -514,6 +517,7 @@ fn process_update_user_pda(program_id: &Pubkey, accounts: &[AccountInfo], args: let inflow_vault = next_account_info(&mut it)?; let instructions_sysvar = next_account_info(&mut it)?; let users_economy_config_pda = next_account_info(&mut it)?; + require!(it.next().is_none(), ShineUsersError::InvalidInstruction); require!(signer.is_signer, ShineUsersError::InvalidSigner); require_keys_eq!(*signer.key, args.fields.device_key, ShineUsersError::InvalidSigner); @@ -976,7 +980,7 @@ fn validate_sessions_fields(mode: u8, sessions: &[SessionRecord]) -> ProgramResu } fn validate_session_record(session: &SessionRecord) -> ProgramResult { - require!(session.session_type == SESSION_TYPE_USER || session.session_type == SESSION_TYPE_SUBSERVER, ShineUsersError::InvalidRecordData); + require!(session.session_type == SESSION_TYPE_USER || session.session_type == SESSION_TYPE_HOMESERVER, ShineUsersError::InvalidRecordData); require!(session.session_version == 1, ShineUsersError::InvalidRecordData); let bytes = session.session_name.as_bytes(); require!(!bytes.is_empty(), ShineUsersError::InvalidRecordData); From d4a0185507378b52e475ae54443f80d26cdb00767e85d34582d4fb71927b06aa Mon Sep 17 00:00:00 2001 From: AidarKC Date: Fri, 12 Jun 2026 22:02:08 +0400 Subject: [PATCH 03/56] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=BD=D1=91?= =?UTF-8?q?=D1=81=20=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=BD=D0=BE=D0=B9=20ESP?= =?UTF-8?q?32-=D1=81=D0=BA=D0=B5=D1=82=D1=87=20=D0=B2=20main-device?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dev_Docs/Keys/DERIVATION.md | 8 ++++---- .../2026-06-08_1150_esp32_auto_flash_script.md | 6 +++--- .../2026-06-08_1940_esp32_nav_minimal_test.md | 2 +- ESP32/CODEX_PORTING_GUIDE.md | 4 ++-- .../CODEX_PORTING_GUIDE.md | 4 ++-- .../esp32/ESP32-S3-Touch-AMOLED-2.16/README.md | 9 ++++++--- .../{test-device => main-device}/README.md | 18 ++++++++++++------ .../argon2_sd_test/argon2_sd_test.ino | 0 .../argon2_sd_test/argon2_types.h | 0 .../{test-device => main-device}/burn.sh | 9 ++++++--- .../flash_shine_homeserver_main.sh} | 4 ++-- .../shine_homeserver_main/README.md | 14 ++++++++++++++ .../shine_homeserver_main.ino} | 0 .../shine_secret_generation.cpp | 0 .../shine_secret_generation.h | 0 .../main-device/shine_homeserver_ui/README.md | 6 ++++++ .../shine_homeserver_ui/qrcode_bridge.c | 0 .../shine_homeserver_ui.ino | 0 .../simple_av_test/audio_hal.h | 0 .../simple_av_test/es7210.cpp | 0 .../simple_av_test/es7210.h | 0 .../simple_av_test/es8311.c | 0 .../simple_av_test/es8311.h | 0 .../simple_av_test/es8311_reg.h | 0 .../simple_av_test/simple_av_test.ino | 0 .../test_sketches/README.md | 10 +++++----- .../gfx_button_layout_test.ino | 0 .../gfx_text_render_test.ino | 0 .../lvgl_basic_test/lvgl_basic_test.ino | 0 .../lvgl_interaction_test.ino | 0 .../lvgl_official_based_test.ino | 0 .../lvgl_russian_font_test/lv_font_ru.h | 0 .../lvgl_russian_font_test/lv_font_ru_18.c | 0 .../lvgl_russian_font_test/lv_font_ru_24.c | 0 .../lvgl_russian_font_test.ino | 0 .../lvgl_subserver_touch_test.ino | 0 .../lvgl_touch_debug_test.ino | 0 .../text_render_test/text_render_test.ino | 0 .../shine_homeserver_ui_nav_minimal_spec.md | 1 + .../reference/shine_homeserver_ui_spec.md | 1 + VERSION.properties | 4 ++-- 41 files changed, 67 insertions(+), 33 deletions(-) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device => main-device}/README.md (66%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device => main-device}/argon2_sd_test/argon2_sd_test.ino (100%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device => main-device}/argon2_sd_test/argon2_types.h (100%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device => main-device}/burn.sh (83%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device/flash_shine_homeserver_ui.sh => main-device/flash_shine_homeserver_main.sh} (91%) create mode 100644 ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/README.md rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device/test_sketches/lvgl_nav_minimal_test/lvgl_nav_minimal_test.ino => main-device/shine_homeserver_main/shine_homeserver_main.ino} (100%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device/test_sketches/lvgl_nav_minimal_test => main-device/shine_homeserver_main}/shine_secret_generation.cpp (100%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device/test_sketches/lvgl_nav_minimal_test => main-device/shine_homeserver_main}/shine_secret_generation.h (100%) create mode 100644 ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_ui/README.md rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device => main-device}/shine_homeserver_ui/qrcode_bridge.c (100%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device => main-device}/shine_homeserver_ui/shine_homeserver_ui.ino (100%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device => main-device}/simple_av_test/audio_hal.h (100%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device => main-device}/simple_av_test/es7210.cpp (100%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device => main-device}/simple_av_test/es7210.h (100%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device => main-device}/simple_av_test/es8311.c (100%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device => main-device}/simple_av_test/es8311.h (100%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device => main-device}/simple_av_test/es8311_reg.h (100%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device => main-device}/simple_av_test/simple_av_test.ino (100%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device => main-device}/test_sketches/README.md (65%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device => main-device}/test_sketches/gfx_button_layout_test/gfx_button_layout_test.ino (100%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device => main-device}/test_sketches/gfx_text_render_test/gfx_text_render_test.ino (100%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device => main-device}/test_sketches/lvgl_basic_test/lvgl_basic_test.ino (100%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device => main-device}/test_sketches/lvgl_interaction_test/lvgl_interaction_test.ino (100%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device => main-device}/test_sketches/lvgl_official_based_test/lvgl_official_based_test.ino (100%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device => main-device}/test_sketches/lvgl_russian_font_test/lv_font_ru.h (100%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device => main-device}/test_sketches/lvgl_russian_font_test/lv_font_ru_18.c (100%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device => main-device}/test_sketches/lvgl_russian_font_test/lv_font_ru_24.c (100%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device => main-device}/test_sketches/lvgl_russian_font_test/lvgl_russian_font_test.ino (100%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device => main-device}/test_sketches/lvgl_subserver_touch_test/lvgl_subserver_touch_test.ino (100%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device => main-device}/test_sketches/lvgl_touch_debug_test/lvgl_touch_debug_test.ino (100%) rename ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/{test-device => main-device}/text_render_test/text_render_test.ino (100%) diff --git a/Dev_Docs/Keys/DERIVATION.md b/Dev_Docs/Keys/DERIVATION.md index 3b66907..b342522 100644 --- a/Dev_Docs/Keys/DERIVATION.md +++ b/Dev_Docs/Keys/DERIVATION.md @@ -123,11 +123,11 @@ homeserver.key:home-b -> ключ B Прежний расходящийся путь `deriveWalletFromPassword` (прямой Argon2 по `dev.key`, мимо `masterSecret`) удалён. ### Деривация ключей (прошивка ESP32) -- `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/shine_homeserver_ui/shine_homeserver_ui.ino` - - `deriveKeysFromMasterSecret` (~782), `restoreDerivedKeysFromSecret` (~806), `deriveFreshSecretAndWallet` (~829); +- `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino` + - основной скетч ESP32-проекта `SHiNE`; `deriveKeysFromMasterSecret` (~782), `restoreDerivedKeysFromSecret` (~806), `deriveFreshSecretAndWallet` (~829); - регистрация/подпись Solana: `registerHomeserverOnSolana` (~1182), `signMessageEd25519` (~1147). -- `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_nav_minimal_test/lvgl_nav_minimal_test.ino` - - homeserver-ключ: `homeserverKeySuffix` (~690), `deriveKeyPairFromSecretSuffix` (~699), `refreshDerivedKeys` (~725); суффикс `homeserver.key:<имя>`. +- `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_ui/shine_homeserver_ui.ino` + - старый тестовый вариант; оставлен как legacy-скетч для сравнения и диагностики. ### Формат PDA (куда попадают ключи) - `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` diff --git a/Dev_Docs/Pending_Features/2026-06-08_1150_esp32_auto_flash_script.md b/Dev_Docs/Pending_Features/2026-06-08_1150_esp32_auto_flash_script.md index 68f9af6..f2d019f 100644 --- a/Dev_Docs/Pending_Features/2026-06-08_1150_esp32_auto_flash_script.md +++ b/Dev_Docs/Pending_Features/2026-06-08_1150_esp32_auto_flash_script.md @@ -1,11 +1,11 @@ # ESP32 авто-прошивка shine_homeserver_ui - краткое описание фичи: - добавлен исполняемый скрипт `flash_shine_homeserver_ui.sh`, который автоматически ищет USB-порт `ESP32` и запускает заливку прошивки `shine_homeserver_ui` без ручного указания `PORT`. + добавлен исполняемый скрипт `flash_shine_homeserver_main.sh`, который автоматически ищет USB-порт `ESP32` и запускает заливку прошивки `shine_homeserver_ui` без ручного указания `PORT`. - что именно проверять: 1. Подключить плату `ESP32` по USB. - 2. Перейти в папку `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/`. - 3. Запустить `./flash_shine_homeserver_ui.sh`. + 2. Перейти в папку `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/`. + 3. Запустить `./flash_shine_homeserver_main.sh`. 4. Убедиться, что скрипт сам показывает найденный порт и успешно запускает compile/upload. - ожидаемый результат: скрипт без ручного ввода порта находит `ESP32`, печатает найденный `/dev/ttyACM*` или `/dev/ttyUSB*` и заливает `shine_homeserver_ui`. diff --git a/Dev_Docs/Pending_Features/2026-06-08_1940_esp32_nav_minimal_test.md b/Dev_Docs/Pending_Features/2026-06-08_1940_esp32_nav_minimal_test.md index 577565a..2e44049 100644 --- a/Dev_Docs/Pending_Features/2026-06-08_1940_esp32_nav_minimal_test.md +++ b/Dev_Docs/Pending_Features/2026-06-08_1940_esp32_nav_minimal_test.md @@ -1,6 +1,6 @@ # ESP32 nav minimal test -- Краткое описание: минимальный UI-прототип для homeserver на базе `LVGL + subserver touch`, с Wi-Fi flow, серверными адресами и общим экраном редактирования текста. +- Краткое описание: раннее имя основного UI-скетча `shine_homeserver_main/` для homeserver на базе `LVGL + subserver touch`, с Wi-Fi flow, серверными адресами и общим экраном редактирования текста. - Что проверять: - стартует экран `HOME`; - на `HOME` видны реальное значение homeserver или `homeserver not set`, реальное значение логина или `login not set`, при отсутствии секрета строка `secret not set`, а также `STATUS`, верхний правый блок с процентом батареи, иконкой батареи и индикатором Wi-Fi, кнопка баланса, строка `SHiNE: ...`, кнопка `SETTINGS` уменьшенной ширины у правого края и нижняя подпись `SHiNE homeserver (v.0.18)`; diff --git a/ESP32/CODEX_PORTING_GUIDE.md b/ESP32/CODEX_PORTING_GUIDE.md index 618e2ae..a08a600 100644 --- a/ESP32/CODEX_PORTING_GUIDE.md +++ b/ESP32/CODEX_PORTING_GUIDE.md @@ -34,7 +34,7 @@ ls -l /dev/ttyACM0 - `official-demo/` — официальный repo Waveshare (примеры+библиотеки) - `original-firmware/` — backup/restore заводской прошивки -- `test-device/` — прошивки и `burn.sh` +- `main-device/` — прошивки и `burn.sh` - `reference/` — заметки и ссылки ## 4) Бэкап перед любыми экспериментами @@ -59,7 +59,7 @@ cd ESP32-S3-Touch-AMOLED-2.16/original-firmware Главный скрипт: ```bash -cd ESP32-S3-Touch-AMOLED-2.16/test-device +cd ESP32-S3-Touch-AMOLED-2.16/main-device ./burn.sh ``` diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/CODEX_PORTING_GUIDE.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/CODEX_PORTING_GUIDE.md index 618e2ae..a08a600 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/CODEX_PORTING_GUIDE.md +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/CODEX_PORTING_GUIDE.md @@ -34,7 +34,7 @@ ls -l /dev/ttyACM0 - `official-demo/` — официальный repo Waveshare (примеры+библиотеки) - `original-firmware/` — backup/restore заводской прошивки -- `test-device/` — прошивки и `burn.sh` +- `main-device/` — прошивки и `burn.sh` - `reference/` — заметки и ссылки ## 4) Бэкап перед любыми экспериментами @@ -59,7 +59,7 @@ cd ESP32-S3-Touch-AMOLED-2.16/original-firmware Главный скрипт: ```bash -cd ESP32-S3-Touch-AMOLED-2.16/test-device +cd ESP32-S3-Touch-AMOLED-2.16/main-device ./burn.sh ``` diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/README.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/README.md index 77cf99d..ad7db00 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/README.md +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/README.md @@ -6,8 +6,9 @@ - `official-demo/` — официальный репозиторий примеров Waveshare - `original-firmware/` — резервная копия заводской прошивки -- `test-device/` — скрипты быстрой проверки устройства +- `main-device/` — скрипты быстрой проверки устройства и основной скетч `shine_homeserver_main/` - `reference/` — локальные заметки по документации и железу +- `main-device/shine_homeserver_main/` — основной рабочий скетч ESP32-проекта `SHiNE` Примечание по git: @@ -20,6 +21,8 @@ 1. Сделать backup текущей прошивки: - `cd original-firmware && ./backup_factory.sh` 2. Залить тест экрана/тача: - - `cd ../test-device && ./burn.sh widgets` + - `cd ../main-device && ./burn.sh widgets` 3. Залить тест динамика: - - `cd ../test-device && ./burn.sh audio` + - `cd ../main-device && ./burn.sh audio` +4. Залить основной UI: + - `cd ../main-device && ./burn.sh shine-homeserver-main` diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/README.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/README.md similarity index 66% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/README.md rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/README.md index b2a3a28..b6993a7 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/README.md +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/README.md @@ -1,6 +1,6 @@ -# Test Device +# Main Device -Скрипт заливает официальные Arduino-примеры для быстрой проверки платы. +Основной скетч homeserver и старые тестовые скетчи для быстрой проверки платы. `burn.sh` теперь: - сам пытается найти USB-порт ESP32; - сначала делает быструю инкрементальную сборку; @@ -14,7 +14,10 @@ - `hello` — базовый тест экрана (пример `01_HelloWorld`) - `simple` — простой кастомный тест: экран + touch + запись/проигрывание + наклон (IMU) - `argon2` — генерация masterSecret через Argon2id с SD-картой как памятью (тест скорости) -- `homeserver-ui` — основной UI-прототип homeserver SHiNE: NVS, PIN, Wi-Fi, серверы, кошелёк, QR, запросы +- `homeserver-ui` — совместимый алиас, указывает на `shine_homeserver_main/` +- `shine-homeserver-main` — основной скетч проекта `SHiNE` для ESP32, текущая рабочая версия UI +- `shine-homeserver-ui-main` — старое имя основного скетча, оставлено как совместимый алиас +- `legacy-homeserver-ui` — старый UI-прототип `shine_homeserver_ui/`, оставлен как тестовый и не является основным - `text-test` — диагностический экран рендера текста: default font, U8g2 ASCII, U8g2 кириллица, кнопки с подписями - `gfx-text-test` — тот же тест рендера текста, но уже внутри новой папки `test_sketches/` - `gfx-layout-test` — тест геометрии и нижних рядов кнопок @@ -22,9 +25,9 @@ - `lvgl-interaction-test` — экран на `LVGL` с большим числом кнопок и сообщением о нажатой кнопке - `lvgl-touch-debug-test` — точечная диагностика touch: сырые координаты, маркер точки и большая тест-кнопка `LVGL` - `lvgl-official-based-test` — наш минимальный экран, но на максимально близкой к официальному `LVGL_Widgets` инициализации -- `lvgl-subserver-touch-test` — гибрид: `LVGL`-интерфейс, но display/touch init и raw touch-read взяты из `shine_homeserver_ui`; подтверждено на устройстве, touch работает, зелёных линий по краям нет +- `lvgl-subserver-touch-test` — старый гибридный тест: `LVGL`-интерфейс, но display/touch init и raw touch-read взяты из старого `shine_homeserver_ui`; подтверждено на устройстве, touch работает, зелёных линий по краям нет - `lvgl-russian-font-test` — тест кастомного `LVGL`-шрифта с кириллицей: русские кнопки, длинные подписи и статусы -- `lvgl-nav-minimal-test` — новый минимальный UI-каркас homeserver: `HOME`, `SETTINGS_MENU`, `Wi-Fi`, `Server`, `Account`, свайпы, крупные кнопки и реальная настройка Wi-Fi с сохранением в NVS +- `lvgl-nav-minimal-test` — старое имя основного скетча, теперь ведёт на `shine_homeserver_main/` для совместимости Запуск: @@ -33,6 +36,9 @@ - `./burn.sh hello` - `./burn.sh simple` - `./burn.sh homeserver-ui` +- `./burn.sh shine-homeserver-main` +- `./burn.sh shine-homeserver-ui-main` +- `./burn.sh legacy-homeserver-ui` - `./burn.sh text-test` - `./burn.sh gfx-text-test` - `./burn.sh gfx-layout-test` @@ -43,4 +49,4 @@ - `./burn.sh lvgl-subserver-touch-test` - `./burn.sh lvgl-russian-font-test` - `./burn.sh lvgl-nav-minimal-test` -- `./flash_shine_homeserver_ui.sh` - автоматически находит USB-порт и заливает `shine_homeserver_ui` +- `./flash_shine_homeserver_main.sh` - автоматически находит USB-порт и заливает `shine_homeserver_main` diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/argon2_sd_test/argon2_sd_test.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/argon2_sd_test/argon2_sd_test.ino similarity index 100% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/argon2_sd_test/argon2_sd_test.ino rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/argon2_sd_test/argon2_sd_test.ino diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/argon2_sd_test/argon2_types.h b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/argon2_sd_test/argon2_types.h similarity index 100% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/argon2_sd_test/argon2_types.h rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/argon2_sd_test/argon2_types.h diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/burn.sh b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/burn.sh similarity index 83% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/burn.sh rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/burn.sh index 763e059..494bcba 100755 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/burn.sh +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/burn.sh @@ -34,7 +34,10 @@ case "${MODE}" in audio) SKETCH_DIR="${DEMO_BASE}/examples/07_ES8311" ;; simple) SKETCH_DIR="${ROOT_DIR}/simple_av_test" ;; argon2) SKETCH_DIR="${ROOT_DIR}/argon2_sd_test" ;; - homeserver-ui) SKETCH_DIR="${ROOT_DIR}/shine_homeserver_ui" ;; + homeserver-ui) SKETCH_DIR="${ROOT_DIR}/shine_homeserver_main" ;; + shine-homeserver-main) SKETCH_DIR="${ROOT_DIR}/shine_homeserver_main" ;; + shine-homeserver-ui-main) SKETCH_DIR="${ROOT_DIR}/shine_homeserver_main" ;; + legacy-homeserver-ui) SKETCH_DIR="${ROOT_DIR}/shine_homeserver_ui" ;; text-test) SKETCH_DIR="${ROOT_DIR}/text_render_test" ;; gfx-text-test) SKETCH_DIR="${ROOT_DIR}/test_sketches/gfx_text_render_test" ;; gfx-layout-test) SKETCH_DIR="${ROOT_DIR}/test_sketches/gfx_button_layout_test" ;; @@ -44,10 +47,10 @@ case "${MODE}" in lvgl-official-based-test) SKETCH_DIR="${ROOT_DIR}/test_sketches/lvgl_official_based_test" ;; lvgl-subserver-touch-test) SKETCH_DIR="${ROOT_DIR}/test_sketches/lvgl_subserver_touch_test" ;; lvgl-russian-font-test) SKETCH_DIR="${ROOT_DIR}/test_sketches/lvgl_russian_font_test" ;; - lvgl-nav-minimal-test) SKETCH_DIR="${ROOT_DIR}/test_sketches/lvgl_nav_minimal_test" ;; + lvgl-nav-minimal-test) SKETCH_DIR="${ROOT_DIR}/shine_homeserver_main" ;; *) echo "Unknown mode: ${MODE}" >&2 - echo "Use one of: hello, widgets, audio, simple, argon2, homeserver-ui, text-test, gfx-text-test, gfx-layout-test, lvgl-basic-test, lvgl-interaction-test, lvgl-touch-debug-test, lvgl-official-based-test, lvgl-subserver-touch-test, lvgl-russian-font-test, lvgl-nav-minimal-test" >&2 + echo "Use one of: hello, widgets, audio, simple, argon2, homeserver-ui, shine-homeserver-main, shine-homeserver-ui-main, legacy-homeserver-ui, text-test, gfx-text-test, gfx-layout-test, lvgl-basic-test, lvgl-interaction-test, lvgl-touch-debug-test, lvgl-official-based-test, lvgl-subserver-touch-test, lvgl-russian-font-test" >&2 exit 2 ;; esac diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/flash_shine_homeserver_ui.sh b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/flash_shine_homeserver_main.sh similarity index 91% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/flash_shine_homeserver_ui.sh rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/flash_shine_homeserver_main.sh index 56ab206..809b922 100755 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/flash_shine_homeserver_ui.sh +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/flash_shine_homeserver_main.sh @@ -43,9 +43,9 @@ fi if [[ -z "${PORT}" ]]; then echo "Не удалось автоматически найти USB-порт ESP32." >&2 echo "Подключите плату и проверьте 'arduino-cli board list'." >&2 - echo "Либо укажите порт вручную: PORT=/dev/ttyACM0 ./flash_shine_homeserver_ui.sh" >&2 + echo "Либо укажите порт вручную: PORT=/dev/ttyACM0 ./flash_shine_homeserver_main.sh" >&2 exit 1 fi echo "== Найден порт: ${PORT}" -PORT="${PORT}" "${ROOT_DIR}/burn.sh" homeserver-ui +PORT="${PORT}" "${ROOT_DIR}/burn.sh" shine-homeserver-main diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/README.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/README.md new file mode 100644 index 0000000..39e9dd8 --- /dev/null +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/README.md @@ -0,0 +1,14 @@ +# SHiNE Homeserver UI Main + +Это основной рабочий скетч ESP32-проекта `SHiNE`. + +Текущая каноническая точка запуска: + +- `./burn.sh shine-homeserver-main` +- `./burn.sh homeserver-ui` + +Историческое имя этого скетча: + +- `lvgl-nav-minimal-test` + +Прежние тестовые варианты для этой платы остаются в `main-device/test_sketches/` и должны восприниматься как старые диагностические сборки, а не как основной UI. diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_nav_minimal_test/lvgl_nav_minimal_test.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino similarity index 100% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_nav_minimal_test/lvgl_nav_minimal_test.ino rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_nav_minimal_test/shine_secret_generation.cpp b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_secret_generation.cpp similarity index 100% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_nav_minimal_test/shine_secret_generation.cpp rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_secret_generation.cpp diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_nav_minimal_test/shine_secret_generation.h b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_secret_generation.h similarity index 100% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_nav_minimal_test/shine_secret_generation.h rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_secret_generation.h diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_ui/README.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_ui/README.md new file mode 100644 index 0000000..2bc0e05 --- /dev/null +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_ui/README.md @@ -0,0 +1,6 @@ +# SHiNE Homeserver UI Legacy + +Это старый тестовый вариант UI для ESP32-платы `Waveshare ESP32-S3-Touch-AMOLED-2.16`. + +Не использовать как основной скетч проекта. +Основной рабочий скетч сейчас лежит в `../shine_homeserver_main/`. diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/shine_homeserver_ui/qrcode_bridge.c b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_ui/qrcode_bridge.c similarity index 100% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/shine_homeserver_ui/qrcode_bridge.c rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_ui/qrcode_bridge.c diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/shine_homeserver_ui/shine_homeserver_ui.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_ui/shine_homeserver_ui.ino similarity index 100% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/shine_homeserver_ui/shine_homeserver_ui.ino rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_ui/shine_homeserver_ui.ino diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/simple_av_test/audio_hal.h b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/simple_av_test/audio_hal.h similarity index 100% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/simple_av_test/audio_hal.h rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/simple_av_test/audio_hal.h diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/simple_av_test/es7210.cpp b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/simple_av_test/es7210.cpp similarity index 100% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/simple_av_test/es7210.cpp rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/simple_av_test/es7210.cpp diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/simple_av_test/es7210.h b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/simple_av_test/es7210.h similarity index 100% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/simple_av_test/es7210.h rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/simple_av_test/es7210.h diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/simple_av_test/es8311.c b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/simple_av_test/es8311.c similarity index 100% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/simple_av_test/es8311.c rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/simple_av_test/es8311.c diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/simple_av_test/es8311.h b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/simple_av_test/es8311.h similarity index 100% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/simple_av_test/es8311.h rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/simple_av_test/es8311.h diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/simple_av_test/es8311_reg.h b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/simple_av_test/es8311_reg.h similarity index 100% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/simple_av_test/es8311_reg.h rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/simple_av_test/es8311_reg.h diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/simple_av_test/simple_av_test.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/simple_av_test/simple_av_test.ino similarity index 100% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/simple_av_test/simple_av_test.ino rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/simple_av_test/simple_av_test.ino diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/README.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/test_sketches/README.md similarity index 65% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/README.md rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/test_sketches/README.md index 841dee6..782eb84 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/README.md +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/test_sketches/README.md @@ -1,8 +1,9 @@ # Test Sketches -Набор отдельных диагностических скетчей для `Waveshare ESP32-S3-Touch-AMOLED-2.16`. +Набор старых отдельных диагностических скетчей для `Waveshare ESP32-S3-Touch-AMOLED-2.16`. -Скетчи в этой папке нужны для быстрой проверки конкретных гипотез без влияния на основной `shine_homeserver_ui`. +Скетчи в этой папке нужны для быстрой проверки конкретных гипотез и не являются основным UI проекта. +Основной скетч сейчас лежит в `main-device/shine_homeserver_main/`. ## Список @@ -12,9 +13,9 @@ - `lvgl_interaction_test/` - расширенный тест `LVGL` с 9 кнопками, touch-вводом и статусом нажатия - `lvgl_touch_debug_test/` - диагностика touch: сырые координаты, точка касания и одна большая кнопка `LVGL` - `lvgl_official_based_test/` - минимальный наш экран поверх максимально близкой к официальному `05_LVGL_Widgets` инициализации -- `lvgl_subserver_touch_test/` - гибридный тест: `LVGL`-экран с инициализацией дисплея и чтением touch из `shine_homeserver_ui`; подтверждён на реальном устройстве +- `lvgl_subserver_touch_test/` - старый гибридный тест: `LVGL`-экран с инициализацией дисплея и чтением touch из старого `shine_homeserver_ui`; подтверждён на реальном устройстве - `lvgl_russian_font_test/` - тест кастомного кириллического `LVGL`-шрифта с русскими кнопками, длинными строками и рабочим touch -- `lvgl_nav_minimal_test/` - новый минимальный навигационный каркас сабсервера на рабочем `LVGL + subserver touch`, расширенный настройкой Wi-Fi и сохранением в NVS +- `lvgl_nav_minimal_test/` - старое тестовое имя, этот скетч перенесён в `shine_homeserver_main/` и теперь является основным ## Запуск @@ -28,4 +29,3 @@ - `./burn.sh lvgl-official-based-test` - `./burn.sh lvgl-subserver-touch-test` - `./burn.sh lvgl-russian-font-test` -- `./burn.sh lvgl-nav-minimal-test` diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/gfx_button_layout_test/gfx_button_layout_test.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/test_sketches/gfx_button_layout_test/gfx_button_layout_test.ino similarity index 100% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/gfx_button_layout_test/gfx_button_layout_test.ino rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/test_sketches/gfx_button_layout_test/gfx_button_layout_test.ino diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/gfx_text_render_test/gfx_text_render_test.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/test_sketches/gfx_text_render_test/gfx_text_render_test.ino similarity index 100% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/gfx_text_render_test/gfx_text_render_test.ino rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/test_sketches/gfx_text_render_test/gfx_text_render_test.ino diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_basic_test/lvgl_basic_test.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/test_sketches/lvgl_basic_test/lvgl_basic_test.ino similarity index 100% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_basic_test/lvgl_basic_test.ino rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/test_sketches/lvgl_basic_test/lvgl_basic_test.ino diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_interaction_test/lvgl_interaction_test.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/test_sketches/lvgl_interaction_test/lvgl_interaction_test.ino similarity index 100% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_interaction_test/lvgl_interaction_test.ino rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/test_sketches/lvgl_interaction_test/lvgl_interaction_test.ino diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_official_based_test/lvgl_official_based_test.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/test_sketches/lvgl_official_based_test/lvgl_official_based_test.ino similarity index 100% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_official_based_test/lvgl_official_based_test.ino rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/test_sketches/lvgl_official_based_test/lvgl_official_based_test.ino diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_russian_font_test/lv_font_ru.h b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/test_sketches/lvgl_russian_font_test/lv_font_ru.h similarity index 100% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_russian_font_test/lv_font_ru.h rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/test_sketches/lvgl_russian_font_test/lv_font_ru.h diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_russian_font_test/lv_font_ru_18.c b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/test_sketches/lvgl_russian_font_test/lv_font_ru_18.c similarity index 100% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_russian_font_test/lv_font_ru_18.c rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/test_sketches/lvgl_russian_font_test/lv_font_ru_18.c diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_russian_font_test/lv_font_ru_24.c b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/test_sketches/lvgl_russian_font_test/lv_font_ru_24.c similarity index 100% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_russian_font_test/lv_font_ru_24.c rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/test_sketches/lvgl_russian_font_test/lv_font_ru_24.c diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_russian_font_test/lvgl_russian_font_test.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/test_sketches/lvgl_russian_font_test/lvgl_russian_font_test.ino similarity index 100% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_russian_font_test/lvgl_russian_font_test.ino rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/test_sketches/lvgl_russian_font_test/lvgl_russian_font_test.ino diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_subserver_touch_test/lvgl_subserver_touch_test.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/test_sketches/lvgl_subserver_touch_test/lvgl_subserver_touch_test.ino similarity index 100% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_subserver_touch_test/lvgl_subserver_touch_test.ino rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/test_sketches/lvgl_subserver_touch_test/lvgl_subserver_touch_test.ino diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_touch_debug_test/lvgl_touch_debug_test.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/test_sketches/lvgl_touch_debug_test/lvgl_touch_debug_test.ino similarity index 100% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_touch_debug_test/lvgl_touch_debug_test.ino rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/test_sketches/lvgl_touch_debug_test/lvgl_touch_debug_test.ino diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/text_render_test/text_render_test.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/text_render_test/text_render_test.ino similarity index 100% rename from ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/text_render_test/text_render_test.ino rename to ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/text_render_test/text_render_test.ino diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_nav_minimal_spec.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_nav_minimal_spec.md index 252e6d0..a3db5d4 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_nav_minimal_spec.md +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_nav_minimal_spec.md @@ -1,6 +1,7 @@ # SHiNE ESP32 Homeserver UI Nav Minimal Spec Минимальный навигационный прототип для `Waveshare ESP32-S3-Touch-AMOLED-2.16`. +Этот прототип был перенесён в основной скетч `../main-device/shine_homeserver_main/`, а старое имя `lvgl-nav-minimal-test` осталось только как историческая ссылка. ## Цель diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md index 2a97bc1..63b4e8e 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md @@ -3,6 +3,7 @@ ## Назначение Этот документ описывает актуальный UI-прототип homeserver `SHiNE` для платы `Waveshare ESP32-S3-Touch-AMOLED-2.16`. +Актуальный основной Arduino-скетч лежит в `../main-device/shine_homeserver_main/`. Документ является источником истины для Arduino-скетча: diff --git a/VERSION.properties b/VERSION.properties index 6fff200..1f4cc56 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.162 -server.version=1.2.151 +client.version=1.2.163 +server.version=1.2.152 From b83543d018422dd5517c61992bd285052169391ffef127372d591d86b76a9295 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Fri, 12 Jun 2026 22:24:21 +0400 Subject: [PATCH 04/56] =?UTF-8?q?ESP32:=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D0=BE=20=D0=BA?= =?UTF-8?q?=D0=BD=D0=BE=D0=BF=D0=BA=D0=B5=20=D0=B8=20=D0=B7=D0=B0=D0=B7?= =?UTF-8?q?=D0=BE=D1=80=20=D0=BC=D0=B5=D0=B6=D0=B4=D1=83=20=D0=BA=D0=BD?= =?UTF-8?q?=D0=BE=D0=BF=D0=BA=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...06-07_1650_esp32_homeserver_ui_прототип.md | 6 +- .../2026-06-08_1940_esp32_nav_minimal_test.md | 6 +- .../shine_homeserver_main.ino | 599 +++++++++++++++++- .../shine_homeserver_ui_nav_minimal_spec.md | 7 +- .../reference/shine_homeserver_ui_spec.md | 10 +- VERSION.properties | 4 +- 6 files changed, 613 insertions(+), 19 deletions(-) diff --git a/Dev_Docs/Pending_Features/2026-06-07_1650_esp32_homeserver_ui_прототип.md b/Dev_Docs/Pending_Features/2026-06-07_1650_esp32_homeserver_ui_прототип.md index f3356da..bef19aa 100644 --- a/Dev_Docs/Pending_Features/2026-06-07_1650_esp32_homeserver_ui_прототип.md +++ b/Dev_Docs/Pending_Features/2026-06-07_1650_esp32_homeserver_ui_прототип.md @@ -12,9 +12,9 @@ 6. Открыть `Аккаунт`, ввести логин, имя homeserver и нажать `Сгенерировать`; проверить, что появились секрет и адрес кошелька, а после перезагрузки они не исчезают. 7. Открыть `Кошелёк`, нажать `Проверить` и убедиться, что баланс реально читается из `Solana RPC`; затем открыть `QR и URI` и проверить, что QR-код отрисовывается и сканируется как `solana:`-ссылка. 8. При необходимости отдельно проверить тестовые кнопки `+/- SOL`: они меняют локальный баланс для UX-сценариев, но после следующей реальной RPC-проверки баланс должен вернуться к сетевому значению. - 9. Вернуться на главный экран и проверить, что до выполнения всех условий кнопка регистрации недоступна, а после выполнения становится доступной. - 10. Выполнить регистрацию и убедиться, что статус меняется на `Homeserver активен`, онлайн-статус становится активным, а на экране появляются краткие отпечатки `PDA/TX`. - 11. После регистрации проверить через `Solana`/UI проекта, что `user_pda` для этого логина реально создана и соответствует `device`-адресу устройства. + 9. Вернуться на главный экран и проверить, что до выполнения всех условий кнопка регистрации недоступна, а после выполнения становится доступной; также убедиться, что между двумя нижними кнопками есть небольшой зазор. + 10. Нажать кнопку регистрации и убедиться, что она запускает on-chain flow сразу без отдельного confirm-экрана, а после завершения статус меняется на `Homeserver активен`, онлайн-статус становится активным, и на экране аккаунта появляются краткие отпечатки `PDA/TX`. + 11. После регистрации проверить через `Solana`/UI проекта, что `user_pda` для этого логина реально создана, сохранена в `NVS` и соответствует `device`-адресу устройства. 12. Открыть `Запросы`, поочерёдно открыть оба демонстрационных запроса и проверить, что кнопки `Разрешить` и `Отклонить` меняют их статус. 13. При необходимости открыть `Настройки -> Сменить PIN` и убедиться, что новый PIN сохраняется, хотя вход по PIN временно не используется на старте. 14. Выполнить `Полный сброс` и убедиться, что все поля, секрет, баланс, онлайн и регистрация очищаются. diff --git a/Dev_Docs/Pending_Features/2026-06-08_1940_esp32_nav_minimal_test.md b/Dev_Docs/Pending_Features/2026-06-08_1940_esp32_nav_minimal_test.md index 2e44049..f45add7 100644 --- a/Dev_Docs/Pending_Features/2026-06-08_1940_esp32_nav_minimal_test.md +++ b/Dev_Docs/Pending_Features/2026-06-08_1940_esp32_nav_minimal_test.md @@ -1,6 +1,6 @@ # ESP32 nav minimal test -- Краткое описание: раннее имя основного UI-скетча `shine_homeserver_main/` для homeserver на базе `LVGL + subserver touch`, с Wi-Fi flow, серверными адресами и общим экраном редактирования текста. +- Краткое описание: легаси-заметка по старому навигационному тесту UI для homeserver на базе `LVGL + subserver touch`. Актуальный основной скетч уже переехал в `shine_homeserver_main/`, а этот файл оставлен как историческая справка по старой версии. - Что проверять: - стартует экран `HOME`; - на `HOME` видны реальное значение homeserver или `homeserver not set`, реальное значение логина или `login not set`, при отсутствии секрета строка `secret not set`, а также `STATUS`, верхний правый блок с процентом батареи, иконкой батареи и индикатором Wi-Fi, кнопка баланса, строка `SHiNE: ...`, кнопка `SETTINGS` уменьшенной ширины у правого края и нижняя подпись `SHiNE homeserver (v.0.18)`; @@ -27,7 +27,7 @@ - кнопка `SETTINGS` открывает `SETTINGS_MENU`; - свайп влево на `HOME` открывает `SETTINGS_MENU`; - если пользователь не найден в Solana PDA, слева снизу появляется `REGISTER ACCOUNT`; - - `REGISTER ACCOUNT` открывает экран-заглушку; + - `REGISTER ACCOUNT` открывает экран-заглушку только в старой тестовой версии; - в `SETTINGS_MENU` сначала видны только `Wi-Fi` и `Server`; - обе видимые карточки меню одного цвета; - свайп вверх показывает `Server` и `Account`; @@ -101,4 +101,4 @@ - если во время реального свайпа палец проходит по кнопке, это не должно открывать кнопку как обычный `click`. - Ожидаемый результат: новый скетч даёт чистый навигационный каркас и уже умеет настраивать Wi-Fi и серверные адреса на самой ESP32. - Дополнительно ожидается: `HOME` уже показывает реальный Solana/WS-статус homeserver, а отсутствие пользователя в Solana заметно сразу без перехода в настройки. -- Статус: pending +- Статус: done diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino index ae9bc23..5919f63 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino @@ -52,6 +52,15 @@ static const char *kShineUsersProgramId = "FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm"; static const char *kShineUsersUserPdaSeedPrefix = "user_login="; +static const char *kShineLoginGuardProgramId = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo"; +static const char *kShinePaymentsProgramId = "c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW"; +static const char *kSystemProgramId = "11111111111111111111111111111111"; +static const char *kEd25519ProgramId = "Ed25519SigVerify111111111111111111111111111"; +static const char *kSysvarInstructionsId = "Sysvar1nstructions1111111111111111111111111"; +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 *kLastBlockPrefix = "SHiNE_LAST_BLOCK"; static const char *kProgramDerivedAddressMarker = "ProgramDerivedAddress"; static const uint8_t kBlockTypeRootKey = 1; static const uint8_t kBlockTypeDeviceKey = 2; @@ -234,6 +243,8 @@ static String gAccountPdaStatusMessage = "Account not checked"; static bool gAccountCheckPending = true; static unsigned long gLastAccountCheckMs = 0; static bool gShowRegisterAccountButton = false; +static String gUserPdaAddress; +static String gRegistrationSignature; static String gShineStatusLine = "SHiNE: account not configured"; static String gShineSessionId; static String gShineSessionKey; @@ -329,6 +340,51 @@ static String shineWsUrl(); static String shineHomeLine(); static String balanceHomeLine(); static uint64_t shineNowMs(); +static void shortVecEncode(size_t value, std::vector &out); +static void pushU32LE(std::vector &out, uint32_t value); +static void pushU64LE(std::vector &out, uint64_t value); +static void pushStrU8(std::vector &out, const String &value); +static void pushFixed(std::vector &out, const uint8_t *data, size_t len); +static String bytesToBase58(const uint8_t *data, size_t len); +static String buildBaseRpcRequest(const char *method, const String ¶msJson); +static bool rpcCallSolana(const char *method, const String ¶msJson, String &payloadOut); +static bool rpcResponseHasError(const String &payload); +static bool getLatestBlockhashBytes(uint8_t out[32], String &blockhashB58, String &messageOut); +static bool pdaAlreadyExists(const String &login, String &pdaAddress, String &messageOut); +static std::vector buildLastBlockStateBytes(const String &login, const String &blockchainName); +static std::vector buildUnsignedCreateRecord( + const String &login, + const String &blockchainName, + const String &serverAddress, + const uint8_t rootPub[32], + const uint8_t devicePub[32], + const uint8_t blockchainPub[32], + const uint8_t lastBlockSignature[64], + uint64_t createdAtMs); +static std::vector buildCreateInstructionData( + const String &login, + const String &blockchainName, + const String &serverAddress, + const uint8_t rootPub[32], + const uint8_t devicePub[32], + const uint8_t blockchainPub[32], + const uint8_t lastBlockSignature[64], + const uint8_t rootSignature[64], + uint64_t createdAtMs); +static std::vector buildEd25519InstructionData(const uint8_t signature[64], const uint8_t publicKey[32], const uint8_t messageHash[32]); +static std::vector buildLegacyMessage( + const uint8_t recentBlockhash[32], + const uint8_t devicePub[32], + const uint8_t userPda[32], + const uint8_t inflowVault[32], + const uint8_t economyConfig[32], + const std::vector &edRootData, + const std::vector &edBchData, + const std::vector &createData); +static bool signMessageEd25519(const std::vector &message, const uint8_t secretKey[64], uint8_t signature[64]); +static String encodeTransactionBase64(const uint8_t signature[64], const std::vector &message); +static bool awaitTransactionConfirmation(const String &signatureB58, String &messageOut); +static bool registerHomeserverOnSolana(String &messageOut); static String buildSessionKeyStringFromPublicBase64(const String &pubB64); static bool deriveKeypairFromSeed32(const uint8_t seed32[32], uint8_t pub32[32], uint8_t sec64[64]); static bool deriveSeedKeypairFromBase58(const String &seedB58, uint8_t seed32[32], uint8_t pub32[32], uint8_t sec64[64]); @@ -569,6 +625,13 @@ static String normalizeLoginValue(const String &value) { return out; } +static String abbreviateValue(const String &value, size_t head = 8, size_t tail = 6) { + if (value.length() <= head + tail + 3) { + return value; + } + return value.substring(0, head) + "..." + value.substring(value.length() - tail); +} + static String jsonEscape(const String &value) { String out; out.reserve(value.length() + 8); @@ -781,6 +844,8 @@ static void markAccountStateDirty() { gAccountPdaStatus = ACCOUNT_PDA_UNKNOWN; gAccountPdaStatusMessage = "Account not checked"; gShowRegisterAccountButton = false; + gUserPdaAddress = ""; + gRegistrationSignature = ""; clearShineSessionState(true); gShineStatusLine = "SHiNE: account not configured"; } @@ -965,6 +1030,501 @@ static uint64_t shineNowMs() { return value > 0 ? (uint64_t)value : (uint64_t)millis(); } +static void shortVecEncode(size_t value, std::vector &out) { + do { + uint8_t byte = value & 0x7F; + value >>= 7; + if (value) { + byte |= 0x80; + } + out.push_back(byte); + } while (value); +} + +static void pushU32LE(std::vector &out, uint32_t value) { + out.push_back((uint8_t)(value & 0xFF)); + out.push_back((uint8_t)((value >> 8) & 0xFF)); + out.push_back((uint8_t)((value >> 16) & 0xFF)); + out.push_back((uint8_t)((value >> 24) & 0xFF)); +} + +static void pushU64LE(std::vector &out, uint64_t value) { + for (int i = 0; i < 8; ++i) { + out.push_back((uint8_t)((value >> (8 * i)) & 0xFF)); + } +} + +static void pushStrU8(std::vector &out, const String &value) { + shortVecEncode(value.length(), out); + for (size_t i = 0; i < value.length(); ++i) { + out.push_back((uint8_t)value.charAt(i)); + } +} + +static void pushFixed(std::vector &out, const uint8_t *data, size_t len) { + out.insert(out.end(), data, data + len); +} + +static String bytesToBase58(const uint8_t *data, size_t len) { + char out[160] = {}; + shineSecretBase58Encode(data, len, out, sizeof(out)); + return String(out); +} + +static String buildBaseRpcRequest(const char *method, const String ¶msJson) { + return "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"" + String(method) + "\",\"params\":" + paramsJson + "}"; +} + +static bool rpcCallSolana(const char *method, const String ¶msJson, String &payloadOut) { + int code = -1; + if (!httpPostJson(gSolanaRpcUrl, buildBaseRpcRequest(method, paramsJson), code, payloadOut)) { + return false; + } + return code >= 200 && code < 300; +} + +static bool rpcResponseHasError(const String &payload) { + return payload.indexOf("\"error\"") >= 0; +} + +static std::vector buildLastBlockStateBytes(const String &login, const String &blockchainName) { + std::vector out; + out.reserve(80); + pushFixed(out, (const uint8_t *)kLastBlockPrefix, strlen(kLastBlockPrefix)); + pushStrU8(out, login); + pushStrU8(out, blockchainName); + pushU32LE(out, 0); + out.insert(out.end(), 32, 0); + pushU64LE(out, 0); + return out; +} + +static std::vector buildUnsignedCreateRecord( + const String &login, + const String &blockchainName, + const String &serverAddress, + const uint8_t rootPub[32], + const uint8_t devicePub[32], + const uint8_t blockchainPub[32], + const uint8_t lastBlockSignature[64], + uint64_t createdAtMs) { + std::vector out; + out.reserve(512); + pushFixed(out, (const uint8_t *)"SHiNE", 5); + out.push_back(1); + out.push_back(0); + out.push_back(0); + out.push_back(0); + pushU64LE(out, createdAtMs); + pushU64LE(out, createdAtMs); + pushU32LE(out, 0); + out.insert(out.end(), 32, 0); + pushStrU8(out, login); + out.push_back(7); + + out.push_back(kBlockTypeRootKey); + out.push_back(0); + pushFixed(out, rootPub, 32); + + out.push_back(kBlockTypeDeviceKey); + out.push_back(0); + pushFixed(out, devicePub, 32); + + out.push_back(kBlockTypeBlockchainRegistry); + out.push_back(0); + out.push_back(1); + out.push_back(1); + pushStrU8(out, blockchainName); + pushFixed(out, blockchainPub, 32); + pushU64LE(out, 100000); + pushU64LE(out, 0); + pushU32LE(out, 0); + out.insert(out.end(), 32, 0); + pushFixed(out, lastBlockSignature, 64); + out.push_back(0); + + out.push_back(kBlockTypeServerProfile); + out.push_back(0); + out.push_back(1); + out.push_back(1); + out.push_back(0); + pushStrU8(out, serverAddress); + out.push_back(0); + + out.push_back(kBlockTypeAccessServers); + out.push_back(0); + out.push_back(0); + + out.push_back(kBlockTypeSessions); + out.push_back(0); + out.push_back(1); + out.push_back(0); + + out.push_back(kBlockTypeTrustedState); + out.push_back(0); + out.push_back(0); + + uint16_t recordLen = (uint16_t)(out.size() + 64); + out[7] = (uint8_t)(recordLen & 0xFF); + out[8] = (uint8_t)((recordLen >> 8) & 0xFF); + return out; +} + +static std::vector buildCreateInstructionData( + const String &login, + const String &blockchainName, + const String &serverAddress, + const uint8_t rootPub[32], + const uint8_t devicePub[32], + const uint8_t blockchainPub[32], + const uint8_t lastBlockSignature[64], + const uint8_t rootSignature[64], + uint64_t createdAtMs) { + std::vector out; + out.reserve(512); + out.push_back(3); + pushStrU8(out, login); + pushFixed(out, rootPub, 32); + pushU64LE(out, createdAtMs); + pushU64LE(out, 0); + pushFixed(out, devicePub, 32); + pushFixed(out, blockchainPub, 32); + pushStrU8(out, blockchainName); + pushU64LE(out, 0); + pushU32LE(out, 0); + out.insert(out.end(), 32, 0); + pushFixed(out, lastBlockSignature, 64); + out.push_back(0); + out.push_back(1); + out.push_back(0); + pushStrU8(out, serverAddress); + out.push_back(0); + out.push_back(0); + out.push_back(1); + out.push_back(0); + out.push_back(0); + pushFixed(out, rootSignature, 64); + return out; +} + +static std::vector buildEd25519InstructionData(const uint8_t signature[64], const uint8_t publicKey[32], const uint8_t messageHash[32]) { + const uint16_t sigOff = 16; + const uint16_t pkOff = sigOff + 64; + const uint16_t msgOff = pkOff + 32; + std::vector out(msgOff + 32, 0); + out[0] = 1; + out[1] = 0; + out[2] = (uint8_t)(sigOff & 0xFF); + out[3] = (uint8_t)((sigOff >> 8) & 0xFF); + out[6] = (uint8_t)(pkOff & 0xFF); + out[7] = (uint8_t)((pkOff >> 8) & 0xFF); + out[10] = (uint8_t)(msgOff & 0xFF); + out[11] = (uint8_t)((msgOff >> 8) & 0xFF); + out[12] = 32; + out[4] = out[8] = out[14] = 0xFF; + out[5] = out[9] = out[15] = 0xFF; + memcpy(out.data() + sigOff, signature, 64); + memcpy(out.data() + pkOff, publicKey, 32); + memcpy(out.data() + msgOff, messageHash, 32); + return out; +} + +static bool getLatestBlockhashBytes(uint8_t out[32], String &blockhashB58, String &messageOut) { + String payload; + if (!rpcCallSolana("getLatestBlockhash", "[{\"commitment\":\"confirmed\"}]", payload)) { + messageOut = "RPC не вернул blockhash"; + return false; + } + if (!jsonStringField(payload, "blockhash", blockhashB58) || blockhashB58.isEmpty()) { + messageOut = "В ответе нет blockhash"; + return false; + } + if (!base58ToFixed32(blockhashB58, out)) { + messageOut = "Некорректный blockhash"; + return false; + } + return true; +} + +static bool pdaAlreadyExists(const String &login, String &pdaAddress, String &messageOut) { + uint8_t userPda[32]; + std::vector> seeds = { + std::vector((const uint8_t *)kUsersSeedPrefix, (const uint8_t *)kUsersSeedPrefix + strlen(kUsersSeedPrefix)), + std::vector((const uint8_t *)login.c_str(), (const uint8_t *)login.c_str() + login.length())}; + if (!findProgramAddress(seeds, kShineUsersProgramId, userPda)) { + messageOut = "Не удалось вычислить user PDA"; + return false; + } + pdaAddress = bytesToBase58(userPda, 32); + String payload; + if (!rpcCallSolana("getAccountInfo", "[\"" + pdaAddress + "\",{\"encoding\":\"base64\",\"commitment\":\"confirmed\"}]", payload)) { + messageOut = "Не удалось проверить PDA"; + return false; + } + if (payload.indexOf("\"value\":null") >= 0) { + return false; + } + if (payload.indexOf("\"value\"") >= 0) { + messageOut = "Такой логин уже зарегистрирован"; + return true; + } + messageOut = "Непонятный ответ getAccountInfo"; + return false; +} + +static std::vector buildLegacyMessage( + const uint8_t recentBlockhash[32], + const uint8_t devicePub[32], + const uint8_t userPda[32], + const uint8_t inflowVault[32], + const uint8_t economyConfig[32], + const std::vector &edRootData, + const std::vector &edBchData, + const std::vector &createData) { + uint8_t systemProgram[32]; + uint8_t ed25519Program[32]; + uint8_t sysvarInstructions[32]; + uint8_t usersProgram[32]; + uint8_t loginGuardProgram[32]; + base58ToFixed32(kSystemProgramId, systemProgram); + base58ToFixed32(kEd25519ProgramId, ed25519Program); + base58ToFixed32(kSysvarInstructionsId, sysvarInstructions); + base58ToFixed32(kShineUsersProgramId, usersProgram); + base58ToFixed32(kShineLoginGuardProgramId, loginGuardProgram); + + std::vector> accountKeys; + accountKeys.emplace_back(devicePub, devicePub + 32); + accountKeys.emplace_back(userPda, userPda + 32); + accountKeys.emplace_back(inflowVault, inflowVault + 32); + accountKeys.emplace_back(systemProgram, systemProgram + 32); + accountKeys.emplace_back(sysvarInstructions, sysvarInstructions + 32); + accountKeys.emplace_back(economyConfig, economyConfig + 32); + accountKeys.emplace_back(loginGuardProgram, loginGuardProgram + 32); + accountKeys.emplace_back(ed25519Program, ed25519Program + 32); + accountKeys.emplace_back(usersProgram, usersProgram + 32); + + std::vector msg; + msg.reserve(512); + msg.push_back(1); + msg.push_back(0); + msg.push_back(6); + shortVecEncode(accountKeys.size(), msg); + for (const auto &key : accountKeys) { + msg.insert(msg.end(), key.begin(), key.end()); + } + msg.insert(msg.end(), recentBlockhash, recentBlockhash + 32); + shortVecEncode(3, msg); + + msg.push_back(7); + msg.push_back(0); + shortVecEncode(edRootData.size(), msg); + msg.insert(msg.end(), edRootData.begin(), edRootData.end()); + + msg.push_back(7); + msg.push_back(0); + shortVecEncode(edBchData.size(), msg); + msg.insert(msg.end(), edBchData.begin(), edBchData.end()); + + msg.push_back(8); + msg.push_back(7); + msg.push_back(0); + msg.push_back(1); + msg.push_back(3); + msg.push_back(2); + msg.push_back(4); + msg.push_back(5); + msg.push_back(6); + shortVecEncode(createData.size(), msg); + msg.insert(msg.end(), createData.begin(), createData.end()); + return msg; +} + +static bool signMessageEd25519(const std::vector &message, const uint8_t secretKey[64], uint8_t signature[64]) { + return crypto_sign_ed25519_detached(signature, nullptr, message.data(), (unsigned long long)message.size(), secretKey) == 0; +} + +static String encodeTransactionBase64(const uint8_t signature[64], const std::vector &message) { + std::vector tx; + tx.reserve(1 + 64 + message.size()); + shortVecEncode(1, tx); + pushFixed(tx, signature, 64); + tx.insert(tx.end(), message.begin(), message.end()); + return bytesToBase64String(tx.data(), tx.size()); +} + +static bool awaitTransactionConfirmation(const String &signatureB58, String &messageOut) { + for (int attempt = 0; attempt < 15; attempt++) { + String payload; + if (!rpcCallSolana("getSignatureStatuses", "[[\"" + signatureB58 + "\"],{\"searchTransactionHistory\":true}]", payload)) { + delay(1000); + continue; + } + if (payload.indexOf("\"err\":null") >= 0 && + (payload.indexOf("\"confirmationStatus\":\"confirmed\"") >= 0 || + payload.indexOf("\"confirmationStatus\":\"finalized\"") >= 0)) { + return true; + } + if (payload.indexOf("\"err\":{") >= 0 || payload.indexOf("\"err\":\"") >= 0) { + messageOut = "Транзакция отклонена сетью"; + return false; + } + delay(1000); + } + messageOut = "RPC не подтвердил транзакцию вовремя"; + return false; +} + +static bool registerHomeserverOnSolana(String &messageOut) { + messageOut = ""; + String cleanLogin = normalizeLoginValue(gLoginValue); + if (cleanLogin.isEmpty()) { + messageOut = "Логин не задан"; + return false; + } + if (!gSecretConfigured || gRootPrivB58.isEmpty() || gBlockchainPrivB58.isEmpty() || gDevicePrivB58.isEmpty()) { + messageOut = "Секрет не готов"; + return false; + } + if (WiFi.status() != WL_CONNECTED) { + messageOut = "Сначала подключите Wi-Fi"; + return false; + } + if (gSolanaRpcUrl.isEmpty()) { + messageOut = "Сначала задайте Solana RPC"; + return false; + } + + String existingPda; + String pdaCheckMessage; + if (pdaAlreadyExists(cleanLogin, existingPda, pdaCheckMessage)) { + gUserPdaAddress = existingPda; + gRegistrationSignature = ""; + saveAccountPrefs(); + gAccountPdaStatus = ACCOUNT_PDA_OK; + gAccountPdaStatusMessage = "Пользователь уже зарегистрирован"; + gShowRegisterAccountButton = false; + gAccountStatusMessage = "Пользователь уже зарегистрирован"; + gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " registered"; + refreshAccountPdaStatus(); + return true; + } + if (pdaCheckMessage == "Не удалось вычислить user PDA" || pdaCheckMessage == "Не удалось проверить PDA" || pdaCheckMessage == "Непонятный ответ getAccountInfo") { + messageOut = pdaCheckMessage; + return false; + } + + uint8_t userPda[32]; + uint8_t economyConfig[32]; + uint8_t inflowVault[32]; + if (!findProgramAddress({ + std::vector((const uint8_t *)kUsersSeedPrefix, (const uint8_t *)kUsersSeedPrefix + strlen(kUsersSeedPrefix)), + std::vector((const uint8_t *)cleanLogin.c_str(), (const uint8_t *)cleanLogin.c_str() + cleanLogin.length()) + }, kShineUsersProgramId, userPda) || + !findProgramAddress({ + std::vector((const uint8_t *)kUsersEconomyConfigSeed, (const uint8_t *)kUsersEconomyConfigSeed + strlen(kUsersEconomyConfigSeed)) + }, kShineUsersProgramId, economyConfig) || + !findProgramAddress({ + std::vector((const uint8_t *)kPaymentsInflowSeed, (const uint8_t *)kPaymentsInflowSeed + strlen(kPaymentsInflowSeed)) + }, kShinePaymentsProgramId, inflowVault)) { + messageOut = "Не удалось вычислить обязательные PDA"; + return false; + } + + uint8_t rootSeed[32] = {}; + uint8_t rootPub[32] = {}; + uint8_t rootSec[64] = {}; + uint8_t blockchainSeed[32] = {}; + uint8_t blockchainPub[32] = {}; + uint8_t blockchainSec[64] = {}; + uint8_t deviceSeed[32] = {}; + uint8_t devicePub[32] = {}; + uint8_t deviceSec[64] = {}; + if (!deriveSeedKeypairFromBase58(gRootPrivB58, rootSeed, rootPub, rootSec) || + !deriveSeedKeypairFromBase58(gBlockchainPrivB58, blockchainSeed, blockchainPub, blockchainSec) || + !deriveSeedKeypairFromBase58(gDevicePrivB58, deviceSeed, devicePub, deviceSec)) { + messageOut = "Не удалось восстановить ключи"; + return false; + } + + String blockchainName = cleanLogin + "-001"; + std::vector lastBlockState = buildLastBlockStateBytes(cleanLogin, blockchainName); + uint8_t lastBlockHash[32]; + uint8_t lastBlockSignature[64]; + sha256calc(lastBlockState.data(), lastBlockState.size(), lastBlockHash); + if (!signMessageEd25519(std::vector(lastBlockHash, lastBlockHash + 32), blockchainSec, lastBlockSignature)) { + messageOut = "Не удалось подписать LastBlockState"; + return false; + } + + uint64_t createdAtMs = shineNowMs(); + std::vector unsignedRecord = buildUnsignedCreateRecord( + cleanLogin, blockchainName, gShineServerUrl, + rootPub, devicePub, blockchainPub, + lastBlockSignature, createdAtMs); + uint8_t unsignedHash[32]; + uint8_t rootSignature[64]; + sha256calc(unsignedRecord.data(), unsignedRecord.size(), unsignedHash); + if (!signMessageEd25519(std::vector(unsignedHash, unsignedHash + 32), rootSec, rootSignature)) { + messageOut = "Не удалось подписать PDA-запись"; + return false; + } + + std::vector createData = buildCreateInstructionData( + cleanLogin, blockchainName, gShineServerUrl, + rootPub, devicePub, blockchainPub, + lastBlockSignature, rootSignature, createdAtMs); + std::vector edRootData = buildEd25519InstructionData(rootSignature, rootPub, unsignedHash); + std::vector edBchData = buildEd25519InstructionData(lastBlockSignature, blockchainPub, lastBlockHash); + + uint8_t recentBlockhash[32]; + String recentBlockhash58; + if (!getLatestBlockhashBytes(recentBlockhash, recentBlockhash58, messageOut)) { + return false; + } + + std::vector message = buildLegacyMessage( + recentBlockhash, + devicePub, + userPda, + inflowVault, + economyConfig, + edRootData, + edBchData, + createData); + uint8_t txSignature[64]; + if (!signMessageEd25519(message, deviceSec, txSignature)) { + messageOut = "Не удалось подписать Solana-транзакцию"; + return false; + } + String txBase64 = encodeTransactionBase64(txSignature, message); + String signatureB58 = bytesToBase58(txSignature, 64); + + String payload; + if (!rpcCallSolana("sendTransaction", "[\"" + txBase64 + "\",{\"encoding\":\"base64\",\"preflightCommitment\":\"confirmed\"}]", payload)) { + messageOut = "RPC не принял транзакцию"; + return false; + } + if (rpcResponseHasError(payload)) { + messageOut = "RPC вернул ошибку sendTransaction"; + return false; + } + if (!awaitTransactionConfirmation(signatureB58, messageOut)) { + return false; + } + + gUserPdaAddress = bytesToBase58(userPda, 32); + gRegistrationSignature = signatureB58; + gAccountStatusMessage = "Solana-регистрация завершена"; + gAccountPdaStatus = ACCOUNT_PDA_OK; + gAccountPdaStatusMessage = "Пользователь зарегистрирован"; + gShowRegisterAccountButton = false; + gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " registered"; + saveAccountPrefs(); + refreshAccountPdaStatus(); + messageOut = "Solana-регистрация подтверждена"; + return true; +} + static bool parseShineUserPdaBytes(const std::vector &bytes, ShinePdaUserState &outState, String &errorOut) { outState = ShinePdaUserState{}; errorOut = ""; @@ -1817,6 +2377,8 @@ static void loadPrefs() { gShineSessionId = gPrefs.getString("shine_sess_id", ""); gShineSessionKey = gPrefs.getString("shine_sess_key", ""); gShineStoragePwd = gPrefs.getString("shine_store_pwd", ""); + gUserPdaAddress = gPrefs.getString("user_pda", ""); + gRegistrationSignature = gPrefs.getString("registration_sig", ""); gBalanceStatusMessage = gDevicePubB58.isEmpty() ? "Balance: secret not set" : "Balance: tap to load"; gAccountCheckPending = true; gLastAccountCheckMs = 0; @@ -1853,6 +2415,16 @@ static void saveAccountPrefs() { gPrefs.putString("homeserver", gHomeserverValue); gPrefs.putBool("secret_set", gSecretConfigured); gPrefs.putString("secret_b58", gSecretBase58); + if (gUserPdaAddress.isEmpty()) { + gPrefs.remove("user_pda"); + } else { + gPrefs.putString("user_pda", gUserPdaAddress); + } + if (gRegistrationSignature.isEmpty()) { + gPrefs.remove("registration_sig"); + } else { + gPrefs.putString("registration_sig", gRegistrationSignature); + } if (gSecretConfigured) { gPrefs.putBytes("secret_bytes", gSecretBytes, 32); } else { @@ -2410,7 +2982,18 @@ static void actionButtonCb(lv_event_t *event) { showScreen(SCREEN_SETTINGS_MENU); break; case ACTION_REGISTER_ACCOUNT: - showScreen(SCREEN_REGISTER_ACCOUNT_PLACEHOLDER); + gAccountStatusMessage = "Регистрация запущена..."; + gShineStatusLine = "SHiNE: регистрация запущена"; + { + String registerMessage; + if (registerHomeserverOnSolana(registerMessage)) { + gAccountStatusMessage = registerMessage; + } else { + gAccountStatusMessage = registerMessage; + gShineStatusLine = String("SHiNE: ") + registerMessage; + } + rebuildScreen(); + } break; case ACTION_OPEN_WIFI: gWifiViewMode = WIFI_VIEW_OVERVIEW; @@ -2677,9 +3260,9 @@ static void drawHome() { makeButton(balanceHomeLine().c_str(), 22, 254, 436, 56, 0x355C7D, ACTION_REFRESH_BALANCE, &lv_font_montserrat_18); showMessageAt(shineHomeLine(), 322); if (gShowRegisterAccountButton) { - makeButton("REGISTER ACCOUNT", 22, 360, 220, 78, 0x2A9D8F, ACTION_REGISTER_ACCOUNT, &lv_font_montserrat_20); + makeButton("REGISTER ACCOUNT", 20, 360, 210, 78, 0x2A9D8F, ACTION_REGISTER_ACCOUNT, &lv_font_montserrat_20); } - makeButton("SETTINGS", 238, 360, 220, 78, 0x2A6F97, ACTION_OPEN_SETTINGS, &lv_font_montserrat_24); + makeButton("SETTINGS", 250, 360, 210, 78, 0x2A6F97, ACTION_OPEN_SETTINGS, &lv_font_montserrat_24); makeVersionTag(); } @@ -2791,6 +3374,12 @@ static void drawAccountScreen() { makeTitle("ACCOUNT", 18, &lv_font_montserrat_24); showMessageAt(gAccountStatusMessage, 56); + if (!gUserPdaAddress.isEmpty()) { + showMessageAt(String("PDA: ") + abbreviateValue(gUserPdaAddress, 10, 6), 80); + } + if (!gRegistrationSignature.isEmpty()) { + showMessageAt(String("TX: ") + abbreviateValue(gRegistrationSignature, 10, 6), 100); + } String loginButton = String("Login (") + (gLoginValue.isEmpty() ? "not set" : gLoginValue) + ")"; String homeserverButton = String("Homeserver (") + (gHomeserverValue.isEmpty() ? "not set" : gHomeserverValue) + ")"; @@ -2939,8 +3528,8 @@ static void drawSecretGenerateCancelConfirmScreen() { static void drawRegisterAccountPlaceholderScreen() { setRootStyle(); makeTitle("REGISTER ACCOUNT", 22, &lv_font_montserrat_24); - makeBody("Registration flow is not implemented yet.", 112, 420); - makeBody("This button is shown when login is not found in Solana PDA.", 156, 420); + makeBody("Registration now starts directly from the home screen button.", 112, 420); + makeBody("This screen is kept only as a fallback status page.", 156, 420); makeButton("BACK", 140, 360, 200, 72, 0x5A6570, ACTION_BACK_HOME, &lv_font_montserrat_22); makeVersionTag(); } diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_nav_minimal_spec.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_nav_minimal_spec.md index a3db5d4..9ca9e84 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_nav_minimal_spec.md +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_nav_minimal_spec.md @@ -1,7 +1,7 @@ # SHiNE ESP32 Homeserver UI Nav Minimal Spec -Минимальный навигационный прототип для `Waveshare ESP32-S3-Touch-AMOLED-2.16`. -Этот прототип был перенесён в основной скетч `../main-device/shine_homeserver_main/`, а старое имя `lvgl-nav-minimal-test` осталось только как историческая ссылка. +Легаси-спецификация старого навигационного теста для `Waveshare ESP32-S3-Touch-AMOLED-2.16`. +Актуальный основной скетч теперь находится в `../main-device/shine_homeserver_main/`; этот документ оставлен только как историческая справка по старому тестовому UI. ## Цель @@ -64,6 +64,9 @@ - кнопка `SETTINGS` -> `SETTINGS_MENU`; - свайп влево -> `SETTINGS_MENU`. +Примечание: +- поведение `REGISTER ACCOUNT -> REGISTER_ACCOUNT_PLACEHOLDER` относится к старой тестовой версии и не является актуальным для основного скетча. + Фоновая логика: - пока открыт `HOME`, экран сам обновляется примерно раз в секунду; - при наличии `login + secret + homeserver` и Wi-Fi устройство читает Solana `user_pda` напрямую через RPC; diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md index 63b4e8e..471ebd8 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md @@ -25,7 +25,7 @@ - реальная проверка доступности `API`, `RPC` и `WS`-адресов; - реальное чтение баланса кошелька из `Solana RPC`; - проверка обязательных условий перед регистрацией; -- живая on-chain регистрация серверного `user_pda` в `shine_users` через `device key` устройства; +- живая on-chain регистрация серверного `user_pda` в `shine_users` по нажатию кнопки на главном экране; - прототип входящих запросов с подтверждением и отклонением; - PIN-блокировка (в текущей временной сборке вход по PIN отключён, устройство открывает `HOME` сразу после старта); - базовые настройки, статус и главный экран; @@ -159,11 +159,12 @@ Дополнительная большая кнопка: -- `Зарегистрировать` +- `REGISTER ACCOUNT` Если регистрация уже сделана: - вместо призыва к регистрации показывается статус `Homeserver активен`. +- две нижние кнопки внизу экрана не прилегают вплотную друг к другу, между ними есть небольшой зазор. ## Экран STATUS @@ -277,6 +278,7 @@ - `Сгенерировать` создаёт новый `master secret` и пересчитывает из него `device`-кошелёк; - `Очистить` удаляет секрет, адрес кошелька, `PDA`, `tx`, регистрацию и онлайн-статус; - логин приводится к нижнему регистру и trim. +- после успешной регистрации на экране сохраняются и отображаются краткие отпечатки `PDA` и `TX`. ## Экран WALLET @@ -438,8 +440,8 @@ QR должен быть сканируемым, а не декоративны 10. открыть `Кошелёк`; 11. при необходимости пополнить баланс; 12. вернуться на `HOME`; -13. нажать `Зарегистрировать`; -14. после подтверждения увидеть статус `Homeserver активен`. +13. нажать `REGISTER ACCOUNT`; +14. после завершения увидеть статус `Homeserver активен`. Примечание: diff --git a/VERSION.properties b/VERSION.properties index 1f4cc56..0f2cf43 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.163 -server.version=1.2.152 +client.version=1.2.164 +server.version=1.2.153 From 0c9afea67afa51387a7052af92cbe247e755d37c55fcc66ca6d932ea4ca5c73b Mon Sep 17 00:00:00 2001 From: AidarKC Date: Fri, 12 Jun 2026 23:02:07 +0400 Subject: [PATCH 05/56] =?UTF-8?q?ESP32:=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...06-07_1650_esp32_homeserver_ui_прототип.md | 11 +- .../shine_homeserver_main.ino | 213 +++++++++++++++--- .../reference/shine_homeserver_ui_spec.md | 53 ++++- VERSION.properties | 4 +- 4 files changed, 241 insertions(+), 40 deletions(-) diff --git a/Dev_Docs/Pending_Features/2026-06-07_1650_esp32_homeserver_ui_прототип.md b/Dev_Docs/Pending_Features/2026-06-07_1650_esp32_homeserver_ui_прототип.md index bef19aa..0d8ef8c 100644 --- a/Dev_Docs/Pending_Features/2026-06-07_1650_esp32_homeserver_ui_прототип.md +++ b/Dev_Docs/Pending_Features/2026-06-07_1650_esp32_homeserver_ui_прототип.md @@ -13,11 +13,12 @@ 7. Открыть `Кошелёк`, нажать `Проверить` и убедиться, что баланс реально читается из `Solana RPC`; затем открыть `QR и URI` и проверить, что QR-код отрисовывается и сканируется как `solana:`-ссылка. 8. При необходимости отдельно проверить тестовые кнопки `+/- SOL`: они меняют локальный баланс для UX-сценариев, но после следующей реальной RPC-проверки баланс должен вернуться к сетевому значению. 9. Вернуться на главный экран и проверить, что до выполнения всех условий кнопка регистрации недоступна, а после выполнения становится доступной; также убедиться, что между двумя нижними кнопками есть небольшой зазор. - 10. Нажать кнопку регистрации и убедиться, что она запускает on-chain flow сразу без отдельного confirm-экрана, а после завершения статус меняется на `Homeserver активен`, онлайн-статус становится активным, и на экране аккаунта появляются краткие отпечатки `PDA/TX`. - 11. После регистрации проверить через `Solana`/UI проекта, что `user_pda` для этого логина реально создана, сохранена в `NVS` и соответствует `device`-адресу устройства. - 12. Открыть `Запросы`, поочерёдно открыть оба демонстрационных запроса и проверить, что кнопки `Разрешить` и `Отклонить` меняют их статус. - 13. При необходимости открыть `Настройки -> Сменить PIN` и убедиться, что новый PIN сохраняется, хотя вход по PIN временно не используется на старте. - 14. Выполнить `Полный сброс` и убедиться, что все поля, секрет, баланс, онлайн и регистрация очищаются. + 10. Нажать кнопку регистрации и убедиться, что открывается отдельный экран проверки, где ещё раз видно `login`, статус свободного `PDA`, баланс, `homeserver1` с пометкой о стандартном значении и сообщение, если `Wi-Fi` не подключён. + 11. На экране проверки нажать `ЗАРЕГИСТРИРОВАТЬ В СИЯНИИ` и убедиться, что после этого появляется отдельный экран результата с успехом либо подробной ошибкой. + 12. После успешной регистрации проверить через `Solana`/UI проекта, что `user_pda` для этого логина реально создана, сохранена в `NVS` и соответствует `device`-адресу устройства, а `tx signature` тоже сохранён. + 13. Открыть `Запросы`, поочерёдно открыть оба демонстрационных запроса и проверить, что кнопки `Разрешить` и `Отклонить` меняют их статус. + 14. При необходимости открыть `Настройки -> Сменить PIN` и убедиться, что новый PIN сохраняется, хотя вход по PIN временно не используется на старте. + 15. Выполнить `Полный сброс` и убедиться, что все поля, секрет, баланс, онлайн и регистрация очищаются. - ожидаемый результат: новый `ESP32`-скетч стабильно запускается, показывает читаемый интерфейс хотя бы в ASCII-транслитерации, сохраняет данные во внутренней памяти устройства, реально подключается к `Wi-Fi`, реально проверяет `API/RPC/WS`, реально читает баланс из `Solana RPC`, рисует рабочий `QR` для `solana:`-URI и позволяет вручную пройти полный сценарий on-chain регистрации homeserver. diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino index 5919f63..f7575dc 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino @@ -84,7 +84,8 @@ enum Screen { SCREEN_SECRET_GENERATE_RUNNING, SCREEN_SECRET_GENERATE_CANCEL_CONFIRM, SCREEN_TEXT_EDIT, - SCREEN_REGISTER_ACCOUNT_PLACEHOLDER, + SCREEN_REGISTER_ACCOUNT_CONFIRM, + SCREEN_REGISTER_ACCOUNT_RESULT, }; enum SwipeDirection { @@ -123,6 +124,7 @@ enum ActionId { ACTION_BACK_ACCOUNT, ACTION_REFRESH_BALANCE, ACTION_REGISTER_ACCOUNT, + ACTION_REGISTER_ACCOUNT_EXECUTE, ACTION_EDITOR_SAVE, ACTION_EDITOR_CANCEL, }; @@ -246,6 +248,14 @@ static bool gShowRegisterAccountButton = false; static String gUserPdaAddress; static String gRegistrationSignature; static String gShineStatusLine = "SHiNE: account not configured"; +static String gRegisterConfirmMessage; +static String gRegisterConfirmBalanceLine; +static String gRegisterConfirmPdaLine; +static String gRegisterConfirmHomeserverLine; +static bool gRegisterConfirmCanSubmit = false; +static String gRegisterResultMessage; +static String gRegisterResultDetails; +static bool gRegisterResultSuccess = false; static String gShineSessionId; static String gShineSessionKey; static String gShineStoragePwd; @@ -340,6 +350,7 @@ static String shineWsUrl(); static String shineHomeLine(); static String balanceHomeLine(); static uint64_t shineNowMs(); +static bool loadWalletBalanceLamports(uint64_t &lamportsOut, String &messageOut); static void shortVecEncode(size_t value, std::vector &out); static void pushU32LE(std::vector &out, uint32_t value); static void pushU64LE(std::vector &out, uint64_t value); @@ -385,6 +396,7 @@ static bool signMessageEd25519(const std::vector &message, const uint8_ static String encodeTransactionBase64(const uint8_t signature[64], const std::vector &message); static bool awaitTransactionConfirmation(const String &signatureB58, String &messageOut); static bool registerHomeserverOnSolana(String &messageOut); +static void prepareRegisterAccountScreen(); static String buildSessionKeyStringFromPublicBase64(const String &pubB64); static bool deriveKeypairFromSeed32(const uint8_t seed32[32], uint8_t pub32[32], uint8_t sec64[64]); static bool deriveSeedKeypairFromBase58(const String &seedB58, uint8_t seed32[32], uint8_t pub32[32], uint8_t sec64[64]); @@ -846,6 +858,14 @@ static void markAccountStateDirty() { gShowRegisterAccountButton = false; gUserPdaAddress = ""; gRegistrationSignature = ""; + gRegisterConfirmMessage = ""; + gRegisterConfirmBalanceLine = ""; + gRegisterConfirmPdaLine = ""; + gRegisterConfirmHomeserverLine = ""; + gRegisterConfirmCanSubmit = false; + gRegisterResultMessage = ""; + gRegisterResultDetails = ""; + gRegisterResultSuccess = false; clearShineSessionState(true); gShineStatusLine = "SHiNE: account not configured"; } @@ -947,15 +967,26 @@ static String formatSolValue(uint64_t lamports) { } static bool refreshWalletBalance(String &messageOut) { + uint64_t lamports = 0; + if (!loadWalletBalanceLamports(lamports, messageOut)) { + gBalanceStatusMessage = messageOut; + return false; + } + + gBalanceStatusMessage = formatSolValue(lamports); + messageOut = gBalanceStatusMessage; + return true; +} + +static bool loadWalletBalanceLamports(uint64_t &lamportsOut, String &messageOut) { messageOut = ""; + lamportsOut = 0; if (WiFi.status() != WL_CONNECTED) { - gBalanceStatusMessage = "Balance: Wi-Fi disconnected"; - messageOut = gBalanceStatusMessage; + messageOut = "Баланс: Wi-Fi не подключен"; return false; } if (gDevicePubB58.isEmpty()) { - gBalanceStatusMessage = "Balance: secret not set"; - messageOut = gBalanceStatusMessage; + messageOut = "Баланс: секрет не задан"; return false; } @@ -963,20 +994,15 @@ static bool refreshWalletBalance(String &messageOut) { String payload; String req = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"getBalance\",\"params\":[\"" + gDevicePubB58 + "\",{\"commitment\":\"confirmed\"}]}"; if (!httpPostJson(gSolanaRpcUrl, req, code, payload)) { - gBalanceStatusMessage = "Balance: failed to load"; - messageOut = gBalanceStatusMessage; + messageOut = "Баланс: RPC не ответил"; return false; } - uint64_t lamports = 0; - if (!jsonInt64Field(payload, "value", lamports)) { - gBalanceStatusMessage = "Balance: failed to load"; - messageOut = gBalanceStatusMessage; + if (!jsonInt64Field(payload, "value", lamportsOut)) { + messageOut = "Баланс: не удалось прочитать"; return false; } - gBalanceStatusMessage = formatSolValue(lamports); - messageOut = gBalanceStatusMessage; return true; } @@ -1525,6 +1551,76 @@ static bool registerHomeserverOnSolana(String &messageOut) { return true; } +static void prepareRegisterAccountScreen() { + gRegisterResultMessage = ""; + gRegisterResultDetails = ""; + gRegisterResultSuccess = false; + gRegisterConfirmMessage = ""; + gRegisterConfirmBalanceLine = ""; + gRegisterConfirmPdaLine = ""; + gRegisterConfirmHomeserverLine = ""; + gRegisterConfirmCanSubmit = false; + + String cleanLogin = normalizeLoginValue(gLoginValue); + if (cleanLogin.isEmpty()) { + gRegisterConfirmMessage = "Логин не задан"; + return; + } + if (!gSecretConfigured) { + gRegisterConfirmMessage = "Секрет не задан"; + return; + } + if (WiFi.status() != WL_CONNECTED) { + gRegisterConfirmMessage = "Wi-Fi не подключен"; + return; + } + + String balanceMessage; + uint64_t lamports = 0; + if (!loadWalletBalanceLamports(lamports, balanceMessage)) { + gRegisterConfirmMessage = balanceMessage; + } else { + gRegisterConfirmBalanceLine = formatSolValue(lamports); + if (lamports < 20000000ULL) { + gRegisterConfirmMessage = "Баланс меньше 0.020 SOL"; + } else { + gRegisterConfirmMessage = "Баланс подходит: 0.020 SOL или выше"; + } + } + + if (gHomeserverValue.isEmpty()) { + gRegisterConfirmHomeserverLine = "Homeserver не задан"; + } else if (gHomeserverValue == "homeserver1") { + gRegisterConfirmHomeserverLine = "Homeserver: homeserver1 (стандартное значение)"; + } else { + gRegisterConfirmHomeserverLine = String("Homeserver: ") + gHomeserverValue; + } + + ShinePdaUserState pdaState; + String pdaError; + if (!readShineUserPda(cleanLogin, pdaState, pdaError)) { + gRegisterConfirmPdaLine = pdaError.isEmpty() ? "PDA: не удалось проверить" : String("PDA: ") + pdaError; + if (gRegisterConfirmMessage.isEmpty()) { + gRegisterConfirmMessage = pdaError.isEmpty() ? "Не удалось проверить PDA" : pdaError; + } + return; + } + + if (pdaState.found) { + gRegisterConfirmPdaLine = "PDA уже занят, этот login не свободен"; + if (gRegisterConfirmMessage.isEmpty()) { + gRegisterConfirmMessage = "Login уже зарегистрирован"; + } + return; + } + + gRegisterConfirmPdaLine = "PDA свободен для регистрации"; + if (gRegisterConfirmMessage.isEmpty()) { + gRegisterConfirmMessage = "Все проверки пройдены"; + } + gRegisterConfirmCanSubmit = true; +} + static bool parseShineUserPdaBytes(const std::vector &bytes, ShinePdaUserState &outState, String &errorOut) { outState = ShinePdaUserState{}; errorOut = ""; @@ -2982,19 +3078,30 @@ static void actionButtonCb(lv_event_t *event) { showScreen(SCREEN_SETTINGS_MENU); break; case ACTION_REGISTER_ACCOUNT: - gAccountStatusMessage = "Регистрация запущена..."; - gShineStatusLine = "SHiNE: регистрация запущена"; - { - String registerMessage; - if (registerHomeserverOnSolana(registerMessage)) { - gAccountStatusMessage = registerMessage; - } else { - gAccountStatusMessage = registerMessage; - gShineStatusLine = String("SHiNE: ") + registerMessage; - } - rebuildScreen(); - } + prepareRegisterAccountScreen(); + showScreen(SCREEN_REGISTER_ACCOUNT_CONFIRM); break; + case ACTION_REGISTER_ACCOUNT_EXECUTE: { + String registerMessage; + if (registerHomeserverOnSolana(registerMessage)) { + gRegisterResultSuccess = true; + gRegisterResultMessage = "Регистрация в Сиянии завершена"; + gRegisterResultDetails = registerMessage; + gAccountStatusMessage = "Регистрация завершена"; + } else { + gRegisterResultSuccess = false; + gRegisterResultMessage = "Регистрация не выполнена"; + gRegisterResultDetails = registerMessage; + gAccountStatusMessage = registerMessage; + } + gRegisterConfirmMessage = ""; + gRegisterConfirmBalanceLine = ""; + gRegisterConfirmPdaLine = ""; + gRegisterConfirmHomeserverLine = ""; + gRegisterConfirmCanSubmit = false; + showScreen(SCREEN_REGISTER_ACCOUNT_RESULT); + break; + } case ACTION_OPEN_WIFI: gWifiViewMode = WIFI_VIEW_OVERVIEW; showScreen(SCREEN_WIFI); @@ -3525,12 +3632,50 @@ static void drawSecretGenerateCancelConfirmScreen() { makeVersionTag(); } -static void drawRegisterAccountPlaceholderScreen() { +static void drawRegisterAccountConfirmScreen() { setRootStyle(); makeTitle("REGISTER ACCOUNT", 22, &lv_font_montserrat_24); - makeBody("Registration now starts directly from the home screen button.", 112, 420); - makeBody("This screen is kept only as a fallback status page.", 156, 420); - makeButton("BACK", 140, 360, 200, 72, 0x5A6570, ACTION_BACK_HOME, &lv_font_montserrat_22); + String topLine = gRegisterConfirmMessage.isEmpty() ? String("Проверка регистрации") : gRegisterConfirmMessage; + makeBody(topLine.c_str(), 96, 420); + if (!gRegisterConfirmPdaLine.isEmpty()) { + makeBody(gRegisterConfirmPdaLine.c_str(), 138, 420); + } + if (!gRegisterConfirmBalanceLine.isEmpty()) { + makeBody(gRegisterConfirmBalanceLine.c_str(), 180, 420); + } + if (!gRegisterConfirmHomeserverLine.isEmpty()) { + makeBody(gRegisterConfirmHomeserverLine.c_str(), 222, 420); + } + if (gRegisterConfirmCanSubmit) { + makeButton("ЗАРЕГИСТРИРОВАТЬ В СИЯНИИ", 22, 296, 436, 74, 0x2A9D8F, ACTION_REGISTER_ACCOUNT_EXECUTE, &lv_font_montserrat_18); + } else { + makeButton("НЕДОСТУПНО", 22, 296, 436, 74, 0x4A5560, ACTION_NONE, &lv_font_montserrat_20); + } + makeButton("BACK", 140, 384, 200, 54, 0x5A6570, ACTION_BACK_HOME, &lv_font_montserrat_20); + makeVersionTag(); +} + +static void drawRegisterAccountResultScreen() { + setRootStyle(); + makeTitle("REGISTER RESULT", 22, &lv_font_montserrat_24); + String resultTopLine = gRegisterResultSuccess ? String("Регистрация завершилась успешно") : String("Регистрация завершилась с ошибкой"); + makeBody(resultTopLine.c_str(), 96, 420); + makeBody(gRegisterResultMessage.c_str(), 140, 420); + if (!gRegisterResultDetails.isEmpty()) { + makeBody(gRegisterResultDetails.c_str(), 184, 420); + } + if (gRegisterResultSuccess) { + if (!gUserPdaAddress.isEmpty()) { + String pdaLine = String("user_pda: ") + abbreviateValue(gUserPdaAddress, 12, 8); + makeBody(pdaLine.c_str(), 228, 420); + } + if (!gRegistrationSignature.isEmpty()) { + String txLine = String("tx: ") + abbreviateValue(gRegistrationSignature, 12, 8); + makeBody(txLine.c_str(), 270, 420); + } + } + makeButton("BACK HOME", 22, 372, 200, 58, 0x5A6570, ACTION_BACK_HOME, &lv_font_montserrat_20); + makeButton("ACCOUNT", 258, 372, 200, 58, 0x2A6F97, ACTION_OPEN_ACCOUNT, &lv_font_montserrat_20); makeVersionTag(); } @@ -3694,8 +3839,11 @@ static void rebuildScreen() { case SCREEN_TEXT_EDIT: drawTextEditScreen(); break; - case SCREEN_REGISTER_ACCOUNT_PLACEHOLDER: - drawRegisterAccountPlaceholderScreen(); + case SCREEN_REGISTER_ACCOUNT_CONFIRM: + drawRegisterAccountConfirmScreen(); + break; + case SCREEN_REGISTER_ACCOUNT_RESULT: + drawRegisterAccountResultScreen(); break; } } @@ -3821,7 +3969,8 @@ static void handleSwipe(SwipeDirection swipe) { case SCREEN_TEXT_EDIT: handleTextEditSwipe(swipe); break; - case SCREEN_REGISTER_ACCOUNT_PLACEHOLDER: + case SCREEN_REGISTER_ACCOUNT_CONFIRM: + case SCREEN_REGISTER_ACCOUNT_RESULT: handleHomeSwipe(swipe); break; } diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md index 471ebd8..90e96f9 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md @@ -102,6 +102,8 @@ 13. `PIN_EDIT` 14. `TEXT_INPUT` 15. `CONFIRM` +16. `REGISTER_ACCOUNT_CONFIRM` +17. `REGISTER_ACCOUNT_RESULT` ## Общие правила интерфейса @@ -166,6 +168,52 @@ - вместо призыва к регистрации показывается статус `Homeserver активен`. - две нижние кнопки внизу экрана не прилегают вплотную друг к другу, между ними есть небольшой зазор. +## Экран REGISTER_ACCOUNT_CONFIRM + +Показывает предварительную проверку перед отправкой транзакции регистрации. + +Отображается: + +- повторный `login`; +- сообщение о том, что логин свободен или уже занят; +- баланс кошелька с проверкой порога `0.020 SOL`; +- имя homeserver; +- пометка, если используется стандартное значение `homeserver1`; +- отдельное предупреждение, если `Wi-Fi` не подключён. + +Кнопки: + +- `ЗАРЕГИСТРИРОВАТЬ В СИЯНИИ` +- `BACK` + +Поведение: + +- если `Wi-Fi` не подключён, на экране прямо показывается уведомление об этом и кнопка регистрации недоступна; +- если `login` уже занят в `shine_users`, регистрация недоступна; +- если баланс меньше `0.020 SOL`, на экране показывается соответствующая ошибка; +- если все проверки пройдены, кнопка регистрации активна и запускает on-chain регистрацию. + +## Экран REGISTER_ACCOUNT_RESULT + +Показывает результат отправки регистрации. + +Отображается: + +- текст успеха или ошибки; +- подробное сообщение; +- при успехе краткий `user_pda`; +- при успехе краткий `tx signature`. + +Кнопки: + +- `BACK HOME` +- `ACCOUNT` + +Поведение: + +- после успешной регистрации данные `user_pda` и `tx signature` сохраняются в `NVS`; +- при ошибке на экране показывается причина отказа. + ## Экран STATUS Показывает сводку: @@ -441,7 +489,10 @@ QR должен быть сканируемым, а не декоративны 11. при необходимости пополнить баланс; 12. вернуться на `HOME`; 13. нажать `REGISTER ACCOUNT`; -14. после завершения увидеть статус `Homeserver активен`. +14. на экране проверки ещё раз увидеть `login`, статус свободного `PDA`, баланс, `homeserver1` и при необходимости сообщение о неподключённом `Wi-Fi`; +15. нажать `ЗАРЕГИСТРИРОВАТЬ В СИЯНИИ`; +16. после завершения увидеть либо экран успеха с `user_pda` и `tx signature`, либо подробную ошибку; +17. после успешной регистрации увидеть статус `Homeserver активен`. Примечание: diff --git a/VERSION.properties b/VERSION.properties index 0f2cf43..cca767c 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.164 -server.version=1.2.153 +client.version=1.2.165 +server.version=1.2.154 From 3262ec9b4ac679ebcfdd66ea13afbbb3500b85a90976e54f1f8c3ef239cf2a8a Mon Sep 17 00:00:00 2001 From: AidarKC Date: Fri, 12 Jun 2026 23:35:05 +0400 Subject: [PATCH 06/56] =?UTF-8?q?ESP32:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B8=20UI=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BD=D0=B0=20=D0=B0?= =?UTF-8?q?=D0=BD=D0=B3=D0=BB=D0=B8=D0=B9=D1=81=D0=BA=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shine_homeserver_main.ino | 100 +++++++++--------- VERSION.properties | 4 +- 2 files changed, 52 insertions(+), 52 deletions(-) diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino index f7575dc..e764e2a 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino @@ -982,11 +982,11 @@ static bool loadWalletBalanceLamports(uint64_t &lamportsOut, String &messageOut) messageOut = ""; lamportsOut = 0; if (WiFi.status() != WL_CONNECTED) { - messageOut = "Баланс: Wi-Fi не подключен"; + messageOut = "Balance: Wi-Fi not connected"; return false; } if (gDevicePubB58.isEmpty()) { - messageOut = "Баланс: секрет не задан"; + messageOut = "Balance: secret not set"; return false; } @@ -994,12 +994,12 @@ static bool loadWalletBalanceLamports(uint64_t &lamportsOut, String &messageOut) String payload; String req = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"getBalance\",\"params\":[\"" + gDevicePubB58 + "\",{\"commitment\":\"confirmed\"}]}"; if (!httpPostJson(gSolanaRpcUrl, req, code, payload)) { - messageOut = "Баланс: RPC не ответил"; + messageOut = "Balance: RPC unavailable"; return false; } if (!jsonInt64Field(payload, "value", lamportsOut)) { - messageOut = "Баланс: не удалось прочитать"; + messageOut = "Balance: failed to load"; return false; } @@ -1258,15 +1258,15 @@ static std::vector buildEd25519InstructionData(const uint8_t signature[ static bool getLatestBlockhashBytes(uint8_t out[32], String &blockhashB58, String &messageOut) { String payload; if (!rpcCallSolana("getLatestBlockhash", "[{\"commitment\":\"confirmed\"}]", payload)) { - messageOut = "RPC не вернул blockhash"; + messageOut = "RPC did not return blockhash"; return false; } if (!jsonStringField(payload, "blockhash", blockhashB58) || blockhashB58.isEmpty()) { - messageOut = "В ответе нет blockhash"; + messageOut = "Blockhash missing in response"; return false; } if (!base58ToFixed32(blockhashB58, out)) { - messageOut = "Некорректный blockhash"; + messageOut = "Invalid blockhash"; return false; } return true; @@ -1278,23 +1278,23 @@ static bool pdaAlreadyExists(const String &login, String &pdaAddress, String &me std::vector((const uint8_t *)kUsersSeedPrefix, (const uint8_t *)kUsersSeedPrefix + strlen(kUsersSeedPrefix)), std::vector((const uint8_t *)login.c_str(), (const uint8_t *)login.c_str() + login.length())}; if (!findProgramAddress(seeds, kShineUsersProgramId, userPda)) { - messageOut = "Не удалось вычислить user PDA"; + messageOut = "Failed to derive user PDA"; return false; } pdaAddress = bytesToBase58(userPda, 32); String payload; if (!rpcCallSolana("getAccountInfo", "[\"" + pdaAddress + "\",{\"encoding\":\"base64\",\"commitment\":\"confirmed\"}]", payload)) { - messageOut = "Не удалось проверить PDA"; + messageOut = "Failed to check PDA"; return false; } if (payload.indexOf("\"value\":null") >= 0) { return false; } if (payload.indexOf("\"value\"") >= 0) { - messageOut = "Такой логин уже зарегистрирован"; + messageOut = "Login is already registered"; return true; } - messageOut = "Непонятный ответ getAccountInfo"; + messageOut = "Unexpected getAccountInfo response"; return false; } @@ -1391,12 +1391,12 @@ static bool awaitTransactionConfirmation(const String &signatureB58, String &mes return true; } if (payload.indexOf("\"err\":{") >= 0 || payload.indexOf("\"err\":\"") >= 0) { - messageOut = "Транзакция отклонена сетью"; + messageOut = "Transaction rejected by network"; return false; } delay(1000); } - messageOut = "RPC не подтвердил транзакцию вовремя"; + messageOut = "RPC did not confirm transaction in time"; return false; } @@ -1404,19 +1404,19 @@ static bool registerHomeserverOnSolana(String &messageOut) { messageOut = ""; String cleanLogin = normalizeLoginValue(gLoginValue); if (cleanLogin.isEmpty()) { - messageOut = "Логин не задан"; + messageOut = "Login is not set"; return false; } if (!gSecretConfigured || gRootPrivB58.isEmpty() || gBlockchainPrivB58.isEmpty() || gDevicePrivB58.isEmpty()) { - messageOut = "Секрет не готов"; + messageOut = "Secret is not ready"; return false; } if (WiFi.status() != WL_CONNECTED) { - messageOut = "Сначала подключите Wi-Fi"; + messageOut = "Connect Wi-Fi first"; return false; } if (gSolanaRpcUrl.isEmpty()) { - messageOut = "Сначала задайте Solana RPC"; + messageOut = "Set Solana RPC first"; return false; } @@ -1427,14 +1427,14 @@ static bool registerHomeserverOnSolana(String &messageOut) { gRegistrationSignature = ""; saveAccountPrefs(); gAccountPdaStatus = ACCOUNT_PDA_OK; - gAccountPdaStatusMessage = "Пользователь уже зарегистрирован"; + gAccountPdaStatusMessage = "User is already registered"; gShowRegisterAccountButton = false; - gAccountStatusMessage = "Пользователь уже зарегистрирован"; + gAccountStatusMessage = "User is already registered"; gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " registered"; refreshAccountPdaStatus(); return true; } - if (pdaCheckMessage == "Не удалось вычислить user PDA" || pdaCheckMessage == "Не удалось проверить PDA" || pdaCheckMessage == "Непонятный ответ getAccountInfo") { + if (pdaCheckMessage == "Failed to derive user PDA" || pdaCheckMessage == "Failed to check PDA" || pdaCheckMessage == "Unexpected getAccountInfo response") { messageOut = pdaCheckMessage; return false; } @@ -1452,7 +1452,7 @@ static bool registerHomeserverOnSolana(String &messageOut) { !findProgramAddress({ std::vector((const uint8_t *)kPaymentsInflowSeed, (const uint8_t *)kPaymentsInflowSeed + strlen(kPaymentsInflowSeed)) }, kShinePaymentsProgramId, inflowVault)) { - messageOut = "Не удалось вычислить обязательные PDA"; + messageOut = "Failed to derive required PDAs"; return false; } @@ -1468,7 +1468,7 @@ static bool registerHomeserverOnSolana(String &messageOut) { if (!deriveSeedKeypairFromBase58(gRootPrivB58, rootSeed, rootPub, rootSec) || !deriveSeedKeypairFromBase58(gBlockchainPrivB58, blockchainSeed, blockchainPub, blockchainSec) || !deriveSeedKeypairFromBase58(gDevicePrivB58, deviceSeed, devicePub, deviceSec)) { - messageOut = "Не удалось восстановить ключи"; + messageOut = "Failed to restore keys"; return false; } @@ -1478,7 +1478,7 @@ static bool registerHomeserverOnSolana(String &messageOut) { uint8_t lastBlockSignature[64]; sha256calc(lastBlockState.data(), lastBlockState.size(), lastBlockHash); if (!signMessageEd25519(std::vector(lastBlockHash, lastBlockHash + 32), blockchainSec, lastBlockSignature)) { - messageOut = "Не удалось подписать LastBlockState"; + messageOut = "Failed to sign LastBlockState"; return false; } @@ -1491,7 +1491,7 @@ static bool registerHomeserverOnSolana(String &messageOut) { uint8_t rootSignature[64]; sha256calc(unsignedRecord.data(), unsignedRecord.size(), unsignedHash); if (!signMessageEd25519(std::vector(unsignedHash, unsignedHash + 32), rootSec, rootSignature)) { - messageOut = "Не удалось подписать PDA-запись"; + messageOut = "Failed to sign PDA record"; return false; } @@ -1519,7 +1519,7 @@ static bool registerHomeserverOnSolana(String &messageOut) { createData); uint8_t txSignature[64]; if (!signMessageEd25519(message, deviceSec, txSignature)) { - messageOut = "Не удалось подписать Solana-транзакцию"; + messageOut = "Failed to sign Solana transaction"; return false; } String txBase64 = encodeTransactionBase64(txSignature, message); @@ -1527,11 +1527,11 @@ static bool registerHomeserverOnSolana(String &messageOut) { String payload; if (!rpcCallSolana("sendTransaction", "[\"" + txBase64 + "\",{\"encoding\":\"base64\",\"preflightCommitment\":\"confirmed\"}]", payload)) { - messageOut = "RPC не принял транзакцию"; + messageOut = "RPC did not accept transaction"; return false; } if (rpcResponseHasError(payload)) { - messageOut = "RPC вернул ошибку sendTransaction"; + messageOut = "RPC returned sendTransaction error"; return false; } if (!awaitTransactionConfirmation(signatureB58, messageOut)) { @@ -1540,14 +1540,14 @@ static bool registerHomeserverOnSolana(String &messageOut) { gUserPdaAddress = bytesToBase58(userPda, 32); gRegistrationSignature = signatureB58; - gAccountStatusMessage = "Solana-регистрация завершена"; + gAccountStatusMessage = "Solana registration complete"; gAccountPdaStatus = ACCOUNT_PDA_OK; - gAccountPdaStatusMessage = "Пользователь зарегистрирован"; + gAccountPdaStatusMessage = "User registered"; gShowRegisterAccountButton = false; gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " registered"; saveAccountPrefs(); refreshAccountPdaStatus(); - messageOut = "Solana-регистрация подтверждена"; + messageOut = "Solana registration confirmed"; return true; } @@ -1563,15 +1563,15 @@ static void prepareRegisterAccountScreen() { String cleanLogin = normalizeLoginValue(gLoginValue); if (cleanLogin.isEmpty()) { - gRegisterConfirmMessage = "Логин не задан"; + gRegisterConfirmMessage = "Login is not set"; return; } if (!gSecretConfigured) { - gRegisterConfirmMessage = "Секрет не задан"; + gRegisterConfirmMessage = "Secret is not set"; return; } if (WiFi.status() != WL_CONNECTED) { - gRegisterConfirmMessage = "Wi-Fi не подключен"; + gRegisterConfirmMessage = "Wi-Fi is not connected"; return; } @@ -1582,16 +1582,16 @@ static void prepareRegisterAccountScreen() { } else { gRegisterConfirmBalanceLine = formatSolValue(lamports); if (lamports < 20000000ULL) { - gRegisterConfirmMessage = "Баланс меньше 0.020 SOL"; + gRegisterConfirmMessage = "Balance is below 0.020 SOL"; } else { - gRegisterConfirmMessage = "Баланс подходит: 0.020 SOL или выше"; + gRegisterConfirmMessage = "Balance is OK: 0.020 SOL or more"; } } if (gHomeserverValue.isEmpty()) { - gRegisterConfirmHomeserverLine = "Homeserver не задан"; + gRegisterConfirmHomeserverLine = "Homeserver is not set"; } else if (gHomeserverValue == "homeserver1") { - gRegisterConfirmHomeserverLine = "Homeserver: homeserver1 (стандартное значение)"; + gRegisterConfirmHomeserverLine = "Homeserver: homeserver1 (default value)"; } else { gRegisterConfirmHomeserverLine = String("Homeserver: ") + gHomeserverValue; } @@ -1599,24 +1599,24 @@ static void prepareRegisterAccountScreen() { ShinePdaUserState pdaState; String pdaError; if (!readShineUserPda(cleanLogin, pdaState, pdaError)) { - gRegisterConfirmPdaLine = pdaError.isEmpty() ? "PDA: не удалось проверить" : String("PDA: ") + pdaError; + gRegisterConfirmPdaLine = pdaError.isEmpty() ? "PDA: failed to check" : String("PDA: ") + pdaError; if (gRegisterConfirmMessage.isEmpty()) { - gRegisterConfirmMessage = pdaError.isEmpty() ? "Не удалось проверить PDA" : pdaError; + gRegisterConfirmMessage = pdaError.isEmpty() ? "Failed to check PDA" : pdaError; } return; } if (pdaState.found) { - gRegisterConfirmPdaLine = "PDA уже занят, этот login не свободен"; + gRegisterConfirmPdaLine = "PDA is already occupied, login is not free"; if (gRegisterConfirmMessage.isEmpty()) { - gRegisterConfirmMessage = "Login уже зарегистрирован"; + gRegisterConfirmMessage = "Login is already registered"; } return; } - gRegisterConfirmPdaLine = "PDA свободен для регистрации"; + gRegisterConfirmPdaLine = "PDA is free for registration"; if (gRegisterConfirmMessage.isEmpty()) { - gRegisterConfirmMessage = "Все проверки пройдены"; + gRegisterConfirmMessage = "All checks passed"; } gRegisterConfirmCanSubmit = true; } @@ -3085,12 +3085,12 @@ static void actionButtonCb(lv_event_t *event) { String registerMessage; if (registerHomeserverOnSolana(registerMessage)) { gRegisterResultSuccess = true; - gRegisterResultMessage = "Регистрация в Сиянии завершена"; + gRegisterResultMessage = "Registration in SHiNE completed"; gRegisterResultDetails = registerMessage; - gAccountStatusMessage = "Регистрация завершена"; + gAccountStatusMessage = "Registration completed"; } else { gRegisterResultSuccess = false; - gRegisterResultMessage = "Регистрация не выполнена"; + gRegisterResultMessage = "Registration failed"; gRegisterResultDetails = registerMessage; gAccountStatusMessage = registerMessage; } @@ -3635,7 +3635,7 @@ static void drawSecretGenerateCancelConfirmScreen() { static void drawRegisterAccountConfirmScreen() { setRootStyle(); makeTitle("REGISTER ACCOUNT", 22, &lv_font_montserrat_24); - String topLine = gRegisterConfirmMessage.isEmpty() ? String("Проверка регистрации") : gRegisterConfirmMessage; + String topLine = gRegisterConfirmMessage.isEmpty() ? String("Registration check") : gRegisterConfirmMessage; makeBody(topLine.c_str(), 96, 420); if (!gRegisterConfirmPdaLine.isEmpty()) { makeBody(gRegisterConfirmPdaLine.c_str(), 138, 420); @@ -3647,9 +3647,9 @@ static void drawRegisterAccountConfirmScreen() { makeBody(gRegisterConfirmHomeserverLine.c_str(), 222, 420); } if (gRegisterConfirmCanSubmit) { - makeButton("ЗАРЕГИСТРИРОВАТЬ В СИЯНИИ", 22, 296, 436, 74, 0x2A9D8F, ACTION_REGISTER_ACCOUNT_EXECUTE, &lv_font_montserrat_18); + makeButton("REGISTER IN SHINE", 22, 296, 436, 74, 0x2A9D8F, ACTION_REGISTER_ACCOUNT_EXECUTE, &lv_font_montserrat_18); } else { - makeButton("НЕДОСТУПНО", 22, 296, 436, 74, 0x4A5560, ACTION_NONE, &lv_font_montserrat_20); + makeButton("UNAVAILABLE", 22, 296, 436, 74, 0x4A5560, ACTION_NONE, &lv_font_montserrat_20); } makeButton("BACK", 140, 384, 200, 54, 0x5A6570, ACTION_BACK_HOME, &lv_font_montserrat_20); makeVersionTag(); @@ -3658,7 +3658,7 @@ static void drawRegisterAccountConfirmScreen() { static void drawRegisterAccountResultScreen() { setRootStyle(); makeTitle("REGISTER RESULT", 22, &lv_font_montserrat_24); - String resultTopLine = gRegisterResultSuccess ? String("Регистрация завершилась успешно") : String("Регистрация завершилась с ошибкой"); + String resultTopLine = gRegisterResultSuccess ? String("Registration completed successfully") : String("Registration failed"); makeBody(resultTopLine.c_str(), 96, 420); makeBody(gRegisterResultMessage.c_str(), 140, 420); if (!gRegisterResultDetails.isEmpty()) { diff --git a/VERSION.properties b/VERSION.properties index cca767c..c790f2a 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.165 -server.version=1.2.154 +client.version=1.2.166 +server.version=1.2.155 From b583a86ade9f78f74ceab0aa95301dbc1eaaa1461d2d01d4fe4ce6dd597c0e67 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Fri, 12 Jun 2026 23:43:42 +0400 Subject: [PATCH 07/56] =?UTF-8?q?ESP32:=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20ABI=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D1=80=D0=BE=D0=B1=D0=BD=D1=8B=D0=B5=20=D0=BE=D1=88=D0=B8?= =?UTF-8?q?=D0=B1=D0=BA=D0=B8=20RPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shine_homeserver_main.ino | 104 +++++++++++++++++- .../reference/shine_homeserver_ui_spec.md | 3 +- VERSION.properties | 4 +- 3 files changed, 103 insertions(+), 8 deletions(-) diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino index e764e2a..708b358 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino @@ -362,6 +362,9 @@ static bool rpcCallSolana(const char *method, const String ¶msJson, String & static bool rpcResponseHasError(const String &payload); static bool getLatestBlockhashBytes(uint8_t out[32], String &blockhashB58, String &messageOut); static bool pdaAlreadyExists(const String &login, String &pdaAddress, String &messageOut); +static bool extractRpcErrorSummary(const String &payload, String &messageOut); +static String compactRpcLogs(const String &payload, int maxLines = 3); +static bool simulateTransactionForError(const String &txBase64, String &messageOut); static std::vector buildLastBlockStateBytes(const String &login, const String &blockchainName); static std::vector buildUnsignedCreateRecord( const String &login, @@ -1223,6 +1226,7 @@ static std::vector buildCreateInstructionData( out.push_back(0); out.push_back(1); out.push_back(0); + out.push_back(0); pushStrU8(out, serverAddress); out.push_back(0); out.push_back(0); @@ -1298,6 +1302,86 @@ static bool pdaAlreadyExists(const String &login, String &pdaAddress, String &me return false; } +static String compactRpcLogs(const String &payload, int maxLines) { + String out; + int pos = payload.indexOf("\"logs\""); + if (pos < 0) { + return ""; + } + int bracket = payload.indexOf('[', pos); + if (bracket < 0) { + return ""; + } + int i = bracket + 1; + int lines = 0; + while (i < (int)payload.length() && lines < maxLines) { + while (i < (int)payload.length() && payload[i] != '"' && payload[i] != ']') { + i++; + } + if (i >= (int)payload.length() || payload[i] == ']') { + break; + } + String line; + bool escape = false; + i++; + for (; i < (int)payload.length(); ++i) { + char ch = payload[i]; + if (escape) { + switch (ch) { + case 'n': line += ' '; break; + case 'r': break; + case 't': line += ' '; break; + default: line += ch; break; + } + escape = false; + continue; + } + if (ch == '\\') { + escape = true; + continue; + } + if (ch == '"') { + i++; + break; + } + line += ch; + } + line.trim(); + if (!line.isEmpty()) { + if (!out.isEmpty()) { + out += " | "; + } + out += line; + lines++; + } + } + return out; +} + +static bool extractRpcErrorSummary(const String &payload, String &messageOut) { + messageOut = ""; + String errorMessage; + if (jsonStringField(payload, "message", errorMessage) && !errorMessage.isEmpty()) { + messageOut = errorMessage; + } + String logs = compactRpcLogs(payload, 3); + if (!logs.isEmpty()) { + if (!messageOut.isEmpty()) { + messageOut += " | "; + } + messageOut += logs; + } + return !messageOut.isEmpty(); +} + +static bool simulateTransactionForError(const String &txBase64, String &messageOut) { + String payload; + if (!rpcCallSolana("simulateTransaction", "[\"" + txBase64 + "\",{\"encoding\":\"base64\",\"sigVerify\":true,\"commitment\":\"processed\"}]", payload)) { + return false; + } + return extractRpcErrorSummary(payload, messageOut); +} + static std::vector buildLegacyMessage( const uint8_t recentBlockhash[32], const uint8_t devicePub[32], @@ -1531,7 +1615,17 @@ static bool registerHomeserverOnSolana(String &messageOut) { return false; } if (rpcResponseHasError(payload)) { - messageOut = "RPC returned sendTransaction error"; + if (!extractRpcErrorSummary(payload, messageOut)) { + messageOut = "RPC returned sendTransaction error"; + } + String simulated; + if (simulateTransactionForError(txBase64, simulated)) { + if (messageOut.isEmpty()) { + messageOut = simulated; + } else if (messageOut.indexOf(simulated) < 0) { + messageOut += " | simulate: " + simulated; + } + } return false; } if (!awaitTransactionConfirmation(signatureB58, messageOut)) { @@ -3085,13 +3179,13 @@ static void actionButtonCb(lv_event_t *event) { String registerMessage; if (registerHomeserverOnSolana(registerMessage)) { gRegisterResultSuccess = true; - gRegisterResultMessage = "Registration in SHiNE completed"; - gRegisterResultDetails = registerMessage; + gRegisterResultMessage = registerMessage; + gRegisterResultDetails = String("user_pda and tx signature were saved"); gAccountStatusMessage = "Registration completed"; } else { gRegisterResultSuccess = false; - gRegisterResultMessage = "Registration failed"; - gRegisterResultDetails = registerMessage; + gRegisterResultMessage = registerMessage; + gRegisterResultDetails = ""; gAccountStatusMessage = registerMessage; } gRegisterConfirmMessage = ""; diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md index 90e96f9..58c1a72 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md @@ -212,7 +212,8 @@ Поведение: - после успешной регистрации данные `user_pda` и `tx signature` сохраняются в `NVS`; -- при ошибке на экране показывается причина отказа. +- при ошибке на экране показывается причина отказа; +- если ошибку вернул `sendTransaction`, экран старается показать не только общий текст, но и детали `RPC`/preflight/simulate-логов. ## Экран STATUS diff --git a/VERSION.properties b/VERSION.properties index c790f2a..8539140 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.166 -server.version=1.2.155 +client.version=1.2.167 +server.version=1.2.156 From 21030b1d510dfedd153d1b10115c90e9b9b7902b9de1a7f9f6d9af3a8acfda80 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Fri, 12 Jun 2026 23:48:38 +0400 Subject: [PATCH 08/56] =?UTF-8?q?ESP32:=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20base64=20=D1=81=D0=B5=D1=80=D0=B8?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8E=20Solana=20?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=BD=D0=B7=D0=B0=D0=BA=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shine_homeserver_main/shine_homeserver_main.ino | 12 ++++++++---- VERSION.properties | 4 ++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino index 708b358..6232505 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino @@ -585,13 +585,17 @@ static void sha256calc(const uint8_t *in, size_t len, uint8_t *out32) { } static String base64Std(const uint8_t *data, size_t len) { - char out[96] = {}; + size_t needed = ((len + 2) / 3) * 4 + 4; + std::vector out(needed, 0); size_t outLen = 0; - if (mbedtls_base64_encode(reinterpret_cast(out), sizeof(out), &outLen, data, len) != 0) { + if (mbedtls_base64_encode(out.data(), out.size(), &outLen, data, len) != 0) { return ""; } - out[min(outLen, sizeof(out) - 1)] = '\0'; - return String(out); + if (outLen >= out.size()) { + return ""; + } + out[outLen] = '\0'; + return String(reinterpret_cast(out.data())); } static String base58From32(const uint8_t *data32) { diff --git a/VERSION.properties b/VERSION.properties index 8539140..6f8bed5 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.167 -server.version=1.2.156 +client.version=1.2.168 +server.version=1.2.157 From 436e1f0c53db23879dbc7c2adfa7d51610b50651531af4d19be5eb8ebcffb552 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sat, 13 Jun 2026 00:01:57 +0400 Subject: [PATCH 09/56] =?UTF-8?q?ESP32:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20USB-=D0=B4=D0=B8=D0=B0=D0=B3=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B8=D0=BA=D1=83=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20Solana?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ..._1335_esp32_usb_диагностика_регистрации.md | 45 +++ .../shine_homeserver_main.ino | 275 ++++++++++++++++-- VERSION.properties | 4 +- 3 files changed, 293 insertions(+), 31 deletions(-) create mode 100644 Dev_Docs/Pending_Features/2026-06-12_1335_esp32_usb_диагностика_регистрации.md diff --git a/Dev_Docs/Pending_Features/2026-06-12_1335_esp32_usb_диагностика_регистрации.md b/Dev_Docs/Pending_Features/2026-06-12_1335_esp32_usb_диагностика_регистрации.md new file mode 100644 index 0000000..c40edab --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-12_1335_esp32_usb_диагностика_регистрации.md @@ -0,0 +1,45 @@ +# ESP32 USB-диагностика регистрации Solana + +- статус: `pending` + +## Что сделано + +- В основной скетч `shine_homeserver_main` добавено сохранение последней диагностики регистрации Solana в `Preferences`. +- Добавлены USB-команды через `Serial`: + - `last_error` + - `last_diag` + - `reg_diag` + - `clear_error` + - `clear_diag` + - `help` +- Перед отправкой `create_user_pda` добавлена RPC-проверка `users_economy_config_pda`. +- Стартовый `paid_limit_bytes` в подписываемой записи теперь берётся из on-chain `users_economy_config`, а не из хардкода. + +## Что проверять + +1. Подключить устройство по USB. +2. Открыть последовательный порт `115200`. +3. Отправить команду `last_error`. +4. Убедиться, что устройство печатает сохранённую диагностику между маркерами: + - `LAST_REGISTER_DIAG_BEGIN` + - `LAST_REGISTER_DIAG_END` +5. Запустить регистрацию с устройства и дождаться ошибки или успеха. +6. Снова отправить `last_error`. +7. Проверить, что в диагностике есть: + - `status` + - `summary` + - `rpc` + - `user_pda` + - `users_economy_config_pda` + - `inflow_vault_pda` + - `root_pub` + - `blockchain_pub` + - `device_pub` +8. При ошибке `0x3` проверить, что текст стал конкретнее и помогает понять, какая PDA или RPC-конфигурация не совпала. + +## Ожидаемый результат + +- Последняя ошибка регистрации читается по USB без просмотра экрана. +- После неудачной регистрации на устройстве остаётся подробная диагностическая запись. +- Если `users_economy_config_pda` отсутствует или принадлежит не той программе, это явно видно до отправки транзакции. + diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino index 6232505..2355b1a 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino @@ -256,6 +256,11 @@ static bool gRegisterConfirmCanSubmit = false; static String gRegisterResultMessage; static String gRegisterResultDetails; static bool gRegisterResultSuccess = false; +static String gLastRegisterDiagStatus = "none"; +static String gLastRegisterDiagSummary; +static String gLastRegisterDiagDetails; +static String gLastRegisterDiagTime; +static String gSerialCommandBuffer; static String gShineSessionId; static String gShineSessionKey; static String gShineStoragePwd; @@ -362,6 +367,12 @@ static bool rpcCallSolana(const char *method, const String ¶msJson, String & static bool rpcResponseHasError(const String &payload); static bool getLatestBlockhashBytes(uint8_t out[32], String &blockhashB58, String &messageOut); static bool pdaAlreadyExists(const String &login, String &pdaAddress, String &messageOut); +static bool loadUsersEconomyConfigState(const String &economyConfigAddress, + uint64_t ®istrationFeeLamportsOut, + uint64_t &lamportsPerLimitStepOut, + uint64_t &startBonusLimitOut, + String &messageOut); +static bool loadAccountOwner(const String &address, String &ownerOut, bool &existsOut, String &messageOut); static bool extractRpcErrorSummary(const String &payload, String &messageOut); static String compactRpcLogs(const String &payload, int maxLines = 3); static bool simulateTransactionForError(const String &txBase64, String &messageOut); @@ -374,6 +385,7 @@ static std::vector buildUnsignedCreateRecord( const uint8_t devicePub[32], const uint8_t blockchainPub[32], const uint8_t lastBlockSignature[64], + uint64_t paidLimitBytes, uint64_t createdAtMs); static std::vector buildCreateInstructionData( const String &login, @@ -399,6 +411,10 @@ static bool signMessageEd25519(const std::vector &message, const uint8_ static String encodeTransactionBase64(const uint8_t signature[64], const std::vector &message); static bool awaitTransactionConfirmation(const String &signatureB58, String &messageOut); static bool registerHomeserverOnSolana(String &messageOut); +static void saveRegisterDiag(const String &status, const String &summary, const String &details); +static void printRegisterDiagToSerial(); +static void clearRegisterDiag(); +static void handleUsbSerialCommands(); static void prepareRegisterAccountScreen(); static String buildSessionKeyStringFromPublicBase64(const String &pubB64); static bool deriveKeypairFromSeed32(const uint8_t seed32[32], uint8_t pub32[32], uint8_t sec64[64]); @@ -1140,6 +1156,7 @@ static std::vector buildUnsignedCreateRecord( const uint8_t devicePub[32], const uint8_t blockchainPub[32], const uint8_t lastBlockSignature[64], + uint64_t paidLimitBytes, uint64_t createdAtMs) { std::vector out; out.reserve(512); @@ -1169,7 +1186,7 @@ static std::vector buildUnsignedCreateRecord( out.push_back(1); pushStrU8(out, blockchainName); pushFixed(out, blockchainPub, 32); - pushU64LE(out, 100000); + pushU64LE(out, paidLimitBytes); pushU64LE(out, 0); pushU32LE(out, 0); out.insert(out.end(), 32, 0); @@ -1306,6 +1323,82 @@ static bool pdaAlreadyExists(const String &login, String &pdaAddress, String &me return false; } +static bool loadAccountOwner(const String &address, String &ownerOut, bool &existsOut, String &messageOut) { + ownerOut = ""; + existsOut = false; + String payload; + if (!rpcCallSolana("getAccountInfo", "[\"" + address + "\",{\"encoding\":\"base64\",\"commitment\":\"confirmed\"}]", payload)) { + messageOut = "Failed to read account info"; + return false; + } + if (payload.indexOf("\"value\":null") >= 0) { + existsOut = false; + return true; + } + existsOut = true; + if (!jsonStringField(payload, "owner", ownerOut) || ownerOut.isEmpty()) { + messageOut = "Account owner missing in RPC response"; + return false; + } + return true; +} + +static bool loadUsersEconomyConfigState(const String &economyConfigAddress, + uint64_t ®istrationFeeLamportsOut, + uint64_t &lamportsPerLimitStepOut, + uint64_t &startBonusLimitOut, + String &messageOut) { + registrationFeeLamportsOut = 0; + lamportsPerLimitStepOut = 0; + startBonusLimitOut = 0; + + String payload; + if (!rpcCallSolana("getAccountInfo", "[\"" + economyConfigAddress + "\",{\"encoding\":\"base64\",\"commitment\":\"confirmed\"}]", payload)) { + messageOut = "Failed to read users economy config"; + return false; + } + if (payload.indexOf("\"value\":null") >= 0) { + messageOut = "Users economy config is missing on this Solana RPC"; + return false; + } + + String owner; + if (!jsonStringField(payload, "owner", owner) || owner.isEmpty()) { + messageOut = "Users economy config owner is missing"; + return false; + } + if (owner != String(kShineUsersProgramId)) { + messageOut = String("Users economy config owner mismatch: ") + owner; + return false; + } + + String dataB64; + if (!jsonStringField(payload, "data", dataB64) || dataB64.isEmpty()) { + messageOut = "Users economy config base64 is missing"; + return false; + } + + std::vector raw; + if (!base64DecodeStd(dataB64, raw)) { + messageOut = "Users economy config base64 decode failed"; + return false; + } + if (raw.size() < 25) { + messageOut = "Users economy config is too short"; + return false; + } + + registrationFeeLamportsOut = 0; + lamportsPerLimitStepOut = 0; + startBonusLimitOut = 0; + for (int i = 0; i < 8; ++i) { + registrationFeeLamportsOut |= ((uint64_t)raw[1 + i]) << (8 * i); + lamportsPerLimitStepOut |= ((uint64_t)raw[9 + i]) << (8 * i); + startBonusLimitOut |= ((uint64_t)raw[17 + i]) << (8 * i); + } + return true; +} + static String compactRpcLogs(const String &payload, int maxLines) { String out; int pos = payload.indexOf("\"logs\""); @@ -1490,22 +1583,33 @@ static bool awaitTransactionConfirmation(const String &signatureB58, String &mes static bool registerHomeserverOnSolana(String &messageOut) { messageOut = ""; - String cleanLogin = normalizeLoginValue(gLoginValue); - if (cleanLogin.isEmpty()) { - messageOut = "Login is not set"; + String diagDetails; + auto failWithDiag = [&](const String &summary) -> bool { + messageOut = summary; + saveRegisterDiag("error", summary, diagDetails); + printRegisterDiagToSerial(); return false; + }; + + String cleanLogin = normalizeLoginValue(gLoginValue); + diagDetails += String("login=") + cleanLogin + "\n"; + diagDetails += String("rpc=") + gSolanaRpcUrl + "\n"; + diagDetails += String("shine_server=") + gShineServerUrl + "\n"; + diagDetails += String("homeserver=") + gHomeserverValue + "\n"; + + if (cleanLogin.isEmpty()) { + return failWithDiag("Login is not set"); } if (!gSecretConfigured || gRootPrivB58.isEmpty() || gBlockchainPrivB58.isEmpty() || gDevicePrivB58.isEmpty()) { - messageOut = "Secret is not ready"; - return false; + return failWithDiag("Secret is not ready"); } if (WiFi.status() != WL_CONNECTED) { - messageOut = "Connect Wi-Fi first"; - return false; + diagDetails += "wifi=disconnected\n"; + return failWithDiag("Connect Wi-Fi first"); } + diagDetails += String("wifi=connected:") + WiFi.localIP().toString() + "\n"; if (gSolanaRpcUrl.isEmpty()) { - messageOut = "Set Solana RPC first"; - return false; + return failWithDiag("Set Solana RPC first"); } String existingPda; @@ -1520,11 +1624,13 @@ static bool registerHomeserverOnSolana(String &messageOut) { gAccountStatusMessage = "User is already registered"; gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " registered"; refreshAccountPdaStatus(); + diagDetails += String("user_pda=") + existingPda + "\n"; + saveRegisterDiag("ok", "User is already registered", diagDetails); return true; } if (pdaCheckMessage == "Failed to derive user PDA" || pdaCheckMessage == "Failed to check PDA" || pdaCheckMessage == "Unexpected getAccountInfo response") { - messageOut = pdaCheckMessage; - return false; + diagDetails += String("user_pda_check=") + pdaCheckMessage + "\n"; + return failWithDiag(pdaCheckMessage); } uint8_t userPda[32]; @@ -1540,10 +1646,44 @@ static bool registerHomeserverOnSolana(String &messageOut) { !findProgramAddress({ std::vector((const uint8_t *)kPaymentsInflowSeed, (const uint8_t *)kPaymentsInflowSeed + strlen(kPaymentsInflowSeed)) }, kShinePaymentsProgramId, inflowVault)) { - messageOut = "Failed to derive required PDAs"; - return false; + return failWithDiag("Failed to derive required PDAs"); } + String userPdaB58 = bytesToBase58(userPda, 32); + String economyConfigB58 = bytesToBase58(economyConfig, 32); + String inflowVaultB58 = bytesToBase58(inflowVault, 32); + diagDetails += String("user_pda=") + userPdaB58 + "\n"; + diagDetails += String("users_economy_config_pda=") + economyConfigB58 + "\n"; + diagDetails += String("inflow_vault_pda=") + inflowVaultB58 + "\n"; + + String accountInfoMessage; + String ownerValue; + bool ownerExists = false; + if (!loadAccountOwner(userPdaB58, ownerValue, ownerExists, accountInfoMessage)) { + diagDetails += String("user_pda_rpc_error=") + accountInfoMessage + "\n"; + return failWithDiag(accountInfoMessage); + } + diagDetails += String("user_pda_exists=") + (ownerExists ? "true" : "false") + "\n"; + if (ownerExists) { + diagDetails += String("user_pda_owner=") + ownerValue + "\n"; + return failWithDiag("User PDA already exists on RPC"); + } + + uint64_t registrationFeeLamports = 0; + uint64_t lamportsPerLimitStep = 0; + uint64_t startBonusLimit = 0; + if (!loadUsersEconomyConfigState(economyConfigB58, + registrationFeeLamports, + lamportsPerLimitStep, + startBonusLimit, + accountInfoMessage)) { + diagDetails += String("users_economy_config_error=") + accountInfoMessage + "\n"; + return failWithDiag(accountInfoMessage); + } + diagDetails += String("registration_fee_lamports=") + String((unsigned long long)registrationFeeLamports) + "\n"; + diagDetails += String("lamports_per_limit_step=") + String((unsigned long long)lamportsPerLimitStep) + "\n"; + diagDetails += String("start_bonus_limit=") + String((unsigned long long)startBonusLimit) + "\n"; + uint8_t rootSeed[32] = {}; uint8_t rootPub[32] = {}; uint8_t rootSec[64] = {}; @@ -1556,9 +1696,11 @@ static bool registerHomeserverOnSolana(String &messageOut) { if (!deriveSeedKeypairFromBase58(gRootPrivB58, rootSeed, rootPub, rootSec) || !deriveSeedKeypairFromBase58(gBlockchainPrivB58, blockchainSeed, blockchainPub, blockchainSec) || !deriveSeedKeypairFromBase58(gDevicePrivB58, deviceSeed, devicePub, deviceSec)) { - messageOut = "Failed to restore keys"; - return false; + return failWithDiag("Failed to restore keys"); } + diagDetails += String("root_pub=") + bytesToBase58(rootPub, 32) + "\n"; + diagDetails += String("blockchain_pub=") + bytesToBase58(blockchainPub, 32) + "\n"; + diagDetails += String("device_pub=") + bytesToBase58(devicePub, 32) + "\n"; String blockchainName = cleanLogin + "-001"; std::vector lastBlockState = buildLastBlockStateBytes(cleanLogin, blockchainName); @@ -1566,21 +1708,19 @@ static bool registerHomeserverOnSolana(String &messageOut) { uint8_t lastBlockSignature[64]; sha256calc(lastBlockState.data(), lastBlockState.size(), lastBlockHash); if (!signMessageEd25519(std::vector(lastBlockHash, lastBlockHash + 32), blockchainSec, lastBlockSignature)) { - messageOut = "Failed to sign LastBlockState"; - return false; + return failWithDiag("Failed to sign LastBlockState"); } uint64_t createdAtMs = shineNowMs(); std::vector unsignedRecord = buildUnsignedCreateRecord( cleanLogin, blockchainName, gShineServerUrl, rootPub, devicePub, blockchainPub, - lastBlockSignature, createdAtMs); + lastBlockSignature, startBonusLimit, createdAtMs); uint8_t unsignedHash[32]; uint8_t rootSignature[64]; sha256calc(unsignedRecord.data(), unsignedRecord.size(), unsignedHash); if (!signMessageEd25519(std::vector(unsignedHash, unsignedHash + 32), rootSec, rootSignature)) { - messageOut = "Failed to sign PDA record"; - return false; + return failWithDiag("Failed to sign PDA record"); } std::vector createData = buildCreateInstructionData( @@ -1593,8 +1733,10 @@ static bool registerHomeserverOnSolana(String &messageOut) { uint8_t recentBlockhash[32]; String recentBlockhash58; if (!getLatestBlockhashBytes(recentBlockhash, recentBlockhash58, messageOut)) { - return false; + diagDetails += String("blockhash_error=") + messageOut + "\n"; + return failWithDiag(messageOut); } + diagDetails += String("recent_blockhash=") + recentBlockhash58 + "\n"; std::vector message = buildLegacyMessage( recentBlockhash, @@ -1607,16 +1749,16 @@ static bool registerHomeserverOnSolana(String &messageOut) { createData); uint8_t txSignature[64]; if (!signMessageEd25519(message, deviceSec, txSignature)) { - messageOut = "Failed to sign Solana transaction"; - return false; + return failWithDiag("Failed to sign Solana transaction"); } String txBase64 = encodeTransactionBase64(txSignature, message); String signatureB58 = bytesToBase58(txSignature, 64); + diagDetails += String("tx_signature=") + signatureB58 + "\n"; String payload; if (!rpcCallSolana("sendTransaction", "[\"" + txBase64 + "\",{\"encoding\":\"base64\",\"preflightCommitment\":\"confirmed\"}]", payload)) { - messageOut = "RPC did not accept transaction"; - return false; + diagDetails += "send_transaction_rpc_error=true\n"; + return failWithDiag("RPC did not accept transaction"); } if (rpcResponseHasError(payload)) { if (!extractRpcErrorSummary(payload, messageOut)) { @@ -1630,13 +1772,15 @@ static bool registerHomeserverOnSolana(String &messageOut) { messageOut += " | simulate: " + simulated; } } - return false; + diagDetails += String("send_transaction_error=") + messageOut + "\n"; + return failWithDiag(messageOut); } if (!awaitTransactionConfirmation(signatureB58, messageOut)) { - return false; + diagDetails += String("confirmation_error=") + messageOut + "\n"; + return failWithDiag(messageOut); } - gUserPdaAddress = bytesToBase58(userPda, 32); + gUserPdaAddress = userPdaB58; gRegistrationSignature = signatureB58; gAccountStatusMessage = "Solana registration complete"; gAccountPdaStatus = ACCOUNT_PDA_OK; @@ -1646,6 +1790,8 @@ static bool registerHomeserverOnSolana(String &messageOut) { saveAccountPrefs(); refreshAccountPdaStatus(); messageOut = "Solana registration confirmed"; + saveRegisterDiag("ok", messageOut, diagDetails); + printRegisterDiagToSerial(); return true; } @@ -2573,6 +2719,10 @@ static void loadPrefs() { gShineStoragePwd = gPrefs.getString("shine_store_pwd", ""); gUserPdaAddress = gPrefs.getString("user_pda", ""); gRegistrationSignature = gPrefs.getString("registration_sig", ""); + gLastRegisterDiagStatus = gPrefs.getString("reg_diag_status", "none"); + gLastRegisterDiagSummary = gPrefs.getString("reg_diag_summary", ""); + gLastRegisterDiagDetails = gPrefs.getString("reg_diag_details", ""); + gLastRegisterDiagTime = gPrefs.getString("reg_diag_time", ""); gBalanceStatusMessage = gDevicePubB58.isEmpty() ? "Balance: secret not set" : "Balance: tap to load"; gAccountCheckPending = true; gLastAccountCheckMs = 0; @@ -2687,6 +2837,71 @@ static String wifiHomeSummary() { return String("Wi-Fi (") + gWifiSavedSsid + ") disconnected"; } +static void saveRegisterDiag(const String &status, const String &summary, const String &details) { + gLastRegisterDiagStatus = status; + gLastRegisterDiagSummary = summary.length() > 240 ? summary.substring(0, 240) : summary; + gLastRegisterDiagDetails = details.length() > 1800 ? details.substring(0, 1800) : details; + gLastRegisterDiagTime = String(shineNowMs()); + gPrefs.putString("reg_diag_status", gLastRegisterDiagStatus); + gPrefs.putString("reg_diag_summary", gLastRegisterDiagSummary); + gPrefs.putString("reg_diag_details", gLastRegisterDiagDetails); + gPrefs.putString("reg_diag_time", gLastRegisterDiagTime); +} + +static void clearRegisterDiag() { + gLastRegisterDiagStatus = "none"; + gLastRegisterDiagSummary = ""; + gLastRegisterDiagDetails = ""; + gLastRegisterDiagTime = ""; + gPrefs.remove("reg_diag_status"); + gPrefs.remove("reg_diag_summary"); + gPrefs.remove("reg_diag_details"); + gPrefs.remove("reg_diag_time"); +} + +static void printRegisterDiagToSerial() { + Serial.println("LAST_REGISTER_DIAG_BEGIN"); + Serial.println(String("status=") + gLastRegisterDiagStatus); + Serial.println(String("time_ms=") + gLastRegisterDiagTime); + Serial.println(String("summary=") + gLastRegisterDiagSummary); + Serial.println("details<<"); + Serial.println(gLastRegisterDiagDetails); + Serial.println(">>details"); + Serial.println("LAST_REGISTER_DIAG_END"); +} + +static void handleUsbSerialCommands() { + while (Serial.available() > 0) { + char ch = (char)Serial.read(); + if (ch == '\r') { + continue; + } + if (ch == '\n') { + String cmd = gSerialCommandBuffer; + gSerialCommandBuffer = ""; + cmd.trim(); + cmd.toLowerCase(); + if (cmd.isEmpty()) { + continue; + } + if (cmd == "last_error" || cmd == "last_diag" || cmd == "reg_diag") { + printRegisterDiagToSerial(); + } else if (cmd == "clear_error" || cmd == "clear_diag") { + clearRegisterDiag(); + Serial.println("register diag cleared"); + } else if (cmd == "help") { + Serial.println("commands: last_error, last_diag, reg_diag, clear_error, clear_diag, help"); + } else { + Serial.println(String("unknown command: ") + cmd); + } + continue; + } + if (gSerialCommandBuffer.length() < 120) { + gSerialCommandBuffer += ch; + } + } +} + static String loginDisplayValue() { return gLoginValue.isEmpty() ? "login not set" : gLoginValue; } @@ -4128,9 +4343,11 @@ void setup() { rebuildScreen(); Serial.println("Minimal nav test ready"); + Serial.println("USB diag commands: last_error, last_diag, reg_diag, clear_error, help"); } void loop() { + handleUsbSerialCommands(); lv_timer_handler(); manageWifiReconnect(); manageAccountPdaRefresh(); diff --git a/VERSION.properties b/VERSION.properties index 6f8bed5..e9b9027 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.168 -server.version=1.2.157 +client.version=1.2.169 +server.version=1.2.158 From 04252e006bbb74d3cc507f486d8206b6d946d6ab42f57907c442737d4a43e443 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sat, 13 Jun 2026 00:24:42 +0400 Subject: [PATCH 10/56] =?UTF-8?q?ESP32:=20=D1=81=D0=BE=D1=85=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D1=8F=D1=82=D1=8C=20=D0=BF=D0=BE=D0=BB=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=20=D0=BE=D1=88=D0=B8=D0=B1?= =?UTF-8?q?=D0=BA=D0=B8=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...5_esp32_полный_текст_ошибки_регистрации.md | 26 ++++++ .../shine_homeserver_main.ino | 86 +++++++++++++++++-- VERSION.properties | 4 +- 3 files changed, 107 insertions(+), 9 deletions(-) create mode 100644 Dev_Docs/Pending_Features/2026-06-13_1235_esp32_полный_текст_ошибки_регистрации.md diff --git a/Dev_Docs/Pending_Features/2026-06-13_1235_esp32_полный_текст_ошибки_регистрации.md b/Dev_Docs/Pending_Features/2026-06-13_1235_esp32_полный_текст_ошибки_регистрации.md new file mode 100644 index 0000000..1d7168e --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-13_1235_esp32_полный_текст_ошибки_регистрации.md @@ -0,0 +1,26 @@ +# ESP32 полный текст ошибки регистрации Solana + +- статус: `pending` + +## Что сделано + +- Локальные правки Solana-программ для диагностических `failed at ...` логов откатены. +- В основном ESP32-скетче сохранение последней ошибки регистрации переведено на полный текст RPC-диагностики. +- В `details` теперь сохраняются полные payload: + - `send_transaction_payload` + - `simulate_payload` +- Длинный текст ошибки сохраняется в `Preferences` по частям, чтобы не теряться из-за лимита одной строки. +- Чтение по USB-команде `last_error` / `last_diag` / `reg_diag` выводит сохранённый текст целиком. + +## Что проверять + +1. Запустить регистрацию на устройстве до ошибки. +2. По USB отправить `last_error`. +3. Проверить, что в ответе есть полный JSON/текст `sendTransaction` и `simulateTransaction`, а не только короткий summary. +4. Убедиться, что текст после перезагрузки устройства остаётся доступным через ту же команду. + +## Ожидаемый результат + +- Последняя ошибка регистрации читается по USB почти целиком. +- Если Solana уже вернула строку с местом падения, она не теряется из-за агрессивного обрезания на ESP32. + diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino index 2355b1a..2b7c6ac 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino @@ -375,6 +375,7 @@ static bool loadUsersEconomyConfigState(const String &economyConfigAddress, static bool loadAccountOwner(const String &address, String &ownerOut, bool &existsOut, String &messageOut); static bool extractRpcErrorSummary(const String &payload, String &messageOut); static String compactRpcLogs(const String &payload, int maxLines = 3); +static bool simulateTransactionPayload(const String &txBase64, String &payloadOut); static bool simulateTransactionForError(const String &txBase64, String &messageOut); static std::vector buildLastBlockStateBytes(const String &login, const String &blockchainName); static std::vector buildUnsignedCreateRecord( @@ -411,6 +412,9 @@ static bool signMessageEd25519(const std::vector &message, const uint8_ static String encodeTransactionBase64(const uint8_t signature[64], const std::vector &message); static bool awaitTransactionConfirmation(const String &signatureB58, String &messageOut); static bool registerHomeserverOnSolana(String &messageOut); +static void loadRegisterDiagDetailsFromPrefs(); +static void saveRegisterDiagDetailsToPrefs(const String &details); +static void clearRegisterDiagDetailsFromPrefs(); static void saveRegisterDiag(const String &status, const String &summary, const String &details); static void printRegisterDiagToSerial(); static void clearRegisterDiag(); @@ -1471,9 +1475,14 @@ static bool extractRpcErrorSummary(const String &payload, String &messageOut) { return !messageOut.isEmpty(); } +static bool simulateTransactionPayload(const String &txBase64, String &payloadOut) { + payloadOut = ""; + return rpcCallSolana("simulateTransaction", "[\"" + txBase64 + "\",{\"encoding\":\"base64\",\"sigVerify\":true,\"commitment\":\"processed\"}]", payloadOut); +} + static bool simulateTransactionForError(const String &txBase64, String &messageOut) { String payload; - if (!rpcCallSolana("simulateTransaction", "[\"" + txBase64 + "\",{\"encoding\":\"base64\",\"sigVerify\":true,\"commitment\":\"processed\"}]", payload)) { + if (!simulateTransactionPayload(txBase64, payload)) { return false; } return extractRpcErrorSummary(payload, messageOut); @@ -1764,13 +1773,21 @@ static bool registerHomeserverOnSolana(String &messageOut) { if (!extractRpcErrorSummary(payload, messageOut)) { messageOut = "RPC returned sendTransaction error"; } + diagDetails += "send_transaction_payload<<\n"; + diagDetails += payload; + diagDetails += "\n>>send_transaction_payload\n"; String simulated; - if (simulateTransactionForError(txBase64, simulated)) { + String simulatedPayload; + if (simulateTransactionPayload(txBase64, simulatedPayload)) { + extractRpcErrorSummary(simulatedPayload, simulated); if (messageOut.isEmpty()) { messageOut = simulated; } else if (messageOut.indexOf(simulated) < 0) { messageOut += " | simulate: " + simulated; } + diagDetails += "simulate_payload<<\n"; + diagDetails += simulatedPayload; + diagDetails += "\n>>simulate_payload\n"; } diagDetails += String("send_transaction_error=") + messageOut + "\n"; return failWithDiag(messageOut); @@ -2721,7 +2738,7 @@ static void loadPrefs() { gRegistrationSignature = gPrefs.getString("registration_sig", ""); gLastRegisterDiagStatus = gPrefs.getString("reg_diag_status", "none"); gLastRegisterDiagSummary = gPrefs.getString("reg_diag_summary", ""); - gLastRegisterDiagDetails = gPrefs.getString("reg_diag_details", ""); + loadRegisterDiagDetailsFromPrefs(); gLastRegisterDiagTime = gPrefs.getString("reg_diag_time", ""); gBalanceStatusMessage = gDevicePubB58.isEmpty() ? "Balance: secret not set" : "Balance: tap to load"; gAccountCheckPending = true; @@ -2837,15 +2854,70 @@ static String wifiHomeSummary() { return String("Wi-Fi (") + gWifiSavedSsid + ") disconnected"; } +static void loadRegisterDiagDetailsFromPrefs() { + gLastRegisterDiagDetails = ""; + int chunks = gPrefs.getInt("rddn", 0); + if (chunks <= 0) { + gLastRegisterDiagDetails = gPrefs.getString("reg_diag_details", ""); + return; + } + if (chunks > 12) { + chunks = 12; + } + for (int i = 0; i < chunks; ++i) { + String key = String("rdd") + i; + gLastRegisterDiagDetails += gPrefs.getString(key.c_str(), ""); + } +} + +static void saveRegisterDiagDetailsToPrefs(const String &details) { + const int chunkSize = 1400; + const int maxChunks = 12; + gPrefs.remove("reg_diag_details"); + for (int i = 0; i < maxChunks; ++i) { + String key = String("rdd") + i; + gPrefs.remove(key.c_str()); + } + + String stored = details; + int requiredChunks = (stored.length() + chunkSize - 1) / chunkSize; + if (requiredChunks > maxChunks) { + int maxLen = chunkSize * maxChunks; + stored = stored.substring(0, maxLen); + stored += "\n[truncated in NVS]\n"; + requiredChunks = (stored.length() + chunkSize - 1) / chunkSize; + if (requiredChunks > maxChunks) { + stored = stored.substring(0, chunkSize * maxChunks); + requiredChunks = maxChunks; + } + } + + for (int i = 0; i < requiredChunks; ++i) { + int start = i * chunkSize; + String key = String("rdd") + i; + gPrefs.putString(key.c_str(), stored.substring(start, min((int)stored.length(), start + chunkSize))); + } + gPrefs.putInt("rddn", requiredChunks); + gLastRegisterDiagDetails = stored; +} + +static void clearRegisterDiagDetailsFromPrefs() { + gPrefs.remove("reg_diag_details"); + gPrefs.remove("rddn"); + for (int i = 0; i < 12; ++i) { + String key = String("rdd") + i; + gPrefs.remove(key.c_str()); + } +} + static void saveRegisterDiag(const String &status, const String &summary, const String &details) { gLastRegisterDiagStatus = status; - gLastRegisterDiagSummary = summary.length() > 240 ? summary.substring(0, 240) : summary; - gLastRegisterDiagDetails = details.length() > 1800 ? details.substring(0, 1800) : details; + gLastRegisterDiagSummary = summary; gLastRegisterDiagTime = String(shineNowMs()); gPrefs.putString("reg_diag_status", gLastRegisterDiagStatus); gPrefs.putString("reg_diag_summary", gLastRegisterDiagSummary); - gPrefs.putString("reg_diag_details", gLastRegisterDiagDetails); gPrefs.putString("reg_diag_time", gLastRegisterDiagTime); + saveRegisterDiagDetailsToPrefs(details); } static void clearRegisterDiag() { @@ -2855,8 +2927,8 @@ static void clearRegisterDiag() { gLastRegisterDiagTime = ""; gPrefs.remove("reg_diag_status"); gPrefs.remove("reg_diag_summary"); - gPrefs.remove("reg_diag_details"); gPrefs.remove("reg_diag_time"); + clearRegisterDiagDetailsFromPrefs(); } static void printRegisterDiagToSerial() { diff --git a/VERSION.properties b/VERSION.properties index e9b9027..0162f47 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.169 -server.version=1.2.158 +client.version=1.2.170 +server.version=1.2.159 From fba6d6bba07860261ca304fa63cfde8fe46bc6f0a7629ff07e145fe9cec99fd7 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sat, 13 Jun 2026 07:36:45 +0400 Subject: [PATCH 11/56] =?UTF-8?q?ESP32:=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20derivation=20user=5Fpda=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20Solana?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shine_homeserver_main/shine_homeserver_main.ino | 7 ++++++- VERSION.properties | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino index 2b7c6ac..ac2e165 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino @@ -8,6 +8,9 @@ #include #include #include + +extern "C" int ge25519_is_canonical(const unsigned char *s); +extern "C" int ge25519_is_on_curve(const unsigned char *p); #include #include #include @@ -915,7 +918,9 @@ static bool findProgramAddress(const std::vector> &seeds, c reinterpret_cast(kProgramDerivedAddressMarker), strlen(kProgramDerivedAddressMarker)); crypto_hash_sha256_final(&st, out32); - if (crypto_core_ed25519_is_valid_point(out32) == 0) { + bool isCanonical = ge25519_is_canonical(out32) != 0; + bool isOnCurve = ge25519_is_on_curve(out32) != 0; + if (!(isCanonical && isOnCurve)) { return true; } } diff --git a/VERSION.properties b/VERSION.properties index 0162f47..8e7f4ef 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.170 -server.version=1.2.159 +client.version=1.2.171 +server.version=1.2.160 From 556004a55788d4ab5a14d50c0ec3036ae996828734af5623403e9feb2cb32bb2 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sat, 13 Jun 2026 08:20:12 +0400 Subject: [PATCH 12/56] =?UTF-8?q?ESP32:=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20off-curve=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BA=D1=83=20=D0=B4=D0=BB=D1=8F=20user=20PDA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shine_homeserver_main.ino | 19 +++++++++++++------ VERSION.properties | 4 ++-- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino index ac2e165..80aab46 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino @@ -8,9 +8,6 @@ #include #include #include - -extern "C" int ge25519_is_canonical(const unsigned char *s); -extern "C" int ge25519_is_on_curve(const unsigned char *p); #include #include #include @@ -38,6 +35,17 @@ extern "C" int ge25519_is_on_curve(const unsigned char *p); #define TAP_CANCEL_THRESHOLD 18 #define MAX_SCAN_RESULTS 8 #define MAX_SAVED_WIFI_NETWORKS 8 + +extern "C" { +typedef int32_t fe25519[10]; +typedef struct { + fe25519 X; + fe25519 Y; + fe25519 Z; + fe25519 T; +} ge25519_p3; +int ge25519_frombytes(ge25519_p3 *h, const unsigned char *s); +} #define WIFI_CONNECT_TIMEOUT_MS 12000 #define WIFI_RECONNECT_FAST_MS 10000 #define WIFI_RECONNECT_SLOW_MS 30000 @@ -918,9 +926,8 @@ static bool findProgramAddress(const std::vector> &seeds, c reinterpret_cast(kProgramDerivedAddressMarker), strlen(kProgramDerivedAddressMarker)); crypto_hash_sha256_final(&st, out32); - bool isCanonical = ge25519_is_canonical(out32) != 0; - bool isOnCurve = ge25519_is_on_curve(out32) != 0; - if (!(isCanonical && isOnCurve)) { + ge25519_p3 point; + if (ge25519_frombytes(&point, out32) != 0) { return true; } } diff --git a/VERSION.properties b/VERSION.properties index 8e7f4ef..44d1b3f 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.171 -server.version=1.2.160 +client.version=1.2.172 +server.version=1.2.161 From 19fd5611b23b383090add98c46764b6ab152770175711550738099fcff6a29e7 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sat, 13 Jun 2026 08:41:25 +0400 Subject: [PATCH 13/56] =?UTF-8?q?ESP32:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=B0=D0=B2=D1=82=D0=BE=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=B8=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20signed=20server=20profile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shine_homeserver_main.ino | 105 +++++++++++++----- VERSION.properties | 4 +- 2 files changed, 77 insertions(+), 32 deletions(-) diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino index 80aab46..a2ee65b 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino @@ -271,6 +271,11 @@ static String gLastRegisterDiagStatus = "none"; static String gLastRegisterDiagSummary; static String gLastRegisterDiagDetails; static String gLastRegisterDiagTime; +static String gRegisterTriggerSource = "manual"; +static unsigned long gBootMillis = 0; +static bool gAutoRegisterTestArmed = true; +static bool gAutoRegisterTestStarted = false; +static bool gAutoRegisterTestFinished = false; static String gSerialCommandBuffer; static String gShineSessionId; static String gShineSessionKey; @@ -423,6 +428,7 @@ static bool signMessageEd25519(const std::vector &message, const uint8_ static String encodeTransactionBase64(const uint8_t signature[64], const std::vector &message); static bool awaitTransactionConfirmation(const String &signatureB58, String &messageOut); static bool registerHomeserverOnSolana(String &messageOut); +static void executeRegisterAccountFlow(const char *triggerSource, bool showResultScreen); static void loadRegisterDiagDetailsFromPrefs(); static void saveRegisterDiagDetailsToPrefs(const String &details); static void clearRegisterDiagDetailsFromPrefs(); @@ -668,6 +674,18 @@ static String bytesToBase64String(const uint8_t *data, size_t len) { return base64Std(data, len); } +static String bytesToHexString(const uint8_t *data, size_t len) { + static const char *kHex = "0123456789abcdef"; + String out; + out.reserve(len * 2); + for (size_t i = 0; i < len; ++i) { + uint8_t v = data[i]; + out += kHex[(v >> 4) & 0x0F]; + out += kHex[v & 0x0F]; + } + return out; +} + static String normalizeLoginValue(const String &value) { String out = value; out.trim(); @@ -1212,7 +1230,7 @@ static std::vector buildUnsignedCreateRecord( out.push_back(kBlockTypeServerProfile); out.push_back(0); out.push_back(1); - out.push_back(1); + out.push_back(0); out.push_back(0); pushStrU8(out, serverAddress); out.push_back(0); @@ -1525,11 +1543,11 @@ static std::vector buildLegacyMessage( accountKeys.emplace_back(userPda, userPda + 32); accountKeys.emplace_back(inflowVault, inflowVault + 32); accountKeys.emplace_back(systemProgram, systemProgram + 32); - accountKeys.emplace_back(sysvarInstructions, sysvarInstructions + 32); - accountKeys.emplace_back(economyConfig, economyConfig + 32); accountKeys.emplace_back(loginGuardProgram, loginGuardProgram + 32); + accountKeys.emplace_back(economyConfig, economyConfig + 32); accountKeys.emplace_back(ed25519Program, ed25519Program + 32); accountKeys.emplace_back(usersProgram, usersProgram + 32); + accountKeys.emplace_back(sysvarInstructions, sysvarInstructions + 32); std::vector msg; msg.reserve(512); @@ -1543,25 +1561,25 @@ static std::vector buildLegacyMessage( msg.insert(msg.end(), recentBlockhash, recentBlockhash + 32); shortVecEncode(3, msg); - msg.push_back(7); + msg.push_back(6); msg.push_back(0); shortVecEncode(edRootData.size(), msg); msg.insert(msg.end(), edRootData.begin(), edRootData.end()); - msg.push_back(7); + msg.push_back(6); msg.push_back(0); shortVecEncode(edBchData.size(), msg); msg.insert(msg.end(), edBchData.begin(), edBchData.end()); - msg.push_back(8); + msg.push_back(7); msg.push_back(7); msg.push_back(0); msg.push_back(1); msg.push_back(3); msg.push_back(2); - msg.push_back(4); + msg.push_back(8); msg.push_back(5); - msg.push_back(6); + msg.push_back(4); shortVecEncode(createData.size(), msg); msg.insert(msg.end(), createData.begin(), createData.end()); return msg; @@ -1613,6 +1631,9 @@ static bool registerHomeserverOnSolana(String &messageOut) { }; String cleanLogin = normalizeLoginValue(gLoginValue); + diagDetails += String("trigger=") + gRegisterTriggerSource + "\n"; + diagDetails += String("boot_millis=") + String(gBootMillis) + "\n"; + diagDetails += String("test_uptime_ms=") + String(millis()) + "\n"; diagDetails += String("login=") + cleanLogin + "\n"; diagDetails += String("rpc=") + gSolanaRpcUrl + "\n"; diagDetails += String("shine_server=") + gShineServerUrl + "\n"; @@ -1724,6 +1745,7 @@ static bool registerHomeserverOnSolana(String &messageOut) { diagDetails += String("device_pub=") + bytesToBase58(devicePub, 32) + "\n"; String blockchainName = cleanLogin + "-001"; + diagDetails += String("blockchain_name=") + blockchainName + "\n"; std::vector lastBlockState = buildLastBlockStateBytes(cleanLogin, blockchainName); uint8_t lastBlockHash[32]; uint8_t lastBlockSignature[64]; @@ -1731,8 +1753,11 @@ static bool registerHomeserverOnSolana(String &messageOut) { if (!signMessageEd25519(std::vector(lastBlockHash, lastBlockHash + 32), blockchainSec, lastBlockSignature)) { return failWithDiag("Failed to sign LastBlockState"); } + diagDetails += String("last_block_hash=") + bytesToHexString(lastBlockHash, 32) + "\n"; + diagDetails += String("last_block_signature_b64=") + bytesToBase64String(lastBlockSignature, 64) + "\n"; uint64_t createdAtMs = shineNowMs(); + diagDetails += String("created_at_ms=") + String((unsigned long long)createdAtMs) + "\n"; std::vector unsignedRecord = buildUnsignedCreateRecord( cleanLogin, blockchainName, gShineServerUrl, rootPub, devicePub, blockchainPub, @@ -1743,6 +1768,9 @@ static bool registerHomeserverOnSolana(String &messageOut) { if (!signMessageEd25519(std::vector(unsignedHash, unsignedHash + 32), rootSec, rootSignature)) { return failWithDiag("Failed to sign PDA record"); } + diagDetails += String("unsigned_record_len=") + String((unsigned long)unsignedRecord.size()) + "\n"; + diagDetails += String("unsigned_record_hash=") + bytesToHexString(unsignedHash, 32) + "\n"; + diagDetails += String("root_signature_b64=") + bytesToBase64String(rootSignature, 64) + "\n"; std::vector createData = buildCreateInstructionData( cleanLogin, blockchainName, gShineServerUrl, @@ -1824,6 +1852,31 @@ static bool registerHomeserverOnSolana(String &messageOut) { return true; } +static void executeRegisterAccountFlow(const char *triggerSource, bool showResultScreen) { + gRegisterTriggerSource = triggerSource ? String(triggerSource) : String("manual"); + prepareRegisterAccountScreen(); + String registerMessage; + if (registerHomeserverOnSolana(registerMessage)) { + gRegisterResultSuccess = true; + gRegisterResultMessage = registerMessage; + gRegisterResultDetails = String("user_pda and tx signature were saved"); + gAccountStatusMessage = "Registration completed"; + } else { + gRegisterResultSuccess = false; + gRegisterResultMessage = registerMessage; + gRegisterResultDetails = ""; + gAccountStatusMessage = registerMessage; + } + gRegisterConfirmMessage = ""; + gRegisterConfirmBalanceLine = ""; + gRegisterConfirmPdaLine = ""; + gRegisterConfirmHomeserverLine = ""; + gRegisterConfirmCanSubmit = false; + if (showResultScreen) { + showScreen(SCREEN_REGISTER_ACCOUNT_RESULT); + } +} + static void prepareRegisterAccountScreen() { gRegisterResultMessage = ""; gRegisterResultDetails = ""; @@ -2884,7 +2937,7 @@ static void loadRegisterDiagDetailsFromPrefs() { static void saveRegisterDiagDetailsToPrefs(const String &details) { const int chunkSize = 1400; - const int maxChunks = 12; + const int maxChunks = 20; gPrefs.remove("reg_diag_details"); for (int i = 0; i < maxChunks; ++i) { String key = String("rdd") + i; @@ -2916,7 +2969,7 @@ static void saveRegisterDiagDetailsToPrefs(const String &details) { static void clearRegisterDiagDetailsFromPrefs() { gPrefs.remove("reg_diag_details"); gPrefs.remove("rddn"); - for (int i = 0; i < 12; ++i) { + for (int i = 0; i < 20; ++i) { String key = String("rdd") + i; gPrefs.remove(key.c_str()); } @@ -3478,27 +3531,9 @@ static void actionButtonCb(lv_event_t *event) { prepareRegisterAccountScreen(); showScreen(SCREEN_REGISTER_ACCOUNT_CONFIRM); break; - case ACTION_REGISTER_ACCOUNT_EXECUTE: { - String registerMessage; - if (registerHomeserverOnSolana(registerMessage)) { - gRegisterResultSuccess = true; - gRegisterResultMessage = registerMessage; - gRegisterResultDetails = String("user_pda and tx signature were saved"); - gAccountStatusMessage = "Registration completed"; - } else { - gRegisterResultSuccess = false; - gRegisterResultMessage = registerMessage; - gRegisterResultDetails = ""; - gAccountStatusMessage = registerMessage; - } - gRegisterConfirmMessage = ""; - gRegisterConfirmBalanceLine = ""; - gRegisterConfirmPdaLine = ""; - gRegisterConfirmHomeserverLine = ""; - gRegisterConfirmCanSubmit = false; - showScreen(SCREEN_REGISTER_ACCOUNT_RESULT); + case ACTION_REGISTER_ACCOUNT_EXECUTE: + executeRegisterAccountFlow("manual", true); break; - } case ACTION_OPEN_WIFI: gWifiViewMode = WIFI_VIEW_OVERVIEW; showScreen(SCREEN_WIFI); @@ -4376,6 +4411,7 @@ static void handleSwipe(SwipeDirection swipe) { void setup() { Serial.begin(115200); sodium_init(); + gBootMillis = millis(); Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL); initPowerManagement(); @@ -4437,6 +4473,15 @@ void loop() { manageAccountPdaRefresh(); manageShineConnection(); + if (gAutoRegisterTestArmed && !gAutoRegisterTestStarted && millis() - gBootMillis >= 15000UL) { + gAutoRegisterTestStarted = true; + gAutoRegisterTestArmed = false; + Serial.println("AUTO_REGISTER_TEST_BEGIN"); + executeRegisterAccountFlow("auto-boot-15s", false); + gAutoRegisterTestFinished = true; + Serial.println("AUTO_REGISTER_TEST_END"); + } + static unsigned long lastHomeRefreshMs = 0; if (gCurrentScreen == SCREEN_HOME && !gTouchDown && millis() - lastHomeRefreshMs >= HOME_REFRESH_MS) { lastHomeRefreshMs = millis(); diff --git a/VERSION.properties b/VERSION.properties index 44d1b3f..5b5e63c 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.172 -server.version=1.2.161 +client.version=1.2.175 +server.version=1.2.164 From a1da8140307f088102141c05193355840128c920b07e79ead635a028c9f4a95e Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sat, 13 Jun 2026 09:07:49 +0400 Subject: [PATCH 14/56] =?UTF-8?q?ESP32:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20flow=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20homeserver=20=D0=B2=20user=20P?= =?UTF-8?q?DA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...06-07_1650_esp32_homeserver_ui_прототип.md | 15 +- ...-06-13_1545_esp32_homeserver_pda_update.md | 24 + .../shine_homeserver_main.ino | 696 +++++++++++++++++- .../reference/shine_homeserver_ui_spec.md | 49 +- VERSION.properties | 4 +- 5 files changed, 746 insertions(+), 42 deletions(-) create mode 100644 Dev_Docs/Pending_Features/2026-06-13_1545_esp32_homeserver_pda_update.md diff --git a/Dev_Docs/Pending_Features/2026-06-07_1650_esp32_homeserver_ui_прототип.md b/Dev_Docs/Pending_Features/2026-06-07_1650_esp32_homeserver_ui_прототип.md index 0d8ef8c..874809d 100644 --- a/Dev_Docs/Pending_Features/2026-06-07_1650_esp32_homeserver_ui_прототип.md +++ b/Dev_Docs/Pending_Features/2026-06-07_1650_esp32_homeserver_ui_прототип.md @@ -14,14 +14,19 @@ 8. При необходимости отдельно проверить тестовые кнопки `+/- SOL`: они меняют локальный баланс для UX-сценариев, но после следующей реальной RPC-проверки баланс должен вернуться к сетевому значению. 9. Вернуться на главный экран и проверить, что до выполнения всех условий кнопка регистрации недоступна, а после выполнения становится доступной; также убедиться, что между двумя нижними кнопками есть небольшой зазор. 10. Нажать кнопку регистрации и убедиться, что открывается отдельный экран проверки, где ещё раз видно `login`, статус свободного `PDA`, баланс, `homeserver1` с пометкой о стандартном значении и сообщение, если `Wi-Fi` не подключён. - 11. На экране проверки нажать `ЗАРЕГИСТРИРОВАТЬ В СИЯНИИ` и убедиться, что после этого появляется отдельный экран результата с успехом либо подробной ошибкой. + 11. На экране проверки нажать `REGISTER IN SHINE` и убедиться, что после этого появляется отдельный экран результата с успехом либо подробной ошибкой. 12. После успешной регистрации проверить через `Solana`/UI проекта, что `user_pda` для этого логина реально создана, сохранена в `NVS` и соответствует `device`-адресу устройства, а `tx signature` тоже сохранён. - 13. Открыть `Запросы`, поочерёдно открыть оба демонстрационных запроса и проверить, что кнопки `Разрешить` и `Отклонить` меняют их статус. - 14. При необходимости открыть `Настройки -> Сменить PIN` и убедиться, что новый PIN сохраняется, хотя вход по PIN временно не используется на старте. - 15. Выполнить `Полный сброс` и убедиться, что все поля, секрет, баланс, онлайн и регистрация очищаются. + 13. После успешной регистрации вернуться на `HOME` и проверить новую жёлтую кнопку: + - если в `PDA` ещё нет текущего homeserver, должна появиться `ADD HOMESERVER`; + - если ключ homeserver в `PDA` не совпадает с локальным секретом, должна появиться `FIX HOMESERVER PASSWORD`. + 14. Нажать жёлтую кнопку и убедиться, что открывается отдельный экран пояснения, а затем экран результата обновления `PDA`. + 15. После успешного `ADD/FIX HOMESERVER` проверить, что основной экран больше не показывает `homeserver not in PDA` или `homeserver key mismatch`, а `SHiNE` может перейти к авторизации. + 16. Открыть `Запросы`, поочерёдно открыть оба демонстрационных запроса и проверить, что кнопки `Разрешить` и `Отклонить` меняют их статус. + 17. При необходимости открыть `Настройки -> Сменить PIN` и убедиться, что новый PIN сохраняется, хотя вход по PIN временно не используется на старте. + 18. Выполнить `Полный сброс` и убедиться, что все поля, секрет, баланс, онлайн и регистрация очищаются. - ожидаемый результат: - новый `ESP32`-скетч стабильно запускается, показывает читаемый интерфейс хотя бы в ASCII-транслитерации, сохраняет данные во внутренней памяти устройства, реально подключается к `Wi-Fi`, реально проверяет `API/RPC/WS`, реально читает баланс из `Solana RPC`, рисует рабочий `QR` для `solana:`-URI и позволяет вручную пройти полный сценарий on-chain регистрации homeserver. + новый `ESP32`-скетч стабильно запускается, показывает читаемый англоязычный интерфейс, сохраняет данные во внутренней памяти устройства, реально подключается к `Wi-Fi`, реально проверяет `API/RPC/WS`, реально читает баланс из `Solana RPC`, рисует рабочий `QR` для `solana:`-URI, позволяет вручную пройти on-chain регистрацию пользователя и затем отдельным действием записать/исправить homeserver-сессию в `shine_users`. - статус: pending diff --git a/Dev_Docs/Pending_Features/2026-06-13_1545_esp32_homeserver_pda_update.md b/Dev_Docs/Pending_Features/2026-06-13_1545_esp32_homeserver_pda_update.md new file mode 100644 index 0000000..b007af3 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-13_1545_esp32_homeserver_pda_update.md @@ -0,0 +1,24 @@ +# ESP32: добавление и исправление homeserver в user PDA + +- краткое описание фичи: + в основном ESP32-скетче добавлен отдельный flow после регистрации пользователя: если homeserver-сессия отсутствует в `shine_users` PDA или её ключ не совпадает с локальным секретом, на главном экране показывается жёлтая кнопка `ADD HOMESERVER` или `FIX HOMESERVER PASSWORD`. Действие открывает отдельный экран объяснения, затем отправляет `update_user_pda` и показывает экран результата. + +- что именно проверять: + 1. Зарегистрировать пользователя или взять уже существующего пользователя без корректной homeserver-сессии. + 2. На `HOME` убедиться, что вместо кнопки регистрации появилась жёлтая кнопка: + `ADD HOMESERVER` либо `FIX HOMESERVER PASSWORD`. + 3. Нажать её и проверить, что открывается отдельный экран с пояснением причины и кнопкой действия. + 4. Выполнить действие и дождаться экрана результата. + 5. После успеха проверить, что: + - `homeserver not in PDA` или `homeserver key mismatch` исчезли; + - в USB-диагностике есть успешный `tx_signature`; + - `SHiNE` может перейти к авторизации на сервере. + 6. Если действие завершается ошибкой, проверить, что: + - текст ошибки показан на экране результата; + - команда `last_error` по USB возвращает полный сохранённый payload. + +- ожидаемый результат: + устройство после обычной регистрации пользователя способно отдельной транзакцией добавить или исправить homeserver-сессию в `shine_users` PDA, а ошибки этого шага сохраняются в ту же USB/NVS-диагностику. + +- статус: + pending diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino index a2ee65b..a964ada 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino @@ -97,6 +97,8 @@ enum Screen { SCREEN_TEXT_EDIT, SCREEN_REGISTER_ACCOUNT_CONFIRM, SCREEN_REGISTER_ACCOUNT_RESULT, + SCREEN_HOMESERVER_PDA_CONFIRM, + SCREEN_HOMESERVER_PDA_RESULT, }; enum SwipeDirection { @@ -136,6 +138,9 @@ enum ActionId { ACTION_REFRESH_BALANCE, ACTION_REGISTER_ACCOUNT, ACTION_REGISTER_ACCOUNT_EXECUTE, + ACTION_HOMESERVER_PDA_ACTION, + ACTION_HOMESERVER_PDA_ADD_EXECUTE, + ACTION_HOMESERVER_PDA_FIX_EXECUTE, ACTION_EDITOR_SAVE, ACTION_EDITOR_CANCEL, }; @@ -178,11 +183,29 @@ struct ShinePdaSessionRecord { struct ShinePdaUserState { bool found = false; String login; + uint64_t createdAtMs = 0; + uint64_t updatedAtMs = 0; + uint32_t recordNumber = 0; + uint8_t prevRecordHash32[32] = {}; + uint8_t blockchainType = 1; bool isServer = false; + uint8_t addressFormatType = 0; + uint8_t addressFormatVersion = 0; String serverAddress; + std::vector syncServers; + std::vector accessServers; + uint8_t sessionsMode = 1; + uint8_t trustedCount = 0; uint8_t rootKey32[32] = {}; uint8_t deviceKey32[32] = {}; uint8_t blockchainKey32[32] = {}; + String blockchainName; + uint64_t paidLimitBytes = 0; + uint64_t usedBytes = 0; + uint32_t lastBlockNumber = 0; + uint8_t lastBlockHash32[32] = {}; + uint8_t lastBlockSignature64[64] = {}; + String arweaveTxId; std::vector sessions; }; @@ -256,6 +279,8 @@ static String gAccountPdaStatusMessage = "Account not checked"; static bool gAccountCheckPending = true; static unsigned long gLastAccountCheckMs = 0; static bool gShowRegisterAccountButton = false; +static bool gShowHomeserverPdaActionButton = false; +static String gHomeserverPdaActionReason; static String gUserPdaAddress; static String gRegistrationSignature; static String gShineStatusLine = "SHiNE: account not configured"; @@ -267,15 +292,18 @@ static bool gRegisterConfirmCanSubmit = false; static String gRegisterResultMessage; static String gRegisterResultDetails; static bool gRegisterResultSuccess = false; +static String gHomeserverPdaActionMessage; +static String gHomeserverPdaActionDetail; +static bool gHomeserverPdaCanAdd = false; +static bool gHomeserverPdaCanFix = false; +static String gHomeserverPdaResultMessage; +static String gHomeserverPdaResultDetails; +static bool gHomeserverPdaResultSuccess = false; static String gLastRegisterDiagStatus = "none"; static String gLastRegisterDiagSummary; static String gLastRegisterDiagDetails; static String gLastRegisterDiagTime; static String gRegisterTriggerSource = "manual"; -static unsigned long gBootMillis = 0; -static bool gAutoRegisterTestArmed = true; -static bool gAutoRegisterTestStarted = false; -static bool gAutoRegisterTestFinished = false; static String gSerialCommandBuffer; static String gShineSessionId; static String gShineSessionKey; @@ -393,7 +421,11 @@ static bool extractRpcErrorSummary(const String &payload, String &messageOut); static String compactRpcLogs(const String &payload, int maxLines = 3); static bool simulateTransactionPayload(const String &txBase64, String &payloadOut); static bool simulateTransactionForError(const String &txBase64, String &messageOut); -static std::vector buildLastBlockStateBytes(const String &login, const String &blockchainName); +static std::vector buildLastBlockStateBytes(const String &login, + const String &blockchainName, + uint32_t lastBlockNumber = 0, + const uint8_t *lastBlockHash32 = nullptr, + uint64_t usedBytes = 0); static std::vector buildUnsignedCreateRecord( const String &login, const String &blockchainName, @@ -415,6 +447,12 @@ static std::vector buildCreateInstructionData( const uint8_t rootSignature[64], uint64_t createdAtMs); static std::vector buildEd25519InstructionData(const uint8_t signature[64], const uint8_t publicKey[32], const uint8_t messageHash[32]); +static std::vector serializeUnsignedRecordState(const ShinePdaUserState &state); +static std::vector buildUpdateInstructionData(const ShinePdaUserState &state, + uint32_t nextVersion, + uint64_t updatedAtMs, + const uint8_t prevHash32[32], + const uint8_t rootSignature64[64]); static std::vector buildLegacyMessage( const uint8_t recentBlockhash[32], const uint8_t devicePub[32], @@ -424,11 +462,21 @@ static std::vector buildLegacyMessage( const std::vector &edRootData, const std::vector &edBchData, const std::vector &createData); +static std::vector buildUpdateLegacyMessage( + const uint8_t recentBlockhash[32], + const uint8_t devicePub[32], + const uint8_t userPda[32], + const uint8_t inflowVault[32], + const uint8_t economyConfig[32], + const std::vector &edRootData, + const std::vector &edBchData, + const std::vector &updateData); static bool signMessageEd25519(const std::vector &message, const uint8_t secretKey[64], uint8_t signature[64]); static String encodeTransactionBase64(const uint8_t signature[64], const std::vector &message); static bool awaitTransactionConfirmation(const String &signatureB58, String &messageOut); static bool registerHomeserverOnSolana(String &messageOut); static void executeRegisterAccountFlow(const char *triggerSource, bool showResultScreen); +static bool updateHomeserverSessionOnSolana(bool requireExisting, String &messageOut); static void loadRegisterDiagDetailsFromPrefs(); static void saveRegisterDiagDetailsToPrefs(const String &details); static void clearRegisterDiagDetailsFromPrefs(); @@ -437,6 +485,7 @@ static void printRegisterDiagToSerial(); static void clearRegisterDiag(); static void handleUsbSerialCommands(); static void prepareRegisterAccountScreen(); +static void prepareHomeserverPdaActionScreen(); static String buildSessionKeyStringFromPublicBase64(const String &pubB64); static bool deriveKeypairFromSeed32(const uint8_t seed32[32], uint8_t pub32[32], uint8_t sec64[64]); static bool deriveSeedKeypairFromBase58(const String &seedB58, uint8_t seed32[32], uint8_t pub32[32], uint8_t sec64[64]); @@ -912,6 +961,15 @@ static void markAccountStateDirty() { gAccountPdaStatus = ACCOUNT_PDA_UNKNOWN; gAccountPdaStatusMessage = "Account not checked"; gShowRegisterAccountButton = false; + gShowHomeserverPdaActionButton = false; + gHomeserverPdaActionReason = ""; + gHomeserverPdaActionMessage = ""; + gHomeserverPdaActionDetail = ""; + gHomeserverPdaCanAdd = false; + gHomeserverPdaCanFix = false; + gHomeserverPdaResultMessage = ""; + gHomeserverPdaResultDetails = ""; + gHomeserverPdaResultSuccess = false; gUserPdaAddress = ""; gRegistrationSignature = ""; gRegisterConfirmMessage = ""; @@ -1170,15 +1228,160 @@ static bool rpcResponseHasError(const String &payload) { return payload.indexOf("\"error\"") >= 0; } -static std::vector buildLastBlockStateBytes(const String &login, const String &blockchainName) { +static std::vector buildLastBlockStateBytes(const String &login, + const String &blockchainName, + uint32_t lastBlockNumber, + const uint8_t *lastBlockHash32, + uint64_t usedBytes) { std::vector out; out.reserve(80); pushFixed(out, (const uint8_t *)kLastBlockPrefix, strlen(kLastBlockPrefix)); pushStrU8(out, login); pushStrU8(out, blockchainName); - pushU32LE(out, 0); - out.insert(out.end(), 32, 0); + pushU32LE(out, lastBlockNumber); + if (lastBlockHash32) { + pushFixed(out, lastBlockHash32, 32); + } else { + out.insert(out.end(), 32, 0); + } + pushU64LE(out, usedBytes); + return out; +} + +static std::vector serializeUnsignedRecordState(const ShinePdaUserState &state) { + std::vector out; + out.reserve(640); + pushFixed(out, (const uint8_t *)"SHiNE", 5); + out.push_back(1); + out.push_back(0); + out.push_back(0); + out.push_back(0); + pushU64LE(out, state.createdAtMs); + pushU64LE(out, state.updatedAtMs); + pushU32LE(out, state.recordNumber); + pushFixed(out, state.prevRecordHash32, 32); + pushStrU8(out, state.login); + out.push_back(state.isServer ? 7 : 6); + + out.push_back(kBlockTypeRootKey); + out.push_back(0); + pushFixed(out, state.rootKey32, 32); + + out.push_back(kBlockTypeDeviceKey); + out.push_back(0); + pushFixed(out, state.deviceKey32, 32); + + out.push_back(kBlockTypeBlockchainRegistry); + out.push_back(0); + out.push_back(1); + out.push_back(state.blockchainType); + pushStrU8(out, state.blockchainName); + pushFixed(out, state.blockchainKey32, 32); + pushU64LE(out, state.paidLimitBytes); + pushU64LE(out, state.usedBytes); + pushU32LE(out, state.lastBlockNumber); + pushFixed(out, state.lastBlockHash32, 32); + pushFixed(out, state.lastBlockSignature64, 64); + if (state.arweaveTxId.isEmpty()) { + out.push_back(0); + } else { + out.push_back(1); + pushStrU8(out, state.arweaveTxId); + } + + if (state.isServer) { + out.push_back(kBlockTypeServerProfile); + out.push_back(0); + out.push_back(1); + out.push_back(state.addressFormatType); + out.push_back(state.addressFormatVersion); + pushStrU8(out, state.serverAddress); + out.push_back((uint8_t)state.syncServers.size()); + for (const auto &value : state.syncServers) { + pushStrU8(out, value); + } + } + + out.push_back(kBlockTypeAccessServers); + out.push_back(0); + out.push_back((uint8_t)state.accessServers.size()); + for (const auto &value : state.accessServers) { + pushStrU8(out, value); + } + + out.push_back(kBlockTypeSessions); + out.push_back(0); + out.push_back(state.sessionsMode); + out.push_back((uint8_t)state.sessions.size()); + for (const auto &session : state.sessions) { + out.push_back(session.sessionType); + out.push_back(session.sessionVersion); + pushStrU8(out, session.sessionName); + pushFixed(out, session.sessionPubKey32, 32); + } + + out.push_back(kBlockTypeTrustedState); + out.push_back(0); + out.push_back(state.trustedCount); + + uint16_t recordLen = (uint16_t)(out.size() + 64); + out[7] = (uint8_t)(recordLen & 0xFF); + out[8] = (uint8_t)((recordLen >> 8) & 0xFF); + return out; +} + +static std::vector buildUpdateInstructionData(const ShinePdaUserState &state, + uint32_t nextVersion, + uint64_t updatedAtMs, + const uint8_t prevHash32[32], + const uint8_t rootSignature64[64]) { + std::vector out; + out.reserve(640); + out.push_back(4); + pushStrU8(out, state.login); + pushFixed(out, state.rootKey32, 32); + pushU64LE(out, state.createdAtMs); + pushU64LE(out, updatedAtMs); + pushU32LE(out, nextVersion); + pushFixed(out, prevHash32, 32); pushU64LE(out, 0); + pushFixed(out, state.deviceKey32, 32); + pushFixed(out, state.blockchainKey32, 32); + pushStrU8(out, state.blockchainName); + pushU64LE(out, state.usedBytes); + pushU32LE(out, state.lastBlockNumber); + pushFixed(out, state.lastBlockHash32, 32); + pushFixed(out, state.lastBlockSignature64, 64); + if (state.arweaveTxId.isEmpty()) { + out.push_back(0); + } else { + out.push_back(1); + pushStrU8(out, state.arweaveTxId); + } + out.push_back(state.isServer ? 1 : 0); + if (state.isServer) { + out.push_back(state.addressFormatType); + out.push_back(state.addressFormatVersion); + pushStrU8(out, state.serverAddress); + out.push_back((uint8_t)state.syncServers.size()); + for (const auto &value : state.syncServers) { + pushStrU8(out, value); + } + } + out.push_back((uint8_t)state.accessServers.size()); + for (const auto &value : state.accessServers) { + pushStrU8(out, value); + } + out.push_back(state.sessionsMode); + out.push_back((uint8_t)state.sessions.size()); + for (const auto &session : state.sessions) { + out.push_back(session.sessionType); + out.push_back(session.sessionVersion); + pushStrU8(out, session.sessionName); + pushFixed(out, session.sessionPubKey32, 32); + } + out.push_back(state.trustedCount); + pushFixed(out, rootSignature64, 64); return out; } @@ -1585,6 +1788,69 @@ static std::vector buildLegacyMessage( return msg; } +static std::vector buildUpdateLegacyMessage( + const uint8_t recentBlockhash[32], + const uint8_t devicePub[32], + const uint8_t userPda[32], + const uint8_t inflowVault[32], + const uint8_t economyConfig[32], + const std::vector &edRootData, + const std::vector &edBchData, + const std::vector &updateData) { + uint8_t systemProgram[32]; + uint8_t ed25519Program[32]; + uint8_t usersProgram[32]; + uint8_t sysvarInstructions[32]; + base58ToFixed32(kSystemProgramId, systemProgram); + base58ToFixed32(kEd25519ProgramId, ed25519Program); + base58ToFixed32(kShineUsersProgramId, usersProgram); + base58ToFixed32(kSysvarInstructionsId, sysvarInstructions); + + std::vector> accountKeys; + accountKeys.emplace_back(devicePub, devicePub + 32); + accountKeys.emplace_back(userPda, userPda + 32); + accountKeys.emplace_back(inflowVault, inflowVault + 32); + accountKeys.emplace_back(systemProgram, systemProgram + 32); + accountKeys.emplace_back(economyConfig, economyConfig + 32); + accountKeys.emplace_back(ed25519Program, ed25519Program + 32); + accountKeys.emplace_back(usersProgram, usersProgram + 32); + accountKeys.emplace_back(sysvarInstructions, sysvarInstructions + 32); + + std::vector msg; + msg.reserve(512); + msg.push_back(1); + msg.push_back(0); + msg.push_back(5); + shortVecEncode(accountKeys.size(), msg); + for (const auto &key : accountKeys) { + msg.insert(msg.end(), key.begin(), key.end()); + } + msg.insert(msg.end(), recentBlockhash, recentBlockhash + 32); + shortVecEncode(3, msg); + + msg.push_back(5); + msg.push_back(0); + shortVecEncode(edRootData.size(), msg); + msg.insert(msg.end(), edRootData.begin(), edRootData.end()); + + msg.push_back(5); + msg.push_back(0); + shortVecEncode(edBchData.size(), msg); + msg.insert(msg.end(), edBchData.begin(), edBchData.end()); + + msg.push_back(6); + msg.push_back(6); + msg.push_back(0); + msg.push_back(1); + msg.push_back(3); + msg.push_back(2); + msg.push_back(7); + msg.push_back(4); + shortVecEncode(updateData.size(), msg); + msg.insert(msg.end(), updateData.begin(), updateData.end()); + return msg; +} + static bool signMessageEd25519(const std::vector &message, const uint8_t secretKey[64], uint8_t signature[64]) { return crypto_sign_ed25519_detached(signature, nullptr, message.data(), (unsigned long long)message.size(), secretKey) == 0; } @@ -1632,7 +1898,6 @@ static bool registerHomeserverOnSolana(String &messageOut) { String cleanLogin = normalizeLoginValue(gLoginValue); diagDetails += String("trigger=") + gRegisterTriggerSource + "\n"; - diagDetails += String("boot_millis=") + String(gBootMillis) + "\n"; diagDetails += String("test_uptime_ms=") + String(millis()) + "\n"; diagDetails += String("login=") + cleanLogin + "\n"; diagDetails += String("rpc=") + gSolanaRpcUrl + "\n"; @@ -1746,7 +2011,7 @@ static bool registerHomeserverOnSolana(String &messageOut) { String blockchainName = cleanLogin + "-001"; diagDetails += String("blockchain_name=") + blockchainName + "\n"; - std::vector lastBlockState = buildLastBlockStateBytes(cleanLogin, blockchainName); + std::vector lastBlockState = buildLastBlockStateBytes(cleanLogin, blockchainName, 0, nullptr, 0); uint8_t lastBlockHash[32]; uint8_t lastBlockSignature[64]; sha256calc(lastBlockState.data(), lastBlockState.size(), lastBlockHash); @@ -1947,6 +2212,264 @@ static void prepareRegisterAccountScreen() { gRegisterConfirmCanSubmit = true; } +static void prepareHomeserverPdaActionScreen() { + gHomeserverPdaActionMessage = ""; + gHomeserverPdaActionDetail = ""; + gHomeserverPdaCanAdd = false; + gHomeserverPdaCanFix = false; + gHomeserverPdaResultMessage = ""; + gHomeserverPdaResultDetails = ""; + gHomeserverPdaResultSuccess = false; + + if (gLoginValue.isEmpty()) { + gHomeserverPdaActionMessage = "Login is not set"; + return; + } + if (!gSecretConfigured) { + gHomeserverPdaActionMessage = "Secret is not set"; + return; + } + if (gHomeserverValue.isEmpty()) { + gHomeserverPdaActionMessage = "Homeserver is not set"; + return; + } + if (WiFi.status() != WL_CONNECTED) { + gHomeserverPdaActionMessage = "Wi-Fi is not connected"; + gHomeserverPdaActionDetail = "Connect Wi-Fi, then try again."; + return; + } + + if (gHomeserverPdaActionReason == "homeserver key mismatch") { + gHomeserverPdaActionMessage = "Homeserver key in PDA does not match local secret"; + gHomeserverPdaActionDetail = String("Homeserver: ") + gHomeserverValue; + gHomeserverPdaCanFix = true; + return; + } + if (gHomeserverPdaActionReason == "homeserver not in PDA") { + gHomeserverPdaActionMessage = "Homeserver session is missing from user PDA"; + gHomeserverPdaActionDetail = String("Homeserver: ") + gHomeserverValue; + gHomeserverPdaCanAdd = true; + return; + } + + gHomeserverPdaActionMessage = "Homeserver PDA action is not available"; +} + +static bool updateHomeserverSessionOnSolana(bool requireExisting, String &messageOut) { + messageOut = ""; + String diagDetails; + auto failWithDiag = [&](const String &summary) -> bool { + messageOut = summary; + saveRegisterDiag("error", summary, diagDetails); + printRegisterDiagToSerial(); + return false; + }; + + String cleanLogin = normalizeLoginValue(gLoginValue); + diagDetails += String("trigger=") + gRegisterTriggerSource + "\n"; + diagDetails += String("action=") + (requireExisting ? "fix_homeserver_key" : "add_homeserver") + "\n"; + diagDetails += String("uptime_ms=") + String(millis()) + "\n"; + diagDetails += String("login=") + cleanLogin + "\n"; + diagDetails += String("homeserver=") + gHomeserverValue + "\n"; + diagDetails += String("rpc=") + gSolanaRpcUrl + "\n"; + + if (cleanLogin.isEmpty()) { + return failWithDiag("Login is not set"); + } + if (!gSecretConfigured) { + return failWithDiag("Secret is not ready"); + } + if (gHomeserverValue.isEmpty()) { + return failWithDiag("Homeserver is not set"); + } + if (WiFi.status() != WL_CONNECTED) { + diagDetails += "wifi=disconnected\n"; + return failWithDiag("Connect Wi-Fi first"); + } + diagDetails += String("wifi=connected:") + WiFi.localIP().toString() + "\n"; + + ShinePdaUserState currentState; + String stateError; + if (!readShineUserPda(cleanLogin, currentState, stateError)) { + diagDetails += String("read_pda_error=") + stateError + "\n"; + return failWithDiag(stateError.isEmpty() ? "Failed to read user PDA" : stateError); + } + if (!currentState.found) { + return failWithDiag("User PDA does not exist yet"); + } + + uint8_t rootSeed[32] = {}; + uint8_t rootPub[32] = {}; + uint8_t rootSec[64] = {}; + uint8_t deviceSeed[32] = {}; + uint8_t devicePub[32] = {}; + uint8_t deviceSec[64] = {}; + uint8_t homeserverPub[32] = {}; + if (!deriveSeedKeypairFromBase58(gRootPrivB58, rootSeed, rootPub, rootSec) + || !deriveSeedKeypairFromBase58(gDevicePrivB58, deviceSeed, devicePub, deviceSec) + || !base58ToFixed32(gHomeserverPubB58, homeserverPub)) { + return failWithDiag("Failed to restore local keys"); + } + if (memcmp(devicePub, currentState.deviceKey32, 32) != 0) { + return failWithDiag("Device key does not match PDA"); + } + + uint8_t userPda[32] = {}; + uint8_t economyConfig[32] = {}; + uint8_t inflowVault[32] = {}; + if (!findProgramAddress({ + std::vector((const uint8_t *)kUsersSeedPrefix, (const uint8_t *)kUsersSeedPrefix + strlen(kUsersSeedPrefix)), + std::vector((const uint8_t *)cleanLogin.c_str(), (const uint8_t *)cleanLogin.c_str() + cleanLogin.length()) + }, kShineUsersProgramId, userPda) + || !findProgramAddress({ + std::vector((const uint8_t *)kUsersEconomyConfigSeed, (const uint8_t *)kUsersEconomyConfigSeed + strlen(kUsersEconomyConfigSeed)) + }, kShineUsersProgramId, economyConfig) + || !findProgramAddress({ + std::vector((const uint8_t *)kPaymentsInflowSeed, (const uint8_t *)kPaymentsInflowSeed + strlen(kPaymentsInflowSeed)) + }, kShinePaymentsProgramId, inflowVault)) { + return failWithDiag("Failed to derive required PDAs"); + } + + String userPdaB58 = bytesToBase58(userPda, 32); + diagDetails += String("user_pda=") + userPdaB58 + "\n"; + diagDetails += String("users_economy_config_pda=") + bytesToBase58(economyConfig, 32) + "\n"; + diagDetails += String("inflow_vault_pda=") + bytesToBase58(inflowVault, 32) + "\n"; + + ShinePdaUserState nextState = currentState; + memcpy(nextState.rootKey32, rootPub, 32); + memcpy(nextState.deviceKey32, devicePub, 32); + nextState.updatedAtMs = shineNowMs(); + nextState.recordNumber = currentState.recordNumber + 1; + if (nextState.sessionsMode == 0) { + nextState.sessionsMode = 1; + } + + bool foundSession = false; + for (auto &session : nextState.sessions) { + if (session.sessionType == kSessionTypeHomeserver && session.sessionName == gHomeserverValue) { + foundSession = true; + memcpy(session.sessionPubKey32, homeserverPub, 32); + session.sessionVersion = 0; + break; + } + } + if (!foundSession) { + if (requireExisting) { + return failWithDiag("Homeserver session is missing in PDA"); + } + ShinePdaSessionRecord rec; + rec.sessionType = kSessionTypeHomeserver; + rec.sessionVersion = 0; + rec.sessionName = gHomeserverValue; + memcpy(rec.sessionPubKey32, homeserverPub, 32); + nextState.sessions.push_back(rec); + } + + std::vector oldUnsigned = serializeUnsignedRecordState(currentState); + uint8_t prevHash32[32] = {}; + sha256calc(oldUnsigned.data(), oldUnsigned.size(), prevHash32); + memcpy(nextState.prevRecordHash32, prevHash32, 32); + diagDetails += String("prev_hash=") + bytesToHexString(prevHash32, 32) + "\n"; + + std::vector newUnsigned = serializeUnsignedRecordState(nextState); + uint8_t unsignedHash[32] = {}; + uint8_t rootSignature[64] = {}; + sha256calc(newUnsigned.data(), newUnsigned.size(), unsignedHash); + if (!signMessageEd25519(std::vector(unsignedHash, unsignedHash + 32), rootSec, rootSignature)) { + return failWithDiag("Failed to sign updated PDA record"); + } + diagDetails += String("unsigned_record_len=") + String((unsigned long)newUnsigned.size()) + "\n"; + diagDetails += String("unsigned_record_hash=") + bytesToHexString(unsignedHash, 32) + "\n"; + + std::vector lastBlockState = buildLastBlockStateBytes( + cleanLogin, + currentState.blockchainName, + currentState.lastBlockNumber, + currentState.lastBlockHash32, + currentState.usedBytes); + uint8_t lastBlockHash[32] = {}; + sha256calc(lastBlockState.data(), lastBlockState.size(), lastBlockHash); + diagDetails += String("last_block_hash=") + bytesToHexString(lastBlockHash, 32) + "\n"; + + std::vector updateData = buildUpdateInstructionData( + nextState, + nextState.recordNumber, + nextState.updatedAtMs, + prevHash32, + rootSignature); + std::vector edRootData = buildEd25519InstructionData(rootSignature, rootPub, unsignedHash); + std::vector edBchData = buildEd25519InstructionData(currentState.lastBlockSignature64, currentState.blockchainKey32, lastBlockHash); + + uint8_t recentBlockhash[32] = {}; + String recentBlockhash58; + if (!getLatestBlockhashBytes(recentBlockhash, recentBlockhash58, messageOut)) { + diagDetails += String("blockhash_error=") + messageOut + "\n"; + return failWithDiag(messageOut); + } + diagDetails += String("recent_blockhash=") + recentBlockhash58 + "\n"; + + std::vector message = buildUpdateLegacyMessage( + recentBlockhash, + devicePub, + userPda, + inflowVault, + economyConfig, + edRootData, + edBchData, + updateData); + uint8_t txSignature[64] = {}; + if (!signMessageEd25519(message, deviceSec, txSignature)) { + return failWithDiag("Failed to sign Solana transaction"); + } + String txBase64 = encodeTransactionBase64(txSignature, message); + String signatureB58 = bytesToBase58(txSignature, 64); + diagDetails += String("tx_signature=") + signatureB58 + "\n"; + + String payload; + if (!rpcCallSolana("sendTransaction", "[\"" + txBase64 + "\",{\"encoding\":\"base64\",\"preflightCommitment\":\"confirmed\"}]", payload)) { + diagDetails += "send_transaction_rpc_error=true\n"; + return failWithDiag("RPC did not accept transaction"); + } + if (rpcResponseHasError(payload)) { + if (!extractRpcErrorSummary(payload, messageOut)) { + messageOut = "RPC returned sendTransaction error"; + } + diagDetails += "send_transaction_payload<<\n"; + diagDetails += payload; + diagDetails += "\n>>send_transaction_payload\n"; + String simulated; + String simulatedPayload; + if (simulateTransactionPayload(txBase64, simulatedPayload)) { + extractRpcErrorSummary(simulatedPayload, simulated); + if (messageOut.isEmpty()) { + messageOut = simulated; + } else if (messageOut.indexOf(simulated) < 0) { + messageOut += " | simulate: " + simulated; + } + diagDetails += "simulate_payload<<\n"; + diagDetails += simulatedPayload; + diagDetails += "\n>>simulate_payload\n"; + } + return failWithDiag(messageOut); + } + if (!awaitTransactionConfirmation(signatureB58, messageOut)) { + diagDetails += String("confirmation_error=") + messageOut + "\n"; + return failWithDiag(messageOut); + } + + gUserPdaAddress = userPdaB58; + gRegistrationSignature = signatureB58; + saveAccountPrefs(); + refreshAccountPdaStatus(); + gHomeserverPdaResultSuccess = true; + gHomeserverPdaResultMessage = requireExisting ? "Homeserver key updated in PDA" : "Homeserver added to user PDA"; + gHomeserverPdaResultDetails = String("tx: ") + abbreviateValue(signatureB58, 12, 8); + messageOut = gHomeserverPdaResultMessage; + saveRegisterDiag("ok", messageOut, diagDetails); + printRegisterDiagToSerial(); + return true; +} + static bool parseShineUserPdaBytes(const std::vector &bytes, ShinePdaUserState &outState, String &errorOut) { outState = ShinePdaUserState{}; errorOut = ""; @@ -2014,8 +2537,6 @@ static bool parseShineUserPdaBytes(const std::vector &bytes, ShinePdaUs uint8_t version = 0; uint8_t flags = 0; uint16_t recordLen = 0; - uint64_t ignoreU64 = 0; - uint32_t ignoreU32 = 0; if (!readU8(version) || !readU8(flags) || !readU16(recordLen)) { errorOut = "Bad PDA header"; return false; @@ -2025,11 +2546,14 @@ static bool parseShineUserPdaBytes(const std::vector &bytes, ShinePdaUs return false; } offset = 9; - if (!readU64(ignoreU64) || !readU64(ignoreU64) || !readU32(ignoreU32)) { + if (!readU64(outState.createdAtMs) || !readU64(outState.updatedAtMs) || !readU32(outState.recordNumber)) { errorOut = "Bad PDA fixed fields"; return false; } - offset += 32; // prev hash + if (!readBytes(outState.prevRecordHash32, 32)) { + errorOut = "Bad PDA prev hash"; + return false; + } if (!readStringU8(outState.login)) { errorOut = "Bad PDA login"; return false; @@ -2095,7 +2619,15 @@ static bool parseShineUserPdaBytes(const std::vector &bytes, ShinePdaUs return false; } if (j == 0) { + outState.blockchainType = blockchainType; + outState.blockchainName = blockchainName; memcpy(outState.blockchainKey32, blockchainKey, 32); + outState.paidLimitBytes = paidLimit; + outState.usedBytes = usedBytes; + outState.lastBlockNumber = lastBlockNumber; + memcpy(outState.lastBlockHash32, lastBlockHash, 32); + memcpy(outState.lastBlockSignature64, lastBlockSig, 64); + outState.arweaveTxId = arweaveTxId; } } continue; @@ -2108,22 +2640,24 @@ static bool parseShineUserPdaBytes(const std::vector &bytes, ShinePdaUs } outState.isServer = isServer == 1; if (outState.isServer) { - uint8_t addressFormatType = 0; - uint8_t addressFormatVersion = 0; + outState.addressFormatType = 0; + outState.addressFormatVersion = 0; uint8_t syncCount = 0; - if (!readU8(addressFormatType) - || !readU8(addressFormatVersion) + if (!readU8(outState.addressFormatType) + || !readU8(outState.addressFormatVersion) || !readStringU8(outState.serverAddress) || !readU8(syncCount)) { errorOut = "Bad server address"; return false; } + outState.syncServers.clear(); for (uint8_t j = 0; j < syncCount; ++j) { - String ignoreSync; - if (!readStringU8(ignoreSync)) { + String syncServer; + if (!readStringU8(syncServer)) { errorOut = "Bad sync_servers"; return false; } + outState.syncServers.push_back(syncServer); } } continue; @@ -2134,12 +2668,14 @@ static bool parseShineUserPdaBytes(const std::vector &bytes, ShinePdaUs errorOut = "Bad access servers"; return false; } + outState.accessServers.clear(); for (uint8_t j = 0; j < accessCount; ++j) { - String ignoreAccess; - if (!readStringU8(ignoreAccess)) { + String accessServer; + if (!readStringU8(accessServer)) { errorOut = "Bad access server item"; return false; } + outState.accessServers.push_back(accessServer); } continue; } @@ -2150,6 +2686,7 @@ static bool parseShineUserPdaBytes(const std::vector &bytes, ShinePdaUs errorOut = "Bad sessions block"; return false; } + outState.sessionsMode = sessionsMode; outState.sessions.clear(); for (uint8_t j = 0; j < sessionsCount; ++j) { ShinePdaSessionRecord rec; @@ -2165,8 +2702,7 @@ static bool parseShineUserPdaBytes(const std::vector &bytes, ShinePdaUs continue; } if (blockType == kBlockTypeTrustedState) { - uint8_t trustedCount = 0; - if (!readU8(trustedCount)) { + if (!readU8(outState.trustedCount)) { errorOut = "Bad trusted block"; return false; } @@ -2176,6 +2712,10 @@ static bool parseShineUserPdaBytes(const std::vector &bytes, ShinePdaUs return false; } + if (offset + 64 > recordLen) { + errorOut = "Missing PDA signature"; + return false; + } outState.found = true; return true; } @@ -2233,6 +2773,10 @@ static bool readShineUserPda(const String &login, ShinePdaUserState &outState, S static void refreshAccountPdaStatus() { gAccountCheckPending = false; gShowRegisterAccountButton = false; + gShowHomeserverPdaActionButton = false; + gHomeserverPdaActionReason = ""; + gHomeserverPdaCanAdd = false; + gHomeserverPdaCanFix = false; if (gLoginValue.isEmpty() || !gSecretConfigured) { gAccountPdaStatus = ACCOUNT_PDA_UNKNOWN; @@ -2314,6 +2858,15 @@ static void refreshAccountPdaStatus() { if (!mismatch.isEmpty()) { gAccountPdaStatus = ACCOUNT_PDA_MISMATCH; gAccountPdaStatusMessage = mismatch; + if (mismatch == "homeserver not in PDA") { + gShowHomeserverPdaActionButton = true; + gHomeserverPdaActionReason = mismatch; + gHomeserverPdaCanAdd = true; + } else if (mismatch == "homeserver key mismatch") { + gShowHomeserverPdaActionButton = true; + gHomeserverPdaActionReason = mismatch; + gHomeserverPdaCanFix = true; + } gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " account not configured"; clearShineSessionState(false); return; @@ -2811,6 +3364,15 @@ static void loadPrefs() { gAccountPdaStatus = ACCOUNT_PDA_UNKNOWN; gAccountPdaStatusMessage = "Account not checked"; gShowRegisterAccountButton = false; + gShowHomeserverPdaActionButton = false; + gHomeserverPdaActionReason = ""; + gHomeserverPdaActionMessage = ""; + gHomeserverPdaActionDetail = ""; + gHomeserverPdaCanAdd = false; + gHomeserverPdaCanFix = false; + gHomeserverPdaResultMessage = ""; + gHomeserverPdaResultDetails = ""; + gHomeserverPdaResultSuccess = false; gShineStatusLine = "SHiNE: account not configured"; } @@ -3534,6 +4096,32 @@ static void actionButtonCb(lv_event_t *event) { case ACTION_REGISTER_ACCOUNT_EXECUTE: executeRegisterAccountFlow("manual", true); break; + case ACTION_HOMESERVER_PDA_ACTION: + prepareHomeserverPdaActionScreen(); + showScreen(SCREEN_HOMESERVER_PDA_CONFIRM); + break; + case ACTION_HOMESERVER_PDA_ADD_EXECUTE: { + gRegisterTriggerSource = "manual-homeserver-add"; + String updateMessage; + if (!updateHomeserverSessionOnSolana(false, updateMessage)) { + gHomeserverPdaResultSuccess = false; + gHomeserverPdaResultMessage = updateMessage; + gHomeserverPdaResultDetails = ""; + } + showScreen(SCREEN_HOMESERVER_PDA_RESULT); + break; + } + case ACTION_HOMESERVER_PDA_FIX_EXECUTE: { + gRegisterTriggerSource = "manual-homeserver-fix"; + String updateMessage; + if (!updateHomeserverSessionOnSolana(true, updateMessage)) { + gHomeserverPdaResultSuccess = false; + gHomeserverPdaResultMessage = updateMessage; + gHomeserverPdaResultDetails = ""; + } + showScreen(SCREEN_HOMESERVER_PDA_RESULT); + break; + } case ACTION_OPEN_WIFI: gWifiViewMode = WIFI_VIEW_OVERVIEW; showScreen(SCREEN_WIFI); @@ -3800,6 +4388,11 @@ static void drawHome() { showMessageAt(shineHomeLine(), 322); if (gShowRegisterAccountButton) { makeButton("REGISTER ACCOUNT", 20, 360, 210, 78, 0x2A9D8F, ACTION_REGISTER_ACCOUNT, &lv_font_montserrat_20); + } else if (gShowHomeserverPdaActionButton) { + const char *label = gHomeserverPdaActionReason == "homeserver key mismatch" + ? "FIX HOMESERVER PASSWORD" + : "ADD HOMESERVER"; + makeButton(label, 20, 360, 210, 78, 0xC59B2A, ACTION_HOMESERVER_PDA_ACTION, &lv_font_montserrat_18); } makeButton("SETTINGS", 250, 360, 210, 78, 0x2A6F97, ACTION_OPEN_SETTINGS, &lv_font_montserrat_24); makeVersionTag(); @@ -4111,6 +4704,43 @@ static void drawRegisterAccountResultScreen() { makeVersionTag(); } +static void drawHomeserverPdaConfirmScreen() { + setRootStyle(); + makeTitle("HOMESERVER PDA", 22, &lv_font_montserrat_24); + String topLine = gHomeserverPdaActionMessage.isEmpty() ? String("Homeserver action") : gHomeserverPdaActionMessage; + makeBody(topLine.c_str(), 96, 420); + if (!gHomeserverPdaActionDetail.isEmpty()) { + makeBody(gHomeserverPdaActionDetail.c_str(), 146, 420); + } + String loginLine = String("Login: ") + loginDisplayValue(); + String homeserverLine = String("Homeserver: ") + homeserverDisplayValue(); + makeBody(loginLine.c_str(), 196, 420); + makeBody(homeserverLine.c_str(), 236, 420); + if (gHomeserverPdaCanAdd) { + makeButton("ADD HOMESERVER", 22, 300, 436, 70, 0xC59B2A, ACTION_HOMESERVER_PDA_ADD_EXECUTE, &lv_font_montserrat_20); + } else if (gHomeserverPdaCanFix) { + makeButton("FIX HOMESERVER PASSWORD", 22, 300, 436, 70, 0xC59B2A, ACTION_HOMESERVER_PDA_FIX_EXECUTE, &lv_font_montserrat_16); + } else { + makeButton("UNAVAILABLE", 22, 300, 436, 70, 0x4A5560, ACTION_NONE, &lv_font_montserrat_20); + } + makeButton("BACK", 140, 386, 200, 54, 0x5A6570, ACTION_BACK_HOME, &lv_font_montserrat_20); + makeVersionTag(); +} + +static void drawHomeserverPdaResultScreen() { + setRootStyle(); + makeTitle("HOMESERVER RESULT", 22, &lv_font_montserrat_24); + String resultTopLine = gHomeserverPdaResultSuccess ? String("Homeserver PDA updated") : String("Homeserver update failed"); + makeBody(resultTopLine.c_str(), 96, 420); + makeBody(gHomeserverPdaResultMessage.c_str(), 146, 420); + if (!gHomeserverPdaResultDetails.isEmpty()) { + makeBody(gHomeserverPdaResultDetails.c_str(), 196, 420); + } + makeButton("BACK HOME", 22, 372, 200, 58, 0x5A6570, ACTION_BACK_HOME, &lv_font_montserrat_20); + makeButton("ACCOUNT", 258, 372, 200, 58, 0x2A6F97, ACTION_OPEN_ACCOUNT, &lv_font_montserrat_20); + makeVersionTag(); +} + static void drawKeyRow(const char *const *tokens, int count, lv_coord_t x, @@ -4277,6 +4907,12 @@ static void rebuildScreen() { case SCREEN_REGISTER_ACCOUNT_RESULT: drawRegisterAccountResultScreen(); break; + case SCREEN_HOMESERVER_PDA_CONFIRM: + drawHomeserverPdaConfirmScreen(); + break; + case SCREEN_HOMESERVER_PDA_RESULT: + drawHomeserverPdaResultScreen(); + break; } } @@ -4403,6 +5039,8 @@ static void handleSwipe(SwipeDirection swipe) { break; case SCREEN_REGISTER_ACCOUNT_CONFIRM: case SCREEN_REGISTER_ACCOUNT_RESULT: + case SCREEN_HOMESERVER_PDA_CONFIRM: + case SCREEN_HOMESERVER_PDA_RESULT: handleHomeSwipe(swipe); break; } @@ -4411,7 +5049,6 @@ static void handleSwipe(SwipeDirection swipe) { void setup() { Serial.begin(115200); sodium_init(); - gBootMillis = millis(); Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL); initPowerManagement(); @@ -4473,15 +5110,6 @@ void loop() { manageAccountPdaRefresh(); manageShineConnection(); - if (gAutoRegisterTestArmed && !gAutoRegisterTestStarted && millis() - gBootMillis >= 15000UL) { - gAutoRegisterTestStarted = true; - gAutoRegisterTestArmed = false; - Serial.println("AUTO_REGISTER_TEST_BEGIN"); - executeRegisterAccountFlow("auto-boot-15s", false); - gAutoRegisterTestFinished = true; - Serial.println("AUTO_REGISTER_TEST_END"); - } - static unsigned long lastHomeRefreshMs = 0; if (gCurrentScreen == SCREEN_HOME && !gTouchDown && millis() - lastHomeRefreshMs >= HOME_REFRESH_MS) { lastHomeRefreshMs = millis(); diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md index 58c1a72..348d4d4 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md @@ -162,10 +162,14 @@ Дополнительная большая кнопка: - `REGISTER ACCOUNT` +- либо жёлтая `ADD HOMESERVER` +- либо жёлтая `FIX HOMESERVER PASSWORD` Если регистрация уже сделана: -- вместо призыва к регистрации показывается статус `Homeserver активен`. +- если пользователь создан, но в `PDA` ещё нет сессии текущего homeserver, показывается жёлтая кнопка `ADD HOMESERVER`; +- если в `PDA` есть homeserver с тем же именем, но с другим ключом, показывается жёлтая кнопка `FIX HOMESERVER PASSWORD`; +- если и пользователь, и homeserver-сессия уже корректны, вместо призыва к регистрации показывается статус `Homeserver активен`. - две нижние кнопки внизу экрана не прилегают вплотную друг к другу, между ними есть небольшой зазор. ## Экран REGISTER_ACCOUNT_CONFIRM @@ -215,6 +219,49 @@ - при ошибке на экране показывается причина отказа; - если ошибку вернул `sendTransaction`, экран старается показать не только общий текст, но и детали `RPC`/preflight/simulate-логов. +## Экран HOMESERVER_PDA_CONFIRM + +Показывает, что именно не так с homeserver-секцией уже существующей пользовательской `PDA`. + +Отображается: + +- причина (`homeserver` отсутствует в `PDA` или ключ не совпадает с локальным секретом); +- `login`; +- имя `homeserver`; +- короткое пояснение, что именно будет сделано. + +Кнопки: + +- `ADD HOMESERVER` или `FIX HOMESERVER PASSWORD` +- `BACK` + +Поведение: + +- если `Wi-Fi` не подключён, действие недоступно; +- экран не создаёт нового пользователя, а запускает `update_user_pda`; +- при `ADD HOMESERVER` в блок `sessions` добавляется запись `session_type=100`; +- при `FIX HOMESERVER PASSWORD` обновляется публичный ключ уже существующей записи `homeserver`. + +## Экран HOMESERVER_PDA_RESULT + +Показывает результат обновления `sessions` в пользовательской `PDA`. + +Отображается: + +- успех или ошибка; +- короткое сообщение; +- при успехе краткий `tx signature`. + +Кнопки: + +- `BACK HOME` +- `ACCOUNT` + +Поведение: + +- при ошибке текст ошибки сохраняется в ту же USB/NVS-диагностику, что и регистрация; +- после успешного обновления выполняется повторная проверка `PDA`, и основной экран должен перейти в состояние `ok`. + ## Экран STATUS Показывает сводку: diff --git a/VERSION.properties b/VERSION.properties index 5b5e63c..516881a 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.175 -server.version=1.2.164 +client.version=1.2.176 +server.version=1.2.165 From 477ab3b58043ccdb7e5c4a36b5be9c560f0e3e3923651827b4467315587cf9de Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sat, 13 Jun 2026 12:53:40 +0400 Subject: [PATCH 15/56] =?UTF-8?q?ESP32:=20=D0=BF=D0=BE=D1=87=D0=B8=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20homeserver=20=D0=B8=20=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BD=D1=83=D1=82=D1=8C=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-06-13_1545_esp32_homeserver_pda_update.md | 1 + .../shine_homeserver_main.ino | 28 +++++++++++++++++-- VERSION.properties | 4 +-- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/Dev_Docs/Pending_Features/2026-06-13_1545_esp32_homeserver_pda_update.md b/Dev_Docs/Pending_Features/2026-06-13_1545_esp32_homeserver_pda_update.md index b007af3..1d4068f 100644 --- a/Dev_Docs/Pending_Features/2026-06-13_1545_esp32_homeserver_pda_update.md +++ b/Dev_Docs/Pending_Features/2026-06-13_1545_esp32_homeserver_pda_update.md @@ -16,6 +16,7 @@ 6. Если действие завершается ошибкой, проверить, что: - текст ошибки показан на экране результата; - команда `last_error` по USB возвращает полный сохранённый payload. + 7. Во временной диагностической сборке после старта устройства подождать 15 секунд и проверить, что при наличии жёлтой homeserver-кнопки устройство само делает попытку `ADD/FIX HOMESERVER`, а результат пишет в USB-диагностику. - ожидаемый результат: устройство после обычной регистрации пользователя способно отдельной транзакцией добавить или исправить homeserver-сессию в `shine_users` PDA, а ошибки этого шага сохраняются в ту же USB/NVS-диагностику. diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino index a964ada..a2c5aa8 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino @@ -304,6 +304,10 @@ static String gLastRegisterDiagSummary; static String gLastRegisterDiagDetails; static String gLastRegisterDiagTime; static String gRegisterTriggerSource = "manual"; +static unsigned long gBootMillis = 0; +static bool gAutoHomeserverTestArmed = true; +static bool gAutoHomeserverTestStarted = false; +static unsigned long gLastAutoHomeserverAttemptMs = 0; static String gSerialCommandBuffer; static String gShineSessionId; static String gShineSessionKey; @@ -2349,7 +2353,7 @@ static bool updateHomeserverSessionOnSolana(bool requireExisting, String &messag if (session.sessionType == kSessionTypeHomeserver && session.sessionName == gHomeserverValue) { foundSession = true; memcpy(session.sessionPubKey32, homeserverPub, 32); - session.sessionVersion = 0; + session.sessionVersion = 1; break; } } @@ -2359,7 +2363,7 @@ static bool updateHomeserverSessionOnSolana(bool requireExisting, String &messag } ShinePdaSessionRecord rec; rec.sessionType = kSessionTypeHomeserver; - rec.sessionVersion = 0; + rec.sessionVersion = 1; rec.sessionName = gHomeserverValue; memcpy(rec.sessionPubKey32, homeserverPub, 32); nextState.sessions.push_back(rec); @@ -5049,6 +5053,7 @@ static void handleSwipe(SwipeDirection swipe) { void setup() { Serial.begin(115200); sodium_init(); + gBootMillis = millis(); Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL); initPowerManagement(); @@ -5110,6 +5115,25 @@ void loop() { manageAccountPdaRefresh(); manageShineConnection(); + if (gAutoHomeserverTestArmed + && !gTouchDown + && millis() - gBootMillis >= 15000UL + && millis() - gLastAutoHomeserverAttemptMs >= 45000UL + && gShowHomeserverPdaActionButton + && (gHomeserverPdaCanAdd || gHomeserverPdaCanFix)) { + gAutoHomeserverTestStarted = true; + gLastAutoHomeserverAttemptMs = millis(); + gRegisterTriggerSource = gHomeserverPdaCanFix ? "auto-boot-fix-homeserver" : "auto-boot-add-homeserver"; + String updateMessage; + Serial.println("AUTO_HOMESERVER_PDA_BEGIN"); + if (!updateHomeserverSessionOnSolana(gHomeserverPdaCanFix, updateMessage)) { + gHomeserverPdaResultSuccess = false; + gHomeserverPdaResultMessage = updateMessage; + gHomeserverPdaResultDetails = ""; + } + Serial.println("AUTO_HOMESERVER_PDA_END"); + } + static unsigned long lastHomeRefreshMs = 0; if (gCurrentScreen == SCREEN_HOME && !gTouchDown && millis() - lastHomeRefreshMs >= HOME_REFRESH_MS) { lastHomeRefreshMs = millis(); diff --git a/VERSION.properties b/VERSION.properties index 516881a..18a6233 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.176 -server.version=1.2.165 +client.version=1.2.177 +server.version=1.2.166 From 3b8ea70d3c6e9b0fef324eb80f0e6722d83e6778b3718593f55caa641e5909a1 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sat, 13 Jun 2026 13:09:32 +0400 Subject: [PATCH 16/56] =?UTF-8?q?ESP32:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=B4=D0=B8=D0=B0=D0=B3=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8=D0=BA=D1=83=20=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20SHiNE=20=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=87=D0=B8=D0=BD=D0=B8=D1=82=D1=8C=20WS=20handshake?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-06-13_1545_esp32_homeserver_pda_update.md | 1 - .../shine_homeserver_main.ino | 124 +++++++++++------- VERSION.properties | 4 +- 3 files changed, 76 insertions(+), 53 deletions(-) diff --git a/Dev_Docs/Pending_Features/2026-06-13_1545_esp32_homeserver_pda_update.md b/Dev_Docs/Pending_Features/2026-06-13_1545_esp32_homeserver_pda_update.md index 1d4068f..b007af3 100644 --- a/Dev_Docs/Pending_Features/2026-06-13_1545_esp32_homeserver_pda_update.md +++ b/Dev_Docs/Pending_Features/2026-06-13_1545_esp32_homeserver_pda_update.md @@ -16,7 +16,6 @@ 6. Если действие завершается ошибкой, проверить, что: - текст ошибки показан на экране результата; - команда `last_error` по USB возвращает полный сохранённый payload. - 7. Во временной диагностической сборке после старта устройства подождать 15 секунд и проверить, что при наличии жёлтой homeserver-кнопки устройство само делает попытку `ADD/FIX HOMESERVER`, а результат пишет в USB-диагностику. - ожидаемый результат: устройство после обычной регистрации пользователя способно отдельной транзакцией добавить или исправить homeserver-сессию в `shine_users` PDA, а ошибки этого шага сохраняются в ту же USB/NVS-диагностику. diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino index a2c5aa8..48791dd 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino @@ -304,10 +304,6 @@ static String gLastRegisterDiagSummary; static String gLastRegisterDiagDetails; static String gLastRegisterDiagTime; static String gRegisterTriggerSource = "manual"; -static unsigned long gBootMillis = 0; -static bool gAutoHomeserverTestArmed = true; -static bool gAutoHomeserverTestStarted = false; -static unsigned long gLastAutoHomeserverAttemptMs = 0; static String gSerialCommandBuffer; static String gShineSessionId; static String gShineSessionKey; @@ -498,6 +494,7 @@ static bool readShineUserPda(const String &login, ShinePdaUserState &outState, S static bool parseShineUserPdaBytes(const std::vector &bytes, ShinePdaUserState &outState, String &errorOut); static void refreshAccountPdaStatus(); static void manageAccountPdaRefresh(); +static void saveShineConnectDiag(const String &status, const String &summary, const String &details); static bool ensureWebSocketConnected(SimpleWebSocketClient &ws, const String &url, String &errorOut); static void closeWebSocket(SimpleWebSocketClient &ws); static bool wsSendText(SimpleWebSocketClient &ws, const String &payload); @@ -2921,7 +2918,7 @@ static bool ensureWebSocketConnected(SimpleWebSocketClient &ws, const String &ur } ws.client.setInsecure(); - ws.client.setTimeout(5); + ws.client.setTimeout(5000); if (!ws.client.connect(ws.host.c_str(), ws.port)) { errorOut = "WS connect failed"; return false; @@ -2944,7 +2941,7 @@ static bool ensureWebSocketConnected(SimpleWebSocketClient &ws, const String &ur String statusLine = ws.client.readStringUntil('\n'); if (statusLine.indexOf("101") < 0) { - errorOut = "WS handshake failed"; + errorOut = String("WS handshake failed: ") + statusLine; closeWebSocket(ws); return false; } @@ -3100,41 +3097,58 @@ static bool shineWsRequest(SimpleWebSocketClient &ws, const String &op, const St static bool ensureShineSessionAuthenticated(String &errorOut) { errorOut = ""; + String diagDetails; + auto failWithDiag = [&](const String &summary) -> bool { + errorOut = summary; + saveShineConnectDiag("error", summary, diagDetails); + printRegisterDiagToSerial(); + return false; + }; + diagDetails += "kind=shine_connect\n"; + diagDetails += String("uptime_ms=") + String(millis()) + "\n"; + diagDetails += String("login=") + gLoginValue + "\n"; + diagDetails += String("homeserver=") + gHomeserverValue + "\n"; + diagDetails += String("server_url=") + gShineServerUrl + "\n"; + diagDetails += String("ws_url=") + shineWsUrl() + "\n"; + diagDetails += String("pda_status=") + gAccountPdaStatusMessage + "\n"; if (WiFi.status() != WL_CONNECTED) { - errorOut = "Wi-Fi disconnected"; - return false; + diagDetails += "wifi=disconnected\n"; + return failWithDiag("Wi-Fi disconnected"); } + diagDetails += String("wifi=connected:") + WiFi.localIP().toString() + "\n"; if (gLoginValue.isEmpty() || !gSecretConfigured || gHomeserverValue.isEmpty()) { - errorOut = "account not configured"; - return false; + return failWithDiag("account not configured"); } if (gAccountPdaStatus != ACCOUNT_PDA_OK) { - errorOut = "account not ready"; - return false; + return failWithDiag("account not ready"); } String wsUrl = shineWsUrl(); if (wsUrl.isEmpty()) { - errorOut = "shine server not set"; - return false; + return failWithDiag("shine server not set"); } if (!ensureWebSocketConnected(gShineWs, wsUrl, errorOut)) { - return false; + diagDetails += String("ws_connect_error=") + errorOut + "\n"; + return failWithDiag(errorOut.isEmpty() ? "WS connect failed" : errorOut); } + diagDetails += "ws_connected=true\n"; { String pingResp; if (!shineWsRequest(gShineWs, "Ping", "{\"ts\":0}", pingResp, SHINE_RPC_TIMEOUT_MS)) { - errorOut = "Ping failed"; - return false; + diagDetails += "ping_error=request_failed\n"; + return failWithDiag("Ping failed"); } + diagDetails += "ping_response<<\n"; + diagDetails += pingResp; + diagDetails += "\n>>ping_response\n"; uint64_t pingStatus = 0; uint64_t serverTs = 0; if (!jsonInt64Field(pingResp, "status", pingStatus) || pingStatus != 200 || !jsonInt64Field(pingResp, "ts", serverTs)) { - errorOut = "Ping rejected"; - return false; + return failWithDiag("Ping rejected"); } gShineServerTimeOffsetMs = (int64_t)serverTs - (int64_t)millis(); + diagDetails += String("server_time_offset_ms=") + String((long long)gShineServerTimeOffsetMs) + "\n"; } uint8_t deviceSeed[32] = {}; @@ -3145,21 +3159,26 @@ static bool ensureShineSessionAuthenticated(String &errorOut) { uint8_t subSec[64] = {}; if (!deriveSeedKeypairFromBase58(gDevicePrivB58, deviceSeed, devicePub, deviceSec) || !deriveSeedKeypairFromBase58(gHomeserverPrivB58, subSeed, subPub, subSec)) { - errorOut = "local key derive failed"; - return false; + return failWithDiag("local key derive failed"); } String sessionKey = buildSessionKeyStringFromPublicBase64(bytesToBase64String(subPub, 32)); + diagDetails += String("session_key=") + sessionKey + "\n"; if (!gShineSessionKey.isEmpty() && gShineSessionKey != sessionKey) { clearShineSessionState(true); + diagDetails += "stored_session_key_reset=true\n"; } if (!gShineSessionId.isEmpty()) { + diagDetails += String("stored_session_id=") + gShineSessionId + "\n"; String response; if (shineWsRequest(gShineWs, "SessionChallenge", String("{\"sessionId\":\"") + jsonEscape(gShineSessionId) + "\"}", response)) { + diagDetails += "session_challenge_response<<\n"; + diagDetails += response; + diagDetails += "\n>>session_challenge_response\n"; uint64_t statusCode = 0; String nonce; if (jsonInt64Field(response, "status", statusCode) && statusCode == 200 && jsonStringField(response, "nonce", nonce)) { @@ -3176,6 +3195,9 @@ static bool ensureShineSessionAuthenticated(String &errorOut) { + "\",\"clientInfo\":\"ESP32 homeserver\"}"; String loginResp; if (shineWsRequest(gShineWs, "SessionLogin", loginReq, loginResp)) { + diagDetails += "session_login_response<<\n"; + diagDetails += loginResp; + diagDetails += "\n>>session_login_response\n"; if (jsonInt64Field(loginResp, "status", statusCode) && statusCode == 200) { String storagePwd; if (jsonStringField(loginResp, "storagePwd", storagePwd)) { @@ -3184,6 +3206,7 @@ static bool ensureShineSessionAuthenticated(String &errorOut) { gShineSessionKey = sessionKey; gShineAuthenticated = true; saveShineSessionPrefs(); + saveShineConnectDiag("ok", "SessionLogin success", diagDetails); return true; } } @@ -3191,8 +3214,10 @@ static bool ensureShineSessionAuthenticated(String &errorOut) { } clearShineSessionState(true); if (!ensureWebSocketConnected(gShineWs, wsUrl, errorOut)) { - return false; + diagDetails += String("ws_reconnect_error=") + errorOut + "\n"; + return failWithDiag(errorOut.isEmpty() ? "WS reconnect failed" : errorOut); } + diagDetails += "stored_session_login_failed=true\n"; } if (gShineStoragePwd.isEmpty()) { @@ -3207,14 +3232,16 @@ static bool ensureShineSessionAuthenticated(String &errorOut) { if (!shineWsRequest(gShineWs, "AuthChallenge", String("{\"login\":\"") + jsonEscape(gLoginValue) + "\"}", authResp)) { - errorOut = "AuthChallenge failed"; - return false; + diagDetails += "auth_challenge_error=request_failed\n"; + return failWithDiag("AuthChallenge failed"); } + diagDetails += "auth_challenge_response<<\n"; + diagDetails += authResp; + diagDetails += "\n>>auth_challenge_response\n"; uint64_t statusCode = 0; String authNonce; if (!jsonInt64Field(authResp, "status", statusCode) || statusCode != 200 || !jsonStringField(authResp, "authNonce", authNonce)) { - errorOut = "AuthChallenge rejected"; - return false; + return failWithDiag("AuthChallenge rejected"); } uint64_t timeMs = shineNowMs(); @@ -3233,17 +3260,21 @@ static bool ensureShineSessionAuthenticated(String &errorOut) { + "\",\"clientInfo\":\"ESP32 homeserver\"}"; String createResp; if (!shineWsRequest(gShineWs, "CreateAuthSession", createReq, createResp)) { - errorOut = "CreateAuthSession failed"; - return false; + diagDetails += "create_auth_session_error=request_failed\n"; + return failWithDiag("CreateAuthSession failed"); } + diagDetails += "create_auth_session_response<<\n"; + diagDetails += createResp; + diagDetails += "\n>>create_auth_session_response\n"; if (!jsonInt64Field(createResp, "status", statusCode) || statusCode != 200 || !jsonStringField(createResp, "sessionId", gShineSessionId)) { - errorOut = "CreateAuthSession rejected"; - return false; + return failWithDiag("CreateAuthSession rejected"); } gShineSessionKey = sessionKey; gShineAuthenticated = true; saveShineSessionPrefs(); + diagDetails += String("new_session_id=") + gShineSessionId + "\n"; + saveShineConnectDiag("ok", "CreateAuthSession success", diagDetails); return true; } @@ -3292,6 +3323,15 @@ static void manageShineConnection() { return; } } + String details; + details += "kind=shine_ping\n"; + details += String("uptime_ms=") + String(millis()) + "\n"; + details += String("server_url=") + gShineServerUrl + "\n"; + details += "ping_response<<\n"; + details += pingResp; + details += "\n>>ping_response\n"; + saveShineConnectDiag("error", "Ping keepalive failed", details); + printRegisterDiagToSerial(); gShineStatusLine = String("SHiNE: ") + serverLabel + " unavailable"; clearShineSessionState(false); } @@ -3562,6 +3602,10 @@ static void clearRegisterDiag() { clearRegisterDiagDetailsFromPrefs(); } +static void saveShineConnectDiag(const String &status, const String &summary, const String &details) { + saveRegisterDiag(status, summary, details); +} + static void printRegisterDiagToSerial() { Serial.println("LAST_REGISTER_DIAG_BEGIN"); Serial.println(String("status=") + gLastRegisterDiagStatus); @@ -5053,7 +5097,6 @@ static void handleSwipe(SwipeDirection swipe) { void setup() { Serial.begin(115200); sodium_init(); - gBootMillis = millis(); Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL); initPowerManagement(); @@ -5115,25 +5158,6 @@ void loop() { manageAccountPdaRefresh(); manageShineConnection(); - if (gAutoHomeserverTestArmed - && !gTouchDown - && millis() - gBootMillis >= 15000UL - && millis() - gLastAutoHomeserverAttemptMs >= 45000UL - && gShowHomeserverPdaActionButton - && (gHomeserverPdaCanAdd || gHomeserverPdaCanFix)) { - gAutoHomeserverTestStarted = true; - gLastAutoHomeserverAttemptMs = millis(); - gRegisterTriggerSource = gHomeserverPdaCanFix ? "auto-boot-fix-homeserver" : "auto-boot-add-homeserver"; - String updateMessage; - Serial.println("AUTO_HOMESERVER_PDA_BEGIN"); - if (!updateHomeserverSessionOnSolana(gHomeserverPdaCanFix, updateMessage)) { - gHomeserverPdaResultSuccess = false; - gHomeserverPdaResultMessage = updateMessage; - gHomeserverPdaResultDetails = ""; - } - Serial.println("AUTO_HOMESERVER_PDA_END"); - } - static unsigned long lastHomeRefreshMs = 0; if (gCurrentScreen == SCREEN_HOME && !gTouchDown && millis() - lastHomeRefreshMs >= HOME_REFRESH_MS) { lastHomeRefreshMs = millis(); diff --git a/VERSION.properties b/VERSION.properties index 18a6233..523bb02 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.177 -server.version=1.2.166 +client.version=1.2.179 +server.version=1.2.168 From 919387f581a27775c6ac923da984413184bea506ad72bd9ff1ec96bbf1307ce6 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sat, 13 Jun 2026 14:15:42 +0400 Subject: [PATCH 17/56] =?UTF-8?q?API=20=D1=81=D0=B5=D1=81=D1=81=D0=B8?= =?UTF-8?q?=D0=B9:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20se?= =?UTF-8?q?ssionType=20=D0=B8=20clientPlatform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dev_Docs/API/02_Authentication_API.md | 25 ++++++ Dev_Docs/API/03_Session_Management_API.md | 15 ++++ .../2026-06-13_2040_session_type_api_и_ui.md | 21 +++++ .../java/shine/db/DatabaseInitializer.java | 2 + .../java/shine/db/SqliteDbController.java | 45 ++++++++++- .../java/shine/db/dao/ActiveSessionsDAO.java | 30 +++++-- .../shine/db/entities/ActiveSessionEntry.java | 12 +++ .../handlers/auth/AuthSessionTypeSupport.java | 46 +++++++++++ .../auth/Net_CreateAuthSession__Handler.java | 44 ++++++++++ .../auth/Net_ListSessions_Handler.java | 2 + .../auth/Net_SessionLogin_Handler.java | 39 +++++++++ .../auth/SolanaUserPdaImportService.java | 81 +++++++++++++++++-- .../Net_CreateAuthSession_Request.java | 22 +++++ .../entyties/Net_ListSessions_Response.java | 22 +++++ .../entyties/Net_SessionLogin_Request.java | 22 +++++ VERSION.properties | 4 +- shine-UI/js/pages/device-session-view.js | 9 +++ shine-UI/js/pages/device-view.js | 8 ++ shine-UI/js/services/auth-service.js | 9 +++ .../formats/shine-user-pda-format-v.1.0.md | 1 + .../shine/doc/programs/shine_users.md | 2 +- 21 files changed, 446 insertions(+), 15 deletions(-) create mode 100644 Dev_Docs/Pending_Features/2026-06-13_2040_session_type_api_и_ui.md create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/AuthSessionTypeSupport.java diff --git a/Dev_Docs/API/02_Authentication_API.md b/Dev_Docs/API/02_Authentication_API.md index 73fe70f..f1b1e45 100644 --- a/Dev_Docs/API/02_Authentication_API.md +++ b/Dev_Docs/API/02_Authentication_API.md @@ -17,6 +17,23 @@ - на втором шаге клиент присылает подписанный ответ; - сервер сверяет актуальные публичные ключи и только потом проверяет подпись. +Новые поля этого раздела: + +- `sessionType` — числовой код типа сессии; +- `clientPlatform` — свободная строка платформы клиента. + +Текущие поддерживаемые коды `sessionType`: + +- `1` — обычный клиент; +- `50` — кошелёк; +- `100` — homeserver. + +Правило проверки `sessionType`: + +1. если в `Solana PDA` нет записи для `sessionKey`, сервер принимает `sessionType`, присланный клиентом; +2. если запись в `PDA` есть, `sessionType` в запросе должен совпадать с `session_type` из `PDA`; +3. при несовпадении сервер возвращает `460 / SESSION_TYPE_MISMATCH`. + Ниже в документе сначала описан сценарий, а потом зафиксированы точные форматы запросов и ответов. ## 1. Поток авторизации @@ -94,6 +111,8 @@ ed25519/BASE64_PUBLIC_KEY "authNonce": "nonce", "deviceKey": "BASE64_DEVICE_PUBLIC_KEY", "signatureB64": "BASE64_SIGNATURE", + "sessionType": 1, + "clientPlatform": "Web", "clientInfo": "Android 15; Pixel 9" } } @@ -153,6 +172,8 @@ AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce} - `400 / EMPTY_DEVICE_KEY` — в запросе не передан `deviceKey`. - `422 / DEVICE_KEY_NOT_ACTUAL` — `deviceKey` не совпадает с актуальной версией на сервере. - `422 / BAD_SIGNATURE` — подпись не прошла проверку. +- `460 / SESSION_TYPE_MISMATCH` — `sessionType` не совпадает с типом сессии, уже опубликованным для этого `sessionKey` в Solana PDA. +- `501 / SESSION_TYPE_PDA_CHECK_FAILED` — сервер не смог проверить `sessionType` по Solana PDA. - `501 / DB_ERROR_SESSION_CREATE` — ошибка БД при создании записи активной сессии. - `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера. @@ -208,6 +229,8 @@ AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce} "sessionKey": "ed25519/BASE64_PUBLIC_KEY", "timeMs": 1774600010456, "signatureB64": "BASE64_SIGNATURE", + "sessionType": 1, + "clientPlatform": "Web", "clientInfo": "Android 15; Pixel 9" } } @@ -258,6 +281,8 @@ SESSION_LOGIN:{sessionId}:{timeMs}:{nonce} - `422 / UNSUPPORTED_KEY_ALGORITHM` — префикс алгоритма в `sessionKey` не поддерживается текущим сервером. - `400 / BAD_BASE64` — неверный Base64 в `sessionKey` или `signatureB64`. - `422 / BAD_SIGNATURE` — подпись не прошла проверку. +- `460 / SESSION_TYPE_MISMATCH` — `sessionType` не совпадает с типом сессии, уже опубликованным для этого `sessionKey` в Solana PDA. +- `501 / SESSION_TYPE_PDA_CHECK_FAILED` — сервер не смог проверить `sessionType` по Solana PDA. - `501 / DB_ERROR_USER_LOOKUP` — ошибка БД при чтении пользователя для этой сессии. - `422 / USER_NOT_FOUND_FOR_SESSION` — пользователь, которому принадлежит сессия, не найден. - `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера. diff --git a/Dev_Docs/API/03_Session_Management_API.md b/Dev_Docs/API/03_Session_Management_API.md index ae98b9d..d67e2ae 100644 --- a/Dev_Docs/API/03_Session_Management_API.md +++ b/Dev_Docs/API/03_Session_Management_API.md @@ -42,6 +42,8 @@ "sessions": [ { "sessionId": "sess_7c5e5c4b", + "sessionType": 1, + "clientPlatform": "Web", "clientInfoFromClient": "Android 15; Pixel 9", "clientInfoFromRequest": "UA=Java-http-client/17.0.18; remote=127.0.0.1", "geo": "RU/Moscow", @@ -58,6 +60,19 @@ - `501 / DB_ERROR_LIST_SESSIONS` — ошибка БД при чтении списка активных сессий. - `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера. +### Поля одной сессии в `ListSessions` + +- `sessionId` — идентификатор активной сессии; +- `sessionType` — числовой код типа сессии: + - `1` — клиент; + - `50` — кошелёк; + - `100` — homeserver; +- `clientPlatform` — строка платформы, как её прислал клиент; +- `clientInfoFromClient` — краткая строка клиента; +- `clientInfoFromRequest` — строка, собранная сервером из запроса; +- `geo` — страна/город или fallback-строка; +- `lastAuthenticatedAtMs` — время последней успешной авторизации этой сессии. + --- ## 2. `CloseActiveSession` diff --git a/Dev_Docs/Pending_Features/2026-06-13_2040_session_type_api_и_ui.md b/Dev_Docs/Pending_Features/2026-06-13_2040_session_type_api_и_ui.md new file mode 100644 index 0000000..93aac1c --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-13_2040_session_type_api_и_ui.md @@ -0,0 +1,21 @@ +# sessionType API и UI + +- краткое описание: + - серверный API авторизации и списка сессий расширен полями `sessionType` и `clientPlatform`; + - сервер сверяет `sessionType` с записью в Solana `PDA`, если для данного `sessionKey` уже есть `SessionRecord`; + - `shine-UI` теперь отправляет `sessionType=1` и `clientPlatform=Web`, а также показывает тип/платформу в экранах сессий; + - в документации Solana `PDA` добавлен тип `50` для `wallet`. + +- что проверять: + - вход через `CreateAuthSession` и повторный вход через `SessionLogin`; + - ответ `ListSessions` должен содержать `sessionType` и `clientPlatform`; + - в UI "Устройства" должен отображаться тип сессии и платформа; + - при искусственном несовпадении `sessionType` с `PDA` сервер должен вернуть `460 / SESSION_TYPE_MISMATCH`. + +- ожидаемый результат: + - обычный web-клиент успешно создаёт/возобновляет сессию с `sessionType=1`; + - список сессий возвращает новые поля; + - несовпадение типа с `PDA` даёт явную прикладную ошибку `460`. + +- статус: + - in_progress diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index b2b9f0b..b664bbb 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -178,6 +178,8 @@ public final class DatabaseInitializer { client_ip TEXT, client_info_from_client TEXT, client_info_from_request TEXT, + session_type INTEGER NOT NULL DEFAULT 1, + client_platform TEXT NOT NULL DEFAULT '', user_language TEXT, FOREIGN KEY (login) REFERENCES solana_users(login) ); diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java index 4f3fbf2..afa321b 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java @@ -14,7 +14,7 @@ import java.sql.Statement; public final class SqliteDbController { private static volatile SqliteDbController instance; - private static final int LATEST_SCHEMA_VERSION = 3; + private static final int LATEST_SCHEMA_VERSION = 4; private final String jdbcUrl; @@ -86,6 +86,7 @@ public final class SqliteDbController { case 1 -> migrateToV1(); case 2 -> migrateToV2(); case 3 -> migrateToV3(); + case 4 -> migrateToV4(); default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion); } } @@ -168,6 +169,26 @@ public final class SqliteDbController { } } + private void migrateToV4() { + try (Connection c = DriverManager.getConnection(jdbcUrl); + Statement st = c.createStatement()) { + c.setAutoCommit(false); + try { + ensureActiveSessionsSessionTypeColumn(c, st); + ensureActiveSessionsClientPlatformColumn(c, st); + setSchemaVersion(c, 4); + c.commit(); + } catch (Exception e) { + try { c.rollback(); } catch (Exception ignored) {} + throw new RuntimeException("DB migration to v4 failed", e); + } finally { + try { c.setAutoCommit(true); } catch (Exception ignored) {} + } + } catch (SQLException e) { + throw new RuntimeException("DB migration to v4 failed", e); + } + } + private static void ensureChat200StateTables(Statement st) throws SQLException { st.executeUpdate(""" CREATE TABLE IF NOT EXISTS chat200_state ( @@ -235,6 +256,28 @@ public final class SqliteDbController { } } + private static void ensureActiveSessionsSessionTypeColumn(Connection c, Statement st) throws SQLException { + if (columnExists(c, "active_sessions", "session_type")) return; + st.executeUpdate("ALTER TABLE active_sessions ADD COLUMN session_type INTEGER NOT NULL DEFAULT 1"); + } + + private static void ensureActiveSessionsClientPlatformColumn(Connection c, Statement st) throws SQLException { + if (columnExists(c, "active_sessions", "client_platform")) return; + st.executeUpdate("ALTER TABLE active_sessions ADD COLUMN client_platform TEXT NOT NULL DEFAULT ''"); + } + + private static boolean columnExists(Connection c, String tableName, String columnName) throws SQLException { + try (Statement probe = c.createStatement(); + ResultSet rs = probe.executeQuery("PRAGMA table_info(" + tableName + ")")) { + while (rs.next()) { + if (columnName.equalsIgnoreCase(rs.getString("name"))) { + return true; + } + } + return false; + } + } + private static void setSchemaVersion(Connection c, int version) throws SQLException { try (var ps = c.prepareStatement(""" INSERT INTO db_schema_version (id, schema_version, updated_at_ms) diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ActiveSessionsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ActiveSessionsDAO.java index 9495f80..885dc8e 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ActiveSessionsDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ActiveSessionsDAO.java @@ -47,8 +47,10 @@ public final class ActiveSessionsDAO { client_ip, client_info_from_client, client_info_from_request, + session_type, + client_platform, user_language - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """; try (PreparedStatement ps = c.prepareStatement(sql)) { @@ -64,7 +66,9 @@ public final class ActiveSessionsDAO { ps.setString(10, session.getClientIp()); ps.setString(11, session.getClientInfoFromClient()); ps.setString(12, session.getClientInfoFromRequest()); - ps.setString(13, session.getUserLanguage()); + ps.setInt(13, session.getSessionType()); + ps.setString(14, session.getClientPlatform()); + ps.setString(15, session.getUserLanguage()); ps.executeUpdate(); } } @@ -92,6 +96,8 @@ public final class ActiveSessionsDAO { client_ip, client_info_from_client, client_info_from_request, + session_type, + client_platform, user_language FROM active_sessions WHERE session_id = ? @@ -127,6 +133,8 @@ public final class ActiveSessionsDAO { client_ip, client_info_from_client, client_info_from_request, + session_type, + client_platform, user_language FROM active_sessions WHERE login = ? COLLATE NOCASE @@ -179,6 +187,8 @@ public final class ActiveSessionsDAO { String clientIp, String clientInfoFromClient, String clientInfoFromRequest, + int sessionType, + String clientPlatform, String userLanguage ) throws SQLException { @@ -189,6 +199,8 @@ public final class ActiveSessionsDAO { client_ip = ?, client_info_from_client = ?, client_info_from_request = ?, + session_type = ?, + client_platform = ?, user_language = ? WHERE session_id = ? """; @@ -198,8 +210,10 @@ public final class ActiveSessionsDAO { ps.setString(2, clientIp); ps.setString(3, clientInfoFromClient); ps.setString(4, clientInfoFromRequest); - ps.setString(5, userLanguage); - ps.setString(6, sessionId); + ps.setInt(5, sessionType); + ps.setString(6, clientPlatform); + ps.setString(7, userLanguage); + ps.setString(8, sessionId); ps.executeUpdate(); } } @@ -210,10 +224,12 @@ public final class ActiveSessionsDAO { String clientIp, String clientInfoFromClient, String clientInfoFromRequest, + int sessionType, + String clientPlatform, String userLanguage ) throws SQLException { try (Connection c = db.getConnection()) { - updateOnRefresh(c, sessionId, lastAuthMs, clientIp, clientInfoFromClient, clientInfoFromRequest, userLanguage); + updateOnRefresh(c, sessionId, lastAuthMs, clientIp, clientInfoFromClient, clientInfoFromRequest, sessionType, clientPlatform, userLanguage); } } @@ -268,6 +284,8 @@ public final class ActiveSessionsDAO { String clientIp = rs.getString("client_ip"); String clientInfoFromClient = rs.getString("client_info_from_client"); String clientInfoFromRequest = rs.getString("client_info_from_request"); + int sessionType = rs.getInt("session_type"); + String clientPlatform = rs.getString("client_platform"); String userLanguage = rs.getString("user_language"); return new ActiveSessionEntry( @@ -283,6 +301,8 @@ public final class ActiveSessionsDAO { clientIp, clientInfoFromClient, clientInfoFromRequest, + sessionType, + clientPlatform, userLanguage ); } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/ActiveSessionEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/ActiveSessionEntry.java index 2a2bb07..9c40c60 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/ActiveSessionEntry.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/ActiveSessionEntry.java @@ -22,6 +22,8 @@ public class ActiveSessionEntry { private String clientIp; private String clientInfoFromClient; private String clientInfoFromRequest; + private int sessionType; + private String clientPlatform; private String userLanguage; public ActiveSessionEntry() { } @@ -38,6 +40,8 @@ public class ActiveSessionEntry { String clientIp, String clientInfoFromClient, String clientInfoFromRequest, + int sessionType, + String clientPlatform, String userLanguage) { this.sessionId = sessionId; this.login = login; @@ -51,6 +55,8 @@ public class ActiveSessionEntry { this.clientIp = clientIp; this.clientInfoFromClient = clientInfoFromClient; this.clientInfoFromRequest = clientInfoFromRequest; + this.sessionType = sessionType; + this.clientPlatform = clientPlatform; this.userLanguage = userLanguage; } @@ -90,6 +96,12 @@ public class ActiveSessionEntry { public String getClientInfoFromRequest() { return clientInfoFromRequest; } public void setClientInfoFromRequest(String clientInfoFromRequest) { this.clientInfoFromRequest = clientInfoFromRequest; } + public int getSessionType() { return sessionType; } + public void setSessionType(int sessionType) { this.sessionType = sessionType; } + + public String getClientPlatform() { return clientPlatform; } + public void setClientPlatform(String clientPlatform) { this.clientPlatform = clientPlatform; } + public String getUserLanguage() { return userLanguage; } public void setUserLanguage(String userLanguage) { this.userLanguage = userLanguage; } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/AuthSessionTypeSupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/AuthSessionTypeSupport.java new file mode 100644 index 0000000..f5fbb28 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/AuthSessionTypeSupport.java @@ -0,0 +1,46 @@ +package server.logic.ws_protocol.JSON.handlers.auth; + +import server.logic.ws_protocol.JSON.utils.AuthKeyUtils; + +import java.util.Base64; + +final class AuthSessionTypeSupport { + + static final int SESSION_TYPE_CLIENT = 1; + static final int SESSION_TYPE_WALLET = 50; + static final int SESSION_TYPE_HOMESERVER = 100; + static final int SESSION_TYPE_MISMATCH_STATUS = 460; + + private AuthSessionTypeSupport() {} + + static int normalizeRequestedSessionType(Integer rawType) { + return rawType == null ? SESSION_TYPE_CLIENT : rawType.intValue(); + } + + static boolean isSupportedSessionType(int sessionType) { + return sessionType == SESSION_TYPE_CLIENT + || sessionType == SESSION_TYPE_WALLET + || sessionType == SESSION_TYPE_HOMESERVER; + } + + static String normalizeClientPlatform(String clientPlatform) { + if (clientPlatform == null) return ""; + String trimmed = clientPlatform.trim(); + if (trimmed.length() <= 64) return trimmed; + return trimmed.substring(0, 64); + } + + static byte[] tryParseSessionPublicKey32(String sessionKeyApi) { + if (sessionKeyApi == null || sessionKeyApi.isBlank()) return null; + try { + return AuthKeyUtils.parseEd25519PublicKey(sessionKeyApi, "sessionKey"); + } catch (Exception ignored) { + try { + byte[] raw = Base64.getDecoder().decode(sessionKeyApi.trim()); + return raw.length == 32 ? raw : null; + } catch (Exception ignoredToo) { + return null; + } + } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java index fb6759c..be89190 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java @@ -213,6 +213,19 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { clientInfoFromClient = clientInfoFromClient.substring(0, 50); } + int requestedSessionType = AuthSessionTypeSupport.normalizeRequestedSessionType(req.getSessionType()); + if (!AuthSessionTypeSupport.isSupportedSessionType(requestedSessionType)) { + Net_Response err = NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "BAD_SESSION_TYPE", + "Неподдерживаемый sessionType" + ); + closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: bad sessionType"); + return err; + } + String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getClientPlatform()); + String deviceKeyFromDb = user.getDeviceKey(); if (deviceKeyFromDb == null || deviceKeyFromDb.isBlank()) { Net_Response err = NetExceptionResponseFactory.error( @@ -315,6 +328,35 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { return err; } + SolanaUserPdaImportService.SessionTypeCheckResult sessionTypeCheck; + try { + sessionTypeCheck = SolanaUserPdaImportService.checkSessionTypeAgainstPda( + canonicalLogin, + sessionKey, + requestedSessionType + ); + } catch (Exception e) { + log.error("Ошибка проверки sessionType по Solana PDA для login={}", canonicalLogin, e); + Net_Response err = NetExceptionResponseFactory.error( + req, + WireCodes.Status.SERVER_DATA_ERROR, + "SESSION_TYPE_PDA_CHECK_FAILED", + "Ошибка проверки sessionType в Solana PDA" + ); + closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: sessionType pda check"); + return err; + } + if (sessionTypeCheck.hasPdaSessionRecord() && !sessionTypeCheck.matchesRequestedType()) { + Net_Response err = NetExceptionResponseFactory.error( + req, + AuthSessionTypeSupport.SESSION_TYPE_MISMATCH_STATUS, + "SESSION_TYPE_MISMATCH", + "sessionType не совпадает с типом сессии в Solana PDA" + ); + closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: sessionType mismatch"); + return err; + } + // --- генерируем sessionId --- String sessionId = generateRandom32B64Url(); long now = System.currentTimeMillis(); @@ -356,6 +398,8 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { clientIp, clientInfoFromClient, clientInfoFromRequest, + requestedSessionType, + clientPlatform, userLanguage ); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListSessions_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListSessions_Handler.java index 50e73b9..6c47505 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListSessions_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListSessions_Handler.java @@ -66,6 +66,8 @@ public class Net_ListSessions_Handler implements JsonMessageHandler { info.setSessionId(s.getSessionId()); info.setClientInfoFromClient(s.getClientInfoFromClient()); info.setClientInfoFromRequest(s.getClientInfoFromRequest()); + info.setSessionType(s.getSessionType()); + info.setClientPlatform(s.getClientPlatform()); info.setLastAuthenticatedAtMs(s.getLastAuthirificatedAtMs()); String ip = s.getClientIp(); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java index 8adac28..7859c3e 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java @@ -216,6 +216,16 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler { if (clientInfoFromClient != null && clientInfoFromClient.length() > 50) { clientInfoFromClient = clientInfoFromClient.substring(0, 50); } + int requestedSessionType = AuthSessionTypeSupport.normalizeRequestedSessionType(req.getSessionType()); + if (!AuthSessionTypeSupport.isSupportedSessionType(requestedSessionType)) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "BAD_SESSION_TYPE", + "Неподдерживаемый sessionType" + ); + } + String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getClientPlatform()); String clientIp = null; String clientInfoFromRequest = null; @@ -235,6 +245,31 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler { } } + SolanaUserPdaImportService.SessionTypeCheckResult sessionTypeCheck; + try { + sessionTypeCheck = SolanaUserPdaImportService.checkSessionTypeAgainstPda( + session.getLogin(), + sessionKeyFromReq, + requestedSessionType + ); + } catch (Exception e) { + log.error("Ошибка проверки sessionType по Solana PDA для login={} sessionId={}", session.getLogin(), sessionId, e); + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.SERVER_DATA_ERROR, + "SESSION_TYPE_PDA_CHECK_FAILED", + "Ошибка проверки sessionType в Solana PDA" + ); + } + if (sessionTypeCheck.hasPdaSessionRecord() && !sessionTypeCheck.matchesRequestedType()) { + return NetExceptionResponseFactory.error( + req, + AuthSessionTypeSupport.SESSION_TYPE_MISMATCH_STATUS, + "SESSION_TYPE_MISMATCH", + "sessionType не совпадает с типом сессии в Solana PDA" + ); + } + long now = System.currentTimeMillis(); try { ActiveSessionsDAO.getInstance().updateOnRefresh( @@ -243,6 +278,8 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler { clientIp, clientInfoFromClient, clientInfoFromRequest, + requestedSessionType, + clientPlatform, userLanguage ); } catch (SQLException e) { @@ -253,6 +290,8 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler { session.setClientIp(clientIp); session.setClientInfoFromClient(clientInfoFromClient); session.setClientInfoFromRequest(clientInfoFromRequest); + session.setSessionType(requestedSessionType); + session.setClientPlatform(clientPlatform); session.setUserLanguage(userLanguage); // ctx diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java index 3183fbe..6b76347 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java @@ -14,7 +14,10 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Base64; +import java.util.List; +import java.util.Locale; /** * Lazy-import пользователя из Solana PDA в локальную БД сервера. @@ -57,6 +60,28 @@ public final class SolanaUserPdaImportService { return usersDao.getByLogin(login); } + public static SessionTypeCheckResult checkSessionTypeAgainstPda(String loginRaw, String sessionKeyApi, int requestedSessionType) throws Exception { + String login = normalizeLogin(loginRaw); + if (login == null) return SessionTypeCheckResult.noRecord(); + + ParsedSolanaUser parsed = fetchFromSolana(login); + if (parsed == null) return SessionTypeCheckResult.noRecord(); + + byte[] sessionPubKey32 = AuthSessionTypeSupport.tryParseSessionPublicKey32(sessionKeyApi); + if (sessionPubKey32 == null) return SessionTypeCheckResult.noRecord(); + + for (ParsedSessionRecord session : parsed.sessions()) { + if (constantTimeEquals(session.sessionPubKey32(), sessionPubKey32)) { + if (session.sessionType() == requestedSessionType) { + return SessionTypeCheckResult.match(session.sessionType(), session.sessionName()); + } + return SessionTypeCheckResult.mismatch(session.sessionType(), session.sessionName()); + } + } + + return SessionTypeCheckResult.noRecord(); + } + private static ParsedSolanaUser fetchFromSolana(String login) throws Exception { String loginB58 = toBase58(login.getBytes(StandardCharsets.UTF_8)); String lenB58 = toBase58(new byte[]{(byte) login.length()}); @@ -135,6 +160,7 @@ public final class SolanaUserPdaImportService { byte[] blockchainKey32 = null; byte[] deviceKey32 = null; long paidLimitBytes = 0L; + List sessions = new ArrayList<>(); for (int i = 0; i < blocksCount; i++) { int blockType = u8(raw, c++); @@ -196,11 +222,19 @@ public final class SolanaUserPdaImportService { int sessionsCount = u8(raw, c++); if (sessionsCount > 64) return null; for (int j = 0; j < sessionsCount; j++) { - c += 1; // session_type - c += 1; // session_version + int sessionType = u8(raw, c++); + int sessionVersion = u8(raw, c++); int n = u8(raw, c++); + String sessionName = new String(raw, c, n, StandardCharsets.UTF_8); c += n; - c += 32; // session_pub_key + byte[] sessionPubKey32 = slice(raw, c, 32); + c += 32; + sessions.add(new ParsedSessionRecord( + sessionType, + sessionVersion, + sessionName, + sessionPubKey32 + )); } } else if (blockType == 70) { c += 1; @@ -217,7 +251,8 @@ public final class SolanaUserPdaImportService { blockchainName, Base64.getEncoder().encodeToString(blockchainKey32), Base64.getEncoder().encodeToString(deviceKey32), - paidLimitBytes + paidLimitBytes, + sessions ); } @@ -225,7 +260,7 @@ public final class SolanaUserPdaImportService { if (login == null) return null; String s = login.trim(); if (s.isEmpty()) return null; - return s.toLowerCase(); + return s.toLowerCase(Locale.ROOT); } private static int u8(byte[] b, int o) { return b[o] & 0xFF; } @@ -272,11 +307,45 @@ public final class SolanaUserPdaImportService { return remainder; } + private static boolean constantTimeEquals(byte[] a, byte[] b) { + if (a == null || b == null || a.length != b.length) return false; + int diff = 0; + for (int i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; + return diff == 0; + } + private record ParsedSolanaUser( String login, String blockchainName, String blockchainKeyB64, String deviceKeyB64, - long paidLimitBytes + long paidLimitBytes, + List sessions ) {} + + private record ParsedSessionRecord( + int sessionType, + int sessionVersion, + String sessionName, + byte[] sessionPubKey32 + ) {} + + public record SessionTypeCheckResult( + boolean hasPdaSessionRecord, + boolean matchesRequestedType, + int pdaSessionType, + String sessionName + ) { + static SessionTypeCheckResult noRecord() { + return new SessionTypeCheckResult(false, true, 0, ""); + } + + static SessionTypeCheckResult match(int pdaSessionType, String sessionName) { + return new SessionTypeCheckResult(true, true, pdaSessionType, sessionName == null ? "" : sessionName); + } + + static SessionTypeCheckResult mismatch(int pdaSessionType, String sessionName) { + return new SessionTypeCheckResult(true, false, pdaSessionType, sessionName == null ? "" : sessionName); + } + } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CreateAuthSession_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CreateAuthSession_Request.java index cda4f80..91fa95b 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CreateAuthSession_Request.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CreateAuthSession_Request.java @@ -41,6 +41,12 @@ public class Net_CreateAuthSession_Request extends Net_Request { /** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */ private String clientInfo; + /** Числовой код типа сессии. */ + private Integer sessionType; + + /** Свободная строка платформы клиента, например Web / Android / ESP32. */ + private String clientPlatform; + public String getLogin() { return login; } @@ -104,4 +110,20 @@ public class Net_CreateAuthSession_Request extends Net_Request { public void setClientInfo(String clientInfo) { this.clientInfo = clientInfo; } + + public Integer getSessionType() { + return sessionType; + } + + public void setSessionType(Integer sessionType) { + this.sessionType = sessionType; + } + + public String getClientPlatform() { + return clientPlatform; + } + + public void setClientPlatform(String clientPlatform) { + this.clientPlatform = clientPlatform; + } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListSessions_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListSessions_Response.java index 08219d1..2bd3571 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListSessions_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListSessions_Response.java @@ -52,6 +52,12 @@ public class Net_ListSessions_Response extends Net_Response { /** Краткая строка, собранная сервером из HTTP-запроса (UA, платформа и т.п.). */ private String clientInfoFromRequest; + /** Числовой код типа сессии. */ + private int sessionType; + + /** Свободная строка платформы, как её прислал клиент. */ + private String clientPlatform; + /** Строка геолокации вида "Country, City" или "unknown". */ private String geo; @@ -84,6 +90,22 @@ public class Net_ListSessions_Response extends Net_Response { this.clientInfoFromRequest = clientInfoFromRequest; } + public int getSessionType() { + return sessionType; + } + + public void setSessionType(int sessionType) { + this.sessionType = sessionType; + } + + public String getClientPlatform() { + return clientPlatform; + } + + public void setClientPlatform(String clientPlatform) { + this.clientPlatform = clientPlatform; + } + public String getGeo() { return geo; } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionLogin_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionLogin_Request.java index e1f1d4d..51483f9 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionLogin_Request.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionLogin_Request.java @@ -21,6 +21,12 @@ public class Net_SessionLogin_Request extends Net_Request { /** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */ private String clientInfo; + /** Числовой код типа сессии. */ + private Integer sessionType; + + /** Свободная строка платформы клиента, например Web / Android / ESP32. */ + private String clientPlatform; + public String getSessionId() { return sessionId; } @@ -60,4 +66,20 @@ public class Net_SessionLogin_Request extends Net_Request { public void setClientInfo(String clientInfo) { this.clientInfo = clientInfo; } + + public Integer getSessionType() { + return sessionType; + } + + public void setSessionType(Integer sessionType) { + this.sessionType = sessionType; + } + + public String getClientPlatform() { + return clientPlatform; + } + + public void setClientPlatform(String clientPlatform) { + this.clientPlatform = clientPlatform; + } } diff --git a/VERSION.properties b/VERSION.properties index 523bb02..f980545 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.179 -server.version=1.2.168 +client.version=1.2.180 +server.version=1.2.169 diff --git a/shine-UI/js/pages/device-session-view.js b/shine-UI/js/pages/device-session-view.js index 2f01aa6..c8cb4df 100644 --- a/shine-UI/js/pages/device-session-view.js +++ b/shine-UI/js/pages/device-session-view.js @@ -10,6 +10,13 @@ import { export const pageMeta = { id: 'device-session-view', title: 'Сеанс устройства' }; +function formatSessionType(sessionType) { + if (Number(sessionType) === 100) return 'Homeserver'; + if (Number(sessionType) === 50) return 'Wallet'; + if (Number(sessionType) === 1) return 'Client'; + return `Type ${Number(sessionType) || 0}`; +} + function formatSessionTime(ms) { return new Date(ms).toLocaleString('ru-RU', { day: '2-digit', @@ -46,6 +53,8 @@ export function render({ navigate, route }) { details.className = 'card stack'; details.innerHTML = `

sessionId

${session.sessionId}

+

sessionType

${formatSessionType(session.sessionType)}

+

clientPlatform

${session.clientPlatform || '-'}

clientInfoFromClient

${session.clientInfoFromClient || '-'}

clientInfoFromRequest

${session.clientInfoFromRequest || '-'}

geo

${session.geo || 'unknown'}

diff --git a/shine-UI/js/pages/device-view.js b/shine-UI/js/pages/device-view.js index 9360e3e..7f5b60d 100644 --- a/shine-UI/js/pages/device-view.js +++ b/shine-UI/js/pages/device-view.js @@ -11,6 +11,13 @@ import { export const pageMeta = { id: 'device-view', title: 'Устройства' }; +function formatSessionType(sessionType) { + if (Number(sessionType) === 100) return 'Homeserver'; + if (Number(sessionType) === 50) return 'Wallet'; + if (Number(sessionType) === 1) return 'Client'; + return `Type ${Number(sessionType) || 0}`; +} + function formatSessionTime(ms) { return new Date(ms).toLocaleString('ru-RU', { day: '2-digit', @@ -60,6 +67,7 @@ export function render({ navigate }) {
${session.clientInfoFromClient || 'unknown client'} + ${formatSessionType(session.sessionType)}${session.clientPlatform ? ` · ${session.clientPlatform}` : ''} ${session.geo || 'unknown'}
${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())} diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 9ba1891..e370da6 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -53,6 +53,7 @@ const CHANNEL_TYPE_PUBLIC = 1; const CHANNEL_TYPE_PERSONAL = 100; const CHANNEL_TYPE_GROUP = 200; const CHANNEL_TYPE_VERSION_DEFAULT = 1; +const SESSION_TYPE_CLIENT = 1; const CONNECTION_SUBTYPES = Object.freeze({ // Legacy alias: friend == close_friend. Оба ключа ведут на один и тот же код 10/11. @@ -110,6 +111,10 @@ function makeClientInfo() { return ua.slice(0, 50); } +function makeClientPlatform() { + return 'Web'; +} + function hexToBytes(hex) { const clean = String(hex || '').trim().toLowerCase(); if (!clean || clean.length % 2 !== 0) throw new Error('Некорректный hex'); @@ -794,6 +799,8 @@ export class AuthService { authNonce, deviceKey: keyBundle.devicePair.publicKeyB64, signatureB64, + sessionType: SESSION_TYPE_CLIENT, + clientPlatform: makeClientPlatform(), clientInfo: makeClientInfo(), }); if (createResp.status !== 200) throw opError('CreateAuthSession', createResp); @@ -932,6 +939,8 @@ export class AuthService { sessionKey: sessionMaterial.sessionKey, timeMs, signatureB64, + sessionType: SESSION_TYPE_CLIENT, + clientPlatform: makeClientPlatform(), clientInfo: makeClientInfo(), }); if (loginResp.status !== 200) throw opError('SessionLogin', loginResp); diff --git a/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md b/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md index 1039e38..556e0c4 100644 --- a/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md +++ b/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md @@ -309,6 +309,7 @@ SessionRecord | Значение | Смысл | |----------|-------| | `1` | Обычная пользовательская сессия. | +| `50` | Кошелёк пользователя. | | `100` | Homeserver пользователя. | Правила: diff --git a/shine-solana/shine/doc/programs/shine_users.md b/shine-solana/shine/doc/programs/shine_users.md index 7f01b48..e53e43d 100644 --- a/shine-solana/shine/doc/programs/shine_users.md +++ b/shine-solana/shine/doc/programs/shine_users.md @@ -534,7 +534,7 @@ signature = Ed25519(blockchain_private_key, message_hash) - максимум `64` записей; - `sessions_mode` допускает только `1` и `10`; -- `session_type` допускает `1` и `100`; +- `session_type` допускает `1`, `50` и `100`; - `session_version` сейчас только `1`; - `session_name` должен содержать только `[A-Za-z0-9_]`; - `session_name` и `session_pub_key` уникальны внутри списка. From 61c21b245ec27ad6d4089299f5398c64b3fbdd58d472d04840173c2257753624 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sat, 13 Jun 2026 15:05:41 +0400 Subject: [PATCH 18/56] =?UTF-8?q?UI:=20=D1=8F=D0=B2=D0=BD=D0=BE=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BA=D0=B0=D0=B7=D0=B0=D1=82=D1=8C=20=D1=82=D0=B8=D0=BF?= =?UTF-8?q?=20=D1=81=D0=B5=D0=B0=D0=BD=D1=81=D0=B0=20=D0=B2=20=D1=81=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=BA=D0=B5=20=D1=83=D1=81=D1=82=D1=80=D0=BE=D0=B9?= =?UTF-8?q?=D1=81=D1=82=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 4 ++-- shine-UI/js/app.js | 4 ++-- shine-UI/js/pages/device-view.js | 4 +++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index f980545..f418b9b 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.180 -server.version=1.2.169 +client.version=1.2.181 +server.version=1.2.170 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 1d0eba7..57972ce 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -52,12 +52,12 @@ import * as settingsView from './pages/settings-view.js'; import * as developerSettingsView from './pages/developer-settings-view.js'; import * as serverSettingsView from './pages/server-settings-view.js'; import * as toolsSettingsView from './pages/tools-settings-view.js'; -import * as deviceView from './pages/device-view.js'; +import * as deviceView from './pages/device-view.js?v=202606131435'; import * as connectDeviceView from './pages/connect-device-view.js'; import * as deviceQrView from './pages/device-qr-view.js'; import * as deviceCameraView from './pages/device-camera-view.js'; import * as showKeysView from './pages/show-keys-view.js'; -import * as deviceSessionView from './pages/device-session-view.js'; +import * as deviceSessionView from './pages/device-session-view.js?v=202606131435'; import * as languageView from './pages/language-view.js'; import * as appLogView from './pages/app-log-view.js'; import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js'; diff --git a/shine-UI/js/pages/device-view.js b/shine-UI/js/pages/device-view.js index 7f5b60d..870a00f 100644 --- a/shine-UI/js/pages/device-view.js +++ b/shine-UI/js/pages/device-view.js @@ -63,11 +63,13 @@ export function render({ navigate }) { const item = document.createElement('button'); item.className = 'session-item'; item.type = 'button'; + const sessionTypeText = formatSessionType(session.sessionType); + const sessionPlatformText = session.clientPlatform ? ` · ${session.clientPlatform}` : ''; item.innerHTML = `
${session.clientInfoFromClient || 'unknown client'} - ${formatSessionType(session.sessionType)}${session.clientPlatform ? ` · ${session.clientPlatform}` : ''} + Type: ${sessionTypeText}${sessionPlatformText} ${session.geo || 'unknown'}
${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())} From 81d1b84a7db93e03d3e81395730af545e162fa63a662e8960ddbc2a16b16aab1 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sat, 13 Jun 2026 15:08:53 +0400 Subject: [PATCH 19/56] =?UTF-8?q?ESP32:=20=D0=BE=D1=82=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D1=8F=D1=82=D1=8C=20homeserver=20sessionType=20?= =?UTF-8?q?=D0=B2=20SHiNE=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...6-06-13_1450_esp32_session_type_homeserver.md | 16 ++++++++++++++++ .../shine_homeserver_main.ino | 5 +++++ .../reference/shine_homeserver_ui_spec.md | 11 +++++++++++ VERSION.properties | 4 ++-- 4 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 Dev_Docs/Pending_Features/2026-06-13_1450_esp32_session_type_homeserver.md diff --git a/Dev_Docs/Pending_Features/2026-06-13_1450_esp32_session_type_homeserver.md b/Dev_Docs/Pending_Features/2026-06-13_1450_esp32_session_type_homeserver.md new file mode 100644 index 0000000..000438f --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-13_1450_esp32_session_type_homeserver.md @@ -0,0 +1,16 @@ +# ESP32 sessionType homeserver + +- краткое описание: + - `ESP32` homeserver теперь должен отправлять в `CreateAuthSession` и `SessionLogin` поля `sessionType = 100` и `clientPlatform = "ESP32"`. + +- что проверять: + - после перепрошивки устройство должно заново подключиться к `SHiNE`; + - в списке устройств у этой сессии должен отображаться тип `Homeserver` и платформа `ESP32`; + - детальная страница сеанса должна показывать те же значения. + +- ожидаемый результат: + - сервер принимает homeserver-сеанс без ошибки `SESSION_TYPE_MISMATCH`; + - UI сервера/клиента показывает `Homeserver · ESP32`. + +- статус: + - in_progress diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino index 48791dd..8a0a921 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino @@ -81,6 +81,7 @@ static const uint8_t kBlockTypeAccessServers = 40; static const uint8_t kBlockTypeSessions = 50; static const uint8_t kBlockTypeTrustedState = 70; static const uint8_t kSessionTypeHomeserver = 100; +static const char *kSessionClientPlatformEsp32 = "ESP32"; enum Screen { SCREEN_HOME, @@ -3192,6 +3193,8 @@ static bool ensureShineSessionAuthenticated(String &errorOut) { + "\",\"sessionKey\":\"" + jsonEscape(sessionKey) + "\",\"timeMs\":" + String((unsigned long long)timeMs) + ",\"signatureB64\":\"" + jsonEscape(bytesToBase64String(signature, 64)) + + ",\"sessionType\":" + String((unsigned int)kSessionTypeHomeserver) + + ",\"clientPlatform\":\"" + jsonEscape(kSessionClientPlatformEsp32) + "\",\"clientInfo\":\"ESP32 homeserver\"}"; String loginResp; if (shineWsRequest(gShineWs, "SessionLogin", loginReq, loginResp)) { @@ -3257,6 +3260,8 @@ static bool ensureShineSessionAuthenticated(String &errorOut) { + ",\"authNonce\":\"" + jsonEscape(authNonce) + "\",\"deviceKey\":\"" + jsonEscape(bytesToBase64String(devicePub, 32)) + "\",\"signatureB64\":\"" + jsonEscape(bytesToBase64String(signature, 64)) + + ",\"sessionType\":" + String((unsigned int)kSessionTypeHomeserver) + + ",\"clientPlatform\":\"" + jsonEscape(kSessionClientPlatformEsp32) + "\",\"clientInfo\":\"ESP32 homeserver\"}"; String createResp; if (!shineWsRequest(gShineWs, "CreateAuthSession", createReq, createResp)) { diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md index 348d4d4..7a900c9 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md @@ -30,6 +30,7 @@ - PIN-блокировка (в текущей временной сборке вход по PIN отключён, устройство открывает `HOME` сразу после старта); - базовые настройки, статус и главный экран; - сохранение `PDA` и `tx signature` после успешной регистрации. +- создание и возобновление серверной сессии `SHiNE` через WebSocket с `sessionType = 100` и `clientPlatform = "ESP32"`. Что пока считается именно прототипом, а не финальной интеграцией: @@ -70,6 +71,16 @@ - флаги: `wifiReady`, `serversReady`, `secretReady`, `registered`, `online`. +## Правило серверной сессии SHiNE + +При подключении к серверу `SHiNE` устройство должно авторизовываться как homeserver-сеанс: + +- `sessionType = 100` +- `clientPlatform = "ESP32"` +- `clientInfo = "ESP32 homeserver"` + +Это относится и к `CreateAuthSession`, и к `SessionLogin`. + ## Правила готовности к регистрации Кнопка регистрации доступна только если одновременно выполнены условия: diff --git a/VERSION.properties b/VERSION.properties index f418b9b..26cb229 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.181 -server.version=1.2.170 +client.version=1.2.182 +server.version=1.2.171 From 0536a018c6404e59e2e58ffd321b3774eb11739842750d6eea42ce38404a74e1 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sat, 13 Jun 2026 15:22:19 +0400 Subject: [PATCH 20/56] =?UTF-8?q?ESP32:=20=D0=BF=D0=BE=D1=87=D0=B8=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20JSON=20auth=20=D0=B4=D0=BB=D1=8F=20homeser?= =?UTF-8?q?ver=20sessionType?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shine_homeserver_main/shine_homeserver_main.ino | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino index 8a0a921..bb1b38c 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino @@ -3193,7 +3193,7 @@ static bool ensureShineSessionAuthenticated(String &errorOut) { + "\",\"sessionKey\":\"" + jsonEscape(sessionKey) + "\",\"timeMs\":" + String((unsigned long long)timeMs) + ",\"signatureB64\":\"" + jsonEscape(bytesToBase64String(signature, 64)) - + ",\"sessionType\":" + String((unsigned int)kSessionTypeHomeserver) + + "\",\"sessionType\":" + String((unsigned int)kSessionTypeHomeserver) + ",\"clientPlatform\":\"" + jsonEscape(kSessionClientPlatformEsp32) + "\",\"clientInfo\":\"ESP32 homeserver\"}"; String loginResp; @@ -3260,7 +3260,7 @@ static bool ensureShineSessionAuthenticated(String &errorOut) { + ",\"authNonce\":\"" + jsonEscape(authNonce) + "\",\"deviceKey\":\"" + jsonEscape(bytesToBase64String(devicePub, 32)) + "\",\"signatureB64\":\"" + jsonEscape(bytesToBase64String(signature, 64)) - + ",\"sessionType\":" + String((unsigned int)kSessionTypeHomeserver) + + "\",\"sessionType\":" + String((unsigned int)kSessionTypeHomeserver) + ",\"clientPlatform\":\"" + jsonEscape(kSessionClientPlatformEsp32) + "\",\"clientInfo\":\"ESP32 homeserver\"}"; String createResp; From 96d292074b147e1ebc7646194d3a60d7e18c1474df71d633af6e01f3396e3303 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sat, 13 Jun 2026 15:49:34 +0400 Subject: [PATCH 21/56] =?UTF-8?q?API:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20online-=D1=84=D0=BB=D0=B0=D0=B3=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20ListSessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dev_Docs/API/03_Session_Management_API.md | 2 ++ ...026-06-13_1540_online_flag_в_list_sessions.md | 16 ++++++++++++++++ .../handlers/auth/Net_ListSessions_Handler.java | 2 ++ .../auth/entyties/Net_ListSessions_Response.java | 11 +++++++++++ VERSION.properties | 4 ++-- shine-UI/js/pages/device-session-view.js | 5 +++++ shine-UI/js/pages/device-view.js | 6 ++++++ 7 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 Dev_Docs/Pending_Features/2026-06-13_1540_online_flag_в_list_sessions.md diff --git a/Dev_Docs/API/03_Session_Management_API.md b/Dev_Docs/API/03_Session_Management_API.md index d67e2ae..8a11287 100644 --- a/Dev_Docs/API/03_Session_Management_API.md +++ b/Dev_Docs/API/03_Session_Management_API.md @@ -44,6 +44,7 @@ "sessionId": "sess_7c5e5c4b", "sessionType": 1, "clientPlatform": "Web", + "isOnlineOnThisServer": true, "clientInfoFromClient": "Android 15; Pixel 9", "clientInfoFromRequest": "UA=Java-http-client/17.0.18; remote=127.0.0.1", "geo": "RU/Moscow", @@ -68,6 +69,7 @@ - `50` — кошелёк; - `100` — homeserver; - `clientPlatform` — строка платформы, как её прислал клиент; +- `isOnlineOnThisServer` — `true`, если эта сессия сейчас держит живое WebSocket-подключение именно к данному серверу; - `clientInfoFromClient` — краткая строка клиента; - `clientInfoFromRequest` — строка, собранная сервером из запроса; - `geo` — страна/город или fallback-строка; diff --git a/Dev_Docs/Pending_Features/2026-06-13_1540_online_flag_в_list_sessions.md b/Dev_Docs/Pending_Features/2026-06-13_1540_online_flag_в_list_sessions.md new file mode 100644 index 0000000..bb4bc88 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-13_1540_online_flag_в_list_sessions.md @@ -0,0 +1,16 @@ +# online flag в ListSessions + +- краткое описание: + - серверный `ListSessions` теперь возвращает флаг `isOnlineOnThisServer` для каждой сессии; + - клиентский UI показывает его и в списке устройств, и на подробной странице сеанса. + +- что проверять: + - у текущего web-клиента в списке должен быть статус `Online now`; + - у активной `ESP32`-сессии должен быть статус `Online now`, пока устройство подключено; + - после ручного закрытия одной из сессий её статус должен стать `Offline`. + +- ожидаемый результат: + - online-флаг соответствует живому наличию `WebSocket`-контекста на этом сервере, а не только данным БД. + +- статус: + - in_progress diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListSessions_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListSessions_Handler.java index 6c47505..e53a24d 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListSessions_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListSessions_Handler.java @@ -2,6 +2,7 @@ package server.logic.ws_protocol.JSON.handlers.auth; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; import server.logic.ws_protocol.JSON.ConnectionContext; import server.logic.ws_protocol.JSON.entyties.Net_Request; import server.logic.ws_protocol.JSON.entyties.Net_Response; @@ -68,6 +69,7 @@ public class Net_ListSessions_Handler implements JsonMessageHandler { info.setClientInfoFromRequest(s.getClientInfoFromRequest()); info.setSessionType(s.getSessionType()); info.setClientPlatform(s.getClientPlatform()); + info.setOnlineOnThisServer(ActiveConnectionsRegistry.getInstance().getBySessionId(s.getSessionId()) != null); info.setLastAuthenticatedAtMs(s.getLastAuthirificatedAtMs()); String ip = s.getClientIp(); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListSessions_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListSessions_Response.java index 2bd3571..00ae36b 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListSessions_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListSessions_Response.java @@ -58,6 +58,9 @@ public class Net_ListSessions_Response extends Net_Response { /** Свободная строка платформы, как её прислал клиент. */ private String clientPlatform; + /** Подключена ли эта сессия прямо сейчас к данному серверу. */ + private boolean onlineOnThisServer; + /** Строка геолокации вида "Country, City" или "unknown". */ private String geo; @@ -106,6 +109,14 @@ public class Net_ListSessions_Response extends Net_Response { this.clientPlatform = clientPlatform; } + public boolean isOnlineOnThisServer() { + return onlineOnThisServer; + } + + public void setOnlineOnThisServer(boolean onlineOnThisServer) { + this.onlineOnThisServer = onlineOnThisServer; + } + public String getGeo() { return geo; } diff --git a/VERSION.properties b/VERSION.properties index 26cb229..26d0b3d 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.182 -server.version=1.2.171 +client.version=1.2.183 +server.version=1.2.172 diff --git a/shine-UI/js/pages/device-session-view.js b/shine-UI/js/pages/device-session-view.js index c8cb4df..6404560 100644 --- a/shine-UI/js/pages/device-session-view.js +++ b/shine-UI/js/pages/device-session-view.js @@ -27,6 +27,10 @@ function formatSessionTime(ms) { }); } +function formatOnlineStatus(isOnlineOnThisServer) { + return isOnlineOnThisServer ? 'Online now on this server' : 'Offline on this server'; +} + export function render({ navigate, route }) { const screen = document.createElement('section'); screen.className = 'stack'; @@ -55,6 +59,7 @@ export function render({ navigate, route }) {

sessionId

${session.sessionId}

sessionType

${formatSessionType(session.sessionType)}

clientPlatform

${session.clientPlatform || '-'}

+

onlineOnThisServer

${formatOnlineStatus(!!session.isOnlineOnThisServer)}

clientInfoFromClient

${session.clientInfoFromClient || '-'}

clientInfoFromRequest

${session.clientInfoFromRequest || '-'}

geo

${session.geo || 'unknown'}

diff --git a/shine-UI/js/pages/device-view.js b/shine-UI/js/pages/device-view.js index 870a00f..12401f0 100644 --- a/shine-UI/js/pages/device-view.js +++ b/shine-UI/js/pages/device-view.js @@ -28,6 +28,10 @@ function formatSessionTime(ms) { }); } +function formatOnlineStatus(isOnlineOnThisServer) { + return isOnlineOnThisServer ? 'Online now' : 'Offline'; +} + export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack'; @@ -65,11 +69,13 @@ export function render({ navigate }) { item.type = 'button'; const sessionTypeText = formatSessionType(session.sessionType); const sessionPlatformText = session.clientPlatform ? ` · ${session.clientPlatform}` : ''; + const onlineStatusText = formatOnlineStatus(!!session.isOnlineOnThisServer); item.innerHTML = `
${session.clientInfoFromClient || 'unknown client'} Type: ${sessionTypeText}${sessionPlatformText} + Status: ${onlineStatusText} ${session.geo || 'unknown'}
${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())} From ca4cfd9d8d55d54686fa7d381a71fb129eab15211d63c0604b0817fa049dadfc Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sat, 13 Jun 2026 15:50:47 +0400 Subject: [PATCH 22/56] =?UTF-8?q?UI:=20=D0=B2=D1=8B=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=BD=D1=8F=D1=82=D1=8C=20online-=D1=84=D0=BB=D0=B0=D0=B3=20?= =?UTF-8?q?=D1=81=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=BE=D0=BC=20ListSessi?= =?UTF-8?q?ons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dev_Docs/API/03_Session_Management_API.md | 4 ++-- VERSION.properties | 4 ++-- shine-UI/js/pages/device-session-view.js | 6 +++--- shine-UI/js/pages/device-view.js | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Dev_Docs/API/03_Session_Management_API.md b/Dev_Docs/API/03_Session_Management_API.md index 8a11287..4a53969 100644 --- a/Dev_Docs/API/03_Session_Management_API.md +++ b/Dev_Docs/API/03_Session_Management_API.md @@ -44,7 +44,7 @@ "sessionId": "sess_7c5e5c4b", "sessionType": 1, "clientPlatform": "Web", - "isOnlineOnThisServer": true, + "onlineOnThisServer": true, "clientInfoFromClient": "Android 15; Pixel 9", "clientInfoFromRequest": "UA=Java-http-client/17.0.18; remote=127.0.0.1", "geo": "RU/Moscow", @@ -69,7 +69,7 @@ - `50` — кошелёк; - `100` — homeserver; - `clientPlatform` — строка платформы, как её прислал клиент; -- `isOnlineOnThisServer` — `true`, если эта сессия сейчас держит живое WebSocket-подключение именно к данному серверу; +- `onlineOnThisServer` — `true`, если эта сессия сейчас держит живое WebSocket-подключение именно к данному серверу; - `clientInfoFromClient` — краткая строка клиента; - `clientInfoFromRequest` — строка, собранная сервером из запроса; - `geo` — страна/город или fallback-строка; diff --git a/VERSION.properties b/VERSION.properties index 26d0b3d..f62ce72 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.183 -server.version=1.2.172 +client.version=1.2.184 +server.version=1.2.173 diff --git a/shine-UI/js/pages/device-session-view.js b/shine-UI/js/pages/device-session-view.js index 6404560..b7787d6 100644 --- a/shine-UI/js/pages/device-session-view.js +++ b/shine-UI/js/pages/device-session-view.js @@ -27,8 +27,8 @@ function formatSessionTime(ms) { }); } -function formatOnlineStatus(isOnlineOnThisServer) { - return isOnlineOnThisServer ? 'Online now on this server' : 'Offline on this server'; +function formatOnlineStatus(onlineOnThisServer) { + return onlineOnThisServer ? 'Online now on this server' : 'Offline on this server'; } export function render({ navigate, route }) { @@ -59,7 +59,7 @@ export function render({ navigate, route }) {

sessionId

${session.sessionId}

sessionType

${formatSessionType(session.sessionType)}

clientPlatform

${session.clientPlatform || '-'}

-

onlineOnThisServer

${formatOnlineStatus(!!session.isOnlineOnThisServer)}

+

onlineOnThisServer

${formatOnlineStatus(!!session.onlineOnThisServer)}

clientInfoFromClient

${session.clientInfoFromClient || '-'}

clientInfoFromRequest

${session.clientInfoFromRequest || '-'}

geo

${session.geo || 'unknown'}

diff --git a/shine-UI/js/pages/device-view.js b/shine-UI/js/pages/device-view.js index 12401f0..381caaf 100644 --- a/shine-UI/js/pages/device-view.js +++ b/shine-UI/js/pages/device-view.js @@ -28,8 +28,8 @@ function formatSessionTime(ms) { }); } -function formatOnlineStatus(isOnlineOnThisServer) { - return isOnlineOnThisServer ? 'Online now' : 'Offline'; +function formatOnlineStatus(onlineOnThisServer) { + return onlineOnThisServer ? 'Online now' : 'Offline'; } export function render({ navigate }) { @@ -69,7 +69,7 @@ export function render({ navigate }) { item.type = 'button'; const sessionTypeText = formatSessionType(session.sessionType); const sessionPlatformText = session.clientPlatform ? ` · ${session.clientPlatform}` : ''; - const onlineStatusText = formatOnlineStatus(!!session.isOnlineOnThisServer); + const onlineStatusText = formatOnlineStatus(!!session.onlineOnThisServer); item.innerHTML = `
From be4a2d135a5c8c804bd57cea40e88cdce29217fa7ffeb07a15bab2f21457fbbe Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sat, 13 Jun 2026 23:01:57 +0400 Subject: [PATCH 23/56] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D0=B5=D0=BA=D1=82:=20?= =?UTF-8?q?=D0=B2=D0=BA=D0=BB=D1=8E=D1=87=D0=B8=D1=82=D1=8C=20ESP32-wallet?= =?UTF-8?q?=20=D0=B2=20=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=BD=D0=BE=D0=B9=20?= =?UTF-8?q?=D1=80=D0=B5=D0=BF=D0=BE=D0=B7=D0=B8=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + ESP32-wallet/.gitignore | 43 ++++ ESP32-wallet/build.gradle | 20 ++ .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 60756 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 + ESP32-wallet/gradlew | 234 ++++++++++++++++++ ESP32-wallet/gradlew.bat | 89 +++++++ ESP32-wallet/settings.gradle | 1 + 8 files changed, 394 insertions(+) create mode 100644 ESP32-wallet/.gitignore create mode 100644 ESP32-wallet/build.gradle create mode 100644 ESP32-wallet/gradle/wrapper/gradle-wrapper.jar create mode 100644 ESP32-wallet/gradle/wrapper/gradle-wrapper.properties create mode 100755 ESP32-wallet/gradlew create mode 100644 ESP32-wallet/gradlew.bat create mode 100644 ESP32-wallet/settings.gradle diff --git a/.gitignore b/.gitignore index 914dba7..09b09a4 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,7 @@ shine-solana/shine/scripts/**/TEMP_*.md # Локальные артефакты и внешние материалы ESP32-подпроекта ESP32/**/.git/ ESP32/**/.idea/ +ESP32-wallet/.idea/ ESP32/**/.arduino-build/ ESP32/**/official-demo/ ESP32/**/original-firmware/*.bin diff --git a/ESP32-wallet/.gitignore b/ESP32-wallet/.gitignore new file mode 100644 index 0000000..39eea9c --- /dev/null +++ b/ESP32-wallet/.gitignore @@ -0,0 +1,43 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ +.kotlin + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/ESP32-wallet/build.gradle b/ESP32-wallet/build.gradle new file mode 100644 index 0000000..0b08b8d --- /dev/null +++ b/ESP32-wallet/build.gradle @@ -0,0 +1,20 @@ +plugins { + id 'java' +} + +group = 'org.example' +version = '1.0-SNAPSHOT' + +repositories { + mavenCentral() +} + +dependencies { + testImplementation platform('org.junit:junit-bom:6.0.0') + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/ESP32-wallet/gradle/wrapper/gradle-wrapper.jar b/ESP32-wallet/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..e06f1edcaa3f16f442e6eb7798fa8434a8f1c19fd263a5d1cf63704be7231a2f GIT binary patch literal 60756 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/ESP32-wallet/gradlew.bat b/ESP32-wallet/gradlew.bat new file mode 100644 index 0000000..9a51268 --- /dev/null +++ b/ESP32-wallet/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/ESP32-wallet/settings.gradle b/ESP32-wallet/settings.gradle new file mode 100644 index 0000000..93873f1 --- /dev/null +++ b/ESP32-wallet/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'ESP-wallet' \ No newline at end of file From 4b15cabd4fc125cba59901d3bdb93b1f0993ea69c641c3d0254e26f282222b04 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sat, 13 Jun 2026 23:20:35 +0400 Subject: [PATCH 24/56] =?UTF-8?q?ESP32:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=B1=D1=8B=D1=81=D1=82=D1=80=D1=8B=D0=B9?= =?UTF-8?q?=20QR-=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20=D0=BA=D0=BE=D1=88=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-13_2258_esp32_wallet_qr_кнопка.md | 20 ++ .../shine_homeserver_main.ino | 220 +++++++++++++++++- .../reference/shine_homeserver_ui_spec.md | 20 +- VERSION.properties | 4 +- 4 files changed, 253 insertions(+), 11 deletions(-) create mode 100644 Dev_Docs/Pending_Features/2026-06-13_2258_esp32_wallet_qr_кнопка.md diff --git a/Dev_Docs/Pending_Features/2026-06-13_2258_esp32_wallet_qr_кнопка.md b/Dev_Docs/Pending_Features/2026-06-13_2258_esp32_wallet_qr_кнопка.md new file mode 100644 index 0000000..20ee2d4 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-13_2258_esp32_wallet_qr_кнопка.md @@ -0,0 +1,20 @@ +# ESP32 wallet QR button + +- краткое описание: + - на `HOME` кнопка баланса стала уже; + - справа появилась отдельная кнопка с иконкой `QR`; + - по нажатию открывается экран с QR-кодом `solana:` и подписью адреса; + - тап по любому месту экрана возвращает на главный экран. + +- что именно проверять: + - на `HOME` кнопка баланса визуально уже прежней; + - справа от неё есть отдельная кнопка с QR-иконкой; + - QR-экран открывается по нажатию; + - QR сканируется кошельком как `solana:` URI; + - тап по экрану QR возвращает на `HOME`. + +- ожидаемый результат: + - пользователь может быстро показать адрес кошелька для пополнения без захода в дополнительные экраны. + +- статус: + - pending diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino index bb1b38c..de0841c 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino @@ -17,6 +17,10 @@ #include "shine_secret_generation.h" #include +extern "C" { +#include "../../official-demo/examples/Arduino-v3.3.5/libraries/lvgl/src/extra/libs/qrcode/qrcodegen.h" +} + #define PIN_LCD_CS 12 #define PIN_LCD_SCLK 38 #define PIN_LCD_D0 4 @@ -85,6 +89,7 @@ static const char *kSessionClientPlatformEsp32 = "ESP32"; enum Screen { SCREEN_HOME, + SCREEN_WALLET_QR, SCREEN_SETTINGS_MENU, SCREEN_WIFI, SCREEN_SERVER, @@ -113,6 +118,7 @@ enum SwipeDirection { enum ActionId { ACTION_NONE, ACTION_OPEN_SETTINGS, + ACTION_OPEN_WALLET_QR, ACTION_OPEN_WIFI, ACTION_OPEN_SERVER, ACTION_OPEN_ACCOUNT, @@ -226,6 +232,7 @@ static lv_color_t *gBuf1 = nullptr; static lv_color_t *gBuf2 = nullptr; static lv_obj_t *gRoot = nullptr; static lv_obj_t *gInputTextArea = nullptr; +static uint8_t *gWalletQrCanvasBuffer = nullptr; Arduino_DataBus *gBus = new Arduino_ESP32QSPI( PIN_LCD_CS, PIN_LCD_SCLK, PIN_LCD_D0, PIN_LCD_D1, PIN_LCD_D2, PIN_LCD_D3); @@ -385,6 +392,7 @@ static void initPowerManagement(); static int batteryPercentValue(); static int wifiSignalLevel(); static void drawTopStatusIndicators(); +static void actionButtonCb(lv_event_t *event); static void markAccountStateDirty(); static void clearShineSessionState(bool clearStoredSession); static void saveShineSessionPrefs(); @@ -399,6 +407,9 @@ static bool parseUrlHostPortPath(const String &url, String &hostOut, uint16_t &p static String shineWsUrl(); static String shineHomeLine(); static String balanceHomeLine(); +static String walletQrUri(); +static String walletQrAddressLine(); +static void releaseTransientUiBuffers(); static uint64_t shineNowMs(); static bool loadWalletBalanceLamports(uint64_t &lamportsOut, String &messageOut); static void shortVecEncode(size_t value, std::vector &out); @@ -1127,6 +1138,17 @@ static String balanceHomeLine() { return gBalanceStatusMessage; } +static String walletQrUri() { + if (gDevicePubB58.isEmpty()) { + return ""; + } + return String("solana:") + gDevicePubB58; +} + +static String walletQrAddressLine() { + return gDevicePubB58.isEmpty() ? String("Wallet not set") : gDevicePubB58; +} + static String buildSessionKeyStringFromPublicBase64(const String &pubB64) { return String("ed25519/") + pubB64; } @@ -4142,6 +4164,9 @@ static void actionButtonCb(lv_event_t *event) { case ACTION_OPEN_SETTINGS: showScreen(SCREEN_SETTINGS_MENU); break; + case ACTION_OPEN_WALLET_QR: + showScreen(SCREEN_WALLET_QR); + break; case ACTION_REGISTER_ACCOUNT: prepareRegisterAccountScreen(); showScreen(SCREEN_REGISTER_ACCOUNT_CONFIRM); @@ -4376,6 +4401,149 @@ static void makeVersionTag() { lv_obj_align(tag, LV_ALIGN_BOTTOM_MID, 0, -10); } +static void releaseTransientUiBuffers() { + if (gWalletQrCanvasBuffer) { + heap_caps_free(gWalletQrCanvasBuffer); + gWalletQrCanvasBuffer = nullptr; + } +} + +static lv_obj_t *makeQrIconButton(lv_coord_t x, + lv_coord_t y, + lv_coord_t w, + lv_coord_t h, + uint32_t bgColor, + ActionId action) { + lv_obj_t *btn = lv_btn_create(gRoot); + lv_obj_set_size(btn, w, h); + lv_obj_set_pos(btn, x, y); + lv_obj_set_style_radius(btn, 18, 0); + lv_obj_set_style_bg_color(btn, lv_color_hex(bgColor), 0); + lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(btn, 2, 0); + lv_obj_set_style_border_color(btn, lv_color_hex(0x6E8AA3), 0); + lv_obj_set_style_shadow_width(btn, 0, 0); + lv_obj_add_event_cb(btn, actionButtonCb, LV_EVENT_CLICKED, reinterpret_cast(static_cast(action))); + + auto makeSquare = [&](lv_coord_t sx, lv_coord_t sy, lv_coord_t size, bool filled) { + lv_obj_t *box = lv_obj_create(btn); + lv_obj_set_size(box, size, size); + lv_obj_set_pos(box, sx, sy); + lv_obj_set_style_radius(box, 2, 0); + lv_obj_set_style_border_width(box, 2, 0); + lv_obj_set_style_border_color(box, lv_color_hex(0xFFFFFF), 0); + lv_obj_set_style_bg_color(box, lv_color_hex(filled ? 0xFFFFFF : bgColor), 0); + lv_obj_set_style_bg_opa(box, filled ? LV_OPA_COVER : LV_OPA_TRANSP, 0); + lv_obj_set_style_shadow_width(box, 0, 0); + lv_obj_clear_flag(box, LV_OBJ_FLAG_SCROLLABLE); + }; + + makeSquare(16, 10, 18, false); + makeSquare(20, 14, 10, true); + makeSquare(48, 10, 18, false); + makeSquare(52, 14, 10, true); + makeSquare(16, 30, 18, false); + makeSquare(20, 34, 10, true); + + const lv_coord_t patternX[] = {40, 44, 48, 52, 40, 48, 56, 56, 48}; + const lv_coord_t patternY[] = {34, 34, 34, 34, 42, 42, 42, 50, 50}; + for (size_t i = 0; i < sizeof(patternX) / sizeof(patternX[0]); ++i) { + lv_obj_t *dot = lv_obj_create(btn); + lv_obj_set_size(dot, 4, 4); + lv_obj_set_pos(dot, patternX[i], patternY[i]); + lv_obj_set_style_radius(dot, 1, 0); + lv_obj_set_style_border_width(dot, 0, 0); + lv_obj_set_style_bg_color(dot, lv_color_hex(0xFFFFFF), 0); + lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); + lv_obj_set_style_shadow_width(dot, 0, 0); + lv_obj_clear_flag(dot, LV_OBJ_FLAG_SCROLLABLE); + } + return btn; +} + +static void drawQrCanvasOnParent(lv_obj_t *parent, + lv_coord_t x, + lv_coord_t y, + lv_coord_t size, + const String &payload) { + lv_obj_t *panel = lv_obj_create(parent); + lv_obj_set_size(panel, size + 20, size + 20); + lv_obj_set_pos(panel, x - 10, y - 10); + lv_obj_set_style_radius(panel, 18, 0); + lv_obj_set_style_bg_color(panel, lv_color_hex(0xFFFFFF), 0); + lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(panel, 0, 0); + lv_obj_set_style_shadow_width(panel, 0, 0); + lv_obj_clear_flag(panel, LV_OBJ_FLAG_SCROLLABLE); + + if (payload.isEmpty()) { + lv_obj_t *error = lv_label_create(parent); + lv_label_set_text(error, "Wallet not set"); + lv_obj_set_width(error, size); + lv_obj_set_style_text_align(error, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_font(error, &lv_font_montserrat_18, 0); + lv_obj_set_style_text_color(error, lv_color_hex(0xD26969), 0); + lv_obj_set_pos(error, x, y + size / 2 - 12); + return; + } + + uint8_t qr[qrcodegen_BUFFER_LEN_MAX]; + uint8_t tmp[qrcodegen_BUFFER_LEN_MAX]; + memset(qr, 0, sizeof(qr)); + memset(tmp, 0, sizeof(tmp)); + bool ok = qrcodegen_encodeText(payload.c_str(), tmp, qr, qrcodegen_Ecc_MEDIUM, 1, 10, qrcodegen_Mask_AUTO, true); + if (!ok) { + lv_obj_t *error = lv_label_create(parent); + lv_label_set_text(error, "QR error"); + lv_obj_set_width(error, size); + lv_obj_set_style_text_align(error, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_font(error, &lv_font_montserrat_18, 0); + lv_obj_set_style_text_color(error, lv_color_hex(0xD26969), 0); + lv_obj_set_pos(error, x, y + size / 2 - 12); + return; + } + + gWalletQrCanvasBuffer = static_cast( + heap_caps_malloc(LV_CANVAS_BUF_SIZE_TRUE_COLOR(size, size), MALLOC_CAP_8BIT)); + if (!gWalletQrCanvasBuffer) { + lv_obj_t *error = lv_label_create(parent); + lv_label_set_text(error, "QR memory error"); + lv_obj_set_width(error, size); + lv_obj_set_style_text_align(error, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_font(error, &lv_font_montserrat_18, 0); + lv_obj_set_style_text_color(error, lv_color_hex(0xD26969), 0); + lv_obj_set_pos(error, x, y + size / 2 - 12); + return; + } + + lv_obj_t *canvas = lv_canvas_create(parent); + lv_obj_set_pos(canvas, x, y); + lv_canvas_set_buffer(canvas, gWalletQrCanvasBuffer, size, size, LV_IMG_CF_TRUE_COLOR); + lv_canvas_fill_bg(canvas, lv_color_hex(0xFFFFFF), LV_OPA_COVER); + + int qrSize = qrcodegen_getSize(qr); + int scale = (size - 16) / qrSize; + if (scale < 1) { + scale = 1; + } + int margin = (size - qrSize * scale) / 2; + for (int yy = 0; yy < qrSize; ++yy) { + for (int xx = 0; xx < qrSize; ++xx) { + if (!qrcodegen_getModule(qr, xx, yy)) { + continue; + } + for (int py = 0; py < scale; ++py) { + for (int px = 0; px < scale; ++px) { + lv_canvas_set_px(canvas, + margin + xx * scale + px, + margin + yy * scale + py, + lv_color_black()); + } + } + } + } +} + static void drawHome() { setRootStyle(); @@ -4437,7 +4605,8 @@ static void drawHome() { drawTopStatusIndicators(); makeTitle("STATUS", 138, &lv_font_montserrat_28); showMessageAt(wifiHomeSummary(), 214); - makeButton(balanceHomeLine().c_str(), 22, 254, 436, 56, 0x355C7D, ACTION_REFRESH_BALANCE, &lv_font_montserrat_18); + makeButton(balanceHomeLine().c_str(), 22, 254, 340, 56, 0x355C7D, ACTION_REFRESH_BALANCE, &lv_font_montserrat_18); + makeQrIconButton(374, 254, 84, 56, 0x2A6F97, ACTION_OPEN_WALLET_QR); showMessageAt(shineHomeLine(), 322); if (gShowRegisterAccountButton) { makeButton("REGISTER ACCOUNT", 20, 360, 210, 78, 0x2A9D8F, ACTION_REGISTER_ACCOUNT, &lv_font_montserrat_20); @@ -4451,6 +4620,48 @@ static void drawHome() { makeVersionTag(); } +static void drawWalletQrScreen() { + setRootStyle(); + + lv_obj_t *tapSurface = lv_obj_create(gRoot); + lv_obj_set_size(tapSurface, DISP_W, DISP_H); + lv_obj_set_pos(tapSurface, 0, 0); + lv_obj_set_style_radius(tapSurface, 0, 0); + lv_obj_set_style_bg_opa(tapSurface, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(tapSurface, 0, 0); + lv_obj_set_style_shadow_width(tapSurface, 0, 0); + lv_obj_clear_flag(tapSurface, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(tapSurface, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(tapSurface, actionButtonCb, LV_EVENT_CLICKED, reinterpret_cast(static_cast(ACTION_BACK_HOME))); + + lv_obj_t *title = lv_label_create(tapSurface); + lv_label_set_text(title, "WALLET QR"); + lv_obj_set_style_text_font(title, &lv_font_montserrat_24, 0); + lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), 0); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 22); + + drawQrCanvasOnParent(tapSurface, 120, 76, 240, walletQrUri()); + + lv_obj_t *address = lv_label_create(tapSurface); + lv_label_set_text(address, walletQrAddressLine().c_str()); + lv_obj_set_width(address, 420); + lv_label_set_long_mode(address, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_align(address, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_font(address, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(address, lv_color_hex(0xB8C6D3), 0); + lv_obj_set_pos(address, 30, 344); + + lv_obj_t *hint = lv_label_create(tapSurface); + lv_label_set_text(hint, "Tap anywhere to return"); + lv_obj_set_width(hint, 420); + lv_obj_set_style_text_align(hint, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(hint, lv_color_hex(0x8398AD), 0); + lv_obj_set_pos(hint, 30, 410); + + makeVersionTag(); +} + static void drawSettingsMenu() { setRootStyle(); @@ -4915,12 +5126,16 @@ static void rebuildScreen() { gRoot = lv_scr_act(); } + releaseTransientUiBuffers(); lv_obj_clean(gRoot); switch (gCurrentScreen) { case SCREEN_HOME: drawHome(); break; + case SCREEN_WALLET_QR: + drawWalletQrScreen(); + break; case SCREEN_SETTINGS_MENU: drawSettingsMenu(); break; @@ -5065,6 +5280,9 @@ static void handleSwipe(SwipeDirection swipe) { case SCREEN_HOME: handleHomeSwipe(swipe); break; + case SCREEN_WALLET_QR: + handleHomeSwipe(swipe); + break; case SCREEN_SETTINGS_MENU: handleSettingsSwipe(swipe); break; diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md index 7a900c9..d92128f 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md @@ -161,6 +161,12 @@ - короткий статус сервера; - короткий статус баланса. +В зоне баланса: + +- основная кнопка показа/обновления баланса занимает примерно 80% строки; +- справа от неё стоит отдельная кнопка с иконкой `QR`; +- нажатие на кнопку `QR` открывает экран `WALLET_QR`. + Нижние кнопки: - `Статус` @@ -417,16 +423,14 @@ Показывает: - QR-код для строки вида: - `solana:?amount=0.20&label=SHiNE%20Register`; -- адрес кошелька; -- сумму; -- текст URI. + `solana:`; +- мелкую подпись с полным адресом кошелька под QR. -Кнопки: +Поведение: -- `Назад` - -QR должен быть сканируемым, а не декоративным. +- QR должен быть сканируемым, а не декоративным; +- адрес кошелька берётся из `device key`, вычисленного из сохранённого `master secret`; +- нажатие в любую точку экрана возвращает пользователя на `HOME`. ## Экран REQUESTS diff --git a/VERSION.properties b/VERSION.properties index f62ce72..9d79143 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.184 -server.version=1.2.173 +client.version=1.2.185 +server.version=1.2.174 From 0ebb71daf140fb72dcc602ae33b82748e4c74bde2d4bd20a8dbfacc2477dab62 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sun, 14 Jun 2026 10:27:10 +0400 Subject: [PATCH 25/56] =?UTF-8?q?ESP32:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20wallet=20QR=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20LVGL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 9 + .../2026-06-14_1450_esp32_wallet_qr_lvgl.md | 26 ++ .../shine_homeserver_main.ino | 235 ++++++------------ .../reference/shine_homeserver_ui_spec.md | 4 +- VERSION.properties | 4 +- 5 files changed, 114 insertions(+), 164 deletions(-) create mode 100644 Dev_Docs/Pending_Features/2026-06-14_1450_esp32_wallet_qr_lvgl.md diff --git a/.gitignore b/.gitignore index 09b09a4..5a1214c 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,15 @@ ESP32/**/.idea/ ESP32-wallet/.idea/ ESP32/**/.arduino-build/ ESP32/**/official-demo/ +!ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/ +ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/** +!ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/examples/ +ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/examples/** +!ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/examples/Arduino-v3.3.5/ +ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/examples/Arduino-v3.3.5/** +!ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/examples/Arduino-v3.3.5/libraries/ +ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/examples/Arduino-v3.3.5/libraries/** +!ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/examples/Arduino-v3.3.5/libraries/lv_conf.h ESP32/**/original-firmware/*.bin ESP32/**/original-firmware/*.bin.sha256 ESP32/**/*.elf diff --git a/Dev_Docs/Pending_Features/2026-06-14_1450_esp32_wallet_qr_lvgl.md b/Dev_Docs/Pending_Features/2026-06-14_1450_esp32_wallet_qr_lvgl.md new file mode 100644 index 0000000..63d70bc --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-14_1450_esp32_wallet_qr_lvgl.md @@ -0,0 +1,26 @@ +# ESP32 wallet QR через LVGL + +- Статус: `pending` + +## Что сделано + +- для `WALLET_QR` включён штатный `LVGL`-виджет `lv_qrcode`; +- кнопка на главном экране оставлена текстовой `QR`; +- экран должен показывать реальный `QR` для `solana:`; +- тап по экрану должен возвращать на `HOME`, без перезагрузки устройства и без потери `Wi-Fi`/`SHiNE`. + +## Что проверять + +1. На главном экране нажать кнопку `QR`. +2. Убедиться, что открывается экран `WALLET QR`. +3. Проверить, что виден настоящий QR-код. +4. Проверить, что внизу мелким текстом показан адрес кошелька. +5. Нажать в любое место экрана и убедиться, что устройство возвращается на `HOME`. +6. Убедиться, что после открытия и закрытия QR-экрана не рвутся `Wi-Fi` и подключение к `SHiNE`. + +## Ожидаемый результат + +- QR-экран открывается стабильно; +- QR-код читается приложением кошелька; +- возврат на главный экран работает обычным тапом; +- устройство не перезагружается, сетевые подключения не теряются. diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino index de0841c..a04ac43 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino @@ -18,7 +18,7 @@ #include extern "C" { -#include "../../official-demo/examples/Arduino-v3.3.5/libraries/lvgl/src/extra/libs/qrcode/qrcodegen.h" +#include "../../official-demo/examples/Arduino-v3.3.5/libraries/lvgl/src/extra/libs/qrcode/lv_qrcode.h" } #define PIN_LCD_CS 12 @@ -254,6 +254,7 @@ static bool gBlockClick = false; static bool gSuppressTouchUntilRelease = false; static uint32_t gTouchSequence = 0; static uint32_t gLastHandledTouchSequence = 0; +static bool gWalletQrTapReturnPending = false; static String gWifiSavedSsid; static String gWifiSavedPassword; @@ -409,7 +410,6 @@ static String shineHomeLine(); static String balanceHomeLine(); static String walletQrUri(); static String walletQrAddressLine(); -static void releaseTransientUiBuffers(); static uint64_t shineNowMs(); static bool loadWalletBalanceLamports(uint64_t &lamportsOut, String &messageOut); static void shortVecEncode(size_t value, std::vector &out); @@ -588,6 +588,11 @@ static void lvglTouchRead(lv_indev_drv_t *indevDrv, lv_indev_data_t *data) { if (gTouchDown) { gTouchDown = false; gPendingSwipe = detectSwipe(gTouchStartX, gTouchStartY, gTouchLastX, gTouchLastY); + if (gCurrentScreen == SCREEN_WALLET_QR + && gPendingSwipe == SWIPE_NONE + && !movedTooFarForTap(gTouchStartX, gTouchStartY, gTouchLastX, gTouchLastY)) { + gWalletQrTapReturnPending = true; + } } gSuppressTouchUntilRelease = false; @@ -1149,6 +1154,17 @@ static String walletQrAddressLine() { return gDevicePubB58.isEmpty() ? String("Wallet not set") : gDevicePubB58; } +static void releaseTransientUiBuffers() { + if (gWalletQrCanvasBuffer) { + heap_caps_free(gWalletQrCanvasBuffer); + gWalletQrCanvasBuffer = nullptr; + } +} + +static void saveUiErrorDiag(const String &summary, const String &details) { + saveRegisterDiag("error", summary, String("kind=ui_runtime\nscreen=wallet_qr\n") + details); +} + static String buildSessionKeyStringFromPublicBase64(const String &pubB64) { return String("ed25519/") + pubB64; } @@ -4401,148 +4417,6 @@ static void makeVersionTag() { lv_obj_align(tag, LV_ALIGN_BOTTOM_MID, 0, -10); } -static void releaseTransientUiBuffers() { - if (gWalletQrCanvasBuffer) { - heap_caps_free(gWalletQrCanvasBuffer); - gWalletQrCanvasBuffer = nullptr; - } -} - -static lv_obj_t *makeQrIconButton(lv_coord_t x, - lv_coord_t y, - lv_coord_t w, - lv_coord_t h, - uint32_t bgColor, - ActionId action) { - lv_obj_t *btn = lv_btn_create(gRoot); - lv_obj_set_size(btn, w, h); - lv_obj_set_pos(btn, x, y); - lv_obj_set_style_radius(btn, 18, 0); - lv_obj_set_style_bg_color(btn, lv_color_hex(bgColor), 0); - lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(btn, 2, 0); - lv_obj_set_style_border_color(btn, lv_color_hex(0x6E8AA3), 0); - lv_obj_set_style_shadow_width(btn, 0, 0); - lv_obj_add_event_cb(btn, actionButtonCb, LV_EVENT_CLICKED, reinterpret_cast(static_cast(action))); - - auto makeSquare = [&](lv_coord_t sx, lv_coord_t sy, lv_coord_t size, bool filled) { - lv_obj_t *box = lv_obj_create(btn); - lv_obj_set_size(box, size, size); - lv_obj_set_pos(box, sx, sy); - lv_obj_set_style_radius(box, 2, 0); - lv_obj_set_style_border_width(box, 2, 0); - lv_obj_set_style_border_color(box, lv_color_hex(0xFFFFFF), 0); - lv_obj_set_style_bg_color(box, lv_color_hex(filled ? 0xFFFFFF : bgColor), 0); - lv_obj_set_style_bg_opa(box, filled ? LV_OPA_COVER : LV_OPA_TRANSP, 0); - lv_obj_set_style_shadow_width(box, 0, 0); - lv_obj_clear_flag(box, LV_OBJ_FLAG_SCROLLABLE); - }; - - makeSquare(16, 10, 18, false); - makeSquare(20, 14, 10, true); - makeSquare(48, 10, 18, false); - makeSquare(52, 14, 10, true); - makeSquare(16, 30, 18, false); - makeSquare(20, 34, 10, true); - - const lv_coord_t patternX[] = {40, 44, 48, 52, 40, 48, 56, 56, 48}; - const lv_coord_t patternY[] = {34, 34, 34, 34, 42, 42, 42, 50, 50}; - for (size_t i = 0; i < sizeof(patternX) / sizeof(patternX[0]); ++i) { - lv_obj_t *dot = lv_obj_create(btn); - lv_obj_set_size(dot, 4, 4); - lv_obj_set_pos(dot, patternX[i], patternY[i]); - lv_obj_set_style_radius(dot, 1, 0); - lv_obj_set_style_border_width(dot, 0, 0); - lv_obj_set_style_bg_color(dot, lv_color_hex(0xFFFFFF), 0); - lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); - lv_obj_set_style_shadow_width(dot, 0, 0); - lv_obj_clear_flag(dot, LV_OBJ_FLAG_SCROLLABLE); - } - return btn; -} - -static void drawQrCanvasOnParent(lv_obj_t *parent, - lv_coord_t x, - lv_coord_t y, - lv_coord_t size, - const String &payload) { - lv_obj_t *panel = lv_obj_create(parent); - lv_obj_set_size(panel, size + 20, size + 20); - lv_obj_set_pos(panel, x - 10, y - 10); - lv_obj_set_style_radius(panel, 18, 0); - lv_obj_set_style_bg_color(panel, lv_color_hex(0xFFFFFF), 0); - lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(panel, 0, 0); - lv_obj_set_style_shadow_width(panel, 0, 0); - lv_obj_clear_flag(panel, LV_OBJ_FLAG_SCROLLABLE); - - if (payload.isEmpty()) { - lv_obj_t *error = lv_label_create(parent); - lv_label_set_text(error, "Wallet not set"); - lv_obj_set_width(error, size); - lv_obj_set_style_text_align(error, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_set_style_text_font(error, &lv_font_montserrat_18, 0); - lv_obj_set_style_text_color(error, lv_color_hex(0xD26969), 0); - lv_obj_set_pos(error, x, y + size / 2 - 12); - return; - } - - uint8_t qr[qrcodegen_BUFFER_LEN_MAX]; - uint8_t tmp[qrcodegen_BUFFER_LEN_MAX]; - memset(qr, 0, sizeof(qr)); - memset(tmp, 0, sizeof(tmp)); - bool ok = qrcodegen_encodeText(payload.c_str(), tmp, qr, qrcodegen_Ecc_MEDIUM, 1, 10, qrcodegen_Mask_AUTO, true); - if (!ok) { - lv_obj_t *error = lv_label_create(parent); - lv_label_set_text(error, "QR error"); - lv_obj_set_width(error, size); - lv_obj_set_style_text_align(error, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_set_style_text_font(error, &lv_font_montserrat_18, 0); - lv_obj_set_style_text_color(error, lv_color_hex(0xD26969), 0); - lv_obj_set_pos(error, x, y + size / 2 - 12); - return; - } - - gWalletQrCanvasBuffer = static_cast( - heap_caps_malloc(LV_CANVAS_BUF_SIZE_TRUE_COLOR(size, size), MALLOC_CAP_8BIT)); - if (!gWalletQrCanvasBuffer) { - lv_obj_t *error = lv_label_create(parent); - lv_label_set_text(error, "QR memory error"); - lv_obj_set_width(error, size); - lv_obj_set_style_text_align(error, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_set_style_text_font(error, &lv_font_montserrat_18, 0); - lv_obj_set_style_text_color(error, lv_color_hex(0xD26969), 0); - lv_obj_set_pos(error, x, y + size / 2 - 12); - return; - } - - lv_obj_t *canvas = lv_canvas_create(parent); - lv_obj_set_pos(canvas, x, y); - lv_canvas_set_buffer(canvas, gWalletQrCanvasBuffer, size, size, LV_IMG_CF_TRUE_COLOR); - lv_canvas_fill_bg(canvas, lv_color_hex(0xFFFFFF), LV_OPA_COVER); - - int qrSize = qrcodegen_getSize(qr); - int scale = (size - 16) / qrSize; - if (scale < 1) { - scale = 1; - } - int margin = (size - qrSize * scale) / 2; - for (int yy = 0; yy < qrSize; ++yy) { - for (int xx = 0; xx < qrSize; ++xx) { - if (!qrcodegen_getModule(qr, xx, yy)) { - continue; - } - for (int py = 0; py < scale; ++py) { - for (int px = 0; px < scale; ++px) { - lv_canvas_set_px(canvas, - margin + xx * scale + px, - margin + yy * scale + py, - lv_color_black()); - } - } - } - } -} static void drawHome() { setRootStyle(); @@ -4606,7 +4480,7 @@ static void drawHome() { makeTitle("STATUS", 138, &lv_font_montserrat_28); showMessageAt(wifiHomeSummary(), 214); makeButton(balanceHomeLine().c_str(), 22, 254, 340, 56, 0x355C7D, ACTION_REFRESH_BALANCE, &lv_font_montserrat_18); - makeQrIconButton(374, 254, 84, 56, 0x2A6F97, ACTION_OPEN_WALLET_QR); + makeButton("QR", 374, 254, 84, 56, 0x2A6F97, ACTION_OPEN_WALLET_QR, &lv_font_montserrat_20); showMessageAt(shineHomeLine(), 322); if (gShowRegisterAccountButton) { makeButton("REGISTER ACCOUNT", 20, 360, 210, 78, 0x2A9D8F, ACTION_REGISTER_ACCOUNT, &lv_font_montserrat_20); @@ -4622,27 +4496,64 @@ static void drawHome() { static void drawWalletQrScreen() { setRootStyle(); + gWalletQrTapReturnPending = false; - lv_obj_t *tapSurface = lv_obj_create(gRoot); - lv_obj_set_size(tapSurface, DISP_W, DISP_H); - lv_obj_set_pos(tapSurface, 0, 0); - lv_obj_set_style_radius(tapSurface, 0, 0); - lv_obj_set_style_bg_opa(tapSurface, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(tapSurface, 0, 0); - lv_obj_set_style_shadow_width(tapSurface, 0, 0); - lv_obj_clear_flag(tapSurface, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_flag(tapSurface, LV_OBJ_FLAG_CLICKABLE); - lv_obj_add_event_cb(tapSurface, actionButtonCb, LV_EVENT_CLICKED, reinterpret_cast(static_cast(ACTION_BACK_HOME))); - - lv_obj_t *title = lv_label_create(tapSurface); + lv_obj_t *title = lv_label_create(gRoot); lv_label_set_text(title, "WALLET QR"); lv_obj_set_style_text_font(title, &lv_font_montserrat_24, 0); lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), 0); lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 22); + String qrPayload = walletQrUri(); + if (qrPayload.isEmpty()) { + saveUiErrorDiag("Wallet QR unavailable", "wallet_address_empty=true\n"); + lv_obj_t *error = lv_label_create(gRoot); + lv_label_set_text(error, "Wallet not set"); + lv_obj_set_width(error, 380); + lv_obj_set_style_text_align(error, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_font(error, &lv_font_montserrat_20, 0); + lv_obj_set_style_text_color(error, lv_color_hex(0xD26969), 0); + lv_obj_set_pos(error, 50, 174); + } else { + lv_obj_t *panel = lv_obj_create(gRoot); + lv_obj_set_size(panel, 300, 220); + lv_obj_set_pos(panel, 90, 88); + lv_obj_set_style_radius(panel, 18, 0); + lv_obj_set_style_bg_color(panel, lv_color_hex(0xFFFFFF), 0); + lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(panel, 0, 0); + lv_obj_set_style_shadow_width(panel, 0, 0); + lv_obj_clear_flag(panel, LV_OBJ_FLAG_SCROLLABLE); - drawQrCanvasOnParent(tapSurface, 120, 76, 240, walletQrUri()); + lv_obj_t *qr = lv_qrcode_create(panel, 180, lv_color_hex(0x111111), lv_color_hex(0xFFFFFF)); + if (qr == nullptr) { + saveUiErrorDiag("Wallet QR create failed", "stage=create\n"); + lv_obj_t *error = lv_label_create(panel); + lv_label_set_text(error, "QR create failed"); + lv_obj_set_width(error, 248); + lv_label_set_long_mode(error, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_align(error, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_font(error, &lv_font_montserrat_18, 0); + lv_obj_set_style_text_color(error, lv_color_hex(0xA23232), 0); + lv_obj_align(error, LV_ALIGN_CENTER, 0, 0); + } else { + lv_obj_center(qr); + if (lv_qrcode_update(qr, qrPayload.c_str(), qrPayload.length()) != LV_RES_OK) { + saveUiErrorDiag("Wallet QR update failed", + String("stage=update\npayload_len=") + String(qrPayload.length()) + "\n"); + lv_obj_del(qr); + lv_obj_t *error = lv_label_create(panel); + lv_label_set_text(error, "QR update failed"); + lv_obj_set_width(error, 248); + lv_label_set_long_mode(error, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_align(error, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_font(error, &lv_font_montserrat_18, 0); + lv_obj_set_style_text_color(error, lv_color_hex(0xA23232), 0); + lv_obj_align(error, LV_ALIGN_CENTER, 0, 0); + } + } + } - lv_obj_t *address = lv_label_create(tapSurface); + lv_obj_t *address = lv_label_create(gRoot); lv_label_set_text(address, walletQrAddressLine().c_str()); lv_obj_set_width(address, 420); lv_label_set_long_mode(address, LV_LABEL_LONG_WRAP); @@ -4651,7 +4562,7 @@ static void drawWalletQrScreen() { lv_obj_set_style_text_color(address, lv_color_hex(0xB8C6D3), 0); lv_obj_set_pos(address, 30, 344); - lv_obj_t *hint = lv_label_create(tapSurface); + lv_obj_t *hint = lv_label_create(gRoot); lv_label_set_text(hint, "Tap anywhere to return"); lv_obj_set_width(hint, 420); lv_obj_set_style_text_align(hint, LV_TEXT_ALIGN_CENTER, 0); @@ -5377,6 +5288,10 @@ void setup() { void loop() { handleUsbSerialCommands(); lv_timer_handler(); + if (gWalletQrTapReturnPending) { + gWalletQrTapReturnPending = false; + showScreen(SCREEN_HOME); + } manageWifiReconnect(); manageAccountPdaRefresh(); manageShineConnection(); diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md index d92128f..b3f7f5f 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md @@ -20,7 +20,7 @@ - локальный UI на тач-экране; - хранение настроек и секретов во внутренней памяти `ESP32` через `NVS`; - русский текст в логике интерфейса; в текущей временной сборке отображение на экране идёт через ASCII-транслитерацию, потому что `U8g2`-шрифты на устройстве временно не рисуются; -- экран пополнения с реальным `solana:` URI и рисованием QR-кода; +- экран пополнения с реальным `solana:` URI и рисованием QR-кода через `LVGL`; - реальное подключение к `Wi-Fi` по сохранённым `SSID/паролю`; - реальная проверка доступности `API`, `RPC` и `WS`-адресов; - реальное чтение баланса кошелька из `Solana RPC`; @@ -164,7 +164,7 @@ В зоне баланса: - основная кнопка показа/обновления баланса занимает примерно 80% строки; -- справа от неё стоит отдельная кнопка с иконкой `QR`; +- справа от неё стоит отдельная кнопка `QR`; - нажатие на кнопку `QR` открывает экран `WALLET_QR`. Нижние кнопки: diff --git a/VERSION.properties b/VERSION.properties index 9d79143..f92f98e 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.185 -server.version=1.2.174 +client.version=1.2.186 +server.version=1.2.175 From 7edc0ba901808793bea470f7d4bc7158d348c61fe508c61b1a971f0185efb828 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sun, 14 Jun 2026 10:28:42 +0400 Subject: [PATCH 26/56] =?UTF-8?q?ESP32:=20=D0=B7=D0=B0=D1=84=D0=B8=D0=BA?= =?UTF-8?q?=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20LVGL=20qrcod?= =?UTF-8?q?e=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Arduino-v3.3.5/libraries/lv_conf.h | 784 ++++++++++++++++++ 1 file changed, 784 insertions(+) create mode 100644 ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/examples/Arduino-v3.3.5/libraries/lv_conf.h diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/examples/Arduino-v3.3.5/libraries/lv_conf.h b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/examples/Arduino-v3.3.5/libraries/lv_conf.h new file mode 100644 index 0000000..956381c --- /dev/null +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/examples/Arduino-v3.3.5/libraries/lv_conf.h @@ -0,0 +1,784 @@ +/** + * @file lv_conf.h + * Configuration file for v8.4.0 + */ + +/* + * Copy this file as `lv_conf.h` + * 1. simply next to the `lvgl` folder + * 2. or any other places and + * - define `LV_CONF_INCLUDE_SIMPLE` + * - add the path as include path + */ + +/* clang-format off */ +#if 1 /*Set it to "1" to enable content*/ + +#ifndef LV_CONF_H +#define LV_CONF_H + +#include + +/*==================== + COLOR SETTINGS + *====================*/ + +/*Color depth: 1 (1 byte per pixel), 8 (RGB332), 16 (RGB565), 32 (ARGB8888)*/ +#define LV_COLOR_DEPTH 16 + +/*Swap the 2 bytes of RGB565 color. Useful if the display has an 8-bit interface (e.g. SPI)*/ +#define LV_COLOR_16_SWAP 0 + +/*Enable features to draw on transparent background. + *It's required if opa, and transform_* style properties are used. + *Can be also used if the UI is above another layer, e.g. an OSD menu or video player.*/ +#define LV_COLOR_SCREEN_TRANSP 0 + +/* Adjust color mix functions rounding. GPUs might calculate color mix (blending) differently. + * 0: round down, 64: round up from x.75, 128: round up from half, 192: round up from x.25, 254: round up */ +#define LV_COLOR_MIX_ROUND_OFS 0 + +/*Images pixels with this color will not be drawn if they are chroma keyed)*/ +#define LV_COLOR_CHROMA_KEY lv_color_hex(0x00ff00) /*pure green*/ + +/*========================= + MEMORY SETTINGS + *=========================*/ + +/*1: use custom malloc/free, 0: use the built-in `lv_mem_alloc()` and `lv_mem_free()`*/ +#define LV_MEM_CUSTOM 0 +#if LV_MEM_CUSTOM == 0 + /*Size of the memory available for `lv_mem_alloc()` in bytes (>= 2kB)*/ + #define LV_MEM_SIZE (48U * 1024U) /*[bytes]*/ + + /*Set an address for the memory pool instead of allocating it as a normal array. Can be in external SRAM too.*/ + #define LV_MEM_ADR 0 /*0: unused*/ + /*Instead of an address give a memory allocator that will be called to get a memory pool for LVGL. E.g. my_malloc*/ + #if LV_MEM_ADR == 0 + #undef LV_MEM_POOL_INCLUDE + #undef LV_MEM_POOL_ALLOC + #endif + +#else /*LV_MEM_CUSTOM*/ + #define LV_MEM_CUSTOM_INCLUDE /*Header for the dynamic memory function*/ + #define LV_MEM_CUSTOM_ALLOC malloc + #define LV_MEM_CUSTOM_FREE free + #define LV_MEM_CUSTOM_REALLOC realloc +#endif /*LV_MEM_CUSTOM*/ + +/*Number of the intermediate memory buffer used during rendering and other internal processing mechanisms. + *You will see an error log message if there wasn't enough buffers. */ +#define LV_MEM_BUF_MAX_NUM 16 + +/*Use the standard `memcpy` and `memset` instead of LVGL's own functions. (Might or might not be faster).*/ +#define LV_MEMCPY_MEMSET_STD 0 + +/*==================== + HAL SETTINGS + *====================*/ + +/*Default display refresh period. LVG will redraw changed areas with this period time*/ +#define LV_DISP_DEF_REFR_PERIOD 10 /*[ms]*/ + +/*Input device read period in milliseconds*/ +#define LV_INDEV_DEF_READ_PERIOD 10 /*[ms]*/ + +/*Use a custom tick source that tells the elapsed time in milliseconds. + *It removes the need to manually update the tick with `lv_tick_inc()`)*/ +#define LV_TICK_CUSTOM 0 +#if LV_TICK_CUSTOM + #define LV_TICK_CUSTOM_INCLUDE "Arduino.h" /*Header for the system time function*/ + #define LV_TICK_CUSTOM_SYS_TIME_EXPR (millis()) /*Expression evaluating to current system time in ms*/ + /*If using lvgl as ESP32 component*/ + // #define LV_TICK_CUSTOM_INCLUDE "esp_timer.h" + // #define LV_TICK_CUSTOM_SYS_TIME_EXPR ((esp_timer_get_time() / 1000LL)) +#endif /*LV_TICK_CUSTOM*/ + +/*Default Dot Per Inch. Used to initialize default sizes such as widgets sized, style paddings. + *(Not so important, you can adjust it to modify default sizes and spaces)*/ +#define LV_DPI_DEF 130 /*[px/inch]*/ + +/*======================= + * FEATURE CONFIGURATION + *=======================*/ + +/*------------- + * Drawing + *-----------*/ + +/*Enable complex draw engine. + *Required to draw shadow, gradient, rounded corners, circles, arc, skew lines, image transformations or any masks*/ +#define LV_DRAW_COMPLEX 1 +#if LV_DRAW_COMPLEX != 0 + + /*Allow buffering some shadow calculation. + *LV_SHADOW_CACHE_SIZE is the max. shadow size to buffer, where shadow size is `shadow_width + radius` + *Caching has LV_SHADOW_CACHE_SIZE^2 RAM cost*/ + #define LV_SHADOW_CACHE_SIZE 0 + + /* Set number of maximally cached circle data. + * The circumference of 1/4 circle are saved for anti-aliasing + * radius * 4 bytes are used per circle (the most often used radiuses are saved) + * 0: to disable caching */ + #define LV_CIRCLE_CACHE_SIZE 4 +#endif /*LV_DRAW_COMPLEX*/ + +/** + * "Simple layers" are used when a widget has `style_opa < 255` to buffer the widget into a layer + * and blend it as an image with the given opacity. + * Note that `bg_opa`, `text_opa` etc don't require buffering into layer) + * The widget can be buffered in smaller chunks to avoid using large buffers. + * + * - LV_LAYER_SIMPLE_BUF_SIZE: [bytes] the optimal target buffer size. LVGL will try to allocate it + * - LV_LAYER_SIMPLE_FALLBACK_BUF_SIZE: [bytes] used if `LV_LAYER_SIMPLE_BUF_SIZE` couldn't be allocated. + * + * Both buffer sizes are in bytes. + * "Transformed layers" (where transform_angle/zoom properties are used) use larger buffers + * and can't be drawn in chunks. So these settings affects only widgets with opacity. + */ +#define LV_LAYER_SIMPLE_BUF_SIZE (24 * 1024) +#define LV_LAYER_SIMPLE_FALLBACK_BUF_SIZE (3 * 1024) + +/*Default image cache size. Image caching keeps the images opened. + *If only the built-in image formats are used there is no real advantage of caching. (I.e. if no new image decoder is added) + *With complex image decoders (e.g. PNG or JPG) caching can save the continuous open/decode of images. + *However the opened images might consume additional RAM. + *0: to disable caching*/ +#define LV_IMG_CACHE_DEF_SIZE 0 + +/*Number of stops allowed per gradient. Increase this to allow more stops. + *This adds (sizeof(lv_color_t) + 1) bytes per additional stop*/ +#define LV_GRADIENT_MAX_STOPS 2 + +/*Default gradient buffer size. + *When LVGL calculates the gradient "maps" it can save them into a cache to avoid calculating them again. + *LV_GRAD_CACHE_DEF_SIZE sets the size of this cache in bytes. + *If the cache is too small the map will be allocated only while it's required for the drawing. + *0 mean no caching.*/ +#define LV_GRAD_CACHE_DEF_SIZE 0 + +/*Allow dithering the gradients (to achieve visual smooth color gradients on limited color depth display) + *LV_DITHER_GRADIENT implies allocating one or two more lines of the object's rendering surface + *The increase in memory consumption is (32 bits * object width) plus 24 bits * object width if using error diffusion */ +#define LV_DITHER_GRADIENT 0 +#if LV_DITHER_GRADIENT + /*Add support for error diffusion dithering. + *Error diffusion dithering gets a much better visual result, but implies more CPU consumption and memory when drawing. + *The increase in memory consumption is (24 bits * object's width)*/ + #define LV_DITHER_ERROR_DIFFUSION 0 +#endif + +/*Maximum buffer size to allocate for rotation. + *Only used if software rotation is enabled in the display driver.*/ +#define LV_DISP_ROT_MAX_BUF (10*1024) + +/*------------- + * GPU + *-----------*/ + +/*Use Arm's 2D acceleration library Arm-2D */ +#define LV_USE_GPU_ARM2D 0 + +/*Use STM32's DMA2D (aka Chrom Art) GPU*/ +#define LV_USE_GPU_STM32_DMA2D 0 +#if LV_USE_GPU_STM32_DMA2D + /*Must be defined to include path of CMSIS header of target processor + e.g. "stm32f7xx.h" or "stm32f4xx.h"*/ + #define LV_GPU_DMA2D_CMSIS_INCLUDE +#endif + +/*Enable RA6M3 G2D GPU*/ +#define LV_USE_GPU_RA6M3_G2D 0 +#if LV_USE_GPU_RA6M3_G2D + /*include path of target processor + e.g. "hal_data.h"*/ + #define LV_GPU_RA6M3_G2D_INCLUDE "hal_data.h" +#endif + +/*Use SWM341's DMA2D GPU*/ +#define LV_USE_GPU_SWM341_DMA2D 0 +#if LV_USE_GPU_SWM341_DMA2D + #define LV_GPU_SWM341_DMA2D_INCLUDE "SWM341.h" +#endif + +/*Use NXP's PXP GPU iMX RTxxx platforms*/ +#define LV_USE_GPU_NXP_PXP 0 +#if LV_USE_GPU_NXP_PXP + /*1: Add default bare metal and FreeRTOS interrupt handling routines for PXP (lv_gpu_nxp_pxp_osa.c) + * and call lv_gpu_nxp_pxp_init() automatically during lv_init(). Note that symbol SDK_OS_FREE_RTOS + * has to be defined in order to use FreeRTOS OSA, otherwise bare-metal implementation is selected. + *0: lv_gpu_nxp_pxp_init() has to be called manually before lv_init() + */ + #define LV_USE_GPU_NXP_PXP_AUTO_INIT 0 +#endif + +/*Use NXP's VG-Lite GPU iMX RTxxx platforms*/ +#define LV_USE_GPU_NXP_VG_LITE 0 + +/*Use SDL renderer API*/ +#define LV_USE_GPU_SDL 0 +#if LV_USE_GPU_SDL + #define LV_GPU_SDL_INCLUDE_PATH + /*Texture cache size, 8MB by default*/ + #define LV_GPU_SDL_LRU_SIZE (1024 * 1024 * 8) + /*Custom blend mode for mask drawing, disable if you need to link with older SDL2 lib*/ + #define LV_GPU_SDL_CUSTOM_BLEND_MODE (SDL_VERSION_ATLEAST(2, 0, 6)) +#endif + +/*------------- + * Logging + *-----------*/ + +/*Enable the log module*/ +#define LV_USE_LOG 0 +#if LV_USE_LOG + + /*How important log should be added: + *LV_LOG_LEVEL_TRACE A lot of logs to give detailed information + *LV_LOG_LEVEL_INFO Log important events + *LV_LOG_LEVEL_WARN Log if something unwanted happened but didn't cause a problem + *LV_LOG_LEVEL_ERROR Only critical issue, when the system may fail + *LV_LOG_LEVEL_USER Only logs added by the user + *LV_LOG_LEVEL_NONE Do not log anything*/ + #define LV_LOG_LEVEL LV_LOG_LEVEL_WARN + + /*1: Print the log with 'printf'; + *0: User need to register a callback with `lv_log_register_print_cb()`*/ + #define LV_LOG_PRINTF 0 + + /*Enable/disable LV_LOG_TRACE in modules that produces a huge number of logs*/ + #define LV_LOG_TRACE_MEM 1 + #define LV_LOG_TRACE_TIMER 1 + #define LV_LOG_TRACE_INDEV 1 + #define LV_LOG_TRACE_DISP_REFR 1 + #define LV_LOG_TRACE_EVENT 1 + #define LV_LOG_TRACE_OBJ_CREATE 1 + #define LV_LOG_TRACE_LAYOUT 1 + #define LV_LOG_TRACE_ANIM 1 + +#endif /*LV_USE_LOG*/ + +/*------------- + * Asserts + *-----------*/ + +/*Enable asserts if an operation is failed or an invalid data is found. + *If LV_USE_LOG is enabled an error message will be printed on failure*/ +#define LV_USE_ASSERT_NULL 1 /*Check if the parameter is NULL. (Very fast, recommended)*/ +#define LV_USE_ASSERT_MALLOC 1 /*Checks is the memory is successfully allocated or no. (Very fast, recommended)*/ +#define LV_USE_ASSERT_STYLE 0 /*Check if the styles are properly initialized. (Very fast, recommended)*/ +#define LV_USE_ASSERT_MEM_INTEGRITY 0 /*Check the integrity of `lv_mem` after critical operations. (Slow)*/ +#define LV_USE_ASSERT_OBJ 0 /*Check the object's type and existence (e.g. not deleted). (Slow)*/ + +/*Add a custom handler when assert happens e.g. to restart the MCU*/ +#define LV_ASSERT_HANDLER_INCLUDE +#define LV_ASSERT_HANDLER while(1); /*Halt by default*/ + +/*------------- + * Others + *-----------*/ + +/*1: Show CPU usage and FPS count*/ +#define LV_USE_PERF_MONITOR 0 +#if LV_USE_PERF_MONITOR + #define LV_USE_PERF_MONITOR_POS LV_ALIGN_BOTTOM_RIGHT +#endif + +/*1: Show the used memory and the memory fragmentation + * Requires LV_MEM_CUSTOM = 0*/ +#define LV_USE_MEM_MONITOR 0 +#if LV_USE_MEM_MONITOR + #define LV_USE_MEM_MONITOR_POS LV_ALIGN_BOTTOM_LEFT +#endif + +/*1: Draw random colored rectangles over the redrawn areas*/ +#define LV_USE_REFR_DEBUG 0 + +/*Change the built in (v)snprintf functions*/ +#define LV_SPRINTF_CUSTOM 0 +#if LV_SPRINTF_CUSTOM + #define LV_SPRINTF_INCLUDE + #define lv_snprintf snprintf + #define lv_vsnprintf vsnprintf +#else /*LV_SPRINTF_CUSTOM*/ + #define LV_SPRINTF_USE_FLOAT 0 +#endif /*LV_SPRINTF_CUSTOM*/ + +#define LV_USE_USER_DATA 1 + +/*Garbage Collector settings + *Used if lvgl is bound to higher level language and the memory is managed by that language*/ +#define LV_ENABLE_GC 0 +#if LV_ENABLE_GC != 0 + #define LV_GC_INCLUDE "gc.h" /*Include Garbage Collector related things*/ +#endif /*LV_ENABLE_GC*/ + +/*===================== + * COMPILER SETTINGS + *====================*/ + +/*For big endian systems set to 1*/ +#define LV_BIG_ENDIAN_SYSTEM 0 + +/*Define a custom attribute to `lv_tick_inc` function*/ +#define LV_ATTRIBUTE_TICK_INC + +/*Define a custom attribute to `lv_timer_handler` function*/ +#define LV_ATTRIBUTE_TIMER_HANDLER + +/*Define a custom attribute to `lv_disp_flush_ready` function*/ +#define LV_ATTRIBUTE_FLUSH_READY + +/*Required alignment size for buffers*/ +#define LV_ATTRIBUTE_MEM_ALIGN_SIZE 1 + +/*Will be added where memories needs to be aligned (with -Os data might not be aligned to boundary by default). + * E.g. __attribute__((aligned(4)))*/ +#define LV_ATTRIBUTE_MEM_ALIGN + +/*Attribute to mark large constant arrays for example font's bitmaps*/ +#define LV_ATTRIBUTE_LARGE_CONST + +/*Compiler prefix for a big array declaration in RAM*/ +#define LV_ATTRIBUTE_LARGE_RAM_ARRAY + +/*Place performance critical functions into a faster memory (e.g RAM)*/ +#define LV_ATTRIBUTE_FAST_MEM + +/*Prefix variables that are used in GPU accelerated operations, often these need to be placed in RAM sections that are DMA accessible*/ +#define LV_ATTRIBUTE_DMA + +/*Export integer constant to binding. This macro is used with constants in the form of LV_ that + *should also appear on LVGL binding API such as Micropython.*/ +#define LV_EXPORT_CONST_INT(int_value) struct _silence_gcc_warning /*The default value just prevents GCC warning*/ + +/*Extend the default -32k..32k coordinate range to -4M..4M by using int32_t for coordinates instead of int16_t*/ +#define LV_USE_LARGE_COORD 0 + +/*================== + * FONT USAGE + *===================*/ + +/*Montserrat fonts with ASCII range and some symbols using bpp = 4 + *https://fonts.google.com/specimen/Montserrat*/ +#define LV_FONT_MONTSERRAT_8 1 +#define LV_FONT_MONTSERRAT_10 1 +#define LV_FONT_MONTSERRAT_12 1 +#define LV_FONT_MONTSERRAT_14 1 +#define LV_FONT_MONTSERRAT_16 1 +#define LV_FONT_MONTSERRAT_18 1 +#define LV_FONT_MONTSERRAT_20 1 +#define LV_FONT_MONTSERRAT_22 1 +#define LV_FONT_MONTSERRAT_24 1 +#define LV_FONT_MONTSERRAT_26 1 +#define LV_FONT_MONTSERRAT_28 1 +#define LV_FONT_MONTSERRAT_30 1 +#define LV_FONT_MONTSERRAT_32 1 +#define LV_FONT_MONTSERRAT_34 1 +#define LV_FONT_MONTSERRAT_36 1 +#define LV_FONT_MONTSERRAT_38 1 +#define LV_FONT_MONTSERRAT_40 1 +#define LV_FONT_MONTSERRAT_42 1 +#define LV_FONT_MONTSERRAT_44 1 +#define LV_FONT_MONTSERRAT_46 1 +#define LV_FONT_MONTSERRAT_48 1 + +/*Demonstrate special features*/ +#define LV_FONT_MONTSERRAT_12_SUBPX 0 +#define LV_FONT_MONTSERRAT_28_COMPRESSED 0 /*bpp = 3*/ +#define LV_FONT_DEJAVU_16_PERSIAN_HEBREW 0 /*Hebrew, Arabic, Persian letters and all their forms*/ +#define LV_FONT_SIMSUN_16_CJK 0 /*1000 most common CJK radicals*/ + +/*Pixel perfect monospace fonts*/ +#define LV_FONT_UNSCII_8 0 +#define LV_FONT_UNSCII_16 0 + +/*Optionally declare custom fonts here. + *You can use these fonts as default font too and they will be available globally. + *E.g. #define LV_FONT_CUSTOM_DECLARE LV_FONT_DECLARE(my_font_1) LV_FONT_DECLARE(my_font_2)*/ +#define LV_FONT_CUSTOM_DECLARE + +/*Always set a default font*/ +#define LV_FONT_DEFAULT &lv_font_montserrat_14 + +/*Enable handling large font and/or fonts with a lot of characters. + *The limit depends on the font size, font face and bpp. + *Compiler error will be triggered if a font needs it.*/ +#define LV_FONT_FMT_TXT_LARGE 0 + +/*Enables/disables support for compressed fonts.*/ +#define LV_USE_FONT_COMPRESSED 0 + +/*Enable subpixel rendering*/ +#define LV_USE_FONT_SUBPX 0 +#if LV_USE_FONT_SUBPX + /*Set the pixel order of the display. Physical order of RGB channels. Doesn't matter with "normal" fonts.*/ + #define LV_FONT_SUBPX_BGR 0 /*0: RGB; 1:BGR order*/ +#endif + +/*Enable drawing placeholders when glyph dsc is not found*/ +#define LV_USE_FONT_PLACEHOLDER 1 + +/*================= + * TEXT SETTINGS + *=================*/ + +/** + * Select a character encoding for strings. + * Your IDE or editor should have the same character encoding + * - LV_TXT_ENC_UTF8 + * - LV_TXT_ENC_ASCII + */ +#define LV_TXT_ENC LV_TXT_ENC_UTF8 + +/*Can break (wrap) texts on these chars*/ +#define LV_TXT_BREAK_CHARS " ,.;:-_" + +/*If a word is at least this long, will break wherever "prettiest" + *To disable, set to a value <= 0*/ +#define LV_TXT_LINE_BREAK_LONG_LEN 0 + +/*Minimum number of characters in a long word to put on a line before a break. + *Depends on LV_TXT_LINE_BREAK_LONG_LEN.*/ +#define LV_TXT_LINE_BREAK_LONG_PRE_MIN_LEN 3 + +/*Minimum number of characters in a long word to put on a line after a break. + *Depends on LV_TXT_LINE_BREAK_LONG_LEN.*/ +#define LV_TXT_LINE_BREAK_LONG_POST_MIN_LEN 3 + +/*The control character to use for signalling text recoloring.*/ +#define LV_TXT_COLOR_CMD "#" + +/*Support bidirectional texts. Allows mixing Left-to-Right and Right-to-Left texts. + *The direction will be processed according to the Unicode Bidirectional Algorithm: + *https://www.w3.org/International/articles/inline-bidi-markup/uba-basics*/ +#define LV_USE_BIDI 0 +#if LV_USE_BIDI + /*Set the default direction. Supported values: + *`LV_BASE_DIR_LTR` Left-to-Right + *`LV_BASE_DIR_RTL` Right-to-Left + *`LV_BASE_DIR_AUTO` detect texts base direction*/ + #define LV_BIDI_BASE_DIR_DEF LV_BASE_DIR_AUTO +#endif + +/*Enable Arabic/Persian processing + *In these languages characters should be replaced with an other form based on their position in the text*/ +#define LV_USE_ARABIC_PERSIAN_CHARS 0 + +/*================== + * WIDGET USAGE + *================*/ + +/*Documentation of the widgets: https://docs.lvgl.io/latest/en/html/widgets/index.html*/ + +#define LV_USE_ARC 1 + +#define LV_USE_BAR 1 + +#define LV_USE_BTN 1 + +#define LV_USE_BTNMATRIX 1 + +#define LV_USE_CANVAS 1 + +#define LV_USE_CHECKBOX 1 + +#define LV_USE_DROPDOWN 1 /*Requires: lv_label*/ + +#define LV_USE_IMG 1 /*Requires: lv_label*/ + +#define LV_USE_LABEL 1 +#if LV_USE_LABEL + #define LV_LABEL_TEXT_SELECTION 1 /*Enable selecting text of the label*/ + #define LV_LABEL_LONG_TXT_HINT 1 /*Store some extra info in labels to speed up drawing of very long texts*/ +#endif + +#define LV_USE_LINE 1 + +#define LV_USE_ROLLER 1 /*Requires: lv_label*/ +#if LV_USE_ROLLER + #define LV_ROLLER_INF_PAGES 7 /*Number of extra "pages" when the roller is infinite*/ +#endif + +#define LV_USE_SLIDER 1 /*Requires: lv_bar*/ + +#define LV_USE_SWITCH 1 + +#define LV_USE_TEXTAREA 1 /*Requires: lv_label*/ +#if LV_USE_TEXTAREA != 0 + #define LV_TEXTAREA_DEF_PWD_SHOW_TIME 1500 /*ms*/ +#endif + +#define LV_USE_TABLE 1 + +/*================== + * EXTRA COMPONENTS + *==================*/ + +/*----------- + * Widgets + *----------*/ +#define LV_USE_ANIMIMG 1 + +#define LV_USE_CALENDAR 1 +#if LV_USE_CALENDAR + #define LV_CALENDAR_WEEK_STARTS_MONDAY 0 + #if LV_CALENDAR_WEEK_STARTS_MONDAY + #define LV_CALENDAR_DEFAULT_DAY_NAMES {"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"} + #else + #define LV_CALENDAR_DEFAULT_DAY_NAMES {"Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"} + #endif + + #define LV_CALENDAR_DEFAULT_MONTH_NAMES {"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"} + #define LV_USE_CALENDAR_HEADER_ARROW 1 + #define LV_USE_CALENDAR_HEADER_DROPDOWN 1 +#endif /*LV_USE_CALENDAR*/ + +#define LV_USE_CHART 1 + +#define LV_USE_COLORWHEEL 1 + +#define LV_USE_IMGBTN 1 + +#define LV_USE_KEYBOARD 1 + +#define LV_USE_LED 1 + +#define LV_USE_LIST 1 + +#define LV_USE_MENU 1 + +#define LV_USE_METER 1 + +#define LV_USE_MSGBOX 1 + +#define LV_USE_SPAN 1 +#if LV_USE_SPAN + /*A line text can contain maximum num of span descriptor */ + #define LV_SPAN_SNIPPET_STACK_SIZE 64 +#endif + +#define LV_USE_SPINBOX 1 + +#define LV_USE_SPINNER 1 + +#define LV_USE_TABVIEW 1 + +#define LV_USE_TILEVIEW 1 + +#define LV_USE_WIN 1 + +/*----------- + * Themes + *----------*/ + +/*A simple, impressive and very complete theme*/ +#define LV_USE_THEME_DEFAULT 1 +#if LV_USE_THEME_DEFAULT + + /*0: Light mode; 1: Dark mode*/ + #define LV_THEME_DEFAULT_DARK 0 + + /*1: Enable grow on press*/ + #define LV_THEME_DEFAULT_GROW 1 + + /*Default transition time in [ms]*/ + #define LV_THEME_DEFAULT_TRANSITION_TIME 80 +#endif /*LV_USE_THEME_DEFAULT*/ + +/*A very simple theme that is a good starting point for a custom theme*/ +#define LV_USE_THEME_BASIC 1 + +/*A theme designed for monochrome displays*/ +#define LV_USE_THEME_MONO 1 + +/*----------- + * Layouts + *----------*/ + +/*A layout similar to Flexbox in CSS.*/ +#define LV_USE_FLEX 1 + +/*A layout similar to Grid in CSS.*/ +#define LV_USE_GRID 1 + +/*--------------------- + * 3rd party libraries + *--------------------*/ + +/*File system interfaces for common APIs */ + +/*API for fopen, fread, etc*/ +#define LV_USE_FS_STDIO 0 +#if LV_USE_FS_STDIO + #define LV_FS_STDIO_LETTER '\0' /*Set an upper cased letter on which the drive will accessible (e.g. 'A')*/ + #define LV_FS_STDIO_PATH "" /*Set the working directory. File/directory paths will be appended to it.*/ + #define LV_FS_STDIO_CACHE_SIZE 0 /*>0 to cache this number of bytes in lv_fs_read()*/ +#endif + +/*API for open, read, etc*/ +#define LV_USE_FS_POSIX 0 +#if LV_USE_FS_POSIX + #define LV_FS_POSIX_LETTER '\0' /*Set an upper cased letter on which the drive will accessible (e.g. 'A')*/ + #define LV_FS_POSIX_PATH "" /*Set the working directory. File/directory paths will be appended to it.*/ + #define LV_FS_POSIX_CACHE_SIZE 0 /*>0 to cache this number of bytes in lv_fs_read()*/ +#endif + +/*API for CreateFile, ReadFile, etc*/ +#define LV_USE_FS_WIN32 0 +#if LV_USE_FS_WIN32 + #define LV_FS_WIN32_LETTER '\0' /*Set an upper cased letter on which the drive will accessible (e.g. 'A')*/ + #define LV_FS_WIN32_PATH "" /*Set the working directory. File/directory paths will be appended to it.*/ + #define LV_FS_WIN32_CACHE_SIZE 0 /*>0 to cache this number of bytes in lv_fs_read()*/ +#endif + +/*API for FATFS (needs to be added separately). Uses f_open, f_read, etc*/ +#define LV_USE_FS_FATFS 0 +#if LV_USE_FS_FATFS + #define LV_FS_FATFS_LETTER '\0' /*Set an upper cased letter on which the drive will accessible (e.g. 'A')*/ + #define LV_FS_FATFS_CACHE_SIZE 0 /*>0 to cache this number of bytes in lv_fs_read()*/ +#endif + +/*API for LittleFS (library needs to be added separately). Uses lfs_file_open, lfs_file_read, etc*/ +#define LV_USE_FS_LITTLEFS 0 +#if LV_USE_FS_LITTLEFS + #define LV_FS_LITTLEFS_LETTER '\0' /*Set an upper cased letter on which the drive will accessible (e.g. 'A')*/ + #define LV_FS_LITTLEFS_CACHE_SIZE 0 /*>0 to cache this number of bytes in lv_fs_read()*/ +#endif + +/*PNG decoder library*/ +#define LV_USE_PNG 0 + +/*BMP decoder library*/ +#define LV_USE_BMP 0 + +/* JPG + split JPG decoder library. + * Split JPG is a custom format optimized for embedded systems. */ +#define LV_USE_SJPG 0 + +/*GIF decoder library*/ +#define LV_USE_GIF 0 + +/*QR code library*/ +#define LV_USE_QRCODE 1 + +/*FreeType library*/ +#define LV_USE_FREETYPE 0 +#if LV_USE_FREETYPE + /*Memory used by FreeType to cache characters [bytes] (-1: no caching)*/ + #define LV_FREETYPE_CACHE_SIZE (16 * 1024) + #if LV_FREETYPE_CACHE_SIZE >= 0 + /* 1: bitmap cache use the sbit cache, 0:bitmap cache use the image cache. */ + /* sbit cache:it is much more memory efficient for small bitmaps(font size < 256) */ + /* if font size >= 256, must be configured as image cache */ + #define LV_FREETYPE_SBIT_CACHE 0 + /* Maximum number of opened FT_Face/FT_Size objects managed by this cache instance. */ + /* (0:use system defaults) */ + #define LV_FREETYPE_CACHE_FT_FACES 0 + #define LV_FREETYPE_CACHE_FT_SIZES 0 + #endif +#endif + +/*Tiny TTF library*/ +#define LV_USE_TINY_TTF 0 +#if LV_USE_TINY_TTF + /*Load TTF data from files*/ + #define LV_TINY_TTF_FILE_SUPPORT 0 +#endif + +/*Rlottie library*/ +#define LV_USE_RLOTTIE 0 + +/*FFmpeg library for image decoding and playing videos + *Supports all major image formats so do not enable other image decoder with it*/ +#define LV_USE_FFMPEG 0 +#if LV_USE_FFMPEG + /*Dump input information to stderr*/ + #define LV_FFMPEG_DUMP_FORMAT 0 +#endif + +/*----------- + * Others + *----------*/ + +/*1: Enable API to take snapshot for object*/ +#define LV_USE_SNAPSHOT 0 + +/*1: Enable Monkey test*/ +#define LV_USE_MONKEY 0 + +/*1: Enable grid navigation*/ +#define LV_USE_GRIDNAV 0 + +/*1: Enable lv_obj fragment*/ +#define LV_USE_FRAGMENT 0 + +/*1: Support using images as font in label or span widgets */ +#define LV_USE_IMGFONT 0 + +/*1: Enable a published subscriber based messaging system */ +#define LV_USE_MSG 0 + +/*1: Enable Pinyin input method*/ +/*Requires: lv_keyboard*/ +#define LV_USE_IME_PINYIN 0 +#if LV_USE_IME_PINYIN + /*1: Use default thesaurus*/ + /*If you do not use the default thesaurus, be sure to use `lv_ime_pinyin` after setting the thesauruss*/ + #define LV_IME_PINYIN_USE_DEFAULT_DICT 1 + /*Set the maximum number of candidate panels that can be displayed*/ + /*This needs to be adjusted according to the size of the screen*/ + #define LV_IME_PINYIN_CAND_TEXT_NUM 6 + + /*Use 9 key input(k9)*/ + #define LV_IME_PINYIN_USE_K9_MODE 1 + #if LV_IME_PINYIN_USE_K9_MODE == 1 + #define LV_IME_PINYIN_K9_CAND_TEXT_NUM 3 + #endif // LV_IME_PINYIN_USE_K9_MODE +#endif + +/*================== +* EXAMPLES +*==================*/ + +/*Enable the examples to be built with the library*/ +#define LV_BUILD_EXAMPLES 1 + +/*=================== + * DEMO USAGE + ====================*/ + +/*Show some widget. It might be required to increase `LV_MEM_SIZE` */ +#define LV_USE_DEMO_WIDGETS 1 +#if LV_USE_DEMO_WIDGETS +#define LV_DEMO_WIDGETS_SLIDESHOW 1 +#endif + +/*Demonstrate the usage of encoder and keyboard*/ +#define LV_USE_DEMO_KEYPAD_AND_ENCODER 0 + +/*Benchmark your system*/ +#define LV_USE_DEMO_BENCHMARK 1 +#if LV_USE_DEMO_BENCHMARK +/*Use RGB565A8 images with 16 bit color depth instead of ARGB8565*/ +#define LV_DEMO_BENCHMARK_RGB565A8 0 +#endif + +/*Stress test for LVGL*/ +#define LV_USE_DEMO_STRESS 1 + +/*Music player demo*/ +#define LV_USE_DEMO_MUSIC 1 +#if LV_USE_DEMO_MUSIC + #define LV_DEMO_MUSIC_SQUARE 0 + #define LV_DEMO_MUSIC_LANDSCAPE 0 + #define LV_DEMO_MUSIC_ROUND 0 + #define LV_DEMO_MUSIC_LARGE 0 + #define LV_DEMO_MUSIC_AUTO_PLAY 0 +#endif + +/*--END OF LV_CONF_H--*/ + +#endif /*LV_CONF_H*/ + +#endif /*End of "Content enable"*/ From 423d490939eeb87a1162f0944623a81750e4e2b3e681cef9953a57f7fb05cfbd Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sun, 14 Jun 2026 10:50:31 +0400 Subject: [PATCH 27/56] =?UTF-8?q?ESP32:=20=D0=B4=D0=BE=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=B0=D1=82=D1=8C=20home=20=D1=8D=D0=BA=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=B8=20wallet=20QR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-14_1450_esp32_wallet_qr_lvgl.md | 6 +- ...026-06-14_1535_esp32_home_status_layout.md | 29 +++++ .../shine_homeserver_main.ino | 110 ++++++++++++++---- .../reference/shine_homeserver_ui_spec.md | 22 +++- VERSION.properties | 4 +- 5 files changed, 142 insertions(+), 29 deletions(-) create mode 100644 Dev_Docs/Pending_Features/2026-06-14_1535_esp32_home_status_layout.md diff --git a/Dev_Docs/Pending_Features/2026-06-14_1450_esp32_wallet_qr_lvgl.md b/Dev_Docs/Pending_Features/2026-06-14_1450_esp32_wallet_qr_lvgl.md index 63d70bc..5c0cd22 100644 --- a/Dev_Docs/Pending_Features/2026-06-14_1450_esp32_wallet_qr_lvgl.md +++ b/Dev_Docs/Pending_Features/2026-06-14_1450_esp32_wallet_qr_lvgl.md @@ -6,7 +6,8 @@ - для `WALLET_QR` включён штатный `LVGL`-виджет `lv_qrcode`; - кнопка на главном экране оставлена текстовой `QR`; -- экран должен показывать реальный `QR` для `solana:`; +- экран должен показывать реальный крупный `QR` для `solana:`; +- адрес кошелька под `QR` увеличен; - тап по экрану должен возвращать на `HOME`, без перезагрузки устройства и без потери `Wi-Fi`/`SHiNE`. ## Что проверять @@ -14,7 +15,7 @@ 1. На главном экране нажать кнопку `QR`. 2. Убедиться, что открывается экран `WALLET QR`. 3. Проверить, что виден настоящий QR-код. -4. Проверить, что внизу мелким текстом показан адрес кошелька. +4. Проверить, что под `QR` крупно показан адрес кошелька. 5. Нажать в любое место экрана и убедиться, что устройство возвращается на `HOME`. 6. Убедиться, что после открытия и закрытия QR-экрана не рвутся `Wi-Fi` и подключение к `SHiNE`. @@ -22,5 +23,6 @@ - QR-экран открывается стабильно; - QR-код читается приложением кошелька; +- размер `QR` и адреса визуально достаточен для удобного сканирования; - возврат на главный экран работает обычным тапом; - устройство не перезагружается, сетевые подключения не теряются. diff --git a/Dev_Docs/Pending_Features/2026-06-14_1535_esp32_home_status_layout.md b/Dev_Docs/Pending_Features/2026-06-14_1535_esp32_home_status_layout.md new file mode 100644 index 0000000..085b559 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-14_1535_esp32_home_status_layout.md @@ -0,0 +1,29 @@ +# ESP32 home status layout и авто-баланс + +- Статус: `pending` + +## Что сделано + +- на `HOME` блок `STATUS` поднят выше; +- строка логина теперь идёт после статусного кружка, сам кружок расположен слева; +- строка `Wi-Fi` показывает `Wi-Fi: connected|disconnected`; +- строка `SHiNE` показывает `SHiNE: connected|unavailable`; +- слово `connected` на обеих строках окрашивается зелёным; +- текст на кнопке баланса сдвинут левее; +- после старта устройство автоматически пытается подгрузить баланс при готовых секрете и `Wi-Fi`; +- в `SETTINGS` первые три пункта переименованы в `1. Wi-Fi`, `2. Server`, `3. Account`. + +## Что проверять + +1. Перезагрузить устройство. +2. Проверить, что на `HOME` баланс подгружается сам, без нажатия на кнопку. +3. Проверить, что строки `Wi-Fi` и `SHiNE` читаются и слово `connected` зелёное при хорошем состоянии. +4. Проверить, что кружок аккаунта расположен слева от логина. +5. Открыть `SETTINGS` и убедиться, что первые три пункта нумеруются как `1. Wi-Fi`, `2. Server`, `3. Account`. + +## Ожидаемый результат + +- `HOME` выглядит компактнее и читаемее; +- баланс после старта появляется автоматически; +- статусные строки не перекрываются; +- меню `SETTINGS` показывает нужную нумерацию. diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino index a04ac43..9e38eb4 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino @@ -224,7 +224,7 @@ struct SimpleWebSocketClient { uint16_t port = 0; }; -static const char *kMenuItems[] = {"Wi-Fi", "Server", "Account"}; +static const char *kMenuItems[] = {"1. Wi-Fi", "2. Server", "3. Account"}; static const size_t kMenuCount = sizeof(kMenuItems) / sizeof(kMenuItems[0]); static lv_disp_draw_buf_t gDrawBuf; @@ -276,6 +276,8 @@ static String gSecretBase58; static uint8_t gSecretBytes[32] = {}; static String gAccountStatusMessage = "Edit account fields"; static String gBalanceStatusMessage = "Balance: tap to load"; +static bool gBalanceAutoRefreshPending = false; +static unsigned long gLastBalanceAutoRefreshAttemptMs = 0; static bool gWifiKnownGood = false; static bool gWifiReconnectEnabled = false; static bool gWifiOperationBusy = false; @@ -1143,6 +1145,25 @@ static String balanceHomeLine() { return gBalanceStatusMessage; } +static String wifiHomeRichLine() { + String ssid = gWifiSavedSsid.isEmpty() ? String("not configured") : gWifiSavedSsid; + if (WiFi.status() == WL_CONNECTED) { + return String("Wi-Fi: ") + ssid + " #38B26D connected#"; + } + return String("Wi-Fi: ") + ssid + " disconnected"; +} + +static String shineHomeRichLine() { + String serverLabel = gShineServerUrl.isEmpty() ? String("not set") : gShineServerUrl; + if (gShineStatusLine.endsWith(" connected")) { + return String("SHiNE: ") + serverLabel + " #38B26D connected#"; + } + if (gShineStatusLine.endsWith(" account not configured")) { + return String("SHiNE: ") + serverLabel + " account not configured"; + } + return String("SHiNE: ") + serverLabel + " unavailable"; +} + static String walletQrUri() { if (gDevicePubB58.isEmpty()) { return ""; @@ -3446,6 +3467,8 @@ static void loadPrefs() { loadRegisterDiagDetailsFromPrefs(); gLastRegisterDiagTime = gPrefs.getString("reg_diag_time", ""); gBalanceStatusMessage = gDevicePubB58.isEmpty() ? "Balance: secret not set" : "Balance: tap to load"; + gBalanceAutoRefreshPending = !gDevicePubB58.isEmpty(); + gLastBalanceAutoRefreshAttemptMs = 0; gAccountCheckPending = true; gLastAccountCheckMs = 0; gAccountPdaStatus = ACCOUNT_PDA_UNKNOWN; @@ -3809,6 +3832,8 @@ static void clearSecretValue() { memset(gSecretBytes, 0, sizeof(gSecretBytes)); refreshDerivedKeys(); gBalanceStatusMessage = "Balance: secret not set"; + gBalanceAutoRefreshPending = false; + gLastBalanceAutoRefreshAttemptMs = 0; saveAccountPrefs(); markAccountStateDirty(); } @@ -3821,6 +3846,8 @@ static void setSecretValue(const uint8_t *bytes32) { gSecretConfigured = true; refreshDerivedKeys(); gBalanceStatusMessage = "Balance: tap to load"; + gBalanceAutoRefreshPending = true; + gLastBalanceAutoRefreshAttemptMs = 0; saveAccountPrefs(); markAccountStateDirty(); } @@ -4382,6 +4409,39 @@ static lv_obj_t *makeButton(const char *text, return btn; } +static lv_obj_t *makeButtonLeftText(const char *text, + lv_coord_t x, + lv_coord_t y, + lv_coord_t w, + lv_coord_t h, + uint32_t bgColor, + ActionId action, + const lv_font_t *font = &lv_font_montserrat_22) { + lv_obj_t *btn = makeButton(text, x, y, w, h, bgColor, action, font); + lv_obj_t *label = lv_obj_get_child(btn, 0); + if (label) { + lv_obj_set_width(label, w - 40); + lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_LEFT, 0); + lv_obj_align(label, LV_ALIGN_LEFT_MID, 20, 0); + } + return btn; +} + +static void makeRichStatusLine(const String &text, + lv_coord_t x, + lv_coord_t y, + lv_coord_t w, + const lv_font_t *font = &lv_font_montserrat_18) { + lv_obj_t *label = lv_label_create(gRoot); + lv_label_set_recolor(label, true); + lv_label_set_text(label, text.c_str()); + lv_obj_set_width(label, w); + lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_font(label, font, 0); + lv_obj_set_style_text_color(label, lv_color_hex(0xD5DEE7), 0); + lv_obj_set_pos(label, x, y); +} + static lv_obj_t *makeKeyboardButton(const char *label, const char *token, lv_coord_t x, @@ -4431,11 +4491,11 @@ static void drawHome() { lv_label_set_text(login, loginDisplayValue().c_str()); lv_obj_set_style_text_font(login, &lv_font_montserrat_22, 0); lv_obj_set_style_text_color(login, lv_color_hex(0xD5DEE7), 0); - lv_obj_align_to(login, homeserver, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 6); + lv_obj_align_to(login, homeserver, LV_ALIGN_OUT_BOTTOM_LEFT, 22, 6); lv_obj_t *accountDot = lv_obj_create(gRoot); lv_obj_set_size(accountDot, 14, 14); - lv_obj_set_pos(accountDot, 250, 52); + lv_obj_set_pos(accountDot, 24, 52); lv_obj_set_style_radius(accountDot, 7, 0); lv_obj_set_style_border_width(accountDot, 2, 0); lv_obj_set_style_bg_opa(accountDot, LV_OPA_COVER, 0); @@ -4465,7 +4525,7 @@ static void drawHome() { lv_label_set_text(accountStatus, gAccountPdaStatusMessage.c_str()); lv_obj_set_style_text_font(accountStatus, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(accountStatus, lv_color_hex(statusColor), 0); - lv_obj_set_pos(accountStatus, 272, 48); + lv_obj_set_pos(accountStatus, 24, 80); } if (!gSecretConfigured) { @@ -4477,11 +4537,11 @@ static void drawHome() { } drawTopStatusIndicators(); - makeTitle("STATUS", 138, &lv_font_montserrat_28); - showMessageAt(wifiHomeSummary(), 214); - makeButton(balanceHomeLine().c_str(), 22, 254, 340, 56, 0x355C7D, ACTION_REFRESH_BALANCE, &lv_font_montserrat_18); + makeTitle("STATUS", 112, &lv_font_montserrat_28); + makeRichStatusLine(wifiHomeRichLine(), 24, 164, 432, &lv_font_montserrat_18); + makeRichStatusLine(shineHomeRichLine(), 24, 202, 432, &lv_font_montserrat_16); + makeButtonLeftText(balanceHomeLine().c_str(), 22, 254, 340, 56, 0x355C7D, ACTION_REFRESH_BALANCE, &lv_font_montserrat_18); makeButton("QR", 374, 254, 84, 56, 0x2A6F97, ACTION_OPEN_WALLET_QR, &lv_font_montserrat_20); - showMessageAt(shineHomeLine(), 322); if (gShowRegisterAccountButton) { makeButton("REGISTER ACCOUNT", 20, 360, 210, 78, 0x2A9D8F, ACTION_REGISTER_ACCOUNT, &lv_font_montserrat_20); } else if (gShowHomeserverPdaActionButton) { @@ -4515,8 +4575,8 @@ static void drawWalletQrScreen() { lv_obj_set_pos(error, 50, 174); } else { lv_obj_t *panel = lv_obj_create(gRoot); - lv_obj_set_size(panel, 300, 220); - lv_obj_set_pos(panel, 90, 88); + lv_obj_set_size(panel, 356, 356); + lv_obj_set_pos(panel, 62, 68); lv_obj_set_style_radius(panel, 18, 0); lv_obj_set_style_bg_color(panel, lv_color_hex(0xFFFFFF), 0); lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0); @@ -4524,7 +4584,7 @@ static void drawWalletQrScreen() { lv_obj_set_style_shadow_width(panel, 0, 0); lv_obj_clear_flag(panel, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_t *qr = lv_qrcode_create(panel, 180, lv_color_hex(0x111111), lv_color_hex(0xFFFFFF)); + lv_obj_t *qr = lv_qrcode_create(panel, 320, lv_color_hex(0x111111), lv_color_hex(0xFFFFFF)); if (qr == nullptr) { saveUiErrorDiag("Wallet QR create failed", "stage=create\n"); lv_obj_t *error = lv_label_create(panel); @@ -4558,19 +4618,9 @@ static void drawWalletQrScreen() { lv_obj_set_width(address, 420); lv_label_set_long_mode(address, LV_LABEL_LONG_WRAP); lv_obj_set_style_text_align(address, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_set_style_text_font(address, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_font(address, &lv_font_montserrat_24, 0); lv_obj_set_style_text_color(address, lv_color_hex(0xB8C6D3), 0); - lv_obj_set_pos(address, 30, 344); - - lv_obj_t *hint = lv_label_create(gRoot); - lv_label_set_text(hint, "Tap anywhere to return"); - lv_obj_set_width(hint, 420); - lv_obj_set_style_text_align(hint, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0); - lv_obj_set_style_text_color(hint, lv_color_hex(0x8398AD), 0); - lv_obj_set_pos(hint, 30, 410); - - makeVersionTag(); + lv_obj_set_pos(address, 30, 430); } static void drawSettingsMenu() { @@ -5296,6 +5346,20 @@ void loop() { manageAccountPdaRefresh(); manageShineConnection(); + if (gBalanceAutoRefreshPending && gSecretConfigured && WiFi.status() == WL_CONNECTED) { + unsigned long now = millis(); + if (gLastBalanceAutoRefreshAttemptMs == 0 || now - gLastBalanceAutoRefreshAttemptMs >= 15000) { + gLastBalanceAutoRefreshAttemptMs = now; + String message; + if (refreshWalletBalance(message)) { + gBalanceAutoRefreshPending = false; + } + if (gCurrentScreen == SCREEN_HOME) { + rebuildScreen(); + } + } + } + static unsigned long lastHomeRefreshMs = 0; if (gCurrentScreen == SCREEN_HOME && !gTouchDown && millis() - lastHomeRefreshMs >= HOME_REFRESH_MS) { lastHomeRefreshMs = millis(); diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md index b3f7f5f..7273343 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md @@ -157,14 +157,22 @@ - крупный статус регистрации; - имя логина; - имя homeserver; -- короткий статус Wi-Fi; -- короткий статус сервера; +- строку `Wi-Fi: connected|disconnected`; +- строку `SHiNE: connected|unavailable`; - короткий статус баланса. +Особенности верхнего блока: + +- зелёный/контурный статусный кружок аккаунта расположен слева от строки логина; +- блок `STATUS` поднят выше относительно предыдущей версии; +- если состояние хорошее, слово `connected` в строках `Wi-Fi` и `SHiNE` показывается зелёным. + В зоне баланса: - основная кнопка показа/обновления баланса занимает примерно 80% строки; +- текст на кнопке баланса выровнен левее центра; - справа от неё стоит отдельная кнопка `QR`; +- после старта устройства баланс пытается загрузиться автоматически, если уже есть секрет и `Wi-Fi`; - нажатие на кнопку `QR` открывает экран `WALLET_QR`. Нижние кнопки: @@ -420,6 +428,16 @@ ## Экран WALLET_QR +Экран показывает: + +- крупный реальный `QR` для строки `solana:`; +- снизу крупный текст самого адреса кошелька. + +Поведение: + +- отдельная текстовая подсказка возврата не показывается; +- возврат на главный экран выполняется обычным тапом по экрану. + Показывает: - QR-код для строки вида: diff --git a/VERSION.properties b/VERSION.properties index f92f98e..30a3d30 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.186 -server.version=1.2.175 +client.version=1.2.187 +server.version=1.2.176 From 05492306c0a117d88471f08fdadc2ff13ec9a1c583b135eeeb145812c86320cc Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sun, 14 Jun 2026 11:01:47 +0400 Subject: [PATCH 28/56] =?UTF-8?q?ESP32:=20=D1=81=D0=BC=D1=8F=D0=B3=D1=87?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20SHiNE=20reconnect=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BB=D0=BE=D1=85=D0=BE=D0=BC=20=D1=81=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...4_1610_esp32_shine_reconnect_antifreeze.md | 26 ++++++++++++ .../shine_homeserver_main.ino | 42 +++++++++++++++++-- VERSION.properties | 4 +- 3 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 Dev_Docs/Pending_Features/2026-06-14_1610_esp32_shine_reconnect_antifreeze.md diff --git a/Dev_Docs/Pending_Features/2026-06-14_1610_esp32_shine_reconnect_antifreeze.md b/Dev_Docs/Pending_Features/2026-06-14_1610_esp32_shine_reconnect_antifreeze.md new file mode 100644 index 0000000..94595b2 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-14_1610_esp32_shine_reconnect_antifreeze.md @@ -0,0 +1,26 @@ +# ESP32 антифриз при неверном SHiNE server + +- Статус: `pending` + +## Что сделано + +- уменьшены таймауты `SHiNE`-запросов и `WS`-подключения; +- повторные попытки подключения переведены на backoff: + - `10s` + - `20s` + - максимум `30s` +- пока открыт экран настроек или редактирования, новые фоновые попытки `SHiNE reconnect` не запускаются. + +## Что проверять + +1. Указать неверный адрес `SHiNE server`. +2. Дождаться статуса `unavailable`. +3. Проверить, что `HOME` не подвисает на секунды. +4. Проверить, что можно открыть `SETTINGS -> Server` и исправить адрес без лагов. +5. После сохранения правильного адреса убедиться, что reconnect снова идёт и устройство подключается. + +## Ожидаемый результат + +- при неверном адресе UI остаётся управляемым; +- вход в настройки и редактирование сервера остаются быстрыми; +- после исправления адреса устройство снова подключается без долгой паузы. diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino index 9e38eb4..a542979 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino @@ -57,8 +57,10 @@ int ge25519_frombytes(ge25519_p3 *h, const unsigned char *s); #define HOME_REFRESH_MS 1000 #define ACCOUNT_CHECK_RETRY_MS 15000 #define SHINE_PING_INTERVAL_MS 60000 -#define SHINE_RECONNECT_MS 10000 -#define SHINE_RPC_TIMEOUT_MS 9000 +#define SHINE_RECONNECT_MIN_MS 10000 +#define SHINE_RECONNECT_MAX_MS 30000 +#define SHINE_RPC_TIMEOUT_MS 2500 +#define SHINE_WS_CONNECT_TIMEOUT_MS 1500 #define TEXT_EDIT_PANEL_X 10 #define TEXT_EDIT_PANEL_Y 112 #define TEXT_EDIT_PANEL_W 460 @@ -322,6 +324,7 @@ static String gShineStoragePwd; static bool gShineAuthenticated = false; static unsigned long gLastShineAttemptMs = 0; static unsigned long gLastShinePingMs = 0; +static unsigned long gShineReconnectDelayMs = SHINE_RECONNECT_MIN_MS; static uint32_t gWsRequestCounter = 1; static int64_t gShineServerTimeOffsetMs = 0; static SimpleWebSocketClient gShineWs; @@ -975,6 +978,25 @@ static void clearShineSessionState(bool clearStoredSession) { } } +static bool shouldPauseShineReconnectForUi() { + switch (gCurrentScreen) { + case SCREEN_SETTINGS_MENU: + case SCREEN_WIFI: + case SCREEN_SERVER: + case SCREEN_ACCOUNT: + case SCREEN_ACCOUNT_HOMESERVER: + case SCREEN_ACCOUNT_SECRET: + case SCREEN_SECRET_SHOW: + case SCREEN_SECRET_GENERATE_INFO: + case SCREEN_SECRET_GENERATE_RUNNING: + case SCREEN_SECRET_GENERATE_CANCEL_CONFIRM: + case SCREEN_TEXT_EDIT: + return true; + default: + return false; + } +} + static void markAccountStateDirty() { gAccountCheckPending = true; gLastAccountCheckMs = 0; @@ -2978,7 +3000,7 @@ static bool ensureWebSocketConnected(SimpleWebSocketClient &ws, const String &ur } ws.client.setInsecure(); - ws.client.setTimeout(5000); + ws.client.setTimeout(SHINE_WS_CONNECT_TIMEOUT_MS); if (!ws.client.connect(ws.host.c_str(), ws.port)) { errorOut = "WS connect failed"; return false; @@ -3347,22 +3369,28 @@ static void manageShineConnection() { if (gLoginValue.isEmpty() || !gSecretConfigured || gHomeserverValue.isEmpty()) { gShineStatusLine = String("SHiNE: ") + serverLabel + " account not configured"; clearShineSessionState(false); + gShineReconnectDelayMs = SHINE_RECONNECT_MIN_MS; return; } if (gAccountPdaStatus != ACCOUNT_PDA_OK) { gShineStatusLine = String("SHiNE: ") + serverLabel + " account not configured"; clearShineSessionState(false); + gShineReconnectDelayMs = SHINE_RECONNECT_MIN_MS; return; } if (WiFi.status() != WL_CONNECTED) { gShineStatusLine = String("SHiNE: ") + serverLabel + " unavailable"; clearShineSessionState(false); + gShineReconnectDelayMs = SHINE_RECONNECT_MIN_MS; return; } unsigned long now = millis(); if (!gShineAuthenticated || !gShineWs.connected || !gShineWs.client.connected()) { - if (now - gLastShineAttemptMs < SHINE_RECONNECT_MS) { + if (shouldPauseShineReconnectForUi()) { + return; + } + if (now - gLastShineAttemptMs < gShineReconnectDelayMs) { return; } gLastShineAttemptMs = now; @@ -3370,9 +3398,11 @@ static void manageShineConnection() { if (ensureShineSessionAuthenticated(error)) { gShineStatusLine = String("SHiNE: ") + serverLabel + " connected"; gLastShinePingMs = now; + gShineReconnectDelayMs = SHINE_RECONNECT_MIN_MS; } else { gShineStatusLine = String("SHiNE: ") + serverLabel + " unavailable"; clearShineSessionState(false); + gShineReconnectDelayMs = min(gShineReconnectDelayMs + SHINE_RECONNECT_MIN_MS, (unsigned long)SHINE_RECONNECT_MAX_MS); } return; } @@ -3383,6 +3413,7 @@ static void manageShineConnection() { uint64_t statusCode = 0; if (jsonInt64Field(pingResp, "status", statusCode) && statusCode == 200) { gLastShinePingMs = now; + gShineReconnectDelayMs = SHINE_RECONNECT_MIN_MS; gShineStatusLine = String("SHiNE: ") + serverLabel + " connected"; return; } @@ -3398,6 +3429,7 @@ static void manageShineConnection() { printRegisterDiagToSerial(); gShineStatusLine = String("SHiNE: ") + serverLabel + " unavailable"; clearShineSessionState(false); + gShineReconnectDelayMs = min(gShineReconnectDelayMs + SHINE_RECONNECT_MIN_MS, (unsigned long)SHINE_RECONNECT_MAX_MS); } } @@ -4012,6 +4044,8 @@ static void applyEditorValue() { saveServerPrefs(); gServerStatusMessage = "Shine server saved"; clearShineSessionState(false); + gShineReconnectDelayMs = SHINE_RECONNECT_MIN_MS; + gLastShineAttemptMs = 0; gShineStatusLine = "SHiNE: reconnect pending"; showScreen(SCREEN_SERVER); return; diff --git a/VERSION.properties b/VERSION.properties index 30a3d30..991fb9e 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.187 -server.version=1.2.176 +client.version=1.2.188 +server.version=1.2.177 From 373f88086e3796dc8c7d0e55ffbdac84850490bba673c4a1c2359e0a77ec6b47 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sun, 14 Jun 2026 11:16:49 +0400 Subject: [PATCH 29/56] =?UTF-8?q?ESP32:=20=D0=BF=D0=BE=D0=B4=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D0=B2=D0=B5=D1=80=D1=82=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=80=D0=B8=D1=82?= =?UTF-8?q?=D0=BC=20wallet=20QR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shine_homeserver_main/shine_homeserver_main.ino | 6 +++--- VERSION.properties | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino index a542979..89e0076 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino @@ -4596,7 +4596,7 @@ static void drawWalletQrScreen() { lv_label_set_text(title, "WALLET QR"); lv_obj_set_style_text_font(title, &lv_font_montserrat_24, 0); lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), 0); - lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 22); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 12); String qrPayload = walletQrUri(); if (qrPayload.isEmpty()) { saveUiErrorDiag("Wallet QR unavailable", "wallet_address_empty=true\n"); @@ -4610,7 +4610,7 @@ static void drawWalletQrScreen() { } else { lv_obj_t *panel = lv_obj_create(gRoot); lv_obj_set_size(panel, 356, 356); - lv_obj_set_pos(panel, 62, 68); + lv_obj_set_pos(panel, 62, 54); lv_obj_set_style_radius(panel, 18, 0); lv_obj_set_style_bg_color(panel, lv_color_hex(0xFFFFFF), 0); lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0); @@ -4654,7 +4654,7 @@ static void drawWalletQrScreen() { lv_obj_set_style_text_align(address, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_style_text_font(address, &lv_font_montserrat_24, 0); lv_obj_set_style_text_color(address, lv_color_hex(0xB8C6D3), 0); - lv_obj_set_pos(address, 30, 430); + lv_obj_set_pos(address, 30, 438); } static void drawSettingsMenu() { diff --git a/VERSION.properties b/VERSION.properties index 991fb9e..56cb018 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.188 -server.version=1.2.177 +client.version=1.2.189 +server.version=1.2.178 From 5d13112b00963222cb108bb9a06862959c8d6b2c68275212b99e642650b3a8a0 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sun, 14 Jun 2026 11:22:11 +0400 Subject: [PATCH 30/56] =?UTF-8?q?ESP32:=20=D1=83=D0=BC=D0=B5=D0=BD=D1=8C?= =?UTF-8?q?=D1=88=D0=B8=D1=82=D1=8C=20=D1=80=D0=B0=D0=BC=D0=BA=D1=83=20wal?= =?UTF-8?q?let=20QR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shine_homeserver_main/shine_homeserver_main.ino | 6 +++--- VERSION.properties | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino index 89e0076..6255cb3 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino @@ -4609,8 +4609,8 @@ static void drawWalletQrScreen() { lv_obj_set_pos(error, 50, 174); } else { lv_obj_t *panel = lv_obj_create(gRoot); - lv_obj_set_size(panel, 356, 356); - lv_obj_set_pos(panel, 62, 54); + lv_obj_set_size(panel, 336, 336); + lv_obj_set_pos(panel, 72, 54); lv_obj_set_style_radius(panel, 18, 0); lv_obj_set_style_bg_color(panel, lv_color_hex(0xFFFFFF), 0); lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0); @@ -4654,7 +4654,7 @@ static void drawWalletQrScreen() { lv_obj_set_style_text_align(address, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_style_text_font(address, &lv_font_montserrat_24, 0); lv_obj_set_style_text_color(address, lv_color_hex(0xB8C6D3), 0); - lv_obj_set_pos(address, 30, 438); + lv_obj_set_pos(address, 30, 404); } static void drawSettingsMenu() { diff --git a/VERSION.properties b/VERSION.properties index 56cb018..d8a6c6e 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.189 -server.version=1.2.178 +client.version=1.2.190 +server.version=1.2.179 From 3e04727022d0a5c719c056198564295efde78194314f7c8077949b75e97de0d8 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sun, 14 Jun 2026 18:21:23 +0400 Subject: [PATCH 31/56] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20ESP=20pairing=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=20=D0=B4=D0=BE=D0=B2=D0=B5=D1=80=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5?= =?UTF-8?q?=20=D1=81=D0=B5=D1=81=D1=81=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dev_Docs/API/02_Authentication_API.md | 36 ++- Dev_Docs/API/03_Session_Management_API.md | 235 +++++++++++++++++ Dev_Docs/API/09_Operations_Index.md | 6 + .../2026-06-14_1715_server_esp_pairing.md | 23 ++ .../ESP_Pairing_и_режимы_подключения.md | 106 ++++++++ .../java/shine/db/DatabaseInitializer.java | 41 +++ .../java/shine/db/SqliteDbController.java | 65 ++++- .../shine/db/dao/EspPairingRequestsDAO.java | 249 ++++++++++++++++++ .../shine/db/dao/EspPairingSettingsDAO.java | 101 +++++++ .../db/entities/EspPairingRequestEntry.java | 149 +++++++++++ .../db/entities/EspPairingSettingsEntry.java | 77 ++++++ .../ws_protocol/JSON/JsonHandlerRegistry.java | 24 ++ .../JSON/handlers/auth/EspPairingSupport.java | 152 +++++++++++ .../auth/Net_ApproveEspPairing_Handler.java | 63 +++++ .../auth/Net_GetEspPairingStatus_Handler.java | 50 ++++ .../Net_ListEspPairingRequests_Handler.java | 58 ++++ .../auth/Net_RejectEspPairing_Handler.java | 58 ++++ .../auth/Net_StartEspPairing_Handler.java | 151 +++++++++++ .../Net_UpsertEspPairingSettings_Handler.java | 56 ++++ .../Net_ApproveEspPairing_Request.java | 24 ++ .../Net_ApproveEspPairing_Response.java | 24 ++ .../Net_GetEspPairingStatus_Request.java | 15 ++ .../Net_GetEspPairingStatus_Response.java | 78 ++++++ .../Net_ListEspPairingRequests_Request.java | 6 + .../Net_ListEspPairingRequests_Response.java | 120 +++++++++ .../Net_RejectEspPairing_Request.java | 24 ++ .../Net_RejectEspPairing_Response.java | 24 ++ .../entyties/Net_StartEspPairing_Request.java | 60 +++++ .../Net_StartEspPairing_Response.java | 60 +++++ .../Net_UpsertEspPairingSettings_Request.java | 33 +++ ...Net_UpsertEspPairingSettings_Response.java | 24 ++ .../java/test/it/cases/IT_07_EspPairing.java | 170 ++++++++++++ .../java/test/it/runner/IT_RunAllMain.java | 6 +- .../java/test/it/utils/json/JsonBuilders.java | 106 +++++++- .../java/test/it/utils/json/JsonParsers.java | 28 ++ VERSION.properties | 4 +- 36 files changed, 2500 insertions(+), 6 deletions(-) create mode 100644 Dev_Docs/Pending_Features/2026-06-14_1715_server_esp_pairing.md create mode 100644 Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingSettingsDAO.java create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/entities/EspPairingRequestEntry.java create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/entities/EspPairingSettingsEntry.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/EspPairingSupport.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ApproveEspPairing_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_GetEspPairingStatus_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListEspPairingRequests_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_RejectEspPairing_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_UpsertEspPairingSettings_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ApproveEspPairing_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ApproveEspPairing_Response.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_GetEspPairingStatus_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_GetEspPairingStatus_Response.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListEspPairingRequests_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListEspPairingRequests_Response.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RejectEspPairing_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RejectEspPairing_Response.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_StartEspPairing_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_StartEspPairing_Response.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_UpsertEspPairingSettings_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_UpsertEspPairingSettings_Response.java create mode 100644 SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java diff --git a/Dev_Docs/API/02_Authentication_API.md b/Dev_Docs/API/02_Authentication_API.md index f1b1e45..d70c27b 100644 --- a/Dev_Docs/API/02_Authentication_API.md +++ b/Dev_Docs/API/02_Authentication_API.md @@ -2,7 +2,7 @@ Этот файл описывает именно этапы авторизации клиента, то есть как создать новую сессию и как войти в уже существующую. -Здесь четыре метода: +Здесь четыре базовых метода обычной авторизации: - `AuthChallenge` - `CreateAuthSession` @@ -36,6 +36,16 @@ Ниже в документе сначала описан сценарий, а потом зафиксированы точные форматы запросов и ответов. +Отдельно появился новый серверный сценарий pairing через доверенный homeserver/ESP. Он не заменяет обычный вход и описан в: + +- `Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md` + +Кратко: + +- `AuthChallenge/CreateAuthSession` и `SessionChallenge/SessionLogin` остаются каноническими потоками обычной авторизации; +- pairing через ESP идёт отдельными `op` и только подготавливает безопасное добавление новой сессии; +- решение об одобрении pairing принимает любая уже авторизованная доверенная сессия пользователя. + ## 1. Поток авторизации Поддерживаются два сценария: @@ -289,6 +299,30 @@ SESSION_LOGIN:{sessionId}:{timeMs}:{nonce} --- +## 6. Pairing через homeserver/ESP + +Новые `op`, относящиеся к этому сценарию: + +- `UpsertEspPairingSettings` +- `StartEspPairing` +- `ListEspPairingRequests` +- `ApproveEspPairing` +- `RejectEspPairing` +- `GetEspPairingStatus` + +В этом потоке: + +- новое устройство не владеет `deviceKey` и не проходит обычный `CreateAuthSession`; +- пароль проверяется сервером только как фильтр; +- решение об одобрении принимает уже авторизованная доверенная сессия пользователя; +- сервер не расшифровывает `encryptedPayload` и не становится источником приватных ключей. + +Точные форматы этих операций см. в `03_Session_Management_API.md` и в протокольном документе: + +- `Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md` + +--- + ## 6. Пример ошибки ```json diff --git a/Dev_Docs/API/03_Session_Management_API.md b/Dev_Docs/API/03_Session_Management_API.md index 4a53969..13715a5 100644 --- a/Dev_Docs/API/03_Session_Management_API.md +++ b/Dev_Docs/API/03_Session_Management_API.md @@ -7,6 +7,18 @@ - `ListSessions` — получить список активных сессий пользователя; - `CloseActiveSession` — закрыть одну из активных сессий. +Дополнительно в этом же слое управления сессиями появился сценарий pairing через доверенную уже авторизованную сессию пользователя: + +- `UpsertEspPairingSettings` +- `ListEspPairingRequests` +- `ApproveEspPairing` +- `RejectEspPairing` + +Анонимное новое устройство работает с двумя связанными операциями: + +- `StartEspPairing` +- `GetEspPairingStatus` + Логика раздела такая: - сначала пользователь проходит `SessionLogin`; @@ -151,3 +163,226 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M Важно: это **не человеко-читаемое имя**, а непрозрачный идентификатор. Нужно передавать его как есть, без нормализации регистра и без URL-экранирования внутри JSON. + +--- + +## 5. ESP pairing через доверенную сессию + +Этот блок относится к сценарию добавления новой сессии через доверенное устройство пользователя. + +### 5.1. `UpsertEspPairingSettings` + +Доступно для любой уже авторизованной доверенной сессии пользователя. + +### Запрос + +```json +{ + "op": "UpsertEspPairingSettings", + "requestId": "esp-set-001", + "payload": { + "enabled": true, + "passwordHash": "argon2id$...", + "ttlSeconds": 180 + } +} +``` + +### Успешный ответ + +```json +{ + "op": "UpsertEspPairingSettings", + "requestId": "esp-set-001", + "status": 200, + "ok": true, + "payload": { + "enabled": true, + "ttlSeconds": 180 + } +} +``` + +### Ошибки + +- `400 / EMPTY_PASSWORD_HASH` — попытка включить pairing без `passwordHash`. +- `463 / PAIRING_REQUIRES_AUTH_SESSION` — операция вызвана без уже авторизованной доверенной сессии пользователя. + +### 5.2. `StartEspPairing` + +Эта операция доступна без уже существующей пользовательской сессии. + +### Запрос + +```json +{ + "op": "StartEspPairing", + "requestId": "esp-start-001", + "payload": { + "login": "alice", + "passwordHash": "argon2id$...", + "requesterSessionKey": "ed25519/BASE64_PUBLIC_KEY", + "requesterSessionType": 1, + "requesterClientPlatform": "Android", + "payloadType": 1 + } +} +``` + +Поле `trustedSessionOnline` показывает, что у пользователя сейчас есть хотя бы одна онлайн доверенная сессия, способная принять pairing-заявку. + +### Успешный ответ + +```json +{ + "op": "StartEspPairing", + "requestId": "esp-start-001", + "status": 200, + "ok": true, + "payload": { + "pairingId": "base64url", + "state": "created", + "shortCode": "4920709", + "fingerprintB58": "ASvYDPQidnAroKzQjtCjTuEQE8ckktV5nmmhYRhDzGaA", + "expiresAtMs": 1781441990538, + "trustedSessionOnline": true + } +} +``` + +### Ошибки + +- `400 / EMPTY_LOGIN` +- `400 / EMPTY_PASSWORD_HASH` +- `400 / EMPTY_REQUESTER_SESSION_KEY` +- `400 / BAD_REQUESTER_SESSION_KEY` +- `400 / BAD_SESSION_TYPE` +- `400 / BAD_PAYLOAD_TYPE` +- `422 / PAIRING_NOT_AVAILABLE` +- `422 / PAIRING_PASSWORD_INVALID` +- `429 / PAIRING_RATE_LIMITED` + +### 5.3. `ListEspPairingRequests` + +Доступно для любой уже авторизованной доверенной сессии пользователя. + +### Успешный ответ + +```json +{ + "op": "ListEspPairingRequests", + "requestId": "esp-list-001", + "status": 200, + "ok": true, + "payload": { + "requests": [ + { + "pairingId": "base64url", + "state": "created", + "requesterSessionKey": "ed25519/BASE64_PUBLIC_KEY", + "requesterSessionType": 1, + "requesterClientPlatform": "Android", + "payloadType": 1, + "shortCode": "4920709", + "fingerprintB58": "ASvYDPQidnAroKzQjtCjTuEQE8ckktV5nmmhYRhDzGaA", + "createdAtMs": 1781441810538, + "expiresAtMs": 1781441990538, + "deliveredToHomeserver": true + } + ] + } +} +``` + +### Ошибки + +- `463 / PAIRING_REQUIRES_AUTH_SESSION` + +### 5.4. `ApproveEspPairing` + +Доступно для любой уже авторизованной доверенной сессии пользователя. + +### Запрос + +```json +{ + "op": "ApproveEspPairing", + "requestId": "esp-approve-001", + "payload": { + "pairingId": "base64url", + "encryptedPayload": "BASE64_OR_OTHER_OPAQUE_PAYLOAD" + } +} +``` + +### Успешный ответ + +```json +{ + "op": "ApproveEspPairing", + "requestId": "esp-approve-001", + "status": 200, + "ok": true, + "payload": { + "pairingId": "base64url", + "state": "approved" + } +} +``` + +### Ошибки + +- `400 / EMPTY_PAIRING_ID` +- `400 / EMPTY_ENCRYPTED_PAYLOAD` +- `404 / PAIRING_NOT_FOUND` +- `422 / PAIRING_OF_ANOTHER_USER` +- `422 / PAIRING_NOT_PENDING` +- `422 / PAIRING_EXPIRED` +- `463 / PAIRING_REQUIRES_AUTH_SESSION` + +### 5.5. `RejectEspPairing` + +Доступно для любой уже авторизованной доверенной сессии пользователя. Похоже на approve, но переводит заявку в `state=rejected`. + +### 5.6. `GetEspPairingStatus` + +Операция для нового устройства. + +### Запрос + +```json +{ + "op": "GetEspPairingStatus", + "requestId": "esp-status-001", + "payload": { + "pairingId": "base64url" + } +} +``` + +### Успешный ответ после approve + +```json +{ + "op": "GetEspPairingStatus", + "requestId": "esp-status-001", + "status": 200, + "ok": true, + "payload": { + "pairingId": "base64url", + "state": "approved", + "shortCode": "4920709", + "fingerprintB58": "ASvYDPQidnAroKzQjtCjTuEQE8ckktV5nmmhYRhDzGaA", + "payloadType": 1, + "encryptedPayload": "AQIDBA==", + "expiresAtMs": 1781441990538 + } +} +``` + +### Возможные `state` + +- `created` +- `approved` +- `rejected` +- `expired` diff --git a/Dev_Docs/API/09_Operations_Index.md b/Dev_Docs/API/09_Operations_Index.md index 0033b48..83c7806 100644 --- a/Dev_Docs/API/09_Operations_Index.md +++ b/Dev_Docs/API/09_Operations_Index.md @@ -19,6 +19,12 @@ | `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии | | `SessionChallenge` | `02_Authentication_API.md` | challenge для входа в существующую сессию | | `SessionLogin` | `02_Authentication_API.md` | вход в существующую сессию | +| `UpsertEspPairingSettings` | `03_Session_Management_API.md` | включение/обновление pairing-настроек доверенной сессией | +| `StartEspPairing` | `03_Session_Management_API.md` | создание pairing-заявки для нового устройства | +| `ListEspPairingRequests` | `03_Session_Management_API.md` | список активных pairing-заявок для доверенной сессии | +| `ApproveEspPairing` | `03_Session_Management_API.md` | подтверждение pairing-заявки доверенной сессией | +| `RejectEspPairing` | `03_Session_Management_API.md` | отклонение pairing-заявки доверенной сессией | +| `GetEspPairingStatus` | `03_Session_Management_API.md` | чтение статуса и результата pairing-заявки | | `ListSessions` | `03_Session_Management_API.md` | список активных сессий | | `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии | | `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн | diff --git a/Dev_Docs/Pending_Features/2026-06-14_1715_server_esp_pairing.md b/Dev_Docs/Pending_Features/2026-06-14_1715_server_esp_pairing.md new file mode 100644 index 0000000..4d16fe1 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-14_1715_server_esp_pairing.md @@ -0,0 +1,23 @@ +# server esp pairing + +- краткое описание фичи: + - на сервере добавлен отдельный pairing-сценарий для подключения нового устройства через доверенную уже авторизованную сессию пользователя без выдачи приватных ключей сервером; + - добавлены `op`: `UpsertEspPairingSettings`, `StartEspPairing`, `ListEspPairingRequests`, `ApproveEspPairing`, `RejectEspPairing`, `GetEspPairingStatus`; + - подтверждать pairing может любая доверенная сессия пользователя. + +- что именно проверять: + - любая уже авторизованная сессия пользователя включает pairing и задаёт `passwordHash`; + - новое устройство создаёт заявку через `StartEspPairing`; + - другая доверенная сессия пользователя видит заявку в `ListEspPairingRequests`; + - доверенная сессия подтверждает заявку через `ApproveEspPairing`; + - новое устройство получает `approved + encryptedPayload` через `GetEspPairingStatus`; + - неавторизованное новое устройство не может вызывать управляющие pairing-операции. + +- ожидаемый результат: + - заявка создаётся со статусом `created`; + - у заявки есть `pairingId`, `shortCode`, `fingerprintB58`, `expiresAtMs`; + - после approve статус становится `approved`, а `encryptedPayload` возвращается новому устройству; + - неавторизованное соединение получает отказ `463 / PAIRING_REQUIRES_AUTH_SESSION`. + +- статус: + - `pending` diff --git a/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md b/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md new file mode 100644 index 0000000..282bff1 --- /dev/null +++ b/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md @@ -0,0 +1,106 @@ +# ESP Pairing и режимы подключения + +Этот документ фиксирует текущие и новые режимы входа/подключения в SHiNE без клиентской UI-реализации. Он нужен как отдельная точка входа по сценариям подключения, чтобы не смешивать обычную авторизацию и серверный pairing через доверенное уже авторизованное устройство пользователя. + +## 1. Текущие режимы + +### 1. Создание новой сессии через `deviceKey` + +Поток: + +`AuthChallenge -> CreateAuthSession` + +Смысл: + +- новое устройство уже владеет приватным `deviceKey`; +- сервер проверяет подпись `deviceKey`; +- создаётся обычная активная сессия пользователя; +- этот поток остаётся без изменений. + +### 2. Повторный вход в существующую сессию через `sessionKey` + +Поток: + +`SessionChallenge -> SessionLogin` + +Смысл: + +- устройство уже владеет приватным `sessionKey`; +- сервер проверяет подпись `sessionKey`; +- соединение снова входит в существующую сессию; +- этот поток тоже остаётся без изменений. + +## 2. Новый режим: добавление сессии через доверенное устройство пользователя + +Новый поток не заменяет обычный логин, а живёт рядом с ним. + +Цель: + +- новое устройство знает `login + pairing password`; +- сервер использует пароль только как фильтр от мусора; +- реальное доверие даёт любая уже онлайн доверенная сессия пользователя; +- сервер не выдаёт приватные ключи сам от себя. + +Поток версии `v1`: + +1. Любая доверенная сессия пользователя создаёт на сервере pairing-настройку: + `UpsertEspPairingSettings` +2. Новое устройство создаёт pending-заявку: + `StartEspPairing` +3. Онлайн доверенная сессия видит список активных заявок: + `ListEspPairingRequests` +4. Доверенная сессия либо подтверждает заявку: + `ApproveEspPairing` +5. Либо отклоняет: + `RejectEspPairing` +6. Новое устройство читает результат: + `GetEspPairingStatus` + +## 3. Что именно делает сервер + +- хранит включённость pairing и opaque `passwordHash`; +- хранит pending/approved/rejected pairing-заявки; +- рассчитывает короткий код `shortCode` из `7` цифр; +- рассчитывает длинный `fingerprintB58` из `SHA-256` заявки; +- уведомляет онлайн доверенные сессии событием `IncomingEspPairingRequest`, если такие сессии подключены; +- хранит переданный `encryptedPayload` как непрозрачную строку и не анализирует его содержимое. + +## 4. Чего сервер в этой версии не делает + +- не передаёт приватный `deviceKey`; +- не расшифровывает `encryptedPayload`; +- не проверяет криптографию содержимого payload; +- не делает клиентский UI; +- не навязывает конкретную схему `Ed25519 -> X25519` в коде сервера. + +Это намеренно: серверная версия `v1` подготавливает безопасный каркас маршрутизации и состояния, а настоящая E2E-логика упаковки ключей будет жить на клиентах и ESP-устройствах. + +## 5. Роли и ограничения + +- любая уже авторизованная доверенная сессия пользователя может вызывать: + - `UpsertEspPairingSettings` + - `ListEspPairingRequests` + - `ApproveEspPairing` + - `RejectEspPairing` +- новое устройство может вызвать `StartEspPairing` и `GetEspPairingStatus` без уже существующей авторизованной сессии; +- `payloadType` поддерживается в вариантах: + - `1` — минимальный пакет + - `2` — расширенный пакет + - `3` — полный пакет + +Сервер не интерпретирует эти три типа глубже, а только фиксирует их в состоянии заявки. + +## 6. Статусы pairing-заявки + +- `created` — заявка создана и ждёт решения доверенной сессии; +- `approved` — доверенная сессия подтвердила и приложила `encryptedPayload`; +- `rejected` — доверенная сессия отклонила заявку; +- `expired` — TTL заявки истёк до подтверждения. + +## 7. Практический смысл + +Эта схема даёт нужное разделение доверия: + +- пароль на сервере только отсеивает лишних; +- онлайн доверенная сессия решает, добавлять ли новую сессию; +- сервер остаётся маршрутизатором и хранилищем состояния, а не владельцем секретов. diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index b664bbb..a3f210c 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -190,6 +190,47 @@ public final class DatabaseInitializer { ON active_sessions (login); """); + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS esp_pairing_settings ( + login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE, + enabled INTEGER NOT NULL DEFAULT 0, + password_hash TEXT NOT NULL DEFAULT '', + ttl_seconds INTEGER NOT NULL DEFAULT 300, + failed_attempts INTEGER NOT NULL DEFAULT 0, + first_failed_at_ms INTEGER NOT NULL DEFAULT 0, + blocked_until_ms INTEGER NOT NULL DEFAULT 0, + updated_at_ms INTEGER NOT NULL, + FOREIGN KEY (login) REFERENCES solana_users(login) + ); + """); + + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS esp_pairing_requests ( + pairing_id TEXT NOT NULL PRIMARY KEY, + login TEXT NOT NULL, + requester_session_key TEXT NOT NULL, + requester_session_type INTEGER NOT NULL DEFAULT 1, + requester_client_platform TEXT NOT NULL DEFAULT '', + payload_type INTEGER NOT NULL, + status TEXT NOT NULL, + short_code TEXT NOT NULL, + fingerprint_b58 TEXT NOT NULL, + encrypted_payload TEXT, + reject_reason TEXT, + approved_by_session_id TEXT, + created_at_ms INTEGER NOT NULL, + expires_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + delivered_to_homeserver INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (login) REFERENCES solana_users(login) + ); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_esp_pairing_requests_login_status + ON esp_pairing_requests (login, status, expires_at_ms); + """); + // 3. users_params st.executeUpdate(""" CREATE TABLE IF NOT EXISTS users_params ( diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java index afa321b..35bda33 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java @@ -14,7 +14,7 @@ import java.sql.Statement; public final class SqliteDbController { private static volatile SqliteDbController instance; - private static final int LATEST_SCHEMA_VERSION = 4; + private static final int LATEST_SCHEMA_VERSION = 5; private final String jdbcUrl; @@ -87,6 +87,7 @@ public final class SqliteDbController { case 2 -> migrateToV2(); case 3 -> migrateToV3(); case 4 -> migrateToV4(); + case 5 -> migrateToV5(); default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion); } } @@ -189,6 +190,25 @@ public final class SqliteDbController { } } + private void migrateToV5() { + try (Connection c = DriverManager.getConnection(jdbcUrl); + Statement st = c.createStatement()) { + c.setAutoCommit(false); + try { + ensureEspPairingTables(st); + setSchemaVersion(c, 5); + c.commit(); + } catch (Exception e) { + try { c.rollback(); } catch (Exception ignored) {} + throw new RuntimeException("DB migration to v5 failed", e); + } finally { + try { c.setAutoCommit(true); } catch (Exception ignored) {} + } + } catch (SQLException e) { + throw new RuntimeException("DB migration to v5 failed", e); + } + } + private static void ensureChat200StateTables(Statement st) throws SQLException { st.executeUpdate(""" CREATE TABLE IF NOT EXISTS chat200_state ( @@ -266,6 +286,49 @@ public final class SqliteDbController { st.executeUpdate("ALTER TABLE active_sessions ADD COLUMN client_platform TEXT NOT NULL DEFAULT ''"); } + private static void ensureEspPairingTables(Statement st) throws SQLException { + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS esp_pairing_settings ( + login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE, + enabled INTEGER NOT NULL DEFAULT 0, + password_hash TEXT NOT NULL DEFAULT '', + ttl_seconds INTEGER NOT NULL DEFAULT 300, + failed_attempts INTEGER NOT NULL DEFAULT 0, + first_failed_at_ms INTEGER NOT NULL DEFAULT 0, + blocked_until_ms INTEGER NOT NULL DEFAULT 0, + updated_at_ms INTEGER NOT NULL, + FOREIGN KEY (login) REFERENCES solana_users(login) + ); + """); + + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS esp_pairing_requests ( + pairing_id TEXT NOT NULL PRIMARY KEY, + login TEXT NOT NULL, + requester_session_key TEXT NOT NULL, + requester_session_type INTEGER NOT NULL DEFAULT 1, + requester_client_platform TEXT NOT NULL DEFAULT '', + payload_type INTEGER NOT NULL, + status TEXT NOT NULL, + short_code TEXT NOT NULL, + fingerprint_b58 TEXT NOT NULL, + encrypted_payload TEXT, + reject_reason TEXT, + approved_by_session_id TEXT, + created_at_ms INTEGER NOT NULL, + expires_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + delivered_to_homeserver INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (login) REFERENCES solana_users(login) + ); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_esp_pairing_requests_login_status + ON esp_pairing_requests (login, status, expires_at_ms); + """); + } + private static boolean columnExists(Connection c, String tableName, String columnName) throws SQLException { try (Statement probe = c.createStatement(); ResultSet rs = probe.executeQuery("PRAGMA table_info(" + tableName + ")")) { diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java new file mode 100644 index 0000000..0aaede8 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java @@ -0,0 +1,249 @@ +package shine.db.dao; + +import shine.db.SqliteDbController; +import shine.db.entities.EspPairingRequestEntry; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +public final class EspPairingRequestsDAO { + + private static volatile EspPairingRequestsDAO instance; + private final SqliteDbController db = SqliteDbController.getInstance(); + + private EspPairingRequestsDAO() { } + + public static EspPairingRequestsDAO getInstance() { + if (instance == null) { + synchronized (EspPairingRequestsDAO.class) { + if (instance == null) instance = new EspPairingRequestsDAO(); + } + } + return instance; + } + + public void insert(EspPairingRequestEntry entry) throws SQLException { + try (Connection c = db.getConnection()) { + insert(c, entry); + } + } + + public void insert(Connection c, EspPairingRequestEntry entry) throws SQLException { + String sql = """ + INSERT INTO esp_pairing_requests ( + pairing_id, + login, + requester_session_key, + requester_session_type, + requester_client_platform, + payload_type, + status, + short_code, + fingerprint_b58, + encrypted_payload, + reject_reason, + approved_by_session_id, + created_at_ms, + expires_at_ms, + updated_at_ms, + delivered_to_homeserver + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, entry.getPairingId()); + ps.setString(2, entry.getLogin()); + ps.setString(3, entry.getRequesterSessionKey()); + ps.setInt(4, entry.getRequesterSessionType()); + ps.setString(5, entry.getRequesterClientPlatform()); + ps.setInt(6, entry.getPayloadType()); + ps.setString(7, entry.getStatus()); + ps.setString(8, entry.getShortCode()); + ps.setString(9, entry.getFingerprintB58()); + ps.setString(10, entry.getEncryptedPayload()); + ps.setString(11, entry.getRejectReason()); + ps.setString(12, entry.getApprovedBySessionId()); + ps.setLong(13, entry.getCreatedAtMs()); + ps.setLong(14, entry.getExpiresAtMs()); + ps.setLong(15, entry.getUpdatedAtMs()); + ps.setInt(16, entry.isDeliveredToHomeserver() ? 1 : 0); + ps.executeUpdate(); + } + } + + public EspPairingRequestEntry getByPairingId(String pairingId) throws SQLException { + try (Connection c = db.getConnection()) { + return getByPairingId(c, pairingId); + } + } + + public EspPairingRequestEntry getByPairingId(Connection c, String pairingId) throws SQLException { + String sql = """ + SELECT pairing_id, login, requester_session_key, requester_session_type, requester_client_platform, + payload_type, status, short_code, fingerprint_b58, encrypted_payload, reject_reason, + approved_by_session_id, created_at_ms, expires_at_ms, updated_at_ms, delivered_to_homeserver + FROM esp_pairing_requests + WHERE pairing_id = ? + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, pairingId); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return mapRow(rs); + } + } + } + + public List listActiveByLogin(String login, long nowMs) throws SQLException { + try (Connection c = db.getConnection()) { + return listActiveByLogin(c, login, nowMs); + } + } + + public List listActiveByLogin(Connection c, String login, long nowMs) throws SQLException { + String sql = """ + SELECT pairing_id, login, requester_session_key, requester_session_type, requester_client_platform, + payload_type, status, short_code, fingerprint_b58, encrypted_payload, reject_reason, + approved_by_session_id, created_at_ms, expires_at_ms, updated_at_ms, delivered_to_homeserver + FROM esp_pairing_requests + WHERE login = ? COLLATE NOCASE + AND expires_at_ms > ? + AND status IN ('created', 'approved', 'rejected') + ORDER BY created_at_ms DESC + """; + List list = new ArrayList<>(); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + ps.setLong(2, nowMs); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) list.add(mapRow(rs)); + } + } + return list; + } + + public int countRecentByLoginAndStatuses(String login, long sinceMs, String... statuses) throws SQLException { + try (Connection c = db.getConnection()) { + StringBuilder sql = new StringBuilder(""" + SELECT COUNT(*) + FROM esp_pairing_requests + WHERE login = ? COLLATE NOCASE + AND created_at_ms >= ? + AND status IN ( + """); + for (int i = 0; i < statuses.length; i++) { + if (i > 0) sql.append(", "); + sql.append("?"); + } + sql.append(")"); + try (PreparedStatement ps = c.prepareStatement(sql.toString())) { + ps.setString(1, login); + ps.setLong(2, sinceMs); + for (int i = 0; i < statuses.length; i++) { + ps.setString(3 + i, statuses[i]); + } + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? rs.getInt(1) : 0; + } + } + } + } + + public void updateDeliveryFlag(String pairingId, boolean delivered, long updatedAtMs) throws SQLException { + updateSimple(pairingId, """ + UPDATE esp_pairing_requests + SET delivered_to_homeserver = ?, updated_at_ms = ? + WHERE pairing_id = ? + """, ps -> { + ps.setInt(1, delivered ? 1 : 0); + ps.setLong(2, updatedAtMs); + ps.setString(3, pairingId); + }); + } + + public void markApproved(String pairingId, String encryptedPayload, String approvedBySessionId, long updatedAtMs) throws SQLException { + updateSimple(pairingId, """ + UPDATE esp_pairing_requests + SET status = 'approved', + encrypted_payload = ?, + approved_by_session_id = ?, + reject_reason = NULL, + updated_at_ms = ? + WHERE pairing_id = ? + """, ps -> { + ps.setString(1, encryptedPayload); + ps.setString(2, approvedBySessionId); + ps.setLong(3, updatedAtMs); + ps.setString(4, pairingId); + }); + } + + public void markRejected(String pairingId, String rejectReason, String approvedBySessionId, long updatedAtMs) throws SQLException { + updateSimple(pairingId, """ + UPDATE esp_pairing_requests + SET status = 'rejected', + reject_reason = ?, + approved_by_session_id = ?, + encrypted_payload = NULL, + updated_at_ms = ? + WHERE pairing_id = ? + """, ps -> { + ps.setString(1, rejectReason); + ps.setString(2, approvedBySessionId); + ps.setLong(3, updatedAtMs); + ps.setString(4, pairingId); + }); + } + + public int expirePending(long nowMs) throws SQLException { + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement(""" + UPDATE esp_pairing_requests + SET status = 'expired', + updated_at_ms = ? + WHERE status = 'created' + AND expires_at_ms <= ? + """)) { + ps.setLong(1, nowMs); + ps.setLong(2, nowMs); + return ps.executeUpdate(); + } + } + + private interface PreparedStatementSetter { + void accept(PreparedStatement ps) throws SQLException; + } + + private void updateSimple(String pairingId, String sql, PreparedStatementSetter setter) throws SQLException { + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement(sql)) { + setter.accept(ps); + ps.executeUpdate(); + } + } + + private static EspPairingRequestEntry mapRow(ResultSet rs) throws SQLException { + EspPairingRequestEntry entry = new EspPairingRequestEntry(); + entry.setPairingId(rs.getString("pairing_id")); + entry.setLogin(rs.getString("login")); + entry.setRequesterSessionKey(rs.getString("requester_session_key")); + entry.setRequesterSessionType(rs.getInt("requester_session_type")); + entry.setRequesterClientPlatform(rs.getString("requester_client_platform")); + entry.setPayloadType(rs.getInt("payload_type")); + entry.setStatus(rs.getString("status")); + entry.setShortCode(rs.getString("short_code")); + entry.setFingerprintB58(rs.getString("fingerprint_b58")); + entry.setEncryptedPayload(rs.getString("encrypted_payload")); + entry.setRejectReason(rs.getString("reject_reason")); + entry.setApprovedBySessionId(rs.getString("approved_by_session_id")); + entry.setCreatedAtMs(rs.getLong("created_at_ms")); + entry.setExpiresAtMs(rs.getLong("expires_at_ms")); + entry.setUpdatedAtMs(rs.getLong("updated_at_ms")); + entry.setDeliveredToHomeserver(rs.getInt("delivered_to_homeserver") != 0); + return entry; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingSettingsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingSettingsDAO.java new file mode 100644 index 0000000..873ba7d --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingSettingsDAO.java @@ -0,0 +1,101 @@ +package shine.db.dao; + +import shine.db.SqliteDbController; +import shine.db.entities.EspPairingSettingsEntry; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +public final class EspPairingSettingsDAO { + + private static volatile EspPairingSettingsDAO instance; + private final SqliteDbController db = SqliteDbController.getInstance(); + + private EspPairingSettingsDAO() { } + + public static EspPairingSettingsDAO getInstance() { + if (instance == null) { + synchronized (EspPairingSettingsDAO.class) { + if (instance == null) instance = new EspPairingSettingsDAO(); + } + } + return instance; + } + + public void upsert(EspPairingSettingsEntry entry) throws SQLException { + try (Connection c = db.getConnection()) { + upsert(c, entry); + } + } + + public void upsert(Connection c, EspPairingSettingsEntry entry) throws SQLException { + String sql = """ + INSERT INTO esp_pairing_settings ( + login, + enabled, + password_hash, + ttl_seconds, + failed_attempts, + first_failed_at_ms, + blocked_until_ms, + updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(login) DO UPDATE SET + enabled = excluded.enabled, + password_hash = excluded.password_hash, + ttl_seconds = excluded.ttl_seconds, + failed_attempts = excluded.failed_attempts, + first_failed_at_ms = excluded.first_failed_at_ms, + blocked_until_ms = excluded.blocked_until_ms, + updated_at_ms = excluded.updated_at_ms + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, entry.getLogin()); + ps.setInt(2, entry.isEnabled() ? 1 : 0); + ps.setString(3, entry.getPasswordHash()); + ps.setInt(4, entry.getTtlSeconds()); + ps.setInt(5, entry.getFailedAttempts()); + ps.setLong(6, entry.getFirstFailedAtMs()); + ps.setLong(7, entry.getBlockedUntilMs()); + ps.setLong(8, entry.getUpdatedAtMs()); + ps.executeUpdate(); + } + } + + public EspPairingSettingsEntry getByLogin(String login) throws SQLException { + try (Connection c = db.getConnection()) { + return getByLogin(c, login); + } + } + + public EspPairingSettingsEntry getByLogin(Connection c, String login) throws SQLException { + String sql = """ + SELECT login, enabled, password_hash, ttl_seconds, failed_attempts, first_failed_at_ms, blocked_until_ms, updated_at_ms + FROM esp_pairing_settings + WHERE login = ? COLLATE NOCASE + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return mapRow(rs); + } + } + } + + private static EspPairingSettingsEntry mapRow(ResultSet rs) throws SQLException { + EspPairingSettingsEntry entry = new EspPairingSettingsEntry(); + entry.setLogin(rs.getString("login")); + entry.setEnabled(rs.getInt("enabled") != 0); + entry.setPasswordHash(rs.getString("password_hash")); + entry.setTtlSeconds(rs.getInt("ttl_seconds")); + entry.setFailedAttempts(rs.getInt("failed_attempts")); + entry.setFirstFailedAtMs(rs.getLong("first_failed_at_ms")); + entry.setBlockedUntilMs(rs.getLong("blocked_until_ms")); + entry.setUpdatedAtMs(rs.getLong("updated_at_ms")); + return entry; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/EspPairingRequestEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/EspPairingRequestEntry.java new file mode 100644 index 0000000..f78e7d2 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/EspPairingRequestEntry.java @@ -0,0 +1,149 @@ +package shine.db.entities; + +public class EspPairingRequestEntry { + + private String pairingId; + private String login; + private String requesterSessionKey; + private int requesterSessionType; + private String requesterClientPlatform; + private int payloadType; + private String status; + private String shortCode; + private String fingerprintB58; + private String encryptedPayload; + private String rejectReason; + private String approvedBySessionId; + private long createdAtMs; + private long expiresAtMs; + private long updatedAtMs; + private boolean deliveredToHomeserver; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getLogin() { + return login; + } + + public void setLogin(String login) { + this.login = login; + } + + public String getRequesterSessionKey() { + return requesterSessionKey; + } + + public void setRequesterSessionKey(String requesterSessionKey) { + this.requesterSessionKey = requesterSessionKey; + } + + public int getRequesterSessionType() { + return requesterSessionType; + } + + public void setRequesterSessionType(int requesterSessionType) { + this.requesterSessionType = requesterSessionType; + } + + public String getRequesterClientPlatform() { + return requesterClientPlatform; + } + + public void setRequesterClientPlatform(String requesterClientPlatform) { + this.requesterClientPlatform = requesterClientPlatform; + } + + public int getPayloadType() { + return payloadType; + } + + public void setPayloadType(int payloadType) { + this.payloadType = payloadType; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getShortCode() { + return shortCode; + } + + public void setShortCode(String shortCode) { + this.shortCode = shortCode; + } + + public String getFingerprintB58() { + return fingerprintB58; + } + + public void setFingerprintB58(String fingerprintB58) { + this.fingerprintB58 = fingerprintB58; + } + + public String getEncryptedPayload() { + return encryptedPayload; + } + + public void setEncryptedPayload(String encryptedPayload) { + this.encryptedPayload = encryptedPayload; + } + + public String getRejectReason() { + return rejectReason; + } + + public void setRejectReason(String rejectReason) { + this.rejectReason = rejectReason; + } + + public String getApprovedBySessionId() { + return approvedBySessionId; + } + + public void setApprovedBySessionId(String approvedBySessionId) { + this.approvedBySessionId = approvedBySessionId; + } + + public long getCreatedAtMs() { + return createdAtMs; + } + + public void setCreatedAtMs(long createdAtMs) { + this.createdAtMs = createdAtMs; + } + + public long getExpiresAtMs() { + return expiresAtMs; + } + + public void setExpiresAtMs(long expiresAtMs) { + this.expiresAtMs = expiresAtMs; + } + + public long getUpdatedAtMs() { + return updatedAtMs; + } + + public void setUpdatedAtMs(long updatedAtMs) { + this.updatedAtMs = updatedAtMs; + } + + public boolean isDeliveredToHomeserver() { + return deliveredToHomeserver; + } + + public void setDeliveredToHomeserver(boolean deliveredToHomeserver) { + this.deliveredToHomeserver = deliveredToHomeserver; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/EspPairingSettingsEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/EspPairingSettingsEntry.java new file mode 100644 index 0000000..50aaee3 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/EspPairingSettingsEntry.java @@ -0,0 +1,77 @@ +package shine.db.entities; + +public class EspPairingSettingsEntry { + + private String login; + private boolean enabled; + private String passwordHash; + private int ttlSeconds; + private int failedAttempts; + private long firstFailedAtMs; + private long blockedUntilMs; + private long updatedAtMs; + + public String getLogin() { + return login; + } + + public void setLogin(String login) { + this.login = login; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getPasswordHash() { + return passwordHash; + } + + public void setPasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } + + public int getTtlSeconds() { + return ttlSeconds; + } + + public void setTtlSeconds(int ttlSeconds) { + this.ttlSeconds = ttlSeconds; + } + + public int getFailedAttempts() { + return failedAttempts; + } + + public void setFailedAttempts(int failedAttempts) { + this.failedAttempts = failedAttempts; + } + + public long getFirstFailedAtMs() { + return firstFailedAtMs; + } + + public void setFirstFailedAtMs(long firstFailedAtMs) { + this.firstFailedAtMs = firstFailedAtMs; + } + + public long getBlockedUntilMs() { + return blockedUntilMs; + } + + public void setBlockedUntilMs(long blockedUntilMs) { + this.blockedUntilMs = blockedUntilMs; + } + + public long getUpdatedAtMs() { + return updatedAtMs; + } + + public void setUpdatedAtMs(long updatedAtMs) { + this.updatedAtMs = updatedAtMs; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index 1c2176d..295bcb3 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -7,20 +7,32 @@ import server.logic.ws_protocol.JSON.handlers.auth.Net_AuthChallenge_Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_CloseActiveSession_Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_CreateAuthSession__Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_ListSessions_Handler; +import server.logic.ws_protocol.JSON.handlers.auth.Net_ListEspPairingRequests_Handler; +import server.logic.ws_protocol.JSON.handlers.auth.Net_ApproveEspPairing_Handler; +import server.logic.ws_protocol.JSON.handlers.auth.Net_GetEspPairingStatus_Handler; +import server.logic.ws_protocol.JSON.handlers.auth.Net_RejectEspPairing_Handler; // --- NEW v2 session login --- import server.logic.ws_protocol.JSON.handlers.auth.Net_SessionChallenge_Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_SessionLogin_Handler; +import server.logic.ws_protocol.JSON.handlers.auth.Net_StartEspPairing_Handler; +import server.logic.ws_protocol.JSON.handlers.auth.Net_UpsertEspPairingSettings_Handler; // --- auth entities --- import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListEspPairingRequests_Request; // --- NEW v2 entities --- +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ApproveEspPairing_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_GetEspPairingStatus_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_RejectEspPairing_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionChallenge_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_StartEspPairing_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairingSettings_Request; import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler; import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request; @@ -121,6 +133,12 @@ public final class JsonHandlerRegistry { // --- login to existing session in 2 steps --- Map.entry("SessionChallenge", new Net_SessionChallenge_Handler()), Map.entry("SessionLogin", new Net_SessionLogin_Handler()), + Map.entry("UpsertEspPairingSettings", new Net_UpsertEspPairingSettings_Handler()), + Map.entry("StartEspPairing", new Net_StartEspPairing_Handler()), + Map.entry("ListEspPairingRequests", new Net_ListEspPairingRequests_Handler()), + Map.entry("ApproveEspPairing", new Net_ApproveEspPairing_Handler()), + Map.entry("RejectEspPairing", new Net_RejectEspPairing_Handler()), + Map.entry("GetEspPairingStatus", new Net_GetEspPairingStatus_Handler()), // --- blockchain --- Map.entry("AddBlock", new Net_AddBlock_Handler()), @@ -179,6 +197,12 @@ public final class JsonHandlerRegistry { // --- NEW v2 --- Map.entry("SessionChallenge", Net_SessionChallenge_Request.class), Map.entry("SessionLogin", Net_SessionLogin_Request.class), + Map.entry("UpsertEspPairingSettings", Net_UpsertEspPairingSettings_Request.class), + Map.entry("StartEspPairing", Net_StartEspPairing_Request.class), + Map.entry("ListEspPairingRequests", Net_ListEspPairingRequests_Request.class), + Map.entry("ApproveEspPairing", Net_ApproveEspPairing_Request.class), + Map.entry("RejectEspPairing", Net_RejectEspPairing_Request.class), + Map.entry("GetEspPairingStatus", Net_GetEspPairingStatus_Request.class), // --- blockchain --- Map.entry("AddBlock", Net_AddBlock_Request.class), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/EspPairingSupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/EspPairingSupport.java new file mode 100644 index 0000000..170f540 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/EspPairingSupport.java @@ -0,0 +1,152 @@ +package server.logic.ws_protocol.JSON.handlers.auth; + +import org.eclipse.jetty.websocket.api.Session; +import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; +import server.logic.ws_protocol.JSON.ConnectionContext; +import shine.db.entities.ActiveSessionEntry; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Locale; + +final class EspPairingSupport { + + static final int DEFAULT_TTL_SECONDS = 300; + static final int MIN_TTL_SECONDS = 60; + static final int MAX_TTL_SECONDS = 1800; + static final int REQUEST_RATE_LIMIT = 5; + static final long REQUEST_RATE_WINDOW_MS = 5 * 60_000L; + + static final int STATUS_PAIRING_REQUIRES_AUTH_SESSION = 463; + static final int STATUS_PAIRING_RATE_LIMIT = 429; + + static final String STATE_CREATED = "created"; + static final String STATE_APPROVED = "approved"; + static final String STATE_REJECTED = "rejected"; + static final String STATE_EXPIRED = "expired"; + + private static final SecureRandom RANDOM = new SecureRandom(); + private static final char[] BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toCharArray(); + + private EspPairingSupport() {} + + static boolean isTrustedUserSession(ConnectionContext ctx) { + if (ctx == null || !ctx.isAuthenticatedUser()) return false; + ActiveSessionEntry activeSession = ctx.getActiveSession(); + return activeSession != null && activeSession.getSessionId() != null && !activeSession.getSessionId().isBlank(); + } + + static List findOnlineTrustedConnections(String login) { + List out = new ArrayList<>(); + for (ConnectionContext candidate : ActiveConnectionsRegistry.getInstance().getByLogin(login)) { + if (!isTrustedUserSession(candidate)) continue; + Session ws = candidate.getWsSession(); + if (ws == null || !ws.isOpen()) continue; + out.add(candidate); + } + return out; + } + + static int normalizeTtlSeconds(Integer raw) { + if (raw == null) return DEFAULT_TTL_SECONDS; + int value = raw; + if (value < MIN_TTL_SECONDS) return MIN_TTL_SECONDS; + if (value > MAX_TTL_SECONDS) return MAX_TTL_SECONDS; + return value; + } + + static int normalizePayloadType(Integer raw) { + if (raw == null) return 1; + return raw; + } + + static boolean isSupportedPayloadType(int payloadType) { + return payloadType >= 1 && payloadType <= 3; + } + + static String normalizeOpaqueHash(String raw) { + if (raw == null) return null; + String value = raw.trim(); + if (value.isEmpty()) return null; + if (value.length() > 512) return value.substring(0, 512); + return value; + } + + static String normalizeEncryptedPayload(String raw) { + if (raw == null) return null; + String value = raw.trim(); + if (value.isEmpty()) return null; + if (value.length() > 32768) return value.substring(0, 32768); + return value; + } + + static String normalizeReason(String raw) { + if (raw == null) return ""; + String value = raw.trim(); + if (value.length() <= 160) return value; + return value.substring(0, 160); + } + + static PairingFingerprint deriveFingerprint(String login, + String requesterSessionKey, + int requesterSessionType, + String clientPlatform, + int payloadType, + long createdAtMs) throws Exception { + String canonical = (login == null ? "" : login.toLowerCase(Locale.ROOT).trim()) + + "|" + requesterSessionKey + + "|" + requesterSessionType + + "|" + (clientPlatform == null ? "" : clientPlatform.trim()) + + "|" + payloadType + + "|" + createdAtMs; + byte[] digest = MessageDigest.getInstance("SHA-256").digest(canonical.getBytes(StandardCharsets.UTF_8)); + long code = ((digest[0] & 0xFFL) << 24) + | ((digest[1] & 0xFFL) << 16) + | ((digest[2] & 0xFFL) << 8) + | (digest[3] & 0xFFL); + long shortCodeNum = code % 10_000_000L; + String shortCode = String.format(Locale.ROOT, "%07d", shortCodeNum); + return new PairingFingerprint(shortCode, toBase58(digest)); + } + + static String newPairingId() { + byte[] random = new byte[32]; + RANDOM.nextBytes(random); + return Base64.getUrlEncoder().withoutPadding().encodeToString(random); + } + + static String toBase58(byte[] input) { + if (input == null || input.length == 0) return ""; + int zeros = 0; + while (zeros < input.length && input[zeros] == 0) zeros++; + byte[] copy = input.clone(); + byte[] tmp = new byte[copy.length * 2]; + int j = tmp.length; + int startAt = zeros; + while (startAt < copy.length) { + int mod = divmod58(copy, startAt); + if (copy[startAt] == 0) startAt++; + tmp[--j] = (byte) BASE58[mod]; + } + while (j < tmp.length && tmp[j] == BASE58[0]) j++; + while (--zeros >= 0) tmp[--j] = (byte) BASE58[0]; + return new String(tmp, j, tmp.length - j, StandardCharsets.US_ASCII); + } + + private static int divmod58(byte[] number, int startAt) { + int remainder = 0; + for (int i = startAt; i < number.length; i++) { + int digit256 = number[i] & 0xFF; + int temp = remainder * 256 + digit256; + number[i] = (byte) (temp / 58); + remainder = temp % 58; + } + return remainder; + } + + record PairingFingerprint(String shortCode, String fingerprintB58) {} +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ApproveEspPairing_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ApproveEspPairing_Handler.java new file mode 100644 index 0000000..90c0719 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ApproveEspPairing_Handler.java @@ -0,0 +1,63 @@ +package server.logic.ws_protocol.JSON.handlers.auth; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ApproveEspPairing_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ApproveEspPairing_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.EspPairingRequestsDAO; +import shine.db.entities.EspPairingRequestEntry; + +public class Net_ApproveEspPairing_Handler implements JsonMessageHandler { + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { + Net_ApproveEspPairing_Request req = (Net_ApproveEspPairing_Request) baseReq; + if (!EspPairingSupport.isTrustedUserSession(ctx)) { + return NetExceptionResponseFactory.error( + req, + EspPairingSupport.STATUS_PAIRING_REQUIRES_AUTH_SESSION, + "PAIRING_REQUIRES_AUTH_SESSION", + "Операция доступна только для авторизованной доверенной сессии пользователя" + ); + } + + String pairingId = req.getPairingId() == null ? "" : req.getPairingId().trim(); + if (pairingId.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PAIRING_ID", "Пустой pairingId"); + } + String encryptedPayload = EspPairingSupport.normalizeEncryptedPayload(req.getEncryptedPayload()); + if (encryptedPayload == null) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_ENCRYPTED_PAYLOAD", "Пустой encryptedPayload"); + } + + long now = System.currentTimeMillis(); + EspPairingRequestsDAO.getInstance().expirePending(now); + EspPairingRequestEntry row = EspPairingRequestsDAO.getInstance().getByPairingId(pairingId); + if (row == null) { + return NetExceptionResponseFactory.error(req, 404, "PAIRING_NOT_FOUND", "Pairing-заявка не найдена"); + } + if (!ctx.getLogin().equalsIgnoreCase(row.getLogin())) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_OF_ANOTHER_USER", "Нельзя подтверждать pairing другого пользователя"); + } + if (!EspPairingSupport.STATE_CREATED.equals(row.getStatus())) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_PENDING", "Заявка уже не находится в статусе created"); + } + if (row.getExpiresAtMs() <= now) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_EXPIRED", "Срок действия pairing-заявки истёк"); + } + + EspPairingRequestsDAO.getInstance().markApproved(pairingId, encryptedPayload, ctx.getSessionId(), now); + + Net_ApproveEspPairing_Response resp = new Net_ApproveEspPairing_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setPairingId(pairingId); + resp.setState(EspPairingSupport.STATE_APPROVED); + return resp; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_GetEspPairingStatus_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_GetEspPairingStatus_Handler.java new file mode 100644 index 0000000..957ed26 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_GetEspPairingStatus_Handler.java @@ -0,0 +1,50 @@ +package server.logic.ws_protocol.JSON.handlers.auth; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_GetEspPairingStatus_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_GetEspPairingStatus_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.EspPairingRequestsDAO; +import shine.db.entities.EspPairingRequestEntry; + +public class Net_GetEspPairingStatus_Handler implements JsonMessageHandler { + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { + Net_GetEspPairingStatus_Request req = (Net_GetEspPairingStatus_Request) baseReq; + String pairingId = req.getPairingId() == null ? "" : req.getPairingId().trim(); + if (pairingId.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PAIRING_ID", "Пустой pairingId"); + } + + long now = System.currentTimeMillis(); + EspPairingRequestsDAO.getInstance().expirePending(now); + EspPairingRequestEntry row = EspPairingRequestsDAO.getInstance().getByPairingId(pairingId); + if (row == null) { + return NetExceptionResponseFactory.error(req, 404, "PAIRING_NOT_FOUND", "Pairing-заявка не найдена"); + } + + String state = row.getStatus(); + if (row.getExpiresAtMs() <= now && EspPairingSupport.STATE_CREATED.equals(state)) { + state = EspPairingSupport.STATE_EXPIRED; + } + + Net_GetEspPairingStatus_Response resp = new Net_GetEspPairingStatus_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setPairingId(row.getPairingId()); + resp.setState(state); + resp.setShortCode(row.getShortCode()); + resp.setFingerprintB58(row.getFingerprintB58()); + resp.setPayloadType(row.getPayloadType()); + resp.setEncryptedPayload(row.getEncryptedPayload()); + resp.setRejectReason(row.getRejectReason()); + resp.setExpiresAtMs(row.getExpiresAtMs()); + return resp; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListEspPairingRequests_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListEspPairingRequests_Handler.java new file mode 100644 index 0000000..5423b6e --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListEspPairingRequests_Handler.java @@ -0,0 +1,58 @@ +package server.logic.ws_protocol.JSON.handlers.auth; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListEspPairingRequests_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListEspPairingRequests_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.EspPairingRequestsDAO; +import shine.db.entities.EspPairingRequestEntry; + +import java.util.ArrayList; +import java.util.List; + +public class Net_ListEspPairingRequests_Handler implements JsonMessageHandler { + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { + Net_ListEspPairingRequests_Request req = (Net_ListEspPairingRequests_Request) baseReq; + if (!EspPairingSupport.isTrustedUserSession(ctx)) { + return NetExceptionResponseFactory.error( + req, + EspPairingSupport.STATUS_PAIRING_REQUIRES_AUTH_SESSION, + "PAIRING_REQUIRES_AUTH_SESSION", + "Операция доступна только для авторизованной доверенной сессии пользователя" + ); + } + + long now = System.currentTimeMillis(); + EspPairingRequestsDAO.getInstance().expirePending(now); + List rows = EspPairingRequestsDAO.getInstance().listActiveByLogin(ctx.getLogin(), now); + List items = new ArrayList<>(); + for (EspPairingRequestEntry row : rows) { + Net_ListEspPairingRequests_Response.PairingRequestItem item = new Net_ListEspPairingRequests_Response.PairingRequestItem(); + item.setPairingId(row.getPairingId()); + item.setState(row.getStatus()); + item.setRequesterSessionKey(row.getRequesterSessionKey()); + item.setRequesterSessionType(row.getRequesterSessionType()); + item.setRequesterClientPlatform(row.getRequesterClientPlatform()); + item.setPayloadType(row.getPayloadType()); + item.setShortCode(row.getShortCode()); + item.setFingerprintB58(row.getFingerprintB58()); + item.setCreatedAtMs(row.getCreatedAtMs()); + item.setExpiresAtMs(row.getExpiresAtMs()); + item.setDeliveredToHomeserver(row.isDeliveredToHomeserver()); + items.add(item); + } + + Net_ListEspPairingRequests_Response resp = new Net_ListEspPairingRequests_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setRequests(items); + return resp; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_RejectEspPairing_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_RejectEspPairing_Handler.java new file mode 100644 index 0000000..d73d143 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_RejectEspPairing_Handler.java @@ -0,0 +1,58 @@ +package server.logic.ws_protocol.JSON.handlers.auth; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_RejectEspPairing_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_RejectEspPairing_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.EspPairingRequestsDAO; +import shine.db.entities.EspPairingRequestEntry; + +public class Net_RejectEspPairing_Handler implements JsonMessageHandler { + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { + Net_RejectEspPairing_Request req = (Net_RejectEspPairing_Request) baseReq; + if (!EspPairingSupport.isTrustedUserSession(ctx)) { + return NetExceptionResponseFactory.error( + req, + EspPairingSupport.STATUS_PAIRING_REQUIRES_AUTH_SESSION, + "PAIRING_REQUIRES_AUTH_SESSION", + "Операция доступна только для авторизованной доверенной сессии пользователя" + ); + } + + String pairingId = req.getPairingId() == null ? "" : req.getPairingId().trim(); + if (pairingId.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PAIRING_ID", "Пустой pairingId"); + } + String reason = EspPairingSupport.normalizeReason(req.getReason()); + if (reason.isBlank()) reason = "rejected_by_homeserver"; + + long now = System.currentTimeMillis(); + EspPairingRequestsDAO.getInstance().expirePending(now); + EspPairingRequestEntry row = EspPairingRequestsDAO.getInstance().getByPairingId(pairingId); + if (row == null) { + return NetExceptionResponseFactory.error(req, 404, "PAIRING_NOT_FOUND", "Pairing-заявка не найдена"); + } + if (!ctx.getLogin().equalsIgnoreCase(row.getLogin())) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_OF_ANOTHER_USER", "Нельзя отклонять pairing другого пользователя"); + } + if (!EspPairingSupport.STATE_CREATED.equals(row.getStatus())) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_PENDING", "Заявка уже не находится в статусе created"); + } + + EspPairingRequestsDAO.getInstance().markRejected(pairingId, reason, ctx.getSessionId(), now); + + Net_RejectEspPairing_Response resp = new Net_RejectEspPairing_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setPairingId(pairingId); + resp.setState(EspPairingSupport.STATE_REJECTED); + return resp; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java new file mode 100644 index 0000000..9f5a399 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java @@ -0,0 +1,151 @@ +package server.logic.ws_protocol.JSON.handlers.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_StartEspPairing_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_StartEspPairing_Response; +import server.logic.ws_protocol.JSON.push.WsEventSender; +import server.logic.ws_protocol.JSON.utils.AuthKeyUtils; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.JSON.utils.NetIdGenerator; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.EspPairingRequestsDAO; +import shine.db.dao.EspPairingSettingsDAO; +import shine.db.dao.SolanaUsersDAO; +import shine.db.entities.EspPairingRequestEntry; +import shine.db.entities.EspPairingSettingsEntry; +import shine.db.entities.SolanaUserEntry; + +import java.util.List; + +public class Net_StartEspPairing_Handler implements JsonMessageHandler { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { + Net_StartEspPairing_Request req = (Net_StartEspPairing_Request) baseReq; + + String login = req.getLogin() == null ? "" : req.getLogin().trim(); + if (login.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_LOGIN", "Пустой login"); + } + + String requesterSessionKey = req.getRequesterSessionKey(); + if (requesterSessionKey == null || requesterSessionKey.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_REQUESTER_SESSION_KEY", "Пустой requesterSessionKey"); + } + try { + requesterSessionKey = AuthKeyUtils.normalize(requesterSessionKey, "requesterSessionKey"); + AuthKeyUtils.parseEd25519PublicKey(requesterSessionKey, "requesterSessionKey"); + } catch (Exception e) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_REQUESTER_SESSION_KEY", "Некорректный requesterSessionKey"); + } + + int requesterSessionType = AuthSessionTypeSupport.normalizeRequestedSessionType(req.getRequesterSessionType()); + if (!AuthSessionTypeSupport.isSupportedSessionType(requesterSessionType)) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_SESSION_TYPE", "Неподдерживаемый requesterSessionType"); + } + int payloadType = EspPairingSupport.normalizePayloadType(req.getPayloadType()); + if (!EspPairingSupport.isSupportedPayloadType(payloadType)) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_PAYLOAD_TYPE", "payloadType должен быть 1, 2 или 3"); + } + String passwordHash = EspPairingSupport.normalizeOpaqueHash(req.getPasswordHash()); + if (passwordHash == null) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PASSWORD_HASH", "Пустой passwordHash"); + } + + SolanaUserEntry user = SolanaUsersDAO.getInstance().getByLogin(login); + if (user == null) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_AVAILABLE", "Для этого login pairing недоступен"); + } + String canonicalLogin = user.getLogin(); + + EspPairingSettingsEntry settings = EspPairingSettingsDAO.getInstance().getByLogin(canonicalLogin); + if (settings == null || !settings.isEnabled() || settings.getPasswordHash() == null || settings.getPasswordHash().isBlank()) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_AVAILABLE", "Для этого login pairing недоступен"); + } + + long now = System.currentTimeMillis(); + EspPairingRequestsDAO.getInstance().expirePending(now); + if (settings.getBlockedUntilMs() > now) { + return NetExceptionResponseFactory.error(req, EspPairingSupport.STATUS_PAIRING_RATE_LIMIT, "PAIRING_RATE_LIMITED", "Временная блокировка pairing по числу неудачных попыток"); + } + int recentAttempts = EspPairingRequestsDAO.getInstance().countRecentByLoginAndStatuses( + canonicalLogin, + now - EspPairingSupport.REQUEST_RATE_WINDOW_MS, + EspPairingSupport.STATE_CREATED, + EspPairingSupport.STATE_REJECTED + ); + if (recentAttempts >= EspPairingSupport.REQUEST_RATE_LIMIT) { + return NetExceptionResponseFactory.error(req, EspPairingSupport.STATUS_PAIRING_RATE_LIMIT, "PAIRING_RATE_LIMITED", "Слишком много pairing-запросов за короткое время"); + } + if (!settings.getPasswordHash().equals(passwordHash)) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_PASSWORD_INVALID", "Неверный pairing-пароль"); + } + + String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getRequesterClientPlatform()); + int ttlSeconds = EspPairingSupport.normalizeTtlSeconds(settings.getTtlSeconds()); + EspPairingSupport.PairingFingerprint fingerprint = EspPairingSupport.deriveFingerprint( + canonicalLogin, + requesterSessionKey, + requesterSessionType, + clientPlatform, + payloadType, + now + ); + + EspPairingRequestEntry entry = new EspPairingRequestEntry(); + entry.setPairingId(EspPairingSupport.newPairingId()); + entry.setLogin(canonicalLogin); + entry.setRequesterSessionKey(requesterSessionKey); + entry.setRequesterSessionType(requesterSessionType); + entry.setRequesterClientPlatform(clientPlatform); + entry.setPayloadType(payloadType); + entry.setStatus(EspPairingSupport.STATE_CREATED); + entry.setShortCode(fingerprint.shortCode()); + entry.setFingerprintB58(fingerprint.fingerprintB58()); + entry.setCreatedAtMs(now); + entry.setExpiresAtMs(now + ttlSeconds * 1000L); + entry.setUpdatedAtMs(now); + entry.setDeliveredToHomeserver(false); + EspPairingRequestsDAO.getInstance().insert(entry); + + List approverConnections = EspPairingSupport.findOnlineTrustedConnections(canonicalLogin); + boolean delivered = false; + for (ConnectionContext targetCtx : approverConnections) { + String eventId = NetIdGenerator.eventId("pair"); + ObjectNode payload = MAPPER.createObjectNode(); + payload.put("pairingId", entry.getPairingId()); + payload.put("login", canonicalLogin); + payload.put("requesterSessionKey", requesterSessionKey); + payload.put("requesterSessionType", requesterSessionType); + payload.put("requesterClientPlatform", clientPlatform); + payload.put("payloadType", payloadType); + payload.put("shortCode", entry.getShortCode()); + payload.put("fingerprintB58", entry.getFingerprintB58()); + payload.put("createdAtMs", entry.getCreatedAtMs()); + payload.put("expiresAtMs", entry.getExpiresAtMs()); + delivered |= WsEventSender.sendEvent(targetCtx, "IncomingEspPairingRequest", eventId, payload); + } + if (delivered) { + EspPairingRequestsDAO.getInstance().updateDeliveryFlag(entry.getPairingId(), true, System.currentTimeMillis()); + } + + Net_StartEspPairing_Response resp = new Net_StartEspPairing_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setPairingId(entry.getPairingId()); + resp.setState(entry.getStatus()); + resp.setShortCode(entry.getShortCode()); + resp.setFingerprintB58(entry.getFingerprintB58()); + resp.setExpiresAtMs(entry.getExpiresAtMs()); + resp.setTrustedSessionOnline(!approverConnections.isEmpty()); + return resp; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_UpsertEspPairingSettings_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_UpsertEspPairingSettings_Handler.java new file mode 100644 index 0000000..03b9763 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_UpsertEspPairingSettings_Handler.java @@ -0,0 +1,56 @@ +package server.logic.ws_protocol.JSON.handlers.auth; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairingSettings_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairingSettings_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.EspPairingSettingsDAO; +import shine.db.entities.EspPairingSettingsEntry; + +public class Net_UpsertEspPairingSettings_Handler implements JsonMessageHandler { + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { + Net_UpsertEspPairingSettings_Request req = (Net_UpsertEspPairingSettings_Request) baseReq; + + if (!EspPairingSupport.isTrustedUserSession(ctx)) { + return NetExceptionResponseFactory.error( + req, + EspPairingSupport.STATUS_PAIRING_REQUIRES_AUTH_SESSION, + "PAIRING_REQUIRES_AUTH_SESSION", + "Операция доступна только для авторизованной доверенной сессии пользователя" + ); + } + + boolean enabled = req.getEnabled() != null && req.getEnabled(); + String passwordHash = EspPairingSupport.normalizeOpaqueHash(req.getPasswordHash()); + int ttlSeconds = EspPairingSupport.normalizeTtlSeconds(req.getTtlSeconds()); + if (enabled && (passwordHash == null || passwordHash.isBlank())) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PASSWORD_HASH", "Для включения pairing нужен passwordHash"); + } + + long now = System.currentTimeMillis(); + EspPairingSettingsEntry entry = new EspPairingSettingsEntry(); + entry.setLogin(ctx.getLogin()); + entry.setEnabled(enabled); + entry.setPasswordHash(passwordHash == null ? "" : passwordHash); + entry.setTtlSeconds(ttlSeconds); + entry.setFailedAttempts(0); + entry.setFirstFailedAtMs(0L); + entry.setBlockedUntilMs(0L); + entry.setUpdatedAtMs(now); + EspPairingSettingsDAO.getInstance().upsert(entry); + + Net_UpsertEspPairingSettings_Response resp = new Net_UpsertEspPairingSettings_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setEnabled(enabled); + resp.setTtlSeconds(ttlSeconds); + return resp; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ApproveEspPairing_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ApproveEspPairing_Request.java new file mode 100644 index 0000000..61d49d4 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ApproveEspPairing_Request.java @@ -0,0 +1,24 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_ApproveEspPairing_Request extends Net_Request { + private String pairingId; + private String encryptedPayload; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getEncryptedPayload() { + return encryptedPayload; + } + + public void setEncryptedPayload(String encryptedPayload) { + this.encryptedPayload = encryptedPayload; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ApproveEspPairing_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ApproveEspPairing_Response.java new file mode 100644 index 0000000..9052198 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ApproveEspPairing_Response.java @@ -0,0 +1,24 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_ApproveEspPairing_Response extends Net_Response { + private String pairingId; + private String state; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_GetEspPairingStatus_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_GetEspPairingStatus_Request.java new file mode 100644 index 0000000..3a071da --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_GetEspPairingStatus_Request.java @@ -0,0 +1,15 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_GetEspPairingStatus_Request extends Net_Request { + private String pairingId; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_GetEspPairingStatus_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_GetEspPairingStatus_Response.java new file mode 100644 index 0000000..8683d02 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_GetEspPairingStatus_Response.java @@ -0,0 +1,78 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_GetEspPairingStatus_Response extends Net_Response { + private String pairingId; + private String state; + private String shortCode; + private String fingerprintB58; + private int payloadType; + private String encryptedPayload; + private String rejectReason; + private long expiresAtMs; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public String getShortCode() { + return shortCode; + } + + public void setShortCode(String shortCode) { + this.shortCode = shortCode; + } + + public String getFingerprintB58() { + return fingerprintB58; + } + + public void setFingerprintB58(String fingerprintB58) { + this.fingerprintB58 = fingerprintB58; + } + + public int getPayloadType() { + return payloadType; + } + + public void setPayloadType(int payloadType) { + this.payloadType = payloadType; + } + + public String getEncryptedPayload() { + return encryptedPayload; + } + + public void setEncryptedPayload(String encryptedPayload) { + this.encryptedPayload = encryptedPayload; + } + + public String getRejectReason() { + return rejectReason; + } + + public void setRejectReason(String rejectReason) { + this.rejectReason = rejectReason; + } + + public long getExpiresAtMs() { + return expiresAtMs; + } + + public void setExpiresAtMs(long expiresAtMs) { + this.expiresAtMs = expiresAtMs; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListEspPairingRequests_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListEspPairingRequests_Request.java new file mode 100644 index 0000000..12cc85f --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListEspPairingRequests_Request.java @@ -0,0 +1,6 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_ListEspPairingRequests_Request extends Net_Request { +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListEspPairingRequests_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListEspPairingRequests_Response.java new file mode 100644 index 0000000..31395f8 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListEspPairingRequests_Response.java @@ -0,0 +1,120 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +import java.util.ArrayList; +import java.util.List; + +public class Net_ListEspPairingRequests_Response extends Net_Response { + private List requests = new ArrayList<>(); + + public List getRequests() { + return requests; + } + + public void setRequests(List requests) { + this.requests = requests; + } + + public static class PairingRequestItem { + private String pairingId; + private String state; + private String requesterSessionKey; + private int requesterSessionType; + private String requesterClientPlatform; + private int payloadType; + private String shortCode; + private String fingerprintB58; + private long createdAtMs; + private long expiresAtMs; + private boolean deliveredToHomeserver; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public String getRequesterSessionKey() { + return requesterSessionKey; + } + + public void setRequesterSessionKey(String requesterSessionKey) { + this.requesterSessionKey = requesterSessionKey; + } + + public int getRequesterSessionType() { + return requesterSessionType; + } + + public void setRequesterSessionType(int requesterSessionType) { + this.requesterSessionType = requesterSessionType; + } + + public String getRequesterClientPlatform() { + return requesterClientPlatform; + } + + public void setRequesterClientPlatform(String requesterClientPlatform) { + this.requesterClientPlatform = requesterClientPlatform; + } + + public int getPayloadType() { + return payloadType; + } + + public void setPayloadType(int payloadType) { + this.payloadType = payloadType; + } + + public String getShortCode() { + return shortCode; + } + + public void setShortCode(String shortCode) { + this.shortCode = shortCode; + } + + public String getFingerprintB58() { + return fingerprintB58; + } + + public void setFingerprintB58(String fingerprintB58) { + this.fingerprintB58 = fingerprintB58; + } + + public long getCreatedAtMs() { + return createdAtMs; + } + + public void setCreatedAtMs(long createdAtMs) { + this.createdAtMs = createdAtMs; + } + + public long getExpiresAtMs() { + return expiresAtMs; + } + + public void setExpiresAtMs(long expiresAtMs) { + this.expiresAtMs = expiresAtMs; + } + + public boolean isDeliveredToHomeserver() { + return deliveredToHomeserver; + } + + public void setDeliveredToHomeserver(boolean deliveredToHomeserver) { + this.deliveredToHomeserver = deliveredToHomeserver; + } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RejectEspPairing_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RejectEspPairing_Request.java new file mode 100644 index 0000000..8d290c5 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RejectEspPairing_Request.java @@ -0,0 +1,24 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_RejectEspPairing_Request extends Net_Request { + private String pairingId; + private String reason; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RejectEspPairing_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RejectEspPairing_Response.java new file mode 100644 index 0000000..59b06f3 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RejectEspPairing_Response.java @@ -0,0 +1,24 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_RejectEspPairing_Response extends Net_Response { + private String pairingId; + private String state; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_StartEspPairing_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_StartEspPairing_Request.java new file mode 100644 index 0000000..c5568a8 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_StartEspPairing_Request.java @@ -0,0 +1,60 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_StartEspPairing_Request extends Net_Request { + private String login; + private String passwordHash; + private String requesterSessionKey; + private Integer requesterSessionType; + private String requesterClientPlatform; + private Integer payloadType; + + public String getLogin() { + return login; + } + + public void setLogin(String login) { + this.login = login; + } + + public String getPasswordHash() { + return passwordHash; + } + + public void setPasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } + + public String getRequesterSessionKey() { + return requesterSessionKey; + } + + public void setRequesterSessionKey(String requesterSessionKey) { + this.requesterSessionKey = requesterSessionKey; + } + + public Integer getRequesterSessionType() { + return requesterSessionType; + } + + public void setRequesterSessionType(Integer requesterSessionType) { + this.requesterSessionType = requesterSessionType; + } + + public String getRequesterClientPlatform() { + return requesterClientPlatform; + } + + public void setRequesterClientPlatform(String requesterClientPlatform) { + this.requesterClientPlatform = requesterClientPlatform; + } + + public Integer getPayloadType() { + return payloadType; + } + + public void setPayloadType(Integer payloadType) { + this.payloadType = payloadType; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_StartEspPairing_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_StartEspPairing_Response.java new file mode 100644 index 0000000..cc70d6f --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_StartEspPairing_Response.java @@ -0,0 +1,60 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_StartEspPairing_Response extends Net_Response { + private String pairingId; + private String state; + private String shortCode; + private String fingerprintB58; + private long expiresAtMs; + private boolean trustedSessionOnline; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public String getShortCode() { + return shortCode; + } + + public void setShortCode(String shortCode) { + this.shortCode = shortCode; + } + + public String getFingerprintB58() { + return fingerprintB58; + } + + public void setFingerprintB58(String fingerprintB58) { + this.fingerprintB58 = fingerprintB58; + } + + public long getExpiresAtMs() { + return expiresAtMs; + } + + public void setExpiresAtMs(long expiresAtMs) { + this.expiresAtMs = expiresAtMs; + } + + public boolean isTrustedSessionOnline() { + return trustedSessionOnline; + } + + public void setTrustedSessionOnline(boolean trustedSessionOnline) { + this.trustedSessionOnline = trustedSessionOnline; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_UpsertEspPairingSettings_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_UpsertEspPairingSettings_Request.java new file mode 100644 index 0000000..30ed3b5 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_UpsertEspPairingSettings_Request.java @@ -0,0 +1,33 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_UpsertEspPairingSettings_Request extends Net_Request { + private Boolean enabled; + private String passwordHash; + private Integer ttlSeconds; + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } + + public String getPasswordHash() { + return passwordHash; + } + + public void setPasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } + + public Integer getTtlSeconds() { + return ttlSeconds; + } + + public void setTtlSeconds(Integer ttlSeconds) { + this.ttlSeconds = ttlSeconds; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_UpsertEspPairingSettings_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_UpsertEspPairingSettings_Response.java new file mode 100644 index 0000000..ad2d883 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_UpsertEspPairingSettings_Response.java @@ -0,0 +1,24 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_UpsertEspPairingSettings_Response extends Net_Response { + private boolean enabled; + private int ttlSeconds; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public int getTtlSeconds() { + return ttlSeconds; + } + + public void setTtlSeconds(int ttlSeconds) { + this.ttlSeconds = ttlSeconds; + } +} diff --git a/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java b/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java new file mode 100644 index 0000000..573429a --- /dev/null +++ b/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java @@ -0,0 +1,170 @@ +package test.it.cases; + +import test.it.utils.TestConfig; +import test.it.utils.json.JsonBuilders; +import test.it.utils.json.JsonParsers; +import test.it.utils.log.TestLog; +import test.it.utils.log.TestResult; +import test.it.utils.ws.WsSession; +import utils.crypto.Ed25519Util; +import shine.db.dao.SolanaUsersDAO; +import shine.db.entities.SolanaUserEntry; + +import java.time.Duration; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.*; + +public class IT_07_EspPairing { + + private static final String LOGIN = TestConfig.LOGIN(); + + public static void main(String[] args) { + TestLog.info("Standalone: при необходимости локально создаю тестового пользователя напрямую в БД"); + String summary = run(); + System.out.println(summary); + } + + public static String run() { + TestResult r = new TestResult("IT_07_EspPairing"); + Duration t = Duration.ofSeconds(5); + + try { + ensureUserSeeded(); + Session clientSession = createSession(LOGIN, 1, "Web", t); + + try (WsSession requesterWs = WsSession.open(); + WsSession clientWs = WsSession.open()) { + + sessionLogin2Steps(clientWs, clientSession, 1, "Web", t, r); + + String passwordHash = "argon2id$v=19$m=65536,t=2,p=1$test$esp_pairing_hash"; + String upsertResp = clientWs.call( + "UpsertEspPairingSettings", + JsonBuilders.upsertEspPairingSettings(true, passwordHash, 180), + t + ); + assertEquals(200, JsonParsers.status(upsertResp), "UpsertEspPairingSettings must be 200"); + + SessionMaterial requesterMaterial = newSessionMaterial(); + String startResp = requesterWs.call( + "StartEspPairing", + JsonBuilders.startEspPairing(LOGIN, passwordHash, requesterMaterial.sessionKey(), 1, "Android", 1), + t + ); + assertEquals(200, JsonParsers.status(startResp), "StartEspPairing must be 200"); + String pairingId = JsonParsers.payloadText(startResp, "pairingId"); + assertNotNull(pairingId, "pairingId must be present"); + assertEquals("created", JsonParsers.payloadText(startResp, "state")); + assertEquals(Boolean.TRUE, JsonParsers.payloadBoolean(startResp, "trustedSessionOnline")); + + String listResp = clientWs.call("ListEspPairingRequests", JsonBuilders.listEspPairingRequests(), t); + assertEquals(200, JsonParsers.status(listResp), "ListEspPairingRequests must be 200"); + assertTrue(listResp.contains(pairingId), "ListEspPairingRequests must contain created pairingId"); + + String approveResp = clientWs.call( + "ApproveEspPairing", + JsonBuilders.approveEspPairing(pairingId, "AQIDBA=="), + t + ); + assertEquals(200, JsonParsers.status(approveResp), "ApproveEspPairing must be 200"); + assertEquals("approved", JsonParsers.payloadText(approveResp, "state")); + + String statusResp = requesterWs.call( + "GetEspPairingStatus", + JsonBuilders.getEspPairingStatus(pairingId), + t + ); + assertEquals(200, JsonParsers.status(statusResp), "GetEspPairingStatus must be 200"); + assertEquals("approved", JsonParsers.payloadText(statusResp, "state")); + assertEquals("AQIDBA==", JsonParsers.payloadText(statusResp, "encryptedPayload")); + + String forbiddenResp = requesterWs.call( + "ListEspPairingRequests#anonymous", + JsonBuilders.listEspPairingRequests(), + t + ); + assertErrorFormat(forbiddenResp, "ListEspPairingRequests", "PAIRING_REQUIRES_AUTH_SESSION"); + + r.ok("ESP pairing: обычная доверенная сессия увидела запрос и подтвердила зашифрованный payload"); + } + } catch (Throwable e) { + r.fail("IT_07_EspPairing упал: " + e.getMessage()); + } + + return r.summaryLine(); + } + + private static Session createSession(String login, int sessionType, String clientPlatform, Duration t) { + try (WsSession ws = WsSession.open()) { + String nonceResp = ws.call("AuthChallenge", JsonBuilders.authChallenge(login), t); + assertEquals(200, JsonParsers.status(nonceResp)); + String authNonce = JsonParsers.authNonce(nonceResp); + assertNotNull(authNonce); + + SessionMaterial material = newSessionMaterial(); + String storagePwd = TestConfig.fakeStoragePwd(); + String createResp = ws.call( + "CreateAuthSession", + JsonBuilders.createAuthSessionV2(login, authNonce, storagePwd, material.sessionKey(), sessionType, clientPlatform), + t + ); + assertEquals(200, JsonParsers.status(createResp), "CreateAuthSession must be 200"); + String sessionId = JsonParsers.sessionId(createResp); + assertNotNull(sessionId); + return new Session(sessionId, material.sessionKey(), material.sessionPrivKey(), storagePwd); + } + } + + private static void sessionLogin2Steps(WsSession ws, + Session s, + int sessionType, + String clientPlatform, + Duration t, + TestResult r) { + String chResp = ws.call("SessionChallenge", JsonBuilders.sessionChallenge(s.sessionId()), t); + assertEquals(200, JsonParsers.status(chResp)); + String nonce = JsonParsers.sessionNonce(chResp); + assertNotNull(nonce); + + String loginResp = ws.call( + "SessionLogin", + JsonBuilders.sessionLogin(s.sessionId(), s.sessionKey(), nonce, s.sessionPrivKey(), sessionType, clientPlatform), + t + ); + assertEquals(200, JsonParsers.status(loginResp)); + assertEquals(s.storagePwd(), JsonParsers.storagePwd(loginResp)); + r.ok("SessionLogin OK for sessionType=" + sessionType); + } + + private static void assertErrorFormat(String resp, String op, String code) { + int status = JsonParsers.status(resp); + assertFalse(status >= 200 && status < 300, "Expected non-2xx status: " + resp); + assertEquals(Boolean.FALSE, JsonParsers.ok(resp), "Expected ok=false: " + resp); + assertEquals(op, JsonParsers.op(resp), "Unexpected op: " + resp); + assertEquals(code, JsonParsers.errorCode(resp), "Unexpected error code: " + resp); + } + + private static SessionMaterial newSessionMaterial() { + byte[] sessionPrivKey = Ed25519Util.generatePrivateKey(); + byte[] sessionPubKey = Ed25519Util.derivePublicKey(sessionPrivKey); + String sessionKey = "ed25519/" + Base64.getEncoder().encodeToString(sessionPubKey); + return new SessionMaterial(sessionKey, sessionPrivKey); + } + + private static void ensureUserSeeded() throws Exception { + if (SolanaUsersDAO.getInstance().existsByLogin(LOGIN)) { + return; + } + SolanaUserEntry entry = new SolanaUserEntry(); + entry.setLogin(LOGIN); + entry.setBlockchainName(TestConfig.getBlockchainName(LOGIN)); + entry.setSolanaKey(TestConfig.solanaPublicKeyB64(LOGIN)); + entry.setBlockchainKey(TestConfig.blockchainPublicKeyB64(LOGIN)); + entry.setDeviceKey(TestConfig.devicePublicKeyB64(LOGIN)); + SolanaUsersDAO.getInstance().insert(entry); + } + + private record Session(String sessionId, String sessionKey, byte[] sessionPrivKey, String storagePwd) {} + private record SessionMaterial(String sessionKey, byte[] sessionPrivKey) {} +} diff --git a/SHiNE-server/src/test/java/test/it/runner/IT_RunAllMain.java b/SHiNE-server/src/test/java/test/it/runner/IT_RunAllMain.java index 6524bed..9c1c9ce 100644 --- a/SHiNE-server/src/test/java/test/it/runner/IT_RunAllMain.java +++ b/SHiNE-server/src/test/java/test/it/runner/IT_RunAllMain.java @@ -7,6 +7,7 @@ import test.it.cases.IT_03_AddBlock_NoAuth; import test.it.cases.IT_04_UserParams_NoAuth; import test.it.cases.IT_05_UserConnections; import test.it.cases.IT_06_ChannelsApi; +import test.it.cases.IT_07_EspPairing; import test.it.cases.Seed_TestDataPopulation; import test.it.utils.log.TestLog; @@ -61,9 +62,12 @@ public class IT_RunAllMain { String s6 = IT_06_ChannelsApi.run(); summaries.add(s6); if (s6.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); } - String s7 = Seed_TestDataPopulation.run(); summaries.add(s7); + String s7 = IT_07_EspPairing.run(); summaries.add(s7); if (s7.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); } + String s8 = Seed_TestDataPopulation.run(); summaries.add(s8); + if (s8.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); } + return finish(summaries, failed); } diff --git a/SHiNE-server/src/test/java/test/it/utils/json/JsonBuilders.java b/SHiNE-server/src/test/java/test/it/utils/json/JsonBuilders.java index 6e88898..f7ef249 100644 --- a/SHiNE-server/src/test/java/test/it/utils/json/JsonBuilders.java +++ b/SHiNE-server/src/test/java/test/it/utils/json/JsonBuilders.java @@ -137,6 +137,10 @@ public final class JsonBuilders { // preimage = "AUTH_CREATE_SESSION:" + login + ":" + sessionKey + ":" + storagePwd + ":" + timeMs + ":" + authNonce public static String createAuthSessionV2(String login, String authNonce, String storagePwd, String sessionKey) { + return createAuthSessionV2(login, authNonce, storagePwd, sessionKey, 1, "IT"); + } + + public static String createAuthSessionV2(String login, String authNonce, String storagePwd, String sessionKey, int sessionType, String clientPlatform) { long timeMs = System.currentTimeMillis(); byte[] devicePriv = TestConfig.getDevicePrivatKey(login); @@ -156,6 +160,8 @@ public final class JsonBuilders { "authNonce": "%s", "deviceKey": "%s", "signatureB64": "%s", + "sessionType": %d, + "clientPlatform": "%s", "clientInfo": "%s" } } @@ -168,6 +174,8 @@ public final class JsonBuilders { authNonce, deviceKey, sigB64, + sessionType, + clientPlatform == null ? "" : clientPlatform, TestConfig.TEST_CLIENT_INFO ); } @@ -192,6 +200,10 @@ public final class JsonBuilders { // preimage = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce public static String sessionLogin(String sessionId, String sessionKey, String nonce, byte[] sessionPrivKey) { + return sessionLogin(sessionId, sessionKey, nonce, sessionPrivKey, 1, "IT"); + } + + public static String sessionLogin(String sessionId, String sessionKey, String nonce, byte[] sessionPrivKey, int sessionType, String clientPlatform) { long timeMs = System.currentTimeMillis(); String sigB64 = signSessionLogin(sessionId, timeMs, nonce, sessionPrivKey); @@ -203,12 +215,14 @@ public final class JsonBuilders { "payload": { "sessionId": "%s", "sessionKey": "%s", + "sessionType": %d, + "clientPlatform": "%s", "timeMs": %d, "signatureB64": "%s", "clientInfo": "%s" } } - """.formatted(requestId, sessionId, sessionKey, timeMs, sigB64, TestConfig.TEST_CLIENT_INFO); + """.formatted(requestId, sessionId, sessionKey, sessionType, clientPlatform == null ? "" : clientPlatform, timeMs, sigB64, TestConfig.TEST_CLIENT_INFO); } // ---------------- ListSessions ---------------- @@ -242,6 +256,96 @@ public final class JsonBuilders { """.formatted(requestId, sessionId, timeMs, signatureB64); } + public static String upsertEspPairingSettings(boolean enabled, String passwordHash, int ttlSeconds) { + String requestId = TestIds.next("esp-set"); + return """ + { + "op": "UpsertEspPairingSettings", + "requestId": "%s", + "payload": { + "enabled": %s, + "passwordHash": "%s", + "ttlSeconds": %d + } + } + """.formatted(requestId, enabled, passwordHash == null ? "" : passwordHash, ttlSeconds); + } + + public static String startEspPairing(String login, + String passwordHash, + String requesterSessionKey, + int requesterSessionType, + String requesterClientPlatform, + int payloadType) { + String requestId = TestIds.next("esp-start"); + return """ + { + "op": "StartEspPairing", + "requestId": "%s", + "payload": { + "login": "%s", + "passwordHash": "%s", + "requesterSessionKey": "%s", + "requesterSessionType": %d, + "requesterClientPlatform": "%s", + "payloadType": %d + } + } + """.formatted(requestId, login, passwordHash, requesterSessionKey, requesterSessionType, requesterClientPlatform == null ? "" : requesterClientPlatform, payloadType); + } + + public static String listEspPairingRequests() { + String requestId = TestIds.next("esp-list"); + return """ + { + "op": "ListEspPairingRequests", + "requestId": "%s", + "payload": {} + } + """.formatted(requestId); + } + + public static String approveEspPairing(String pairingId, String encryptedPayload) { + String requestId = TestIds.next("esp-approve"); + return """ + { + "op": "ApproveEspPairing", + "requestId": "%s", + "payload": { + "pairingId": "%s", + "encryptedPayload": "%s" + } + } + """.formatted(requestId, pairingId, encryptedPayload); + } + + public static String rejectEspPairing(String pairingId, String reason) { + String requestId = TestIds.next("esp-reject"); + return """ + { + "op": "RejectEspPairing", + "requestId": "%s", + "payload": { + "pairingId": "%s", + "reason": "%s" + } + } + """.formatted(requestId, pairingId, reason == null ? "" : reason); + } + + public static String getEspPairingStatus(String pairingId) { + String requestId = TestIds.next("esp-status"); + return """ + { + "op": "GetEspPairingStatus", + "requestId": "%s", + "payload": { + "pairingId": "%s" + } + } + """.formatted(requestId, pairingId); + } + // ---------------- ListSubscribedChannels ---------------- public static String listSubscribedChannels(String login) { diff --git a/SHiNE-server/src/test/java/test/it/utils/json/JsonParsers.java b/SHiNE-server/src/test/java/test/it/utils/json/JsonParsers.java index 7309208..16d7d46 100644 --- a/SHiNE-server/src/test/java/test/it/utils/json/JsonParsers.java +++ b/SHiNE-server/src/test/java/test/it/utils/json/JsonParsers.java @@ -147,6 +147,19 @@ public final class JsonParsers { return getPayloadText(json, field); } + public static Boolean payloadBoolean(String json, String field) { + try { + JsonNode root = MAPPER.readTree(json); + JsonNode payload = root.get("payload"); + if (payload != null && payload.has(field) && !payload.get(field).isNull()) { + return payload.get(field).asBoolean(); + } + return null; + } catch (Exception e) { + return null; + } + } + public static List sessionIds(String json) { List res = new ArrayList<>(); try { @@ -315,4 +328,19 @@ public final class JsonParsers { return -1; } } + + public static String firstArrayItemText(String json, String arrayField, String field) { + try { + JsonNode root = MAPPER.readTree(json); + JsonNode payload = root.get("payload"); + if (payload == null) return null; + JsonNode arr = payload.get(arrayField); + if (arr == null || !arr.isArray() || arr.isEmpty()) return null; + JsonNode item = arr.get(0); + if (item == null || !item.has(field) || item.get(field).isNull()) return null; + return item.get(field).asText(); + } catch (Exception e) { + return null; + } + } } diff --git a/VERSION.properties b/VERSION.properties index d8a6c6e..d2707ae 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.190 -server.version=1.2.179 +client.version=1.2.191 +server.version=1.2.180 From b166013707a97e78cc148f2ea620ac4140a82166ed8879dd34a9742ad7ebfb8b Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sun, 14 Jun 2026 20:30:17 +0400 Subject: [PATCH 32/56] =?UTF-8?q?=D0=91=D0=BE=D1=82:=20=D0=BF=D0=BE=D1=87?= =?UTF-8?q?=D0=B8=D0=BD=D0=B8=D1=82=D1=8C=20resume-=D0=B2=D1=8B=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=20Codex=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SHiNE-agent-bot-coder/py_bot_service.py | 100 ++++++++++++++++++++++-- VERSION.properties | 4 +- 2 files changed, 95 insertions(+), 9 deletions(-) diff --git a/SHiNE-agent-bot-coder/py_bot_service.py b/SHiNE-agent-bot-coder/py_bot_service.py index ddececc..fa53d0d 100644 --- a/SHiNE-agent-bot-coder/py_bot_service.py +++ b/SHiNE-agent-bot-coder/py_bot_service.py @@ -656,13 +656,33 @@ class ShinePyBotService: self.state["current_history_file"] = str(history_file) self._persist_state() - def _current_history_file_for_user(self, username: str) -> Path: + def _user_session_state(self, username: str) -> dict[str, Any]: uname = normalize_username(username) or self.cfg.allowed_username self._ensure_user_session(uname) sessions = self.state.get("user_sessions") or {} - session = sessions.get(uname) or {} + session = sessions.get(uname) + if not isinstance(session, dict): + session = {} + sessions[uname] = session + return session + + def _current_history_file_for_user(self, username: str) -> Path: + session = self._user_session_state(username) return Path(session["current_history_file"]) + def _codex_thread_id_for_user(self, username: str) -> str: + thread_id = (self._user_session_state(username).get("codex_thread_id") or "").strip() + return thread_id + + def _set_codex_thread_id_for_user(self, username: str, thread_id: str) -> None: + session = self._user_session_state(username) + normalized = (thread_id or "").strip() + if normalized: + session["codex_thread_id"] = normalized + else: + session.pop("codex_thread_id", None) + self._persist_state() + def _create_new_history_file(self, reason: str, username: str) -> Path: ts = dt.datetime.now().strftime("%Y-%m-%d_%H%M%S") rnd = "".join(random.choices(string.hexdigits.lower(), k=8)) @@ -690,7 +710,12 @@ class ShinePyBotService: if not isinstance(sessions, dict): sessions = {} self.state["user_sessions"] = sessions + previous = sessions.get(uname) if isinstance(sessions.get(uname), dict) else {} sessions[uname] = {"current_history_file": str(new_file)} + if reason != "command_new" and isinstance(previous, dict): + thread_id = (previous.get("codex_thread_id") or "").strip() + if thread_id: + sessions[uname]["codex_thread_id"] = thread_id if uname == self.cfg.allowed_username: self.state["current_history_file"] = str(new_file) self._persist_state() @@ -926,7 +951,7 @@ class ShinePyBotService: text = ( f"Привет, {player_name}.\n" "Можно задавать вопросы по проекту, просить анализ, идеи и подготовку готового ТЗ.\n" - "Команда /new начинает новую сессию и архивирует текущую историю." + "Команда /new начинает новую Codex-сессию и архивирует текущую историю." ) reminder = self._task_center_counts_text(uname) if reminder: @@ -1449,7 +1474,7 @@ class ShinePyBotService: "/tasks — список ваших задач и предложений", "/stop — остановить текущую задачу", "/cancel — удалить задачу по id (префикс) или все", - "/new — архивировать историю и начать новую", + "/new — архивировать историю и начать новую Codex-сессию", "/help — эта справка", ] if is_owner: @@ -1680,9 +1705,31 @@ class ShinePyBotService: ) def _run_codex(self, prompt: str, job: dict[str, Any]) -> str: + username = job.get("username") or self.cfg.allowed_username + thread_id = self._codex_thread_id_for_user(username) + try: + return self._run_codex_once(prompt, job, thread_id=thread_id) + except RuntimeError as e: + if not thread_id or not self._is_missing_codex_session_error(str(e)): + raise + self._set_codex_thread_id_for_user(username, "") + self._append_history( + Path(job["history_file"]), + "system_event", + { + "event": "codex_thread_reset", + "reason": "missing_session", + "username": normalize_username(username), + "oldThreadId": thread_id, + }, + ) + return self._run_codex_once(prompt, job, thread_id="") + + def _run_codex_once(self, prompt: str, job: dict[str, Any], *, thread_id: str) -> str: output_lines: list[str] = [] job_id = str(job["id"]) job_num = job.get("num", "?") + username = job.get("username") or self.cfg.allowed_username with tempfile.NamedTemporaryFile(prefix="shine-codex-last-message-", suffix=".txt", delete=False) as tmp: output_file = Path(tmp.name) @@ -1693,9 +1740,12 @@ class ShinePyBotService: "--json", "-C", str(self.cfg.codex_workdir), "-o", str(output_file), - prompt, ] - print(f"[py-bot] codex exec start job={job_id[:8]}", flush=True) + if thread_id: + cmd.extend(["resume", thread_id]) + cmd.append(prompt) + mode = f"resume {thread_id}" if thread_id else "new" + print(f"[py-bot] codex exec start job={job_id[:8]} mode={mode}", flush=True) process = subprocess.Popen( cmd, stdin=subprocess.DEVNULL, @@ -1714,10 +1764,14 @@ class ShinePyBotService: last_user_note_at = 0.0 codex_started_at = time.time() last_job_message_at = codex_started_at + seen_thread_id = "" def on_line(line: str) -> None: - nonlocal last_user_note, last_user_note_at, last_job_message_at + nonlocal last_user_note, last_user_note_at, last_job_message_at, seen_thread_id output_lines.append(line) + current_thread_id = self._extract_codex_thread_id(line) + if current_thread_id: + seen_thread_id = current_thread_id note = self._extract_codex_user_note(line) now = time.time() if note and note != last_user_note and now - last_user_note_at > 8: @@ -1770,6 +1824,9 @@ class ShinePyBotService: tail = "\n".join(output_lines[-40:]) raise RuntimeError(f"Codex exited with code {return_code}. Output tail:\n{tail}") + if seen_thread_id and seen_thread_id != thread_id: + self._set_codex_thread_id_for_user(username, seen_thread_id) + if output_file.exists(): answer = output_file.read_text(encoding="utf-8").strip() try: @@ -2829,6 +2886,35 @@ class ShinePyBotService: return line return "" + @staticmethod + def _extract_codex_thread_id(line: str) -> str: + s = (line or "").strip() + if not s.startswith("{"): + return "" + try: + obj = json.loads(s) + except Exception: + return "" + if obj.get("type") != "thread.started": + return "" + thread_id = (obj.get("thread_id") or "").strip() + return thread_id + + @staticmethod + def _is_missing_codex_session_error(text: str) -> bool: + lowered = (text or "").lower() + markers = [ + "session not found", + "conversation not found", + "thread not found", + "no session found", + "invalid session", + "unknown session", + "no conversation found", + "unknown thread", + ] + return any(marker in lowered for marker in markers) + @staticmethod def _format_duration(seconds: int) -> str: seconds = max(0, seconds) diff --git a/VERSION.properties b/VERSION.properties index d2707ae..29012d9 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.191 -server.version=1.2.180 +client.version=1.2.192 +server.version=1.2.181 From c681b4d684d0f48550cedf0123aa4007a8c719f79670d49f18bcde48c54c0df7 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sun, 14 Jun 2026 20:39:05 +0400 Subject: [PATCH 33/56] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20UI=20pairing=20=D0=BF=D0=BE=20=D0=BA=D0=BE=D0=B4?= =?UTF-8?q?=D1=83=20=D0=B8=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8E=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-14_2035_ui_подключение_по_коду.md | 25 ++ SHiNE-agent-bot-coder/AGENT.md | 1 + SHiNE-agent-bot-coder/README.md | 2 +- VERSION.properties | 4 +- shine-UI/js/app.js | 4 + shine-UI/js/pages/connect-device-view.js | 7 +- shine-UI/js/pages/device-pairing-view.js | 321 ++++++++++++++++++ shine-UI/js/pages/login-other-device-view.js | 240 +++++++++++++ shine-UI/js/pages/login-view.js | 8 +- shine-UI/js/router.js | 2 + shine-UI/js/services/auth-service.js | 62 ++++ shine-UI/js/services/crypto-utils.js | 15 + .../js/services/device-pairing-service.js | 160 +++++++++ 13 files changed, 846 insertions(+), 5 deletions(-) create mode 100644 Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md create mode 100644 shine-UI/js/pages/device-pairing-view.js create mode 100644 shine-UI/js/pages/login-other-device-view.js create mode 100644 shine-UI/js/services/device-pairing-service.js diff --git a/Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md b/Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md new file mode 100644 index 0000000..0c5b2fe --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md @@ -0,0 +1,25 @@ +# ui подключение по коду + +- краткое описание фичи: + - в UI добавлен новый сценарий подключения устройства через доверенную уже авторизованную сессию пользователя; + - на экране входа появилась кнопка `Войти через другое устройство`; + - на доверённом устройстве в `Подключить устройство` появилась кнопка `Подключить по коду`; + - доверённое устройство может включить pairing-пароль, увидеть заявки, подтвердить подключение только с `device key` или с передачей выбранных ключей. + +- что именно проверять: + - на уже авторизованном устройстве включить pairing-пароль; + - на новом устройстве открыть `Войти через другое устройство`, ввести `login + pairing password` и получить 7-значный код; + - на доверённом устройстве открыть `Подключить по коду`, найти заявку по коду и подтвердить её: + - без доп. ключей; + - с передачей выбранных ключей; + - убедиться, что новое устройство реально входит в аккаунт и сохраняет нужные ключи; + - отдельно проверить отклонение заявки и истечение TTL. + +- ожидаемый результат: + - новое устройство получает код, доверённое устройство видит ту же заявку и может её подтвердить или отклонить; + - после approve новое устройство автоматически входит в аккаунт; + - в режиме без доп. ключей переносится только `device key`; + - в расширенном режиме переносятся `device key` и отмеченные ключи `blockchain/root`, если они есть на доверённом устройстве. + +- статус: + - `pending` diff --git a/SHiNE-agent-bot-coder/AGENT.md b/SHiNE-agent-bot-coder/AGENT.md index a0236c1..f9c2f20 100644 --- a/SHiNE-agent-bot-coder/AGENT.md +++ b/SHiNE-agent-bot-coder/AGENT.md @@ -27,6 +27,7 @@ - Сервис ведёт состояние активной задачи и текущего файла истории, а после рестарта продолжает незавершённую обработку с учётом сохранённого состояния. - Истории диалогов хранятся в JSONL по каждому разрешённому username отдельно: `data/history//`. - Архив истории после `/new`: `data/history//archive/`. +- После `/new` для этого же пользователя должен сбрасываться и контекст продолжения Codex-сессии; следующий запрос запускается как новая сессия, не через resume. - Для просмотра истории игрока открывать файлы в его папке истории по username. - Дедупликация входящих Telegram update нужна, чтобы одно сообщение не попало в обработку повторно. - Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус с общим временем работы задачи; при дальнейшем молчании повторяет статус каждые 2 минуты. diff --git a/SHiNE-agent-bot-coder/README.md b/SHiNE-agent-bot-coder/README.md index a6ab372..e1b815d 100644 --- a/SHiNE-agent-bot-coder/README.md +++ b/SHiNE-agent-bot-coder/README.md @@ -89,7 +89,7 @@ python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь - `/queue` — список задач в очереди. - `/stop` — остановить текущую задачу. - `/cancel ` — удалить задачу по id/префиксу или очистить очередь. -- `/new` — архивировать текущую историю и начать новый диалог. +- `/new` — архивировать текущую историю, сбросить продолжение Codex-сессии для этого пользователя и начать новый диалог. - `/voice_on` — включить озвучивание финальных ответов для текущего пользователя. - `/voice_off` — выключить озвучивание финальных ответов для текущего пользователя. - `/voice_rewrite_on` — включить адаптацию текста перед озвучкой. diff --git a/VERSION.properties b/VERSION.properties index 29012d9..fdc2f20 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.192 -server.version=1.2.181 +client.version=1.2.193 +server.version=1.2.182 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 57972ce..a5b8ad7 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -42,6 +42,7 @@ import * as topupView from './pages/topup-view.js'; import * as devnetTopupView from './pages/devnet-topup-view.js'; import * as loginView from './pages/login-view.js'; import * as loginCameraView from './pages/login-camera-view.js'; +import * as loginOtherDeviceView from './pages/login-other-device-view.js'; import * as loginPasswordView from './pages/login-password-view.js'; import * as keyStorageView from './pages/key-storage-view.js'; @@ -54,6 +55,7 @@ import * as serverSettingsView from './pages/server-settings-view.js'; import * as toolsSettingsView from './pages/tools-settings-view.js'; import * as deviceView from './pages/device-view.js?v=202606131435'; import * as connectDeviceView from './pages/connect-device-view.js'; +import * as devicePairingView from './pages/device-pairing-view.js'; import * as deviceQrView from './pages/device-qr-view.js'; import * as deviceCameraView from './pages/device-camera-view.js'; import * as showKeysView from './pages/show-keys-view.js'; @@ -85,6 +87,7 @@ const routes = { 'devnet-topup-view': devnetTopupView, 'login-view': loginView, 'login-camera-view': loginCameraView, + 'login-other-device-view': loginOtherDeviceView, 'login-password-view': loginPasswordView, 'key-storage-view': keyStorageView, 'profile-view': profileView, @@ -96,6 +99,7 @@ const routes = { 'tools-settings-view': toolsSettingsView, 'device-view': deviceView, 'connect-device-view': connectDeviceView, + 'device-pairing-view': devicePairingView, 'device-qr-view': deviceQrView, 'device-camera-view': deviceCameraView, 'show-keys-view': showKeysView, diff --git a/shine-UI/js/pages/connect-device-view.js b/shine-UI/js/pages/connect-device-view.js index 80787b2..b135721 100644 --- a/shine-UI/js/pages/connect-device-view.js +++ b/shine-UI/js/pages/connect-device-view.js @@ -28,6 +28,7 @@ export function render({ navigate }) {

+
`; @@ -37,6 +38,7 @@ export function render({ navigate }) { const deviceToggle = card.querySelector('#connect-device'); const statusEl = card.querySelector('#connect-keys-status'); const openQrBtn = card.querySelector('#open-qr'); + const openPairBtn = card.querySelector('#open-pairing'); deviceToggle.checked = true; rootToggle.addEventListener('change', () => { @@ -70,7 +72,7 @@ export function render({ navigate }) {

подключение происходит напрямую через QR

сервер не используется

текущая логика: устройство 1 показывает QR, устройство 2 сканирует

-

обратный сценарий пока не реализован

+

для сценария через сервер используйте кнопку «Подключить по коду»

@@ -87,6 +89,7 @@ export function render({ navigate }) { card.querySelector('#tech-help').addEventListener('click', openHelp); card.querySelector('#open-qr').addEventListener('click', () => navigate('device-qr-view')); + card.querySelector('#open-pairing').addEventListener('click', () => navigate('device-pairing-view')); card.querySelector('#open-camera').addEventListener('click', () => navigate('device-camera-view')); (async () => { @@ -109,6 +112,7 @@ export function render({ navigate }) { blockchainToggle.checked = state.deviceConnect.blockchain; deviceToggle.checked = hasDevice; openQrBtn.disabled = !hasDevice; + openPairBtn.disabled = !hasDevice; const available = [ hasDevice ? 'device' : '', @@ -126,6 +130,7 @@ export function render({ navigate }) { state.deviceConnect.blockchain = false; state.deviceConnect.device = false; openQrBtn.disabled = true; + openPairBtn.disabled = true; statusEl.textContent = 'Не удалось прочитать сохранённые ключи на этом устройстве.'; } })(); diff --git a/shine-UI/js/pages/device-pairing-view.js b/shine-UI/js/pages/device-pairing-view.js new file mode 100644 index 0000000..705b50e --- /dev/null +++ b/shine-UI/js/pages/device-pairing-view.js @@ -0,0 +1,321 @@ +import { renderHeader } from '../components/header.js'; +import { + authService, + refreshSessions, + setAuthError, + setAuthInfo, + state, +} from '../state.js'; +import { formatRelativeTime, showToast } from '../services/channels-ux.js'; +import { + buildSecretsPayload, + deriveEspPairingPasswordHash, + encryptPairingPayloadForRequester, +} from '../services/device-pairing-service.js'; +import { loadEncryptedUserSecrets } from '../services/key-vault.js'; +import { toUserMessage } from '../services/ui-error-texts.js'; + +export const pageMeta = { id: 'device-pairing-view', title: 'Подключить по коду' }; + +function setStatus(statusEl, message, kind = 'info') { + statusEl.classList.toggle('is-unavailable', kind === 'error'); + statusEl.classList.toggle('is-available', kind !== 'error'); + statusEl.textContent = message; + statusEl.style.display = message ? '' : 'none'; +} + +function normalizeCode(value) { + return String(value || '').replace(/\D+/g, '').slice(0, 7); +} + +function buildTransferKeys(savedKeys, { withExtras = false }) { + const keys = { + deviceKey: String(savedKeys?.deviceKey || '').trim(), + blockchainKey: '', + rootKey: '', + }; + if (!keys.deviceKey) { + throw new Error('На этом устройстве нет сохранённого device key для передачи.'); + } + if (withExtras) { + if (state.deviceConnect.blockchain && savedKeys?.blockchainKey) { + keys.blockchainKey = String(savedKeys.blockchainKey || '').trim(); + } + if (state.deviceConnect.root && savedKeys?.rootKey) { + keys.rootKey = String(savedKeys.rootKey || '').trim(); + } + } + return keys; +} + +function requestCardHtml(request) { + const shortCode = String(request?.shortCode || '').trim() || '0000000'; + const client = String(request?.requesterClientPlatform || 'unknown'); + const expiresText = request?.expiresAtMs ? formatRelativeTime(request.expiresAtMs) : '—'; + return ` +
+
+
+
${shortCode}
+ Платформа: ${client} + Тип payload: ${Number(request?.payloadType || 0)} + Истекает: ${expiresText} +
+
+
+ + + +
+
+ `; +} + +export function render({ navigate }) { + const screen = document.createElement('section'); + screen.className = 'stack'; + let savedKeys = null; + let requests = []; + let cleanupEvent = () => {}; + let disposed = false; + + screen.append( + renderHeader({ + title: 'Подключить по коду', + leftAction: { label: '←', onClick: () => navigate('connect-device-view') }, + }), + ); + + const settingsCard = document.createElement('div'); + settingsCard.className = 'card stack'; + settingsCard.innerHTML = ` +

Пароль подключения

+ +
+ + +
+

Пароль хранится на сервере только в виде hash. После включения можно переходить к заявкам ниже.

+ `; + + const keySummaryCard = document.createElement('div'); + keySummaryCard.className = 'card stack'; + keySummaryCard.innerHTML = ` +

Что передаётся при расширенном подключении

+

Проверяем локальные ключи...

+ `; + + const requestsCard = document.createElement('div'); + requestsCard.className = 'card stack'; + requestsCard.innerHTML = ` +
+ + +
+

Если код не введён, показываются все активные заявки. Для подключения без доп. ключей передаётся только device key.

+
+ `; + + const status = document.createElement('p'); + status.className = 'status-line is-unavailable'; + status.style.display = 'none'; + + const passwordInput = settingsCard.querySelector('#pairing-password'); + const enableBtn = settingsCard.querySelector('#enable-pairing-btn'); + const disableBtn = settingsCard.querySelector('#disable-pairing-btn'); + const keySummaryEl = keySummaryCard.querySelector('#pairing-key-summary'); + const codeFilterInput = requestsCard.querySelector('#pairing-code-filter'); + const refreshBtn = requestsCard.querySelector('#refresh-pairing-requests'); + const requestsListEl = requestsCard.querySelector('#pairing-requests-list'); + + const renderRequests = () => { + const filterCode = normalizeCode(codeFilterInput.value); + const filtered = filterCode + ? requests.filter((item) => normalizeCode(item?.shortCode) === filterCode) + : requests; + requestsListEl.innerHTML = ''; + + if (!filtered.length) { + const empty = document.createElement('p'); + empty.className = 'meta-muted'; + empty.textContent = filterCode + ? 'Заявка с таким кодом пока не найдена.' + : 'Активных заявок сейчас нет.'; + requestsListEl.append(empty); + return; + } + + filtered.forEach((request) => { + const wrapper = document.createElement('div'); + wrapper.innerHTML = requestCardHtml(request); + requestsListEl.append(wrapper.firstElementChild); + }); + }; + + const loadSavedKeys = async () => { + savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory); + const available = []; + if (savedKeys?.deviceKey) available.push('device'); + if (savedKeys?.blockchainKey && state.deviceConnect.blockchain) available.push('blockchain'); + if (savedKeys?.rootKey && state.deviceConnect.root) available.push('root'); + keySummaryEl.textContent = available.length + ? `При расширенном подключении будут переданы: ${available.join(', ')}.` + : 'На этом устройстве доступен только device key.'; + }; + + const reloadRequests = async ({ silent = false } = {}) => { + try { + requests = await authService.listEspPairingRequests(); + renderRequests(); + if (!silent) { + setStatus(status, 'Список pairing-заявок обновлён.', 'info'); + } + } catch (error) { + const message = toUserMessage(error, 'Не удалось загрузить pairing-заявки.'); + setAuthError(message); + setStatus(status, message, 'error'); + } + }; + + const setButtonsBusy = (flag) => { + enableBtn.disabled = flag; + disableBtn.disabled = flag; + refreshBtn.disabled = flag; + }; + + const approveRequest = async (request, mode) => { + const withExtras = mode === 'with-extras'; + const keys = buildTransferKeys(savedKeys, { withExtras }); + const payload = buildSecretsPayload({ + login: state.session.login, + keys, + mode: withExtras ? 'with-extras' : 'device-only', + }); + const encryptedPayload = await encryptPairingPayloadForRequester(request?.requesterSessionKey, payload); + await authService.approveEspPairing(request?.pairingId, encryptedPayload); + showToast(withExtras ? 'Ключи переданы на новое устройство' : 'Новое устройство подключено'); + setAuthInfo(withExtras ? 'Заявка подтверждена, ключи переданы.' : 'Заявка подтверждена без передачи доп. ключей.'); + await refreshSessions().catch(() => {}); + await reloadRequests({ silent: true }); + }; + + settingsCard.addEventListener('click', async (event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + + if (target.id === 'enable-pairing-btn') { + const password = String(passwordInput.value || ''); + if (!password) { + setStatus(status, 'Введите pairing-пароль.', 'error'); + return; + } + setButtonsBusy(true); + try { + const passwordHash = await deriveEspPairingPasswordHash(state.session.login, password); + const payload = await authService.upsertEspPairingSettings({ + enabled: true, + passwordHash, + ttlSeconds: 180, + }); + setAuthInfo(`Подключение по коду включено. TTL: ${payload?.ttlSeconds || 180} сек.`); + setStatus(status, 'Подключение по коду включено или обновлено.', 'info'); + passwordInput.value = ''; + } catch (error) { + const message = toUserMessage(error, 'Не удалось включить pairing.'); + setAuthError(message); + setStatus(status, message, 'error'); + } finally { + setButtonsBusy(false); + } + return; + } + + if (target.id === 'disable-pairing-btn') { + setButtonsBusy(true); + try { + await authService.upsertEspPairingSettings({ + enabled: false, + passwordHash: '', + ttlSeconds: 180, + }); + setAuthInfo('Подключение по коду выключено.'); + setStatus(status, 'Подключение по коду выключено.', 'info'); + } catch (error) { + const message = toUserMessage(error, 'Не удалось выключить pairing.'); + setAuthError(message); + setStatus(status, message, 'error'); + } finally { + setButtonsBusy(false); + } + } + }); + + refreshBtn.addEventListener('click', () => { + void reloadRequests(); + }); + codeFilterInput.addEventListener('input', () => { + codeFilterInput.value = normalizeCode(codeFilterInput.value); + renderRequests(); + }); + + requestsListEl.addEventListener('click', async (event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + const action = String(target.dataset.action || ''); + if (!action) return; + const card = target.closest('[data-pairing-id]'); + if (!(card instanceof HTMLElement)) return; + const pairingId = String(card.dataset.pairingId || ''); + const request = requests.find((item) => String(item?.pairingId || '') === pairingId); + if (!request) return; + + const buttons = [...card.querySelectorAll('button')]; + buttons.forEach((btn) => { btn.disabled = true; }); + try { + if (action === 'approve-device') { + await approveRequest(request, 'device-only'); + } else if (action === 'approve-full') { + await approveRequest(request, 'with-extras'); + } else if (action === 'reject') { + await authService.rejectEspPairing(pairingId, 'rejected_by_user'); + showToast('Заявка отклонена', { kind: 'error' }); + await reloadRequests({ silent: true }); + } + } catch (error) { + const message = toUserMessage(error, 'Не удалось обработать pairing-заявку.'); + setAuthError(message); + setStatus(status, message, 'error'); + buttons.forEach((btn) => { btn.disabled = false; }); + } + }); + + void (async () => { + try { + await loadSavedKeys(); + await reloadRequests({ silent: true }); + cleanupEvent = authService.onEvent('IncomingEspPairingRequest', () => { + if (disposed) return; + showToast('Пришла новая заявка на подключение устройства'); + void reloadRequests({ silent: true }); + }); + } catch (error) { + const message = toUserMessage(error, 'Не удалось подготовить экран pairing.'); + setAuthError(message); + setStatus(status, message, 'error'); + } + })(); + + screen.cleanup = () => { + disposed = true; + cleanupEvent(); + }; + + screen.append(settingsCard, keySummaryCard, requestsCard, status); + return screen; +} diff --git a/shine-UI/js/pages/login-other-device-view.js b/shine-UI/js/pages/login-other-device-view.js new file mode 100644 index 0000000..f59acf3 --- /dev/null +++ b/shine-UI/js/pages/login-other-device-view.js @@ -0,0 +1,240 @@ +import { renderHeader } from '../components/header.js'; +import { + authService, + authorizeSession, + clearAuthMessages, + clearBrowserClientData, + refreshSessions, + setAuthBusy, + setAuthError, + setAuthInfo, + state, + terminateCurrentSession, +} from '../state.js'; +import { showToast } from '../services/channels-ux.js'; +import { decryptPairingPayloadFromEnvelope, deriveEspPairingPasswordHash, createRequesterPairingMaterial } from '../services/device-pairing-service.js'; +import { clearClientAuthData, saveEncryptedUserSecrets } from '../services/key-vault.js'; +import { clearStoredMessages } from '../services/message-store.js'; +import { toUserMessage } from '../services/ui-error-texts.js'; + +export const pageMeta = { id: 'login-other-device-view', title: 'Войти через другое устройство', showAppChrome: false }; + +function setStatus(statusEl, message, kind = 'info') { + statusEl.classList.toggle('is-unavailable', kind === 'error'); + statusEl.classList.toggle('is-available', kind !== 'error'); + statusEl.textContent = message; + statusEl.style.display = message ? '' : 'none'; +} + +function codeCardHtml() { + return ` +
+

Код подключения

+
0000000
+

Покажите этот код на уже подключённом устройстве в разделе «Подключить по коду».

+

+

+
+ `; +} + +function formatExpiresAt(ms) { + const ts = Number(ms || 0); + if (!Number.isFinite(ts) || ts <= 0) return ''; + return new Date(ts).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +} + +export function render({ navigate }) { + const screen = document.createElement('section'); + screen.className = 'stack'; + let pollTimer = 0; + let activePairingId = ''; + let requesterMaterial = null; + let isDisposed = false; + + clearAuthMessages(); + + screen.append( + renderHeader({ + title: 'Войти через другое устройство', + leftAction: { label: '←', onClick: () => navigate('login-view') }, + }), + ); + + const formCard = document.createElement('div'); + formCard.className = 'card stack'; + formCard.innerHTML = ` + + + +

Сначала вводится ваш логин и pairing-пароль. После этого появится 7-значный код для подтверждения на уже подключённом устройстве.

+ `; + + const status = document.createElement('p'); + status.className = 'status-line is-unavailable'; + status.style.display = 'none'; + + const resultWrap = document.createElement('div'); + resultWrap.className = 'stack'; + resultWrap.style.display = 'none'; + resultWrap.innerHTML = codeCardHtml(); + + const loginInput = formCard.querySelector('#pair-login'); + const passwordInput = formCard.querySelector('#pair-password'); + const startBtn = formCard.querySelector('#pair-start-btn'); + const shortCodeEl = resultWrap.querySelector('#pairing-short-code'); + const statusHintEl = resultWrap.querySelector('#pairing-status-hint'); + const onlineHintEl = resultWrap.querySelector('#pairing-online-hint'); + const expireHintEl = resultWrap.querySelector('#pairing-expire-hint'); + + const stopPolling = () => { + if (pollTimer) { + window.clearTimeout(pollTimer); + pollTimer = 0; + } + }; + + const finalizeAuthorizedLogin = async (keys, login) => { + const session = await authService.createSessionFromImportedSecrets(login, keys); + await clearStoredMessages().catch(() => {}); + clearBrowserClientData(); + await clearClientAuthData().catch(() => {}); + await terminateCurrentSession(); + await saveEncryptedUserSecrets(session.login, session.storagePwd, keys); + await authService.persistSessionMaterial(session.login, session.sessionMaterial); + const resumed = await authService.resumeSession(session.login, session.sessionId); + authorizeSession({ + login: resumed.login || session.login, + sessionId: resumed.sessionId || session.sessionId, + storagePwd: resumed.storagePwd || session.storagePwd, + }); + state.loginDraft.login = resumed.login || session.login; + state.loginDraft.password = ''; + await refreshSessions(); + setAuthInfo(`Вход через другое устройство выполнен для @${resumed.login || session.login}.`); + showToast(`Устройство подключено для @${resumed.login || session.login}`); + navigate('profile-view'); + }; + + const schedulePoll = () => { + stopPolling(); + if (!activePairingId || isDisposed) return; + pollTimer = window.setTimeout(async () => { + try { + const payload = await authService.getEspPairingStatus(activePairingId); + const stateValue = String(payload?.state || ''); + if (stateValue === 'created') { + schedulePoll(); + return; + } + if (stateValue === 'approved') { + setStatus(status, 'Заявка подтверждена. Подключаем устройство...', 'info'); + const decoded = await decryptPairingPayloadFromEnvelope(payload?.encryptedPayload, requesterMaterial); + if (String(decoded?.type || '') !== 'shine-esp-pairing-transfer') { + throw new Error('Получен неподдерживаемый pairing payload'); + } + await finalizeAuthorizedLogin(decoded.keys, decoded.login || loginInput.value); + return; + } + if (stateValue === 'rejected') { + stopPolling(); + startBtn.disabled = false; + setStatus(status, 'Подключение отклонено на доверенном устройстве.', 'error'); + statusHintEl.textContent = 'Заявка отклонена. Можно попробовать снова.'; + return; + } + if (stateValue === 'expired') { + stopPolling(); + startBtn.disabled = false; + setStatus(status, 'Время ожидания истекло. Получите новый код.', 'error'); + statusHintEl.textContent = 'Заявка истекла. Создайте новую заявку.'; + return; + } + schedulePoll(); + } catch (error) { + stopPolling(); + startBtn.disabled = false; + const message = toUserMessage(error, 'Не удалось проверить статус pairing.'); + setAuthError(message); + setStatus(status, message, 'error'); + } + }, 2200); + }; + + startBtn.addEventListener('click', async () => { + const login = String(loginInput.value || '').trim(); + const password = String(passwordInput.value || ''); + if (!login) { + setStatus(status, 'Введите логин.', 'error'); + return; + } + if (!password) { + setStatus(status, 'Введите пароль подключения.', 'error'); + return; + } + + startBtn.disabled = true; + setAuthBusy(true); + setAuthError(''); + setAuthInfo(''); + setStatus(status, 'Проверяем пользователя и создаём pairing-заявку...', 'info'); + resultWrap.style.display = 'none'; + stopPolling(); + + try { + await authService.reconnect(state.entrySettings.shineServer); + const user = await authService.getUser(login); + if (!user?.exists) { + throw new Error('Пользователь не найден.'); + } + + requesterMaterial = await createRequesterPairingMaterial(); + const passwordHash = await deriveEspPairingPasswordHash(login, password); + const payload = await authService.startEspPairing({ + login, + passwordHash, + requesterSessionKey: requesterMaterial.sessionKey, + payloadType: 3, + }); + + activePairingId = String(payload?.pairingId || ''); + if (!activePairingId) { + throw new Error('Сервер не вернул pairingId.'); + } + + shortCodeEl.textContent = String(payload?.shortCode || '0000000'); + statusHintEl.textContent = 'Откройте на доверенном устройстве: Настройки -> Устройства -> Подключить устройство -> Подключить по коду.'; + onlineHintEl.textContent = payload?.trustedSessionOnline + ? 'Сейчас есть хотя бы одна онлайн доверенная сессия, которая может принять заявку.' + : 'Сейчас нет онлайн доверенной сессии. Заявка будет ждать, пока пользователь откроет уже подключённое устройство.'; + expireHintEl.textContent = payload?.expiresAtMs + ? `Код действует до ${formatExpiresAt(payload.expiresAtMs)}.` + : ''; + resultWrap.style.display = ''; + state.loginDraft.login = login; + setStatus(status, 'Код создан. Ожидаем подтверждение на другом устройстве...', 'info'); + schedulePoll(); + } catch (error) { + startBtn.disabled = false; + const message = toUserMessage(error, 'Не удалось начать вход через другое устройство.'); + setAuthError(message); + setStatus(status, message, 'error'); + } finally { + setAuthBusy(false); + } + }); + + screen.cleanup = () => { + isDisposed = true; + stopPolling(); + }; + + screen.append(formCard, status, resultWrap); + return screen; +} diff --git a/shine-UI/js/pages/login-view.js b/shine-UI/js/pages/login-view.js index c6590c0..7912de8 100644 --- a/shine-UI/js/pages/login-view.js +++ b/shine-UI/js/pages/login-view.js @@ -48,9 +48,15 @@ export function render({ navigate }) { loginButton.textContent = 'Войти по логину'; loginButton.addEventListener('click', () => navigate('login-password-view')); + const otherDeviceButton = document.createElement('button'); + otherDeviceButton.className = 'text-btn'; + otherDeviceButton.type = 'button'; + otherDeviceButton.textContent = 'Войти через другое устройство'; + otherDeviceButton.addEventListener('click', () => navigate('login-other-device-view')); + const actions = document.createElement('div'); actions.className = 'auth-actions login-actions-wide'; - actions.append(cameraButton, loginButton); + actions.append(cameraButton, loginButton, otherDeviceButton); const backButton = document.createElement('button'); backButton.className = 'ghost-btn'; diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js index 9bc56d2..1bc2fc7 100644 --- a/shine-UI/js/router.js +++ b/shine-UI/js/router.js @@ -13,6 +13,7 @@ export const PRE_AUTH_PAGES = [ 'devnet-topup-view', 'login-view', 'login-camera-view', + 'login-other-device-view', 'login-password-view', 'key-storage-view', ]; @@ -178,6 +179,7 @@ export function resolveToolbarActive(pageId) { pageId === 'tools-settings-view' || pageId === 'device-view' || pageId === 'connect-device-view' || + pageId === 'device-pairing-view' || pageId === 'device-qr-view' || pageId === 'device-camera-view' || pageId === 'show-keys-view' || diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index e370da6..c453d7c 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -966,6 +966,68 @@ export class AuthService { if (response.status !== 200) throw opError('CloseActiveSession', response); } + async upsertEspPairingSettings({ enabled, passwordHash = '', ttlSeconds = 180 }) { + const response = await this.ws.request('UpsertEspPairingSettings', { + enabled: !!enabled, + passwordHash: String(passwordHash || '').trim(), + ttlSeconds: Number(ttlSeconds) || 180, + }); + if (response.status !== 200) throw opError('UpsertEspPairingSettings', response); + return response.payload || {}; + } + + async startEspPairing({ + login, + passwordHash, + requesterSessionKey, + requesterSessionType = SESSION_TYPE_CLIENT, + requesterClientPlatform = makeClientPlatform(), + payloadType = 3, + }) { + const response = await this.ws.request('StartEspPairing', { + login: String(login || '').trim(), + passwordHash: String(passwordHash || '').trim(), + requesterSessionKey: String(requesterSessionKey || '').trim(), + requesterSessionType: Number(requesterSessionType) || SESSION_TYPE_CLIENT, + requesterClientPlatform: String(requesterClientPlatform || '').trim() || makeClientPlatform(), + payloadType: Number(payloadType) || 3, + }); + if (response.status !== 200) throw opError('StartEspPairing', response); + return response.payload || {}; + } + + async listEspPairingRequests() { + const response = await this.ws.request('ListEspPairingRequests', {}); + if (response.status !== 200) throw opError('ListEspPairingRequests', response); + return Array.isArray(response?.payload?.requests) ? response.payload.requests : []; + } + + async approveEspPairing(pairingId, encryptedPayload) { + const response = await this.ws.request('ApproveEspPairing', { + pairingId: String(pairingId || '').trim(), + encryptedPayload: String(encryptedPayload || '').trim(), + }); + if (response.status !== 200) throw opError('ApproveEspPairing', response); + return response.payload || {}; + } + + async rejectEspPairing(pairingId, reason = '') { + const response = await this.ws.request('RejectEspPairing', { + pairingId: String(pairingId || '').trim(), + reason: String(reason || '').trim(), + }); + if (response.status !== 200) throw opError('RejectEspPairing', response); + return response.payload || {}; + } + + async getEspPairingStatus(pairingId) { + const response = await this.ws.request('GetEspPairingStatus', { + pairingId: String(pairingId || '').trim(), + }); + if (response.status !== 200) throw opError('GetEspPairingStatus', response); + return response.payload || {}; + } + async listSubscriptionsFeed(login, limit = 200) { const response = await this.ws.request('ListSubscriptionsFeed', { login, limit }); if (response.status !== 200) throw opError('ListSubscriptionsFeed', response); diff --git a/shine-UI/js/services/crypto-utils.js b/shine-UI/js/services/crypto-utils.js index d15f291..93e063a 100644 --- a/shine-UI/js/services/crypto-utils.js +++ b/shine-UI/js/services/crypto-utils.js @@ -171,6 +171,21 @@ export async function deriveMasterSecretFromPassword(password, options = {}) { }); } +export async function deriveOpaqueArgon2Hash(password, options = {}) { + const normalizedPassword = String(password ?? ''); + const normalizedLogin = String(options?.login ?? ''); + const normalizedSuffix = String(options?.suffix || 'opaque.hash'); + const salt = await makeArgon2Salt(normalizedLogin, normalizedSuffix); + const passBytes = utf8Bytes(`${normalizeLoginForKdf(normalizedLogin)}\n${normalizedPassword}`); + const out = await argon2idAsync(passBytes, salt, { + t: 2, + m: 65536, + p: 1, + dkLen: 32, + }); + return `argon2id$v=19$m=65536,t=2,p=1$${bytesToBase64(salt)}$${bytesToBase64(new Uint8Array(out))}`; +} + export async function deriveEd25519FromMasterSecret(masterSecret32, suffix) { const secretBytes = masterSecret32 instanceof Uint8Array ? masterSecret32 diff --git a/shine-UI/js/services/device-pairing-service.js b/shine-UI/js/services/device-pairing-service.js new file mode 100644 index 0000000..a8e037a --- /dev/null +++ b/shine-UI/js/services/device-pairing-service.js @@ -0,0 +1,160 @@ +import { + base64ToBytes, + bytesToBase64, + deriveOpaqueArgon2Hash, + exportEd25519PublicKeyB64, + exportPkcs8B64, + generateEd25519Pair, + sha256Bytes, + utf8Bytes, +} from './crypto-utils.js'; +import { + edwardsToMontgomeryPriv, + edwardsToMontgomeryPub, + x25519, +} from 'https://esm.sh/@noble/curves@1.5.0/ed25519'; + +const PAIRING_HASH_SUFFIX = 'esp.pairing.password'; +const PAIRING_ENVELOPE_PREFIX = 'shine-esp-pairing-v1:'; +const ED25519_PKCS8_PREFIX = new Uint8Array([ + 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20, +]); + +function getCryptoApi() { + const api = globalThis.crypto; + if (!api?.subtle || typeof api.getRandomValues !== 'function') { + throw new Error('Криптография браузера недоступна. Откройте приложение через HTTPS или localhost.'); + } + return api; +} + +function bytesToBase64Url(bytes) { + return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +function base64UrlToBytes(value) { + const normalized = String(value || '').trim().replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4); + return base64ToBytes(padded); +} + +function extractSeedFromPkcs8(pkcs8B64) { + const raw = base64ToBytes(pkcs8B64); + if (raw.length !== ED25519_PKCS8_PREFIX.length + 32) { + throw new Error('Некорректный приватный Ed25519 ключ'); + } + for (let i = 0; i < ED25519_PKCS8_PREFIX.length; i += 1) { + if (raw[i] !== ED25519_PKCS8_PREFIX[i]) { + throw new Error('Неподдерживаемый формат приватного Ed25519 ключа'); + } + } + return raw.slice(ED25519_PKCS8_PREFIX.length); +} + +function extractSessionPublicKeyB64(sessionKey) { + const raw = String(sessionKey || '').trim(); + if (!raw.startsWith('ed25519/')) { + throw new Error('Неподдерживаемый requesterSessionKey'); + } + const publicKeyB64 = raw.slice('ed25519/'.length).trim(); + if (!publicKeyB64) { + throw new Error('Пустой requesterSessionKey'); + } + return publicKeyB64; +} + +async function importAesKeyFromSharedSecret(sharedSecretBytes) { + const digest = await sha256Bytes(sharedSecretBytes); + return getCryptoApi().subtle.importKey('raw', digest, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); +} + +function normalizeKeys(keys = {}) { + return { + deviceKey: String(keys?.deviceKey || '').trim(), + blockchainKey: String(keys?.blockchainKey || '').trim(), + rootKey: String(keys?.rootKey || '').trim(), + }; +} + +export function detectPairingPayloadType(keys = {}) { + const normalized = normalizeKeys(keys); + if (normalized.rootKey) return 3; + if (normalized.blockchainKey) return 2; + return 1; +} + +export async function deriveEspPairingPasswordHash(login, password) { + return deriveOpaqueArgon2Hash(password, { + login, + suffix: PAIRING_HASH_SUFFIX, + }); +} + +export async function createRequesterPairingMaterial() { + const sessionPair = await generateEd25519Pair(); + const sessionPublicB64 = await exportEd25519PublicKeyB64(sessionPair.publicKey); + return { + sessionKey: `ed25519/${sessionPublicB64}`, + sessionPrivPkcs8: await exportPkcs8B64(sessionPair.privateKey), + }; +} + +export async function encryptPairingPayloadForRequester(requesterSessionKey, payload) { + const requesterPublicB64 = extractSessionPublicKeyB64(requesterSessionKey); + const requesterMontPub = edwardsToMontgomeryPub(base64ToBytes(requesterPublicB64)); + const ephemeralPriv = x25519.utils.randomPrivateKey(); + const ephemeralPub = x25519.getPublicKey(ephemeralPriv); + const sharedSecret = x25519.getSharedSecret(ephemeralPriv, requesterMontPub); + const aesKey = await importAesKeyFromSharedSecret(sharedSecret); + const iv = getCryptoApi().getRandomValues(new Uint8Array(12)); + const plainBytes = utf8Bytes(JSON.stringify(payload)); + const cipher = await getCryptoApi().subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, plainBytes); + const envelope = { + v: 1, + alg: 'x25519-aes256-gcm', + ephPubB64: bytesToBase64(ephemeralPub), + ivB64: bytesToBase64(iv), + cipherB64: bytesToBase64(new Uint8Array(cipher)), + createdAtMs: Date.now(), + }; + return `${PAIRING_ENVELOPE_PREFIX}${bytesToBase64Url(utf8Bytes(JSON.stringify(envelope)))}`; +} + +export async function decryptPairingPayloadFromEnvelope(encryptedPayload, requesterPairingMaterial) { + const raw = String(encryptedPayload || '').trim(); + if (!raw.startsWith(PAIRING_ENVELOPE_PREFIX)) { + throw new Error('Неподдерживаемый формат pairing payload'); + } + const jsonBytes = base64UrlToBytes(raw.slice(PAIRING_ENVELOPE_PREFIX.length)); + const envelope = JSON.parse(new TextDecoder().decode(jsonBytes)); + if (Number(envelope?.v) !== 1 || String(envelope?.alg || '') !== 'x25519-aes256-gcm') { + throw new Error('Неподдерживаемая версия pairing payload'); + } + + const requesterSeed = extractSeedFromPkcs8(String(requesterPairingMaterial?.sessionPrivPkcs8 || '')); + const requesterMontPriv = edwardsToMontgomeryPriv(requesterSeed); + const sharedSecret = x25519.getSharedSecret(requesterMontPriv, base64ToBytes(String(envelope?.ephPubB64 || ''))); + const aesKey = await importAesKeyFromSharedSecret(sharedSecret); + const plain = await getCryptoApi().subtle.decrypt( + { name: 'AES-GCM', iv: base64ToBytes(String(envelope?.ivB64 || '')) }, + aesKey, + base64ToBytes(String(envelope?.cipherB64 || '')), + ); + const payload = JSON.parse(new TextDecoder().decode(plain)); + return { + ...payload, + keys: normalizeKeys(payload?.keys), + }; +} + +export function buildSecretsPayload({ login, keys, mode }) { + return { + v: 1, + type: 'shine-esp-pairing-transfer', + login: String(login || '').trim(), + mode: String(mode || 'device-only').trim() || 'device-only', + keys: normalizeKeys(keys), + payloadType: detectPairingPayloadType(keys), + createdAtMs: Date.now(), + }; +} From dd69a52273dff5e02d83c44c62f8974504c553314d8521589659d46bd52c963c Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sun, 14 Jun 2026 21:13:22 +0400 Subject: [PATCH 34/56] =?UTF-8?q?=D0=A4=D0=BE=D1=80=D1=81=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20UI=20=D0=BC=D0=BE=D0=B4=D1=83?= =?UTF-8?q?=D0=BB=D0=B5=D0=B9=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 4 ++-- shine-UI/js/app.js | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index fdc2f20..b4f3e86 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.193 -server.version=1.2.182 +client.version=1.2.194 +server.version=1.2.183 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index a5b8ad7..31875bf 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -40,9 +40,9 @@ import * as registrationKeysView from './pages/registration-keys-view.js'; import * as registrationDraftKeysView from './pages/registration-draft-keys-view.js'; import * as topupView from './pages/topup-view.js'; import * as devnetTopupView from './pages/devnet-topup-view.js'; -import * as loginView from './pages/login-view.js'; +import * as loginView from './pages/login-view.js?v=202606142055'; import * as loginCameraView from './pages/login-camera-view.js'; -import * as loginOtherDeviceView from './pages/login-other-device-view.js'; +import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606142055'; import * as loginPasswordView from './pages/login-password-view.js'; import * as keyStorageView from './pages/key-storage-view.js'; @@ -54,8 +54,8 @@ import * as developerSettingsView from './pages/developer-settings-view.js'; import * as serverSettingsView from './pages/server-settings-view.js'; import * as toolsSettingsView from './pages/tools-settings-view.js'; import * as deviceView from './pages/device-view.js?v=202606131435'; -import * as connectDeviceView from './pages/connect-device-view.js'; -import * as devicePairingView from './pages/device-pairing-view.js'; +import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055'; +import * as devicePairingView from './pages/device-pairing-view.js?v=202606142055'; import * as deviceQrView from './pages/device-qr-view.js'; import * as deviceCameraView from './pages/device-camera-view.js'; import * as showKeysView from './pages/show-keys-view.js'; From 49fdbbf7ae2d86d6085e920a58e04610a9f3c897682619bdf0643244585f5520 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sun, 14 Jun 2026 21:33:43 +0400 Subject: [PATCH 35/56] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=20=D1=81=D0=BE=20=D1=81=D1=82=D0=B0=D1=80=D1=82=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20=D0=B2=D1=8B=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=D0=B0=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 4 ++-- shine-UI/js/app.js | 2 +- shine-UI/js/pages/start-view.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index b4f3e86..f562c45 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.194 -server.version=1.2.183 +client.version=1.2.195 +server.version=1.2.184 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 31875bf..0cfe534 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -32,7 +32,7 @@ import { setContacts, } from './state.js'; -import * as startView from './pages/start-view.js'; +import * as startView from './pages/start-view.js?v=202606142105'; import * as entrySettingsView from './pages/entry-settings-view.js'; import * as registerView from './pages/register-view.js'; import * as registrationPaymentView from './pages/registration-payment-view.js'; diff --git a/shine-UI/js/pages/start-view.js b/shine-UI/js/pages/start-view.js index 56748f2..63cd52f 100644 --- a/shine-UI/js/pages/start-view.js +++ b/shine-UI/js/pages/start-view.js @@ -24,7 +24,7 @@ export function render({ navigate }) { loginButton.className = 'primary-btn'; loginButton.type = 'button'; loginButton.textContent = 'Войти'; - loginButton.addEventListener('click', () => navigate('login-password-view')); + loginButton.addEventListener('click', () => navigate('login-view')); const registerButton = document.createElement('button'); registerButton.className = 'ghost-btn'; From bef205aec73ea7604a076b63215f66b848dcbb3a9aca5eb1d81d79fcb4c06434 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Mon, 15 Jun 2026 00:54:56 +0400 Subject: [PATCH 36/56] =?UTF-8?q?=D0=A0=D0=B0=D0=B7=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20pairing=20=D0=B1=D0=B5=D0=B7=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=BF=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dev_Docs/API/03_Session_Management_API.md | 6 ++-- .../2026-06-14_2035_ui_подключение_по_коду.md | 8 +++-- .../ESP_Pairing_и_режимы_подключения.md | 6 ++-- .../auth/Net_StartEspPairing_Handler.java | 10 +++--- .../Net_UpsertEspPairingSettings_Handler.java | 3 -- .../java/test/it/cases/IT_07_EspPairing.java | 17 +++++++++- VERSION.properties | 4 +-- shine-UI/js/app.js | 4 +-- shine-UI/js/pages/device-pairing-view.js | 34 ++++++++++++++++--- shine-UI/js/pages/login-other-device-view.js | 29 ++++++++++++++-- 10 files changed, 94 insertions(+), 27 deletions(-) diff --git a/Dev_Docs/API/03_Session_Management_API.md b/Dev_Docs/API/03_Session_Management_API.md index 13715a5..3bd2aa5 100644 --- a/Dev_Docs/API/03_Session_Management_API.md +++ b/Dev_Docs/API/03_Session_Management_API.md @@ -188,6 +188,8 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M } ``` +Если pairing должен работать **без доп. пароля**, клиент может включить его с пустым `passwordHash`. + ### Успешный ответ ```json @@ -205,7 +207,6 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M ### Ошибки -- `400 / EMPTY_PASSWORD_HASH` — попытка включить pairing без `passwordHash`. - `463 / PAIRING_REQUIRES_AUTH_SESSION` — операция вызвана без уже авторизованной доверенной сессии пользователя. ### 5.2. `StartEspPairing` @@ -229,6 +230,8 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M } ``` +Если на доверённом устройстве pairing включён **без доп. пароля**, новое устройство может отправить пустой `passwordHash`. + Поле `trustedSessionOnline` показывает, что у пользователя сейчас есть хотя бы одна онлайн доверенная сессия, способная принять pairing-заявку. ### Успешный ответ @@ -253,7 +256,6 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M ### Ошибки - `400 / EMPTY_LOGIN` -- `400 / EMPTY_PASSWORD_HASH` - `400 / EMPTY_REQUESTER_SESSION_KEY` - `400 / BAD_REQUESTER_SESSION_KEY` - `400 / BAD_SESSION_TYPE` diff --git a/Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md b/Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md index 0c5b2fe..92c0d3d 100644 --- a/Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md +++ b/Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md @@ -4,11 +4,13 @@ - в UI добавлен новый сценарий подключения устройства через доверенную уже авторизованную сессию пользователя; - на экране входа появилась кнопка `Войти через другое устройство`; - на доверённом устройстве в `Подключить устройство` появилась кнопка `Подключить по коду`; - - доверённое устройство может включить pairing-пароль, увидеть заявки, подтвердить подключение только с `device key` или с передачей выбранных ключей. + - доверённое устройство может включить pairing с доп. паролем или без него, увидеть заявки, подтвердить подключение только с `device key` или с передачей выбранных ключей. - что именно проверять: - - на уже авторизованном устройстве включить pairing-пароль; - - на новом устройстве открыть `Войти через другое устройство`, ввести `login + pairing password` и получить 7-значный код; + - на уже авторизованном устройстве включить pairing без доп. пароля; + - на новом устройстве открыть `Войти через другое устройство`, оставить галочку доп. пароля выключенной и получить 7-значный код; + - отдельно включить pairing с доп. паролем; + - на новом устройстве открыть `Войти через другое устройство`, включить галочку доп. пароля, ввести `login + pairing password` и получить 7-значный код; - на доверённом устройстве открыть `Подключить по коду`, найти заявку по коду и подтвердить её: - без доп. ключей; - с передачей выбранных ключей; diff --git a/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md b/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md index 282bff1..e9a44a7 100644 --- a/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md +++ b/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md @@ -36,7 +36,7 @@ Цель: -- новое устройство знает `login + pairing password`; +- новое устройство знает `login`, а `pairing password` используется только если он включён на доверённом устройстве; - сервер использует пароль только как фильтр от мусора; - реальное доверие даёт любая уже онлайн доверенная сессия пользователя; - сервер не выдаёт приватные ключи сам от себя. @@ -58,7 +58,7 @@ ## 3. Что именно делает сервер -- хранит включённость pairing и opaque `passwordHash`; +- хранит включённость pairing и optional opaque `passwordHash`; - хранит pending/approved/rejected pairing-заявки; - рассчитывает короткий код `shortCode` из `7` цифр; - рассчитывает длинный `fingerprintB58` из `SHA-256` заявки; @@ -101,6 +101,6 @@ Эта схема даёт нужное разделение доверия: -- пароль на сервере только отсеивает лишних; +- пароль на сервере, если он включён, только отсеивает лишних; - онлайн доверенная сессия решает, добавлять ли новую сессию; - сервер остаётся маршрутизатором и хранилищем состояния, а не владельцем секретов. diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java index 9f5a399..4e32f11 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java @@ -55,9 +55,6 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_PAYLOAD_TYPE", "payloadType должен быть 1, 2 или 3"); } String passwordHash = EspPairingSupport.normalizeOpaqueHash(req.getPasswordHash()); - if (passwordHash == null) { - return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PASSWORD_HASH", "Пустой passwordHash"); - } SolanaUserEntry user = SolanaUsersDAO.getInstance().getByLogin(login); if (user == null) { @@ -84,9 +81,14 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler { if (recentAttempts >= EspPairingSupport.REQUEST_RATE_LIMIT) { return NetExceptionResponseFactory.error(req, EspPairingSupport.STATUS_PAIRING_RATE_LIMIT, "PAIRING_RATE_LIMITED", "Слишком много pairing-запросов за короткое время"); } - if (!settings.getPasswordHash().equals(passwordHash)) { + String configuredPasswordHash = settings.getPasswordHash() == null ? "" : settings.getPasswordHash().trim(); + boolean requiresPassword = !configuredPasswordHash.isBlank(); + if (requiresPassword && !configuredPasswordHash.equals(passwordHash)) { return NetExceptionResponseFactory.error(req, 422, "PAIRING_PASSWORD_INVALID", "Неверный pairing-пароль"); } + if (!requiresPassword && passwordHash != null && !passwordHash.isBlank()) { + passwordHash = ""; + } String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getRequesterClientPlatform()); int ttlSeconds = EspPairingSupport.normalizeTtlSeconds(settings.getTtlSeconds()); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_UpsertEspPairingSettings_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_UpsertEspPairingSettings_Handler.java index 03b9763..0d40845 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_UpsertEspPairingSettings_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_UpsertEspPairingSettings_Handler.java @@ -29,9 +29,6 @@ public class Net_UpsertEspPairingSettings_Handler implements JsonMessageHandler boolean enabled = req.getEnabled() != null && req.getEnabled(); String passwordHash = EspPairingSupport.normalizeOpaqueHash(req.getPasswordHash()); int ttlSeconds = EspPairingSupport.normalizeTtlSeconds(req.getTtlSeconds()); - if (enabled && (passwordHash == null || passwordHash.isBlank())) { - return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PASSWORD_HASH", "Для включения pairing нужен passwordHash"); - } long now = System.currentTimeMillis(); EspPairingSettingsEntry entry = new EspPairingSettingsEntry(); diff --git a/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java b/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java index 573429a..95970ad 100644 --- a/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java +++ b/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java @@ -79,6 +79,21 @@ public class IT_07_EspPairing { assertEquals("approved", JsonParsers.payloadText(statusResp, "state")); assertEquals("AQIDBA==", JsonParsers.payloadText(statusResp, "encryptedPayload")); + String upsertNoPasswordResp = clientWs.call( + "UpsertEspPairingSettings", + JsonBuilders.upsertEspPairingSettings(true, "", 180), + t + ); + assertEquals(200, JsonParsers.status(upsertNoPasswordResp), "UpsertEspPairingSettings without password must be 200"); + + SessionMaterial requesterNoPasswordMaterial = newSessionMaterial(); + String startNoPasswordResp = requesterWs.call( + "StartEspPairing", + JsonBuilders.startEspPairing(LOGIN, "", requesterNoPasswordMaterial.sessionKey(), 1, "Android", 1), + t + ); + assertEquals(200, JsonParsers.status(startNoPasswordResp), "StartEspPairing without password must be 200"); + String forbiddenResp = requesterWs.call( "ListEspPairingRequests#anonymous", JsonBuilders.listEspPairingRequests(), @@ -86,7 +101,7 @@ public class IT_07_EspPairing { ); assertErrorFormat(forbiddenResp, "ListEspPairingRequests", "PAIRING_REQUIRES_AUTH_SESSION"); - r.ok("ESP pairing: обычная доверенная сессия увидела запрос и подтвердила зашифрованный payload"); + r.ok("ESP pairing: доверенная сессия принимает заявки как с доп. паролем, так и без него"); } } catch (Throwable e) { r.fail("IT_07_EspPairing упал: " + e.getMessage()); diff --git a/VERSION.properties b/VERSION.properties index f562c45..cb06f40 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.195 -server.version=1.2.184 +client.version=1.2.196 +server.version=1.2.185 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 0cfe534..88065e2 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -42,7 +42,7 @@ import * as topupView from './pages/topup-view.js'; import * as devnetTopupView from './pages/devnet-topup-view.js'; import * as loginView from './pages/login-view.js?v=202606142055'; import * as loginCameraView from './pages/login-camera-view.js'; -import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606142055'; +import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606150010'; import * as loginPasswordView from './pages/login-password-view.js'; import * as keyStorageView from './pages/key-storage-view.js'; @@ -55,7 +55,7 @@ import * as serverSettingsView from './pages/server-settings-view.js'; import * as toolsSettingsView from './pages/tools-settings-view.js'; import * as deviceView from './pages/device-view.js?v=202606131435'; import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055'; -import * as devicePairingView from './pages/device-pairing-view.js?v=202606142055'; +import * as devicePairingView from './pages/device-pairing-view.js?v=202606150010'; import * as deviceQrView from './pages/device-qr-view.js'; import * as deviceCameraView from './pages/device-camera-view.js'; import * as showKeysView from './pages/show-keys-view.js'; diff --git a/shine-UI/js/pages/device-pairing-view.js b/shine-UI/js/pages/device-pairing-view.js index 705b50e..7e28ae5 100644 --- a/shine-UI/js/pages/device-pairing-view.js +++ b/shine-UI/js/pages/device-pairing-view.js @@ -90,8 +90,12 @@ export function render({ navigate }) { settingsCard.className = 'card stack'; settingsCard.innerHTML = `

Пароль подключения

+
@@ -127,6 +131,8 @@ export function render({ navigate }) { status.style.display = 'none'; const passwordInput = settingsCard.querySelector('#pairing-password'); + const usePasswordInput = settingsCard.querySelector('#pairing-use-password'); + const passwordHelpEl = settingsCard.querySelector('#pairing-password-help'); const enableBtn = settingsCard.querySelector('#enable-pairing-btn'); const disableBtn = settingsCard.querySelector('#disable-pairing-btn'); const keySummaryEl = keySummaryCard.querySelector('#pairing-key-summary'); @@ -134,6 +140,17 @@ export function render({ navigate }) { const refreshBtn = requestsCard.querySelector('#refresh-pairing-requests'); const requestsListEl = requestsCard.querySelector('#pairing-requests-list'); + const syncPasswordUi = () => { + const usePassword = !!usePasswordInput.checked; + passwordInput.parentElement.style.display = usePassword ? '' : 'none'; + passwordHelpEl.textContent = usePassword + ? 'Если включено, новое устройство должно будет ввести этот пароль перед получением кода.' + : 'Если выключено, новое устройство сможет входить без доп. пароля.'; + if (!usePassword) { + passwordInput.value = ''; + } + }; + const renderRequests = () => { const filterCode = normalizeCode(codeFilterInput.value); const filtered = filterCode @@ -187,6 +204,7 @@ export function render({ navigate }) { enableBtn.disabled = flag; disableBtn.disabled = flag; refreshBtn.disabled = flag; + usePasswordInput.disabled = flag; }; const approveRequest = async (request, mode) => { @@ -205,26 +223,33 @@ export function render({ navigate }) { await reloadRequests({ silent: true }); }; + usePasswordInput.addEventListener('change', syncPasswordUi); + settingsCard.addEventListener('click', async (event) => { const target = event.target; if (!(target instanceof HTMLElement)) return; if (target.id === 'enable-pairing-btn') { + const usePassword = !!usePasswordInput.checked; const password = String(passwordInput.value || ''); - if (!password) { + if (usePassword && !password) { setStatus(status, 'Введите pairing-пароль.', 'error'); return; } setButtonsBusy(true); try { - const passwordHash = await deriveEspPairingPasswordHash(state.session.login, password); + const passwordHash = usePassword + ? await deriveEspPairingPasswordHash(state.session.login, password) + : ''; const payload = await authService.upsertEspPairingSettings({ enabled: true, passwordHash, ttlSeconds: 180, }); setAuthInfo(`Подключение по коду включено. TTL: ${payload?.ttlSeconds || 180} сек.`); - setStatus(status, 'Подключение по коду включено или обновлено.', 'info'); + setStatus(status, usePassword + ? 'Подключение по коду включено с доп. паролем.' + : 'Подключение по коду включено без доп. пароля.', 'info'); passwordInput.value = ''; } catch (error) { const message = toUserMessage(error, 'Не удалось включить pairing.'); @@ -297,6 +322,7 @@ export function render({ navigate }) { void (async () => { try { + syncPasswordUi(); await loadSavedKeys(); await reloadRequests({ silent: true }); cleanupEvent = authService.onEvent('IncomingEspPairingRequest', () => { diff --git a/shine-UI/js/pages/login-other-device-view.js b/shine-UI/js/pages/login-other-device-view.js index f59acf3..cea7200 100644 --- a/shine-UI/js/pages/login-other-device-view.js +++ b/shine-UI/js/pages/login-other-device-view.js @@ -68,12 +68,16 @@ export function render({ navigate }) { Логин + -

Сначала вводится ваш логин и pairing-пароль. После этого появится 7-значный код для подтверждения на уже подключённом устройстве.

+

Сначала вводится ваш логин. Если на доверённом устройстве включён доп. пароль, включите галочку и введите его. После этого появится 7-значный код для подтверждения.

`; const status = document.createElement('p'); @@ -86,13 +90,26 @@ export function render({ navigate }) { resultWrap.innerHTML = codeCardHtml(); const loginInput = formCard.querySelector('#pair-login'); + const usePasswordInput = formCard.querySelector('#pair-use-password'); const passwordInput = formCard.querySelector('#pair-password'); const startBtn = formCard.querySelector('#pair-start-btn'); + const modeHintEl = formCard.querySelector('#pair-mode-hint'); const shortCodeEl = resultWrap.querySelector('#pairing-short-code'); const statusHintEl = resultWrap.querySelector('#pairing-status-hint'); const onlineHintEl = resultWrap.querySelector('#pairing-online-hint'); const expireHintEl = resultWrap.querySelector('#pairing-expire-hint'); + const syncPasswordUi = () => { + const usePassword = !!usePasswordInput.checked; + passwordInput.parentElement.style.display = usePassword ? '' : 'none'; + modeHintEl.textContent = usePassword + ? 'Введите логин и доп. пароль, который был задан на доверённом устройстве.' + : 'Введите логин. Если на доверённом устройстве пароль не задан, вход пойдёт без доп. пароля.'; + if (!usePassword) { + passwordInput.value = ''; + } + }; + const stopPolling = () => { if (pollTimer) { window.clearTimeout(pollTimer); @@ -167,14 +184,18 @@ export function render({ navigate }) { }, 2200); }; + usePasswordInput.addEventListener('change', syncPasswordUi); + syncPasswordUi(); + startBtn.addEventListener('click', async () => { const login = String(loginInput.value || '').trim(); + const usePassword = !!usePasswordInput.checked; const password = String(passwordInput.value || ''); if (!login) { setStatus(status, 'Введите логин.', 'error'); return; } - if (!password) { + if (usePassword && !password) { setStatus(status, 'Введите пароль подключения.', 'error'); return; } @@ -195,7 +216,9 @@ export function render({ navigate }) { } requesterMaterial = await createRequesterPairingMaterial(); - const passwordHash = await deriveEspPairingPasswordHash(login, password); + const passwordHash = usePassword + ? await deriveEspPairingPasswordHash(login, password) + : ''; const payload = await authService.startEspPairing({ login, passwordHash, From 7972676eb8075032085f1b7a4289c458ddfacf4f474db01b734d6ffa070949b0 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Mon, 15 Jun 2026 02:21:21 +0400 Subject: [PATCH 37/56] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20pairing=20=D0=B1=D0=B5=D0=B7=20=D0=BF?= =?UTF-8?q?=D0=B0=D1=80=D0=BE=D0=BB=D1=8F=20=D0=B8=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D1=84=D0=B0=D0=BD=D1=82=D0=BE=D0=BC=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dev_Docs/API/03_Session_Management_API.md | 1 + Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md | 2 +- .../src/main/java/shine/db/dao/EspPairingRequestsDAO.java | 2 +- .../JSON/handlers/auth/Net_StartEspPairing_Handler.java | 2 +- VERSION.properties | 4 ++-- shine-UI/js/app.js | 2 +- shine-UI/js/pages/device-pairing-view.js | 2 +- 7 files changed, 8 insertions(+), 7 deletions(-) diff --git a/Dev_Docs/API/03_Session_Management_API.md b/Dev_Docs/API/03_Session_Management_API.md index 3bd2aa5..0c2a8df 100644 --- a/Dev_Docs/API/03_Session_Management_API.md +++ b/Dev_Docs/API/03_Session_Management_API.md @@ -267,6 +267,7 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M ### 5.3. `ListEspPairingRequests` Доступно для любой уже авторизованной доверенной сессии пользователя. +Возвращает только реально активные pending-заявки со `state = created`. Уже `approved` и `rejected` заявки в этот список больше не попадают. ### Успешный ответ diff --git a/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md b/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md index e9a44a7..df1ffa7 100644 --- a/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md +++ b/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md @@ -59,7 +59,7 @@ ## 3. Что именно делает сервер - хранит включённость pairing и optional opaque `passwordHash`; -- хранит pending/approved/rejected pairing-заявки; +- хранит pairing-заявки всех статусов, но в список активных для доверённого устройства отдаёт только pending `created`; - рассчитывает короткий код `shortCode` из `7` цифр; - рассчитывает длинный `fingerprintB58` из `SHA-256` заявки; - уведомляет онлайн доверенные сессии событием `IncomingEspPairingRequest`, если такие сессии подключены; diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java index 0aaede8..beff033 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java @@ -112,7 +112,7 @@ public final class EspPairingRequestsDAO { FROM esp_pairing_requests WHERE login = ? COLLATE NOCASE AND expires_at_ms > ? - AND status IN ('created', 'approved', 'rejected') + AND status = 'created' ORDER BY created_at_ms DESC """; List list = new ArrayList<>(); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java index 4e32f11..f7b2c2f 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java @@ -63,7 +63,7 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler { String canonicalLogin = user.getLogin(); EspPairingSettingsEntry settings = EspPairingSettingsDAO.getInstance().getByLogin(canonicalLogin); - if (settings == null || !settings.isEnabled() || settings.getPasswordHash() == null || settings.getPasswordHash().isBlank()) { + if (settings == null || !settings.isEnabled()) { return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_AVAILABLE", "Для этого login pairing недоступен"); } diff --git a/VERSION.properties b/VERSION.properties index cb06f40..1df7483 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.196 -server.version=1.2.185 +client.version=1.2.197 +server.version=1.2.186 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 88065e2..618684d 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -55,7 +55,7 @@ import * as serverSettingsView from './pages/server-settings-view.js'; import * as toolsSettingsView from './pages/tools-settings-view.js'; import * as deviceView from './pages/device-view.js?v=202606131435'; import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055'; -import * as devicePairingView from './pages/device-pairing-view.js?v=202606150010'; +import * as devicePairingView from './pages/device-pairing-view.js?v=202606150030'; import * as deviceQrView from './pages/device-qr-view.js'; import * as deviceCameraView from './pages/device-camera-view.js'; import * as showKeysView from './pages/show-keys-view.js'; diff --git a/shine-UI/js/pages/device-pairing-view.js b/shine-UI/js/pages/device-pairing-view.js index 7e28ae5..932b522 100644 --- a/shine-UI/js/pages/device-pairing-view.js +++ b/shine-UI/js/pages/device-pairing-view.js @@ -102,7 +102,7 @@ export function render({ navigate }) {
-

Пароль хранится на сервере только в виде hash. После включения можно переходить к заявкам ниже.

+

Чтобы включить pairing без пароля: оставьте галочку выключенной и нажмите `Включить / обновить`. Чтобы включить pairing с паролем: включите галочку, введите пароль и нажмите ту же кнопку.

`; const keySummaryCard = document.createElement('div'); From af1304022eba5fd111f185fa1b694c0f71a939620637c177f09038670f0fa4ee Mon Sep 17 00:00:00 2001 From: AidarKC Date: Mon, 15 Jun 2026 02:30:17 +0400 Subject: [PATCH 38/56] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=81=D0=B8=D0=BD=D1=82=D0=B0=D0=BA=D1=81?= =?UTF-8?q?=D0=B8=D1=81=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B0=20pairing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 4 ++-- shine-UI/js/app.js | 2 +- shine-UI/js/pages/device-pairing-view.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index 1df7483..f9a0d9b 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.197 -server.version=1.2.186 +client.version=1.2.198 +server.version=1.2.187 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 618684d..2ca07f7 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -55,7 +55,7 @@ import * as serverSettingsView from './pages/server-settings-view.js'; import * as toolsSettingsView from './pages/tools-settings-view.js'; import * as deviceView from './pages/device-view.js?v=202606131435'; import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055'; -import * as devicePairingView from './pages/device-pairing-view.js?v=202606150030'; +import * as devicePairingView from './pages/device-pairing-view.js?v=202606150045'; import * as deviceQrView from './pages/device-qr-view.js'; import * as deviceCameraView from './pages/device-camera-view.js'; import * as showKeysView from './pages/show-keys-view.js'; diff --git a/shine-UI/js/pages/device-pairing-view.js b/shine-UI/js/pages/device-pairing-view.js index 932b522..1dd5bad 100644 --- a/shine-UI/js/pages/device-pairing-view.js +++ b/shine-UI/js/pages/device-pairing-view.js @@ -102,7 +102,7 @@ export function render({ navigate }) {
-

Чтобы включить pairing без пароля: оставьте галочку выключенной и нажмите `Включить / обновить`. Чтобы включить pairing с паролем: включите галочку, введите пароль и нажмите ту же кнопку.

+

Чтобы включить pairing без пароля: оставьте галочку выключенной и нажмите "Включить / обновить". Чтобы включить pairing с паролем: включите галочку, введите пароль и нажмите ту же кнопку.

`; const keySummaryCard = document.createElement('div'); From 9fcdcd087bbc8f0551d47d6f80f35ec4bbdd87eec3337a1e115d44ad36afd885 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Mon, 15 Jun 2026 02:37:26 +0400 Subject: [PATCH 39/56] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20QR?= =?UTF-8?q?-=D0=B7=D0=B0=D0=B3=D0=BB=D1=83=D1=88=D0=BA=D1=83=20=D0=B8=20?= =?UTF-8?q?=D0=BE=D1=87=D0=B8=D1=89=D0=B0=D1=82=D1=8C=20=D0=BA=D0=BE=D0=B4?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20reject?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-14_2035_ui_подключение_по_коду.md | 2 ++ VERSION.properties | 4 +-- shine-UI/js/app.js | 4 +-- shine-UI/js/pages/login-other-device-view.js | 17 ++++++++-- shine-UI/js/pages/login-view.js | 31 ------------------- 5 files changed, 20 insertions(+), 38 deletions(-) diff --git a/Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md b/Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md index 92c0d3d..5d326d1 100644 --- a/Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md +++ b/Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md @@ -16,6 +16,8 @@ - с передачей выбранных ключей; - убедиться, что новое устройство реально входит в аккаунт и сохраняет нужные ключи; - отдельно проверить отклонение заявки и истечение TTL. + - при отклонении заявки убедиться, что на новом устройстве сразу исчезает карточка со старым 7-значным кодом и остаётся только сообщение об отклонении; + - убедиться, что экран `Войти` больше не показывает неработающую QR-заглушку сверху. - ожидаемый результат: - новое устройство получает код, доверённое устройство видит ту же заявку и может её подтвердить или отклонить; diff --git a/VERSION.properties b/VERSION.properties index f9a0d9b..d88584a 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.198 -server.version=1.2.187 +client.version=1.2.199 +server.version=1.2.188 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 2ca07f7..d798963 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -40,9 +40,9 @@ import * as registrationKeysView from './pages/registration-keys-view.js'; import * as registrationDraftKeysView from './pages/registration-draft-keys-view.js'; import * as topupView from './pages/topup-view.js'; import * as devnetTopupView from './pages/devnet-topup-view.js'; -import * as loginView from './pages/login-view.js?v=202606142055'; +import * as loginView from './pages/login-view.js?v=202606150110'; import * as loginCameraView from './pages/login-camera-view.js'; -import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606150010'; +import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606150110'; import * as loginPasswordView from './pages/login-password-view.js'; import * as keyStorageView from './pages/key-storage-view.js'; diff --git a/shine-UI/js/pages/login-other-device-view.js b/shine-UI/js/pages/login-other-device-view.js index cea7200..40cc7ae 100644 --- a/shine-UI/js/pages/login-other-device-view.js +++ b/shine-UI/js/pages/login-other-device-view.js @@ -44,6 +44,14 @@ function formatExpiresAt(ms) { return new Date(ts).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); } +function resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl) { + resultWrap.style.display = 'none'; + shortCodeEl.textContent = '0000000'; + statusHintEl.textContent = 'Покажите этот код на уже подключённом устройстве в разделе «Подключить по коду».'; + onlineHintEl.textContent = ''; + expireHintEl.textContent = ''; +} + export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack'; @@ -161,16 +169,18 @@ export function render({ navigate }) { } if (stateValue === 'rejected') { stopPolling(); + activePairingId = ''; startBtn.disabled = false; + resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl); setStatus(status, 'Подключение отклонено на доверенном устройстве.', 'error'); - statusHintEl.textContent = 'Заявка отклонена. Можно попробовать снова.'; return; } if (stateValue === 'expired') { stopPolling(); + activePairingId = ''; startBtn.disabled = false; + resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl); setStatus(status, 'Время ожидания истекло. Получите новый код.', 'error'); - statusHintEl.textContent = 'Заявка истекла. Создайте новую заявку.'; return; } schedulePoll(); @@ -205,8 +215,9 @@ export function render({ navigate }) { setAuthError(''); setAuthInfo(''); setStatus(status, 'Проверяем пользователя и создаём pairing-заявку...', 'info'); - resultWrap.style.display = 'none'; stopPolling(); + activePairingId = ''; + resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl); try { await authService.reconnect(state.entrySettings.shineServer); diff --git a/shine-UI/js/pages/login-view.js b/shine-UI/js/pages/login-view.js index 7912de8..6674476 100644 --- a/shine-UI/js/pages/login-view.js +++ b/shine-UI/js/pages/login-view.js @@ -2,40 +2,10 @@ import { renderHeader } from '../components/header.js'; export const pageMeta = { id: 'login-view', title: 'Войти', showAppChrome: false }; -function createQrCode() { - const svgNS = 'http://www.w3.org/2000/svg'; - const svg = document.createElementNS(svgNS, 'svg'); - svg.setAttribute('viewBox', '0 0 100 100'); - svg.classList.add('qr-code'); - - const cells = [ - [6, 6, 22, 22], [72, 6, 22, 22], [6, 72, 22, 22], [14, 14, 6, 6], [80, 14, 6, 6], [14, 80, 6, 6], - [38, 12, 8, 8], [52, 12, 8, 8], [38, 26, 8, 8], [52, 26, 8, 8], [32, 40, 10, 10], [48, 40, 10, 10], - [64, 40, 10, 10], [40, 56, 8, 8], [56, 56, 8, 8], [72, 56, 8, 8], [32, 72, 8, 8], [48, 72, 8, 8], - [64, 72, 8, 8], [48, 86, 8, 8], - ]; - - cells.forEach(([x, y, width, height]) => { - const rect = document.createElementNS(svgNS, 'rect'); - rect.setAttribute('x', x); - rect.setAttribute('y', y); - rect.setAttribute('width', width); - rect.setAttribute('height', height); - rect.setAttribute('rx', '2'); - svg.append(rect); - }); - - return svg; -} - export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack'; - const qrCard = document.createElement('div'); - qrCard.className = 'card stack qr-card'; - qrCard.append(createQrCode()); - const cameraButton = document.createElement('button'); cameraButton.className = 'primary-btn'; cameraButton.type = 'button'; @@ -69,7 +39,6 @@ export function render({ navigate }) { title: 'Войти', leftAction: { label: '←', onClick: () => navigate('start-view') }, }), - qrCard, actions, backButton, ); From 9a489801c59e67b3e4e7defedc84079e181ebfca4ceffa90d699e7f3d2b31fb5 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Mon, 15 Jun 2026 13:13:16 +0400 Subject: [PATCH 40/56] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D1=8C=20UX=20=D0=B8=20=D0=BE=D1=82=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=83=20pairing=20=D0=BF=D0=BE=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=B4=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dev_Docs/API/02_Authentication_API.md | 1 + Dev_Docs/API/03_Session_Management_API.md | 46 ++++++- Dev_Docs/API/09_Operations_Index.md | 1 + .../2026-06-14_2035_ui_подключение_по_коду.md | 4 + .../shine/db/dao/EspPairingRequestsDAO.java | 16 +++ .../ws_protocol/JSON/JsonHandlerRegistry.java | 4 + .../JSON/handlers/auth/EspPairingSupport.java | 1 + .../auth/Net_CancelEspPairing_Handler.java | 60 +++++++++ .../auth/Net_StartEspPairing_Handler.java | 17 ++- .../Net_CancelEspPairing_Request.java | 24 ++++ .../Net_CancelEspPairing_Response.java | 24 ++++ .../java/test/it/cases/IT_07_EspPairing.java | 38 ++++++ .../java/test/it/utils/json/JsonBuilders.java | 14 ++ VERSION.properties | 4 +- shine-UI/js/app.js | 2 +- shine-UI/js/pages/login-other-device-view.js | 120 ++++++++++++++---- shine-UI/js/services/auth-service.js | 9 ++ shine-UI/js/services/ui-error-texts.js | 8 ++ 18 files changed, 362 insertions(+), 31 deletions(-) create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CancelEspPairing_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CancelEspPairing_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CancelEspPairing_Response.java diff --git a/Dev_Docs/API/02_Authentication_API.md b/Dev_Docs/API/02_Authentication_API.md index d70c27b..6d11fdb 100644 --- a/Dev_Docs/API/02_Authentication_API.md +++ b/Dev_Docs/API/02_Authentication_API.md @@ -308,6 +308,7 @@ SESSION_LOGIN:{sessionId}:{timeMs}:{nonce} - `ListEspPairingRequests` - `ApproveEspPairing` - `RejectEspPairing` +- `CancelEspPairing` - `GetEspPairingStatus` В этом потоке: diff --git a/Dev_Docs/API/03_Session_Management_API.md b/Dev_Docs/API/03_Session_Management_API.md index 0c2a8df..0762e70 100644 --- a/Dev_Docs/API/03_Session_Management_API.md +++ b/Dev_Docs/API/03_Session_Management_API.md @@ -13,6 +13,7 @@ - `ListEspPairingRequests` - `ApproveEspPairing` - `RejectEspPairing` +- `CancelEspPairing` Анонимное новое устройство работает с двумя связанными операциями: @@ -261,7 +262,8 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M - `400 / BAD_SESSION_TYPE` - `400 / BAD_PAYLOAD_TYPE` - `422 / PAIRING_NOT_AVAILABLE` -- `422 / PAIRING_PASSWORD_INVALID` +- `422 / PAIRING_PASSWORD_INVALID` — pairing-пароль не подходит. Та же ошибка возвращается и если новое устройство ввело пароль, а у пользователя режим pairing включён без пароля. +- `422 / PAIRING_NO_TRUSTED_SESSION_ONLINE` — сейчас нет ни одной онлайн доверённой сессии пользователя, поэтому код не создаётся. - `429 / PAIRING_RATE_LIMITED` ### 5.3. `ListEspPairingRequests` @@ -388,4 +390,46 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M - `created` - `approved` - `rejected` +- `canceled` - `expired` + +### 5.7. `CancelEspPairing` + +Операция для нового устройства, которое уже создало pairing-заявку и хочет принудительно снять ожидание до истечения TTL. + +### Запрос + +```json +{ + "op": "CancelEspPairing", + "requestId": "esp-cancel-001", + "payload": { + "pairingId": "base64url", + "requesterSessionKey": "ed25519/BASE64_PUBLIC_KEY" + } +} +``` + +### Успешный ответ + +```json +{ + "op": "CancelEspPairing", + "requestId": "esp-cancel-001", + "status": 200, + "ok": true, + "payload": { + "pairingId": "base64url", + "state": "canceled" + } +} +``` + +### Ошибки + +- `400 / EMPTY_PAIRING_ID` +- `400 / EMPTY_REQUESTER_SESSION_KEY` +- `400 / BAD_REQUESTER_SESSION_KEY` +- `404 / PAIRING_NOT_FOUND` +- `422 / PAIRING_OF_ANOTHER_REQUESTER` +- `422 / PAIRING_NOT_PENDING` diff --git a/Dev_Docs/API/09_Operations_Index.md b/Dev_Docs/API/09_Operations_Index.md index 83c7806..93702fa 100644 --- a/Dev_Docs/API/09_Operations_Index.md +++ b/Dev_Docs/API/09_Operations_Index.md @@ -24,6 +24,7 @@ | `ListEspPairingRequests` | `03_Session_Management_API.md` | список активных pairing-заявок для доверенной сессии | | `ApproveEspPairing` | `03_Session_Management_API.md` | подтверждение pairing-заявки доверенной сессией | | `RejectEspPairing` | `03_Session_Management_API.md` | отклонение pairing-заявки доверенной сессией | +| `CancelEspPairing` | `03_Session_Management_API.md` | отмена pairing-заявки со стороны нового ожидающего устройства | | `GetEspPairingStatus` | `03_Session_Management_API.md` | чтение статуса и результата pairing-заявки | | `ListSessions` | `03_Session_Management_API.md` | список активных сессий | | `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии | diff --git a/Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md b/Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md index 5d326d1..99fda0a 100644 --- a/Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md +++ b/Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md @@ -18,6 +18,10 @@ - отдельно проверить отклонение заявки и истечение TTL. - при отклонении заявки убедиться, что на новом устройстве сразу исчезает карточка со старым 7-значным кодом и остаётся только сообщение об отклонении; - убедиться, что экран `Войти` больше не показывает неработающую QR-заглушку сверху. + - убедиться, что при неверном pairing-пароле и при попытке ввести пароль там, где он не включён, пользователь видит одинаковую ошибку `Пароль подключения не подходит.`; + - убедиться, что без онлайн доверённой сессии новое устройство сразу получает явную ошибку и код вообще не создаётся; + - убедиться, что countdown под кодом убывает в реальном времени; + - убедиться, что кнопка `Отмена` на новом устройстве действительно снимает заявку и она пропадает у доверённого устройства без ожидания TTL. - ожидаемый результат: - новое устройство получает код, доверённое устройство видит ту же заявку и может её подтвердить или отклонить; diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java index beff033..9c90c97 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java @@ -199,6 +199,22 @@ public final class EspPairingRequestsDAO { }); } + public void markCanceled(String pairingId, String rejectReason, long updatedAtMs) throws SQLException { + updateSimple(pairingId, """ + UPDATE esp_pairing_requests + SET status = 'canceled', + reject_reason = ?, + approved_by_session_id = NULL, + encrypted_payload = NULL, + updated_at_ms = ? + WHERE pairing_id = ? + """, ps -> { + ps.setString(1, rejectReason); + ps.setLong(2, updatedAtMs); + ps.setString(3, pairingId); + }); + } + public int expirePending(long nowMs) throws SQLException { try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(""" diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index 295bcb3..2748f8f 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -9,6 +9,7 @@ import server.logic.ws_protocol.JSON.handlers.auth.Net_CreateAuthSession__Handle import server.logic.ws_protocol.JSON.handlers.auth.Net_ListSessions_Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_ListEspPairingRequests_Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_ApproveEspPairing_Handler; +import server.logic.ws_protocol.JSON.handlers.auth.Net_CancelEspPairing_Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_GetEspPairingStatus_Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_RejectEspPairing_Handler; @@ -27,6 +28,7 @@ import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListEspPairingRe // --- NEW v2 entities --- import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ApproveEspPairing_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CancelEspPairing_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_GetEspPairingStatus_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_RejectEspPairing_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionChallenge_Request; @@ -138,6 +140,7 @@ public final class JsonHandlerRegistry { Map.entry("ListEspPairingRequests", new Net_ListEspPairingRequests_Handler()), Map.entry("ApproveEspPairing", new Net_ApproveEspPairing_Handler()), Map.entry("RejectEspPairing", new Net_RejectEspPairing_Handler()), + Map.entry("CancelEspPairing", new Net_CancelEspPairing_Handler()), Map.entry("GetEspPairingStatus", new Net_GetEspPairingStatus_Handler()), // --- blockchain --- @@ -202,6 +205,7 @@ public final class JsonHandlerRegistry { Map.entry("ListEspPairingRequests", Net_ListEspPairingRequests_Request.class), Map.entry("ApproveEspPairing", Net_ApproveEspPairing_Request.class), Map.entry("RejectEspPairing", Net_RejectEspPairing_Request.class), + Map.entry("CancelEspPairing", Net_CancelEspPairing_Request.class), Map.entry("GetEspPairingStatus", Net_GetEspPairingStatus_Request.class), // --- blockchain --- diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/EspPairingSupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/EspPairingSupport.java index 170f540..6d9efb6 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/EspPairingSupport.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/EspPairingSupport.java @@ -27,6 +27,7 @@ final class EspPairingSupport { static final String STATE_CREATED = "created"; static final String STATE_APPROVED = "approved"; static final String STATE_REJECTED = "rejected"; + static final String STATE_CANCELED = "canceled"; static final String STATE_EXPIRED = "expired"; private static final SecureRandom RANDOM = new SecureRandom(); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CancelEspPairing_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CancelEspPairing_Handler.java new file mode 100644 index 0000000..67b14cd --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CancelEspPairing_Handler.java @@ -0,0 +1,60 @@ +package server.logic.ws_protocol.JSON.handlers.auth; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CancelEspPairing_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CancelEspPairing_Response; +import server.logic.ws_protocol.JSON.utils.AuthKeyUtils; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.EspPairingRequestsDAO; +import shine.db.entities.EspPairingRequestEntry; + +public class Net_CancelEspPairing_Handler implements JsonMessageHandler { + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { + Net_CancelEspPairing_Request req = (Net_CancelEspPairing_Request) baseReq; + + String pairingId = req.getPairingId() == null ? "" : req.getPairingId().trim(); + if (pairingId.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PAIRING_ID", "Пустой pairingId"); + } + + String requesterSessionKey = req.getRequesterSessionKey(); + if (requesterSessionKey == null || requesterSessionKey.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_REQUESTER_SESSION_KEY", "Пустой requesterSessionKey"); + } + try { + requesterSessionKey = AuthKeyUtils.normalize(requesterSessionKey, "requesterSessionKey"); + AuthKeyUtils.parseEd25519PublicKey(requesterSessionKey, "requesterSessionKey"); + } catch (Exception e) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_REQUESTER_SESSION_KEY", "Некорректный requesterSessionKey"); + } + + long now = System.currentTimeMillis(); + EspPairingRequestsDAO.getInstance().expirePending(now); + EspPairingRequestEntry row = EspPairingRequestsDAO.getInstance().getByPairingId(pairingId); + if (row == null) { + return NetExceptionResponseFactory.error(req, 404, "PAIRING_NOT_FOUND", "Pairing-заявка не найдена"); + } + if (!requesterSessionKey.equals(row.getRequesterSessionKey())) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_OF_ANOTHER_REQUESTER", "Нельзя отменять pairing другого устройства"); + } + if (!EspPairingSupport.STATE_CREATED.equals(row.getStatus())) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_PENDING", "Заявка уже не находится в статусе created"); + } + + EspPairingRequestsDAO.getInstance().markCanceled(pairingId, "canceled_by_requester", now); + + Net_CancelEspPairing_Response resp = new Net_CancelEspPairing_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setPairingId(pairingId); + resp.setState(EspPairingSupport.STATE_CANCELED); + return resp; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java index f7b2c2f..b9d1e7a 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java @@ -83,15 +83,23 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler { } String configuredPasswordHash = settings.getPasswordHash() == null ? "" : settings.getPasswordHash().trim(); boolean requiresPassword = !configuredPasswordHash.isBlank(); - if (requiresPassword && !configuredPasswordHash.equals(passwordHash)) { + boolean suppliedPassword = passwordHash != null && !passwordHash.isBlank(); + if ((requiresPassword && !configuredPasswordHash.equals(passwordHash)) + || (!requiresPassword && suppliedPassword)) { return NetExceptionResponseFactory.error(req, 422, "PAIRING_PASSWORD_INVALID", "Неверный pairing-пароль"); } - if (!requiresPassword && passwordHash != null && !passwordHash.isBlank()) { - passwordHash = ""; - } String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getRequesterClientPlatform()); int ttlSeconds = EspPairingSupport.normalizeTtlSeconds(settings.getTtlSeconds()); + List approverConnections = EspPairingSupport.findOnlineTrustedConnections(canonicalLogin); + if (approverConnections.isEmpty()) { + return NetExceptionResponseFactory.error( + req, + 422, + "PAIRING_NO_TRUSTED_SESSION_ONLINE", + "Нет ни одной активной доверенной сессии пользователя в сети" + ); + } EspPairingSupport.PairingFingerprint fingerprint = EspPairingSupport.deriveFingerprint( canonicalLogin, requesterSessionKey, @@ -117,7 +125,6 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler { entry.setDeliveredToHomeserver(false); EspPairingRequestsDAO.getInstance().insert(entry); - List approverConnections = EspPairingSupport.findOnlineTrustedConnections(canonicalLogin); boolean delivered = false; for (ConnectionContext targetCtx : approverConnections) { String eventId = NetIdGenerator.eventId("pair"); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CancelEspPairing_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CancelEspPairing_Request.java new file mode 100644 index 0000000..24196ef --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CancelEspPairing_Request.java @@ -0,0 +1,24 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_CancelEspPairing_Request extends Net_Request { + private String pairingId; + private String requesterSessionKey; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getRequesterSessionKey() { + return requesterSessionKey; + } + + public void setRequesterSessionKey(String requesterSessionKey) { + this.requesterSessionKey = requesterSessionKey; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CancelEspPairing_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CancelEspPairing_Response.java new file mode 100644 index 0000000..575cb2a --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CancelEspPairing_Response.java @@ -0,0 +1,24 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_CancelEspPairing_Response extends Net_Response { + private String pairingId; + private String state; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } +} diff --git a/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java b/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java index 95970ad..7c9fa45 100644 --- a/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java +++ b/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java @@ -94,6 +94,44 @@ public class IT_07_EspPairing { ); assertEquals(200, JsonParsers.status(startNoPasswordResp), "StartEspPairing without password must be 200"); + String startWrongPasswordResp = requesterWs.call( + "StartEspPairing", + JsonBuilders.startEspPairing(LOGIN, passwordHash, requesterNoPasswordMaterial.sessionKey(), 1, "Android", 1), + t + ); + assertErrorFormat(startWrongPasswordResp, "StartEspPairing", "PAIRING_PASSWORD_INVALID"); + + SessionMaterial cancelMaterial = newSessionMaterial(); + String startCancelableResp = requesterWs.call( + "StartEspPairing", + JsonBuilders.startEspPairing(LOGIN, "", cancelMaterial.sessionKey(), 1, "Android", 1), + t + ); + assertEquals(200, JsonParsers.status(startCancelableResp), "StartEspPairing for cancel must be 200"); + String cancelPairingId = JsonParsers.payloadText(startCancelableResp, "pairingId"); + String cancelResp = requesterWs.call( + "CancelEspPairing", + JsonBuilders.cancelEspPairing(cancelPairingId, cancelMaterial.sessionKey()), + t + ); + assertEquals(200, JsonParsers.status(cancelResp), "CancelEspPairing must be 200"); + assertEquals("canceled", JsonParsers.payloadText(cancelResp, "state")); + + String closeResp = clientWs.call( + "CloseActiveSession", + JsonBuilders.closeActiveSession(clientSession.sessionId(), 0, ""), + t + ); + assertEquals(200, JsonParsers.status(closeResp), "CloseActiveSession must be 200"); + + SessionMaterial requesterOfflineMaterial = newSessionMaterial(); + String startOfflineResp = requesterWs.call( + "StartEspPairing", + JsonBuilders.startEspPairing(LOGIN, "", requesterOfflineMaterial.sessionKey(), 1, "Android", 1), + t + ); + assertErrorFormat(startOfflineResp, "StartEspPairing", "PAIRING_NO_TRUSTED_SESSION_ONLINE"); + String forbiddenResp = requesterWs.call( "ListEspPairingRequests#anonymous", JsonBuilders.listEspPairingRequests(), diff --git a/SHiNE-server/src/test/java/test/it/utils/json/JsonBuilders.java b/SHiNE-server/src/test/java/test/it/utils/json/JsonBuilders.java index f7ef249..3952366 100644 --- a/SHiNE-server/src/test/java/test/it/utils/json/JsonBuilders.java +++ b/SHiNE-server/src/test/java/test/it/utils/json/JsonBuilders.java @@ -333,6 +333,20 @@ public final class JsonBuilders { """.formatted(requestId, pairingId, reason == null ? "" : reason); } + public static String cancelEspPairing(String pairingId, String requesterSessionKey) { + String requestId = TestIds.next("esp-cancel"); + return """ + { + "op": "CancelEspPairing", + "requestId": "%s", + "payload": { + "pairingId": "%s", + "requesterSessionKey": "%s" + } + } + """.formatted(requestId, pairingId, requesterSessionKey); + } + public static String getEspPairingStatus(String pairingId) { String requestId = TestIds.next("esp-status"); return """ diff --git a/VERSION.properties b/VERSION.properties index d88584a..5a753b1 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.199 -server.version=1.2.188 +client.version=1.2.200 +server.version=1.2.189 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index d798963..8948d07 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -42,7 +42,7 @@ import * as topupView from './pages/topup-view.js'; import * as devnetTopupView from './pages/devnet-topup-view.js'; import * as loginView from './pages/login-view.js?v=202606150110'; import * as loginCameraView from './pages/login-camera-view.js'; -import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606150110'; +import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606150215'; import * as loginPasswordView from './pages/login-password-view.js'; import * as keyStorageView from './pages/key-storage-view.js'; diff --git a/shine-UI/js/pages/login-other-device-view.js b/shine-UI/js/pages/login-other-device-view.js index 40cc7ae..32c2073 100644 --- a/shine-UI/js/pages/login-other-device-view.js +++ b/shine-UI/js/pages/login-other-device-view.js @@ -38,10 +38,11 @@ function codeCardHtml() { `; } -function formatExpiresAt(ms) { - const ts = Number(ms || 0); - if (!Number.isFinite(ts) || ts <= 0) return ''; - return new Date(ts).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +function formatRemaining(ms) { + const safe = Math.max(0, Math.floor(Number(ms || 0) / 1000)); + const minutes = Math.floor(safe / 60); + const seconds = safe % 60; + return `${minutes} мин ${seconds} сек`; } function resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl) { @@ -56,8 +57,10 @@ export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack'; let pollTimer = 0; + let countdownTimer = 0; let activePairingId = ''; let requesterMaterial = null; + let activeExpiresAtMs = 0; let isDisposed = false; clearAuthMessages(); @@ -73,8 +76,8 @@ export function render({ navigate }) { formCard.className = 'card stack'; formCard.innerHTML = ` -

Сначала вводится ваш логин. Если на доверённом устройстве включён доп. пароль, включите галочку и введите его. После этого появится 7-значный код для подтверждения.

+

Получить код для входа можно только если сейчас есть хотя бы одно другое активное устройство этого пользователя в сети.

`; const status = document.createElement('p'); @@ -106,13 +109,18 @@ export function render({ navigate }) { const statusHintEl = resultWrap.querySelector('#pairing-status-hint'); const onlineHintEl = resultWrap.querySelector('#pairing-online-hint'); const expireHintEl = resultWrap.querySelector('#pairing-expire-hint'); + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'ghost-btn'; + cancelBtn.type = 'button'; + cancelBtn.textContent = 'Отмена'; + cancelBtn.style.display = 'none'; const syncPasswordUi = () => { const usePassword = !!usePasswordInput.checked; passwordInput.parentElement.style.display = usePassword ? '' : 'none'; modeHintEl.textContent = usePassword - ? 'Введите логин и доп. пароль, который был задан на доверённом устройстве.' - : 'Введите логин. Если на доверённом устройстве пароль не задан, вход пойдёт без доп. пароля.'; + ? 'Получить код для входа можно только если сейчас есть хотя бы одно другое активное устройство этого пользователя в сети. Если на доверённом устройстве включён доп. пароль, введите его.' + : 'Получить код для входа можно только если сейчас есть хотя бы одно другое активное устройство этого пользователя в сети.'; if (!usePassword) { passwordInput.value = ''; } @@ -125,6 +133,45 @@ export function render({ navigate }) { } }; + const stopCountdown = () => { + if (countdownTimer) { + window.clearInterval(countdownTimer); + countdownTimer = 0; + } + }; + + const updateCountdown = () => { + const leftMs = activeExpiresAtMs - Date.now(); + if (leftMs <= 0) { + stopPolling(); + stopCountdown(); + activePairingId = ''; + activeExpiresAtMs = 0; + startBtn.disabled = false; + cancelBtn.style.display = 'none'; + resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl); + setStatus(status, 'Время ожидания истекло. Получите новый код.', 'error'); + return; + } + expireHintEl.textContent = `Код действителен ещё ${formatRemaining(leftMs)}.`; + }; + + const startCountdown = (expiresAtMs) => { + activeExpiresAtMs = Number(expiresAtMs || 0); + stopCountdown(); + updateCountdown(); + countdownTimer = window.setInterval(updateCountdown, 1000); + }; + + const clearActivePairing = () => { + stopPolling(); + stopCountdown(); + activePairingId = ''; + activeExpiresAtMs = 0; + cancelBtn.style.display = 'none'; + resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl); + }; + const finalizeAuthorizedLogin = async (keys, login) => { const session = await authService.createSessionFromImportedSecrets(login, keys); await clearStoredMessages().catch(() => {}); @@ -159,6 +206,7 @@ export function render({ navigate }) { return; } if (stateValue === 'approved') { + stopCountdown(); setStatus(status, 'Заявка подтверждена. Подключаем устройство...', 'info'); const decoded = await decryptPairingPayloadFromEnvelope(payload?.encryptedPayload, requesterMaterial); if (String(decoded?.type || '') !== 'shine-esp-pairing-transfer') { @@ -168,18 +216,20 @@ export function render({ navigate }) { return; } if (stateValue === 'rejected') { - stopPolling(); - activePairingId = ''; + clearActivePairing(); startBtn.disabled = false; - resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl); setStatus(status, 'Подключение отклонено на доверенном устройстве.', 'error'); return; } - if (stateValue === 'expired') { - stopPolling(); - activePairingId = ''; + if (stateValue === 'canceled') { + clearActivePairing(); + startBtn.disabled = false; + setStatus(status, 'Ожидание подключения отменено.', 'error'); + return; + } + if (stateValue === 'expired') { + clearActivePairing(); startBtn.disabled = false; - resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl); setStatus(status, 'Время ожидания истекло. Получите новый код.', 'error'); return; } @@ -215,9 +265,7 @@ export function render({ navigate }) { setAuthError(''); setAuthInfo(''); setStatus(status, 'Проверяем пользователя и создаём pairing-заявку...', 'info'); - stopPolling(); - activePairingId = ''; - resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl); + clearActivePairing(); try { await authService.reconnect(state.entrySettings.shineServer); @@ -247,10 +295,9 @@ export function render({ navigate }) { onlineHintEl.textContent = payload?.trustedSessionOnline ? 'Сейчас есть хотя бы одна онлайн доверенная сессия, которая может принять заявку.' : 'Сейчас нет онлайн доверенной сессии. Заявка будет ждать, пока пользователь откроет уже подключённое устройство.'; - expireHintEl.textContent = payload?.expiresAtMs - ? `Код действует до ${formatExpiresAt(payload.expiresAtMs)}.` - : ''; resultWrap.style.display = ''; + cancelBtn.style.display = ''; + startCountdown(payload?.expiresAtMs); state.loginDraft.login = login; setStatus(status, 'Код создан. Ожидаем подтверждение на другом устройстве...', 'info'); schedulePoll(); @@ -264,11 +311,40 @@ export function render({ navigate }) { } }); + cancelBtn.addEventListener('click', async () => { + if (!activePairingId || !requesterMaterial?.sessionKey) { + clearActivePairing(); + startBtn.disabled = false; + cancelBtn.style.display = 'none'; + return; + } + cancelBtn.disabled = true; + try { + await authService.cancelEspPairing(activePairingId, requesterMaterial.sessionKey); + clearActivePairing(); + startBtn.disabled = false; + setStatus(status, 'Ожидание подключения отменено.', 'info'); + } catch (error) { + const message = toUserMessage(error, 'Не удалось отменить ожидание подключения.'); + setAuthError(message); + setStatus(status, message, 'error'); + } finally { + cancelBtn.disabled = false; + cancelBtn.style.display = activePairingId ? '' : 'none'; + } + }); + screen.cleanup = () => { isDisposed = true; stopPolling(); + stopCountdown(); }; + const resultActions = document.createElement('div'); + resultActions.className = 'row'; + resultActions.append(cancelBtn); + resultWrap.append(resultActions); + screen.append(formCard, status, resultWrap); return screen; } diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index c453d7c..0491d1b 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -1020,6 +1020,15 @@ export class AuthService { return response.payload || {}; } + async cancelEspPairing(pairingId, requesterSessionKey) { + const response = await this.ws.request('CancelEspPairing', { + pairingId: String(pairingId || '').trim(), + requesterSessionKey: String(requesterSessionKey || '').trim(), + }); + if (response.status !== 200) throw opError('CancelEspPairing', response); + return response.payload || {}; + } + async getEspPairingStatus(pairingId) { const response = await this.ws.request('GetEspPairingStatus', { pairingId: String(pairingId || '').trim(), diff --git a/shine-UI/js/services/ui-error-texts.js b/shine-UI/js/services/ui-error-texts.js index 384b331..7e0b6d2 100644 --- a/shine-UI/js/services/ui-error-texts.js +++ b/shine-UI/js/services/ui-error-texts.js @@ -50,6 +50,14 @@ export function toUserMessage(error, fallback = 'Действие не выпо return 'Пользователь не найден. Проверьте логин.'; } + if (code === 'PAIRING_NO_TRUSTED_SESSION_ONLINE') { + return 'К сожалению сейчас нет ни одного активного устройства этого пользователя, подключенного к этому серверу в сети, и поэтому вход таким образом выполнить невозможно.'; + } + + if (code === 'PAIRING_PASSWORD_INVALID') { + return 'Пароль подключения не подходит.'; + } + if ( code === 'PREMIUMLOGIN' || text.includes('premiumlogin') || From d6c5757dfa55cac569abe5adaddaa051303304c8354e575a60e3dd6b69d4bec2 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Mon, 15 Jun 2026 13:35:05 +0400 Subject: [PATCH 41/56] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B0=D1=82=D1=8C=20UI=20=D0=B4=D0=BE=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?pairing-=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-14_2035_ui_подключение_по_коду.md | 3 + VERSION.properties | 4 +- shine-UI/js/app.js | 2 +- shine-UI/js/pages/device-pairing-view.js | 327 +++++++++++++----- 4 files changed, 249 insertions(+), 87 deletions(-) diff --git a/Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md b/Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md index 99fda0a..0c7afd2 100644 --- a/Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md +++ b/Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md @@ -22,6 +22,9 @@ - убедиться, что без онлайн доверённой сессии новое устройство сразу получает явную ошибку и код вообще не создаётся; - убедиться, что countdown под кодом убывает в реальном времени; - убедиться, что кнопка `Отмена` на новом устройстве действительно снимает заявку и она пропадает у доверённого устройства без ожидания TTL. + - убедиться, что на экране `Подключить по коду` блок дополнительного пароля показывает два понятных состояния: пароль не задан / пароль установлен; + - убедиться, что `Задать пароль` и `Изменить пароль` открывают верхний диалог с двумя полями и кнопками-глазами; + - убедиться, что `Убрать пароль` не выключает pairing целиком, а переводит его в режим без дополнительного пароля. - ожидаемый результат: - новое устройство получает код, доверённое устройство видит ту же заявку и может её подтвердить или отклонить; diff --git a/VERSION.properties b/VERSION.properties index 5a753b1..5963aaf 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.200 -server.version=1.2.189 +client.version=1.2.201 +server.version=1.2.190 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 8948d07..35e6c55 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -55,7 +55,7 @@ import * as serverSettingsView from './pages/server-settings-view.js'; import * as toolsSettingsView from './pages/tools-settings-view.js'; import * as deviceView from './pages/device-view.js?v=202606131435'; import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055'; -import * as devicePairingView from './pages/device-pairing-view.js?v=202606150045'; +import * as devicePairingView from './pages/device-pairing-view.js?v=202606151000'; import * as deviceQrView from './pages/device-qr-view.js'; import * as deviceCameraView from './pages/device-camera-view.js'; import * as showKeysView from './pages/show-keys-view.js'; diff --git a/shine-UI/js/pages/device-pairing-view.js b/shine-UI/js/pages/device-pairing-view.js index 1dd5bad..b3f3188 100644 --- a/shine-UI/js/pages/device-pairing-view.js +++ b/shine-UI/js/pages/device-pairing-view.js @@ -17,6 +17,8 @@ import { toUserMessage } from '../services/ui-error-texts.js'; export const pageMeta = { id: 'device-pairing-view', title: 'Подключить по коду' }; +const PAIRING_PASSWORD_STATE_PREFIX = 'shine_pairing_password_state_v1'; + function setStatus(statusEl, message, kind = 'info') { statusEl.classList.toggle('is-unavailable', kind === 'error'); statusEl.classList.toggle('is-available', kind !== 'error'); @@ -71,6 +73,48 @@ function requestCardHtml(request) { `; } +function makePasswordToggleIcons() { + return { + eye: ` + + `, + eyeOff: ` + + `, + }; +} + +function localPairingPasswordStateKey(login, serverUrl) { + return `${PAIRING_PASSWORD_STATE_PREFIX}:${String(serverUrl || '').trim()}:${String(login || '').trim().toLowerCase()}`; +} + +function loadLocalPairingPasswordState(login, serverUrl) { + try { + const raw = localStorage.getItem(localPairingPasswordStateKey(login, serverUrl)); + if (!raw) return false; + const parsed = JSON.parse(raw); + return !!parsed?.hasPassword; + } catch { + return false; + } +} + +function saveLocalPairingPasswordState(login, serverUrl, hasPassword) { + try { + localStorage.setItem(localPairingPasswordStateKey(login, serverUrl), JSON.stringify({ + hasPassword: !!hasPassword, + updatedAtMs: Date.now(), + })); + } catch {} +} + export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack'; @@ -78,6 +122,9 @@ export function render({ navigate }) { let requests = []; let cleanupEvent = () => {}; let disposed = false; + let settingsBusy = false; + let pairingPasswordConfigured = loadLocalPairingPasswordState(state.session.login, state.entrySettings.shineServer); + let dialogMode = ''; screen.append( renderHeader({ @@ -88,22 +135,7 @@ export function render({ navigate }) { const settingsCard = document.createElement('div'); settingsCard.className = 'card stack'; - settingsCard.innerHTML = ` -

Пароль подключения

- - -
- - -
-

Чтобы включить pairing без пароля: оставьте галочку выключенной и нажмите "Включить / обновить". Чтобы включить pairing с паролем: включите галочку, введите пароль и нажмите ту же кнопку.

- `; + const passwordIcons = makePasswordToggleIcons(); const keySummaryCard = document.createElement('div'); keySummaryCard.className = 'card stack'; @@ -129,26 +161,169 @@ export function render({ navigate }) { const status = document.createElement('p'); status.className = 'status-line is-unavailable'; status.style.display = 'none'; - - const passwordInput = settingsCard.querySelector('#pairing-password'); - const usePasswordInput = settingsCard.querySelector('#pairing-use-password'); - const passwordHelpEl = settingsCard.querySelector('#pairing-password-help'); - const enableBtn = settingsCard.querySelector('#enable-pairing-btn'); - const disableBtn = settingsCard.querySelector('#disable-pairing-btn'); const keySummaryEl = keySummaryCard.querySelector('#pairing-key-summary'); const codeFilterInput = requestsCard.querySelector('#pairing-code-filter'); const refreshBtn = requestsCard.querySelector('#refresh-pairing-requests'); const requestsListEl = requestsCard.querySelector('#pairing-requests-list'); - const syncPasswordUi = () => { - const usePassword = !!usePasswordInput.checked; - passwordInput.parentElement.style.display = usePassword ? '' : 'none'; - passwordHelpEl.textContent = usePassword - ? 'Если включено, новое устройство должно будет ввести этот пароль перед получением кода.' - : 'Если выключено, новое устройство сможет входить без доп. пароля.'; - if (!usePassword) { - passwordInput.value = ''; + const passwordDialog = document.createElement('div'); + passwordDialog.hidden = true; + passwordDialog.style.position = 'fixed'; + passwordDialog.style.inset = '0'; + passwordDialog.style.zIndex = '30'; + passwordDialog.innerHTML = ` +
+
+
+
+

Задать дополнительный пароль

+

Дополнительный пароль не даёт права на подключение сам по себе. Он только отсекает лишние заявки, чтобы посторонние не могли засыпать ваш аккаунт запросами. Обычно он не нужен, поэтому при желании можно задать и что-то простое, что легко запомнить.

+
+ +
+ + +
+ + +
+
+ `; + screen.append(passwordDialog); + + const dialogTitleEl = passwordDialog.querySelector('#pairing-dialog-title'); + const dialogTextEl = passwordDialog.querySelector('#pairing-dialog-text'); + const dialogPasswordInput = passwordDialog.querySelector('#pairing-dialog-password'); + const dialogPasswordConfirmInput = passwordDialog.querySelector('#pairing-dialog-password-confirm'); + const dialogSaveBtn = passwordDialog.querySelector('#pairing-dialog-save'); + const dialogPasswordToggleBtn = passwordDialog.querySelector('#pairing-dialog-password-toggle'); + const dialogPasswordConfirmToggleBtn = passwordDialog.querySelector('#pairing-dialog-password-confirm-toggle'); + + const bindPasswordToggle = (input, button) => { + button.addEventListener('click', () => { + if (input.type === 'password') { + input.type = 'text'; + button.innerHTML = passwordIcons.eye; + button.setAttribute('aria-label', 'Скрыть пароль'); + button.title = 'Скрыть пароль'; + } else { + input.type = 'password'; + button.innerHTML = passwordIcons.eyeOff; + button.setAttribute('aria-label', 'Показать пароль'); + button.title = 'Показать пароль'; + } + }); + }; + bindPasswordToggle(dialogPasswordInput, dialogPasswordToggleBtn); + bindPasswordToggle(dialogPasswordConfirmInput, dialogPasswordConfirmToggleBtn); + + const openPasswordDialog = (mode) => { + dialogMode = mode; + dialogTitleEl.textContent = mode === 'change' + ? 'Изменить дополнительный пароль' + : 'Задать дополнительный пароль'; + dialogTextEl.textContent = + 'Дополнительный пароль не даёт права на подключение сам по себе. Он только отсекает лишние заявки, чтобы посторонние не могли засыпать ваш аккаунт запросами. Обычно он не нужен, поэтому при желании можно задать и что-то простое, что легко запомнить.'; + dialogPasswordInput.value = ''; + dialogPasswordConfirmInput.value = ''; + dialogPasswordInput.type = 'password'; + dialogPasswordConfirmInput.type = 'password'; + dialogPasswordToggleBtn.innerHTML = passwordIcons.eyeOff; + dialogPasswordConfirmToggleBtn.innerHTML = passwordIcons.eyeOff; + passwordDialog.hidden = false; + }; + + const closePasswordDialog = () => { + dialogMode = ''; + passwordDialog.hidden = true; + }; + + const setSettingsBusy = (flag) => { + settingsBusy = flag; + renderSettingsCard(); + }; + + const renderSettingsCard = () => { + settingsCard.innerHTML = ''; + + const title = document.createElement('p'); + title.className = 'field-label'; + title.textContent = 'Дополнительный пароль'; + + const stateText = document.createElement('p'); + stateText.className = 'meta-muted'; + stateText.textContent = pairingPasswordConfigured + ? 'Установлен дополнительный пароль для подключения через другое устройство.' + : 'Дополнительный пароль для подключения через другое устройство не задан.'; + + const note = document.createElement('p'); + note.className = 'meta-muted'; + note.textContent = pairingPasswordConfigured + ? 'Этот пароль не даёт права на вход сам по себе. Он только отсекает лишние заявки до того, как пользователь подтвердит подключение на доверённом устройстве.' + : 'Сейчас подключение работает без дополнительного пароля. Обычно этого достаточно. Если хотите, можно добавить простой пароль только как защиту от лишних заявок.'; + + const actions = document.createElement('div'); + actions.className = 'row'; + actions.style.flexWrap = 'wrap'; + + if (pairingPasswordConfigured) { + const changeBtn = document.createElement('button'); + changeBtn.className = 'primary-btn'; + changeBtn.type = 'button'; + changeBtn.textContent = 'Изменить пароль'; + changeBtn.disabled = settingsBusy; + changeBtn.addEventListener('click', () => openPasswordDialog('change')); + + const removeBtn = document.createElement('button'); + removeBtn.className = 'ghost-btn'; + removeBtn.type = 'button'; + removeBtn.textContent = 'Убрать пароль'; + removeBtn.disabled = settingsBusy; + removeBtn.addEventListener('click', async () => { + setSettingsBusy(true); + try { + const payload = await authService.upsertEspPairingSettings({ + enabled: true, + passwordHash: '', + ttlSeconds: 180, + }); + pairingPasswordConfigured = false; + saveLocalPairingPasswordState(state.session.login, state.entrySettings.shineServer, false); + setAuthInfo(`Подключение по коду без дополнительного пароля включено. TTL: ${payload?.ttlSeconds || 180} сек.`); + setStatus(status, 'Дополнительный пароль убран. Подключение по коду теперь работает без него.', 'info'); + } catch (error) { + const message = toUserMessage(error, 'Не удалось убрать дополнительный пароль.'); + setAuthError(message); + setStatus(status, message, 'error'); + } finally { + setSettingsBusy(false); + } + }); + + actions.append(changeBtn, removeBtn); + } else { + const setBtn = document.createElement('button'); + setBtn.className = 'primary-btn'; + setBtn.type = 'button'; + setBtn.textContent = 'Задать дополнительный пароль'; + setBtn.disabled = settingsBusy; + setBtn.addEventListener('click', () => openPasswordDialog('set')); + actions.append(setBtn); } + + settingsCard.append(title, stateText, note, actions); }; const renderRequests = () => { @@ -201,10 +376,7 @@ export function render({ navigate }) { }; const setButtonsBusy = (flag) => { - enableBtn.disabled = flag; - disableBtn.disabled = flag; refreshBtn.disabled = flag; - usePasswordInput.disabled = flag; }; const approveRequest = async (request, mode) => { @@ -223,61 +395,48 @@ export function render({ navigate }) { await reloadRequests({ silent: true }); }; - usePasswordInput.addEventListener('change', syncPasswordUi); - - settingsCard.addEventListener('click', async (event) => { + passwordDialog.addEventListener('click', (event) => { const target = event.target; if (!(target instanceof HTMLElement)) return; + if (target.dataset.action === 'close-dialog') { + closePasswordDialog(); + } + }); - if (target.id === 'enable-pairing-btn') { - const usePassword = !!usePasswordInput.checked; - const password = String(passwordInput.value || ''); - if (usePassword && !password) { - setStatus(status, 'Введите pairing-пароль.', 'error'); - return; - } - setButtonsBusy(true); - try { - const passwordHash = usePassword - ? await deriveEspPairingPasswordHash(state.session.login, password) - : ''; - const payload = await authService.upsertEspPairingSettings({ - enabled: true, - passwordHash, - ttlSeconds: 180, - }); - setAuthInfo(`Подключение по коду включено. TTL: ${payload?.ttlSeconds || 180} сек.`); - setStatus(status, usePassword - ? 'Подключение по коду включено с доп. паролем.' - : 'Подключение по коду включено без доп. пароля.', 'info'); - passwordInput.value = ''; - } catch (error) { - const message = toUserMessage(error, 'Не удалось включить pairing.'); - setAuthError(message); - setStatus(status, message, 'error'); - } finally { - setButtonsBusy(false); - } + dialogSaveBtn.addEventListener('click', async () => { + const password = String(dialogPasswordInput.value || ''); + const confirm = String(dialogPasswordConfirmInput.value || ''); + const currentMode = dialogMode; + if (!password) { + setStatus(status, 'Введите дополнительный пароль.', 'error'); return; } - - if (target.id === 'disable-pairing-btn') { - setButtonsBusy(true); - try { - await authService.upsertEspPairingSettings({ - enabled: false, - passwordHash: '', - ttlSeconds: 180, - }); - setAuthInfo('Подключение по коду выключено.'); - setStatus(status, 'Подключение по коду выключено.', 'info'); - } catch (error) { - const message = toUserMessage(error, 'Не удалось выключить pairing.'); - setAuthError(message); - setStatus(status, message, 'error'); - } finally { - setButtonsBusy(false); - } + if (password !== confirm) { + setStatus(status, 'Пароли не совпадают.', 'error'); + return; + } + dialogSaveBtn.disabled = true; + try { + const passwordHash = await deriveEspPairingPasswordHash(state.session.login, password); + const payload = await authService.upsertEspPairingSettings({ + enabled: true, + passwordHash, + ttlSeconds: 180, + }); + pairingPasswordConfigured = true; + saveLocalPairingPasswordState(state.session.login, state.entrySettings.shineServer, true); + closePasswordDialog(); + renderSettingsCard(); + setAuthInfo(`Подключение по коду включено с дополнительным паролем. TTL: ${payload?.ttlSeconds || 180} сек.`); + setStatus(status, currentMode === 'change' + ? 'Дополнительный пароль изменён.' + : 'Дополнительный пароль задан.', 'info'); + } catch (error) { + const message = toUserMessage(error, 'Не удалось сохранить дополнительный пароль.'); + setAuthError(message); + setStatus(status, message, 'error'); + } finally { + dialogSaveBtn.disabled = false; } }); @@ -322,7 +481,7 @@ export function render({ navigate }) { void (async () => { try { - syncPasswordUi(); + renderSettingsCard(); await loadSavedKeys(); await reloadRequests({ silent: true }); cleanupEvent = authService.onEvent('IncomingEspPairingRequest', () => { From e1f2b54de3d934c633469b8d3e671ec536298a316cc052db47823fa15b5c565e Mon Sep 17 00:00:00 2001 From: AidarKC Date: Mon, 15 Jun 2026 15:23:19 +0400 Subject: [PATCH 42/56] =?UTF-8?q?=D0=A1=D1=83=D0=B7=D0=B8=D1=82=D1=8C=20?= =?UTF-8?q?=D0=B4=D0=B8=D0=B0=D0=BB=D0=BE=D0=B3=20=D0=B8=D0=B7=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20pairing-=D0=BF=D0=B0=D1=80?= =?UTF-8?q?=D0=BE=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 4 +- shine-UI/js/app.js | 2 +- shine-UI/js/pages/device-pairing-view.js | 57 ++++++++++++++++-------- 3 files changed, 42 insertions(+), 21 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index 5963aaf..763d3e1 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.201 -server.version=1.2.190 +client.version=1.2.202 +server.version=1.2.191 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 35e6c55..9bbb7f5 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -55,7 +55,7 @@ import * as serverSettingsView from './pages/server-settings-view.js'; import * as toolsSettingsView from './pages/tools-settings-view.js'; import * as deviceView from './pages/device-view.js?v=202606131435'; import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055'; -import * as devicePairingView from './pages/device-pairing-view.js?v=202606151000'; +import * as devicePairingView from './pages/device-pairing-view.js?v=202606151030'; import * as deviceQrView from './pages/device-qr-view.js'; import * as deviceCameraView from './pages/device-camera-view.js'; import * as showKeysView from './pages/show-keys-view.js'; diff --git a/shine-UI/js/pages/device-pairing-view.js b/shine-UI/js/pages/device-pairing-view.js index b3f3188..22f0886 100644 --- a/shine-UI/js/pages/device-pairing-view.js +++ b/shine-UI/js/pages/device-pairing-view.js @@ -173,13 +173,10 @@ export function render({ navigate }) { passwordDialog.style.zIndex = '30'; passwordDialog.innerHTML = `
-
-
-
-

Задать дополнительный пароль

-

Дополнительный пароль не даёт права на подключение сам по себе. Он только отсекает лишние заявки, чтобы посторонние не могли засыпать ваш аккаунт запросами. Обычно он не нужен, поэтому при желании можно задать и что-то простое, что легко запомнить.

-
- +
+
+

Задать дополнительный пароль

+

Дополнительный пароль не даёт права на подключение сам по себе. Он только отсекает лишние заявки, чтобы посторонние не могли засыпать ваш аккаунт запросами. Обычно он не нужен, поэтому при желании можно задать и что-то простое, что легко запомнить.