Обновить pairing устройств и доработать ESP32 UI
This commit is contained in:
parent
cc074a941f
commit
a788d8bcf5
1
.gitignore
vendored
1
.gitignore
vendored
@ -76,6 +76,7 @@ shine-solana/shine/scripts/**/*.env
|
|||||||
shine-solana/shine/scripts/**/TEMP_*.md
|
shine-solana/shine/scripts/**/TEMP_*.md
|
||||||
|
|
||||||
# Локальные артефакты и внешние материалы ESP32-подпроекта
|
# Локальные артефакты и внешние материалы ESP32-подпроекта
|
||||||
|
ESP32/esp32-config-tool/
|
||||||
ESP32/**/.git/
|
ESP32/**/.git/
|
||||||
ESP32/**/.idea/
|
ESP32/**/.idea/
|
||||||
ESP32-wallet/.idea/
|
ESP32-wallet/.idea/
|
||||||
|
|||||||
@ -281,6 +281,7 @@ sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
|
|||||||
Если на доверённом устройстве вход включён **без доп. пароля**, новое устройство может отправить пустой `passwordHash`.
|
Если на доверённом устройстве вход включён **без доп. пароля**, новое устройство может отправить пустой `passwordHash`.
|
||||||
|
|
||||||
Поле `trustedSessionOnline` показывает, что у пользователя сейчас есть хотя бы одна онлайн доверенная сессия, способная принять pairing-заявку.
|
Поле `trustedSessionOnline` показывает, что у пользователя сейчас есть хотя бы одна онлайн доверенная сессия, способная принять pairing-заявку.
|
||||||
|
Поле `shortCode` теперь содержит `10` цифр. В UI его рекомендуется показывать как `5` пар, например: `49 20 70 91 23`.
|
||||||
|
|
||||||
TTL заявки фиксирован на сервере и сейчас всегда равен `300` секундам.
|
TTL заявки фиксирован на сервере и сейчас всегда равен `300` секундам.
|
||||||
|
|
||||||
@ -295,7 +296,7 @@ TTL заявки фиксирован на сервере и сейчас все
|
|||||||
"payload": {
|
"payload": {
|
||||||
"pairingId": "base64url",
|
"pairingId": "base64url",
|
||||||
"state": "created",
|
"state": "created",
|
||||||
"shortCode": "4920709",
|
"shortCode": "4920709123",
|
||||||
"fingerprintB58": "ASvYDPQidnAroKzQjtCjTuEQE8ckktV5nmmhYRhDzGaA",
|
"fingerprintB58": "ASvYDPQidnAroKzQjtCjTuEQE8ckktV5nmmhYRhDzGaA",
|
||||||
"expiresAtMs": 1781441990538,
|
"expiresAtMs": 1781441990538,
|
||||||
"trustedSessionOnline": true
|
"trustedSessionOnline": true
|
||||||
@ -337,7 +338,7 @@ TTL заявки фиксирован на сервере и сейчас все
|
|||||||
"requesterSessionType": 1,
|
"requesterSessionType": 1,
|
||||||
"requesterClientPlatform": "Android",
|
"requesterClientPlatform": "Android",
|
||||||
"payloadType": 1,
|
"payloadType": 1,
|
||||||
"shortCode": "4920709",
|
"shortCode": "4920709123",
|
||||||
"fingerprintB58": "ASvYDPQidnAroKzQjtCjTuEQE8ckktV5nmmhYRhDzGaA",
|
"fingerprintB58": "ASvYDPQidnAroKzQjtCjTuEQE8ckktV5nmmhYRhDzGaA",
|
||||||
"createdAtMs": 1781441810538,
|
"createdAtMs": 1781441810538,
|
||||||
"expiresAtMs": 1781441990538,
|
"expiresAtMs": 1781441990538,
|
||||||
@ -425,7 +426,7 @@ TTL заявки фиксирован на сервере и сейчас все
|
|||||||
"payload": {
|
"payload": {
|
||||||
"pairingId": "base64url",
|
"pairingId": "base64url",
|
||||||
"state": "approved",
|
"state": "approved",
|
||||||
"shortCode": "4920709",
|
"shortCode": "4920709123",
|
||||||
"fingerprintB58": "ASvYDPQidnAroKzQjtCjTuEQE8ckktV5nmmhYRhDzGaA",
|
"fingerprintB58": "ASvYDPQidnAroKzQjtCjTuEQE8ckktV5nmmhYRhDzGaA",
|
||||||
"payloadType": 1,
|
"payloadType": 1,
|
||||||
"encryptedPayload": "AQIDBA==",
|
"encryptedPayload": "AQIDBA==",
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
# Быстрое скрытие экрана звонка и остановка гудков при отклонении
|
||||||
|
|
||||||
|
- краткое описание фичи:
|
||||||
|
- Исправлена нижняя подпись вкладки личных сообщений на `личные`.
|
||||||
|
- Исправлена логика звонка, когда одна из входящих сессий отклоняет вызов до принятия: исходящая сессия теперь должна прекращать гудки даже если ранее `RINGING` пришёл от другой входящей сессии.
|
||||||
|
- На устройстве, где пользователь нажимает отмену/сброс звонка, экран вызова теперь скрывается сразу локально, без ожидания сетевого ответа.
|
||||||
|
|
||||||
|
- что именно проверять:
|
||||||
|
- В нижней панели с 5 кнопками подпись первой кнопки должна отображаться как `личные`.
|
||||||
|
- Сценарий: один пользователь звонит, у второго входящий вызов приходит на несколько устройств; на любом одном входящем устройстве нажать `Сбросить`.
|
||||||
|
- Проверить, что у звонящего сразу прекращаются гудки и экран вызова корректно завершается.
|
||||||
|
- Проверить, что на устройстве, где нажали `Сбросить` или `Положить трубку`, overlay звонка исчезает сразу, без заметной задержки.
|
||||||
|
- Проверить, что после принятия звонка на одном устройстве поздние отмены с других устройств не ломают уже выбранную пару соединения.
|
||||||
|
|
||||||
|
- ожидаемый результат:
|
||||||
|
- Подпись в нижней панели корректная.
|
||||||
|
- При отклонении входящего звонка любым устройством звонящего не оставляет в состоянии бесконечных гудков.
|
||||||
|
- Локальный экран звонка скрывается мгновенно после нажатия кнопки отмены.
|
||||||
|
- Уже зафиксированный сценарий соединения после `ACCEPT` не сбивается другими сессиями.
|
||||||
|
|
||||||
|
- статус:
|
||||||
|
- `pending`
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
# ESP32 homeserver: заявки на подключение устройств
|
||||||
|
|
||||||
|
- краткое описание фичи:
|
||||||
|
- на ESP32 homeserver в `SETTINGS` добавлен первый пункт `Device requests`, который появляется только после авторизации homeserver в SHiNE;
|
||||||
|
- экран показывает список активных pairing-заявок, позволяет открыть каждую заявку и подтвердить или отклонить её;
|
||||||
|
- формат кода подключения изменён на `10` цифр и показывается как `5` пар.
|
||||||
|
|
||||||
|
- что проверять:
|
||||||
|
- на обычном клиенте и в wallet-plugin код отображается как `XX XX XX XX XX`;
|
||||||
|
- на доверенном веб-клиенте экран `Подключить по коду` показывает все активные заявки без поля ручного ввода;
|
||||||
|
- на ESP32 после успешной homeserver-авторизации в `SETTINGS` появляется пункт `Device requests` первым;
|
||||||
|
- `REFRESH` реально загружает активные заявки;
|
||||||
|
- на экране видно две плитки, список листается вертикально;
|
||||||
|
- client-session заявка после `YES` подключается с передачей только `device key`;
|
||||||
|
- wallet-session заявка после `YES` подключается без передачи ключей, через выпуск отдельной wallet-session;
|
||||||
|
- `NO` отклоняет заявку и она исчезает из списка активных.
|
||||||
|
|
||||||
|
- ожидаемый результат:
|
||||||
|
- все три клиента используют единый формат кода;
|
||||||
|
- активные заявки видны без ручного ввода кода;
|
||||||
|
- ESP32 может одобрять и отклонять живые pairing-заявки пользователя.
|
||||||
|
|
||||||
|
- статус:
|
||||||
|
- pending
|
||||||
@ -60,7 +60,7 @@
|
|||||||
|
|
||||||
- хранит включённость pairing и optional `passwordHash` в формате `sha256$<hex>`;
|
- хранит включённость pairing и optional `passwordHash` в формате `sha256$<hex>`;
|
||||||
- хранит pairing-заявки всех статусов, но в список активных для доверённого устройства отдаёт только pending `created`;
|
- хранит pairing-заявки всех статусов, но в список активных для доверённого устройства отдаёт только pending `created`;
|
||||||
- рассчитывает короткий код `shortCode` из `7` цифр;
|
- рассчитывает короткий код `shortCode` из `10` цифр;
|
||||||
- рассчитывает длинный `fingerprintB58` из `SHA-256` заявки;
|
- рассчитывает длинный `fingerprintB58` из `SHA-256` заявки;
|
||||||
- уведомляет онлайн доверенные сессии событием `IncomingEspPairingRequest`, если такие сессии подключены;
|
- уведомляет онлайн доверенные сессии событием `IncomingEspPairingRequest`, если такие сессии подключены;
|
||||||
- хранит переданный `encryptedPayload` как непрозрачную строку и не анализирует его содержимое.
|
- хранит переданный `encryptedPayload` как непрозрачную строку и не анализирует его содержимое.
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
#include <lvgl.h>
|
#include <lvgl.h>
|
||||||
#include <Arduino_GFX_Library.h>
|
#include <Arduino_GFX_Library.h>
|
||||||
#include <TouchDrvCSTXXX.hpp>
|
#include <TouchDrvCSTXXX.hpp>
|
||||||
|
#include <mbedtls/gcm.h>
|
||||||
#include <mbedtls/sha256.h>
|
#include <mbedtls/sha256.h>
|
||||||
#include <mbedtls/base64.h>
|
#include <mbedtls/base64.h>
|
||||||
#include <Ed25519.h>
|
#include <Ed25519.h>
|
||||||
@ -93,6 +94,8 @@ enum Screen {
|
|||||||
SCREEN_HOME,
|
SCREEN_HOME,
|
||||||
SCREEN_WALLET_QR,
|
SCREEN_WALLET_QR,
|
||||||
SCREEN_SETTINGS_MENU,
|
SCREEN_SETTINGS_MENU,
|
||||||
|
SCREEN_PAIRING_REQUESTS,
|
||||||
|
SCREEN_PAIRING_REQUEST_DETAIL,
|
||||||
SCREEN_WIFI,
|
SCREEN_WIFI,
|
||||||
SCREEN_SERVER,
|
SCREEN_SERVER,
|
||||||
SCREEN_ACCOUNT,
|
SCREEN_ACCOUNT,
|
||||||
@ -121,6 +124,7 @@ enum ActionId {
|
|||||||
ACTION_NONE,
|
ACTION_NONE,
|
||||||
ACTION_OPEN_SETTINGS,
|
ACTION_OPEN_SETTINGS,
|
||||||
ACTION_OPEN_WALLET_QR,
|
ACTION_OPEN_WALLET_QR,
|
||||||
|
ACTION_OPEN_PAIRING_REQUESTS,
|
||||||
ACTION_OPEN_WIFI,
|
ACTION_OPEN_WIFI,
|
||||||
ACTION_OPEN_SERVER,
|
ACTION_OPEN_SERVER,
|
||||||
ACTION_OPEN_ACCOUNT,
|
ACTION_OPEN_ACCOUNT,
|
||||||
@ -145,6 +149,9 @@ enum ActionId {
|
|||||||
ACTION_BACK_SECRET_MENU,
|
ACTION_BACK_SECRET_MENU,
|
||||||
ACTION_BACK_ACCOUNT,
|
ACTION_BACK_ACCOUNT,
|
||||||
ACTION_REFRESH_BALANCE,
|
ACTION_REFRESH_BALANCE,
|
||||||
|
ACTION_PAIRING_REFRESH,
|
||||||
|
ACTION_PAIRING_APPROVE,
|
||||||
|
ACTION_PAIRING_REJECT,
|
||||||
ACTION_REGISTER_ACCOUNT,
|
ACTION_REGISTER_ACCOUNT,
|
||||||
ACTION_REGISTER_ACCOUNT_EXECUTE,
|
ACTION_REGISTER_ACCOUNT_EXECUTE,
|
||||||
ACTION_HOMESERVER_PDA_ACTION,
|
ACTION_HOMESERVER_PDA_ACTION,
|
||||||
@ -226,8 +233,14 @@ struct SimpleWebSocketClient {
|
|||||||
uint16_t port = 0;
|
uint16_t port = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
static const char *kMenuItems[] = {"1. Wi-Fi", "2. Server", "3. Account"};
|
struct PairingRequestUiItem {
|
||||||
static const size_t kMenuCount = sizeof(kMenuItems) / sizeof(kMenuItems[0]);
|
String pairingId;
|
||||||
|
String requesterSessionKey;
|
||||||
|
int requesterSessionType = 0;
|
||||||
|
String requesterClientPlatform;
|
||||||
|
String shortCode;
|
||||||
|
uint64_t expiresAtMs = 0;
|
||||||
|
};
|
||||||
|
|
||||||
static lv_disp_draw_buf_t gDrawBuf;
|
static lv_disp_draw_buf_t gDrawBuf;
|
||||||
static lv_color_t *gBuf1 = nullptr;
|
static lv_color_t *gBuf1 = nullptr;
|
||||||
@ -328,6 +341,10 @@ static unsigned long gShineReconnectDelayMs = SHINE_RECONNECT_MIN_MS;
|
|||||||
static uint32_t gWsRequestCounter = 1;
|
static uint32_t gWsRequestCounter = 1;
|
||||||
static int64_t gShineServerTimeOffsetMs = 0;
|
static int64_t gShineServerTimeOffsetMs = 0;
|
||||||
static SimpleWebSocketClient gShineWs;
|
static SimpleWebSocketClient gShineWs;
|
||||||
|
static std::vector<PairingRequestUiItem> gPairingRequests;
|
||||||
|
static int gSelectedPairingRequestIndex = -1;
|
||||||
|
static String gPairingStatusMessage = "Refresh requests";
|
||||||
|
static bool gPairingBusy = false;
|
||||||
|
|
||||||
struct DerivedKeyInfo {
|
struct DerivedKeyInfo {
|
||||||
String title;
|
String title;
|
||||||
@ -409,7 +426,25 @@ static String bytesToBase64String(const uint8_t *data, size_t len);
|
|||||||
static String jsonEscape(const String &value);
|
static String jsonEscape(const String &value);
|
||||||
static bool jsonStringField(const String &json, const String &field, String &valueOut);
|
static bool jsonStringField(const String &json, const String &field, String &valueOut);
|
||||||
static bool jsonBoolField(const String &json, const String &field, bool &valueOut);
|
static bool jsonBoolField(const String &json, const String &field, bool &valueOut);
|
||||||
|
static bool jsonInt64Field(const String &json, const String &field, uint64_t &valueOut);
|
||||||
static bool parseUrlHostPortPath(const String &url, String &hostOut, uint16_t &portOut, String &pathOut, bool &secureOut);
|
static bool parseUrlHostPortPath(const String &url, String &hostOut, uint16_t &portOut, String &pathOut, bool &secureOut);
|
||||||
|
static String formatPairingShortCode(const String &value);
|
||||||
|
static bool pairingMenuVisible();
|
||||||
|
static int settingsMenuCount();
|
||||||
|
static String settingsMenuLabel(int itemIndex);
|
||||||
|
static ActionId settingsMenuAction(int itemIndex);
|
||||||
|
static String pairingSessionKindLabel(int sessionType);
|
||||||
|
static String pairingSessionNameLabel(const PairingRequestUiItem &item);
|
||||||
|
static bool findJsonArrayBounds(const String &json, const String &field, int &startOut, int &endOut);
|
||||||
|
static bool extractJsonObjectAt(const String &json, int startIndex, int &endOut, String &objectOut);
|
||||||
|
static bool parsePairingRequestsResponse(const String &json, std::vector<PairingRequestUiItem> &itemsOut, String &errorOut);
|
||||||
|
static bool refreshPairingRequests(String &errorOut);
|
||||||
|
static bool buildPkcs8FromSeed32(const uint8_t seed32[32], String &pkcs8B64Out);
|
||||||
|
static bool buildPairingSecretsPayload(const PairingRequestUiItem &item, String &payloadJsonOut, String &errorOut);
|
||||||
|
static bool createDelegatedWalletSessionPayload(const PairingRequestUiItem &item, String &payloadJsonOut, String &errorOut);
|
||||||
|
static bool encryptPairingPayloadForRequester(const String &requesterSessionKey, const String &payloadJson, String &encryptedPayloadOut, String &errorOut);
|
||||||
|
static bool approvePairingRequest(const PairingRequestUiItem &item, String &errorOut);
|
||||||
|
static bool rejectPairingRequest(const PairingRequestUiItem &item, String &errorOut);
|
||||||
static String shineWsUrl();
|
static String shineWsUrl();
|
||||||
static String shineHomeLine();
|
static String shineHomeLine();
|
||||||
static String balanceHomeLine();
|
static String balanceHomeLine();
|
||||||
@ -855,6 +890,195 @@ static bool jsonBoolField(const String &json, const String &field, bool &valueOu
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String formatPairingShortCode(const String &value) {
|
||||||
|
String digits;
|
||||||
|
for (int i = 0; i < (int)value.length(); ++i) {
|
||||||
|
char ch = value.charAt(i);
|
||||||
|
if (ch >= '0' && ch <= '9') {
|
||||||
|
digits += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (digits.length() < 10) {
|
||||||
|
digits = String("0") + digits;
|
||||||
|
}
|
||||||
|
if (digits.length() > 10) {
|
||||||
|
digits = digits.substring(0, 10);
|
||||||
|
}
|
||||||
|
String out;
|
||||||
|
for (int i = 0; i < 10; i += 2) {
|
||||||
|
if (!out.isEmpty()) out += " ";
|
||||||
|
out += digits.substring(i, i + 2);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool pairingMenuVisible() {
|
||||||
|
return gShineAuthenticated;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int settingsMenuCount() {
|
||||||
|
return pairingMenuVisible() ? 4 : 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String settingsMenuLabel(int itemIndex) {
|
||||||
|
if (pairingMenuVisible()) {
|
||||||
|
switch (itemIndex) {
|
||||||
|
case 0: return "1. Device requests";
|
||||||
|
case 1: return "2. Wi-Fi";
|
||||||
|
case 2: return "3. Server";
|
||||||
|
case 3: return "4. Account";
|
||||||
|
default: return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (itemIndex) {
|
||||||
|
case 0: return "1. Wi-Fi";
|
||||||
|
case 1: return "2. Server";
|
||||||
|
case 2: return "3. Account";
|
||||||
|
default: return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static ActionId settingsMenuAction(int itemIndex) {
|
||||||
|
if (pairingMenuVisible()) {
|
||||||
|
switch (itemIndex) {
|
||||||
|
case 0: return ACTION_OPEN_PAIRING_REQUESTS;
|
||||||
|
case 1: return ACTION_OPEN_WIFI;
|
||||||
|
case 2: return ACTION_OPEN_SERVER;
|
||||||
|
case 3: return ACTION_OPEN_ACCOUNT;
|
||||||
|
default: return ACTION_NONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (itemIndex) {
|
||||||
|
case 0: return ACTION_OPEN_WIFI;
|
||||||
|
case 1: return ACTION_OPEN_SERVER;
|
||||||
|
case 2: return ACTION_OPEN_ACCOUNT;
|
||||||
|
default: return ACTION_NONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String pairingSessionKindLabel(int sessionType) {
|
||||||
|
return sessionType == 50 ? "Wallet session" : "Client session";
|
||||||
|
}
|
||||||
|
|
||||||
|
static String pairingSessionNameLabel(const PairingRequestUiItem &item) {
|
||||||
|
String value = item.requesterClientPlatform;
|
||||||
|
value.trim();
|
||||||
|
return value.isEmpty() ? "Unknown client" : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool findJsonArrayBounds(const String &json, const String &field, int &startOut, int &endOut) {
|
||||||
|
String needle = "\"" + field + "\"";
|
||||||
|
int keyPos = json.indexOf(needle);
|
||||||
|
if (keyPos < 0) return false;
|
||||||
|
int colon = json.indexOf(':', keyPos + needle.length());
|
||||||
|
if (colon < 0) return false;
|
||||||
|
int start = json.indexOf('[', colon + 1);
|
||||||
|
if (start < 0) return false;
|
||||||
|
bool inString = false;
|
||||||
|
bool escape = false;
|
||||||
|
int depth = 0;
|
||||||
|
for (int i = start; i < (int)json.length(); ++i) {
|
||||||
|
char ch = json.charAt(i);
|
||||||
|
if (escape) {
|
||||||
|
escape = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch == '\\') {
|
||||||
|
escape = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch == '"') {
|
||||||
|
inString = !inString;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inString) continue;
|
||||||
|
if (ch == '[') depth++;
|
||||||
|
if (ch == ']') {
|
||||||
|
depth--;
|
||||||
|
if (depth == 0) {
|
||||||
|
startOut = start;
|
||||||
|
endOut = i;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool extractJsonObjectAt(const String &json, int startIndex, int &endOut, String &objectOut) {
|
||||||
|
int start = json.indexOf('{', startIndex);
|
||||||
|
if (start < 0) return false;
|
||||||
|
bool inString = false;
|
||||||
|
bool escape = false;
|
||||||
|
int depth = 0;
|
||||||
|
for (int i = start; i < (int)json.length(); ++i) {
|
||||||
|
char ch = json.charAt(i);
|
||||||
|
if (escape) {
|
||||||
|
escape = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch == '\\') {
|
||||||
|
escape = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch == '"') {
|
||||||
|
inString = !inString;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inString) continue;
|
||||||
|
if (ch == '{') depth++;
|
||||||
|
if (ch == '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth == 0) {
|
||||||
|
endOut = i;
|
||||||
|
objectOut = json.substring(start, i + 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool parsePairingRequestsResponse(const String &json, std::vector<PairingRequestUiItem> &itemsOut, String &errorOut) {
|
||||||
|
itemsOut.clear();
|
||||||
|
errorOut = "";
|
||||||
|
uint64_t status = 0;
|
||||||
|
if (!jsonInt64Field(json, "status", status) || status != 200) {
|
||||||
|
errorOut = "ListTrustedDeviceLoginRequests rejected";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int arrayStart = 0;
|
||||||
|
int arrayEnd = 0;
|
||||||
|
if (!findJsonArrayBounds(json, "requests", arrayStart, arrayEnd)) {
|
||||||
|
errorOut = "requests array missing";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int cursor = arrayStart + 1;
|
||||||
|
while (cursor < arrayEnd) {
|
||||||
|
String objectJson;
|
||||||
|
int objectEnd = 0;
|
||||||
|
if (!extractJsonObjectAt(json, cursor, objectEnd, objectJson) || objectEnd > arrayEnd) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
PairingRequestUiItem item;
|
||||||
|
uint64_t sessionType = 0;
|
||||||
|
uint64_t expiresAtMs = 0;
|
||||||
|
jsonStringField(objectJson, "pairingId", item.pairingId);
|
||||||
|
jsonStringField(objectJson, "requesterSessionKey", item.requesterSessionKey);
|
||||||
|
jsonStringField(objectJson, "requesterClientPlatform", item.requesterClientPlatform);
|
||||||
|
jsonStringField(objectJson, "shortCode", item.shortCode);
|
||||||
|
jsonInt64Field(objectJson, "requesterSessionType", sessionType);
|
||||||
|
jsonInt64Field(objectJson, "expiresAtMs", expiresAtMs);
|
||||||
|
item.requesterSessionType = (int)sessionType;
|
||||||
|
item.expiresAtMs = expiresAtMs;
|
||||||
|
if (!item.pairingId.isEmpty()) {
|
||||||
|
itemsOut.push_back(item);
|
||||||
|
}
|
||||||
|
cursor = objectEnd + 1;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
static bool parseUrlHostPortPath(const String &url, String &hostOut, uint16_t &portOut, String &pathOut, bool &secureOut) {
|
static bool parseUrlHostPortPath(const String &url, String &hostOut, uint16_t &portOut, String &pathOut, bool &secureOut) {
|
||||||
hostOut = "";
|
hostOut = "";
|
||||||
pathOut = "/";
|
pathOut = "/";
|
||||||
@ -3177,6 +3401,289 @@ static bool shineWsRequest(SimpleWebSocketClient &ws, const String &op, const St
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool refreshPairingRequests(String &errorOut) {
|
||||||
|
errorOut = "";
|
||||||
|
String authError;
|
||||||
|
if (!ensureShineSessionAuthenticated(authError)) {
|
||||||
|
errorOut = authError.isEmpty() ? "Homeserver is offline" : authError;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String response;
|
||||||
|
if (!shineWsRequest(gShineWs, "ListTrustedDeviceLoginRequests", "{}", response, SHINE_RPC_TIMEOUT_MS)) {
|
||||||
|
errorOut = "ListTrustedDeviceLoginRequests failed";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::vector<PairingRequestUiItem> items;
|
||||||
|
if (!parsePairingRequestsResponse(response, items, errorOut)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
gPairingRequests = items;
|
||||||
|
if (gSelectedPairingRequestIndex >= (int)gPairingRequests.size()) {
|
||||||
|
gSelectedPairingRequestIndex = -1;
|
||||||
|
}
|
||||||
|
gPairingStatusMessage = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool buildPkcs8FromSeed32(const uint8_t seed32[32], String &pkcs8B64Out) {
|
||||||
|
static const uint8_t kEd25519Pkcs8Prefix[] = {
|
||||||
|
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
|
||||||
|
0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20
|
||||||
|
};
|
||||||
|
std::vector<uint8_t> raw;
|
||||||
|
raw.reserve(sizeof(kEd25519Pkcs8Prefix) + 32);
|
||||||
|
raw.insert(raw.end(), kEd25519Pkcs8Prefix, kEd25519Pkcs8Prefix + sizeof(kEd25519Pkcs8Prefix));
|
||||||
|
raw.insert(raw.end(), seed32, seed32 + 32);
|
||||||
|
pkcs8B64Out = bytesToBase64String(raw.data(), raw.size());
|
||||||
|
return !pkcs8B64Out.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool buildPairingSecretsPayload(const PairingRequestUiItem &item, String &payloadJsonOut, String &errorOut) {
|
||||||
|
payloadJsonOut = "";
|
||||||
|
errorOut = "";
|
||||||
|
uint8_t deviceSeed[32] = {};
|
||||||
|
uint8_t devicePub[32] = {};
|
||||||
|
uint8_t deviceSec[64] = {};
|
||||||
|
if (!deriveSeedKeypairFromBase58(gDevicePrivB58, deviceSeed, devicePub, deviceSec)) {
|
||||||
|
errorOut = "Failed to derive device key";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String devicePkcs8;
|
||||||
|
if (!buildPkcs8FromSeed32(deviceSeed, devicePkcs8)) {
|
||||||
|
errorOut = "Failed to encode device key";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
payloadJsonOut = String("{\"v\":1,\"type\":\"shine-esp-pairing-transfer\",\"login\":\"") + jsonEscape(gLoginValue)
|
||||||
|
+ "\",\"mode\":\"device-only\",\"keys\":{\"deviceKey\":\"" + jsonEscape(devicePkcs8)
|
||||||
|
+ "\",\"blockchainKey\":\"\",\"rootKey\":\"\"},\"payloadType\":1,\"createdAtMs\":"
|
||||||
|
+ String((unsigned long long)shineNowMs()) + "}";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool createDelegatedWalletSessionPayload(const PairingRequestUiItem &item, String &payloadJsonOut, String &errorOut) {
|
||||||
|
payloadJsonOut = "";
|
||||||
|
errorOut = "";
|
||||||
|
uint8_t deviceSeed[32] = {};
|
||||||
|
uint8_t devicePub[32] = {};
|
||||||
|
uint8_t deviceSec[64] = {};
|
||||||
|
if (!deriveSeedKeypairFromBase58(gDevicePrivB58, deviceSeed, devicePub, deviceSec)) {
|
||||||
|
errorOut = "Failed to derive device key";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String wsUrl = shineWsUrl();
|
||||||
|
if (wsUrl.isEmpty()) {
|
||||||
|
errorOut = "Shine server is not configured";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t storageRaw[32];
|
||||||
|
for (size_t i = 0; i < sizeof(storageRaw); ++i) storageRaw[i] = (uint8_t)esp_random();
|
||||||
|
String storagePwd = bytesToBase64String(storageRaw, sizeof(storageRaw));
|
||||||
|
|
||||||
|
SimpleWebSocketClient tempWs;
|
||||||
|
String wsError;
|
||||||
|
if (!ensureWebSocketConnected(tempWs, wsUrl, wsError)) {
|
||||||
|
errorOut = wsError.isEmpty() ? "Delegated session WS connect failed" : wsError;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ok = false;
|
||||||
|
do {
|
||||||
|
String authResp;
|
||||||
|
if (!shineWsRequest(tempWs, "AuthChallenge",
|
||||||
|
String("{\"login\":\"") + jsonEscape(gLoginValue) + "\"}",
|
||||||
|
authResp, SHINE_RPC_TIMEOUT_MS)) {
|
||||||
|
errorOut = "Delegated AuthChallenge failed";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
uint64_t statusCode = 0;
|
||||||
|
String authNonce;
|
||||||
|
if (!jsonInt64Field(authResp, "status", statusCode) || statusCode != 200 || !jsonStringField(authResp, "authNonce", authNonce)) {
|
||||||
|
errorOut = "Delegated AuthChallenge rejected";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64_t timeMs = shineNowMs();
|
||||||
|
String preimage = String("AUTH_CREATE_SESSION:") + gLoginValue + ":" + item.requesterSessionKey + ":" + storagePwd + ":" + String((unsigned long long)timeMs) + ":" + authNonce;
|
||||||
|
uint8_t signature[64] = {};
|
||||||
|
crypto_sign_ed25519_detached(signature, nullptr,
|
||||||
|
reinterpret_cast<const unsigned char *>(preimage.c_str()),
|
||||||
|
preimage.length(), deviceSec);
|
||||||
|
String clientPlatform = item.requesterClientPlatform;
|
||||||
|
clientPlatform.trim();
|
||||||
|
if (clientPlatform.isEmpty()) clientPlatform = "Wallet session";
|
||||||
|
String createReq = String("{\"login\":\"") + jsonEscape(gLoginValue)
|
||||||
|
+ "\",\"sessionKey\":\"" + jsonEscape(item.requesterSessionKey)
|
||||||
|
+ "\",\"storagePwd\":\"" + jsonEscape(storagePwd)
|
||||||
|
+ "\",\"timeMs\":" + String((unsigned long long)timeMs)
|
||||||
|
+ ",\"authNonce\":\"" + jsonEscape(authNonce)
|
||||||
|
+ "\",\"deviceKey\":\"" + jsonEscape(bytesToBase64String(devicePub, 32))
|
||||||
|
+ "\",\"signatureB64\":\"" + jsonEscape(bytesToBase64String(signature, 64))
|
||||||
|
+ "\",\"sessionType\":50"
|
||||||
|
+ ",\"clientPlatform\":\"" + jsonEscape(clientPlatform)
|
||||||
|
+ "\",\"clientInfo\":\"Wallet session approved via ESP32 pairing\"}";
|
||||||
|
String createResp;
|
||||||
|
if (!shineWsRequest(tempWs, "CreateAuthSession", createReq, createResp, SHINE_RPC_TIMEOUT_MS)) {
|
||||||
|
errorOut = "Delegated CreateAuthSession failed";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
String sessionId;
|
||||||
|
if (!jsonInt64Field(createResp, "status", statusCode) || statusCode != 200 || !jsonStringField(createResp, "sessionId", sessionId)) {
|
||||||
|
errorOut = "Delegated CreateAuthSession rejected";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
payloadJsonOut = String("{\"v\":1,\"type\":\"shine-esp-session-attach\",\"login\":\"") + jsonEscape(gLoginValue)
|
||||||
|
+ "\",\"session\":{\"sessionId\":\"" + jsonEscape(sessionId)
|
||||||
|
+ "\",\"sessionKey\":\"" + jsonEscape(item.requesterSessionKey)
|
||||||
|
+ "\",\"storagePwd\":\"" + jsonEscape(storagePwd)
|
||||||
|
+ "\",\"sessionType\":50,\"clientPlatform\":\"" + jsonEscape(clientPlatform)
|
||||||
|
+ "\"},\"createdAtMs\":" + String((unsigned long long)shineNowMs()) + "}";
|
||||||
|
ok = true;
|
||||||
|
} while (false);
|
||||||
|
|
||||||
|
closeWebSocket(tempWs);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool encryptPairingPayloadForRequester(const String &requesterSessionKey, const String &payloadJson, String &encryptedPayloadOut, String &errorOut) {
|
||||||
|
encryptedPayloadOut = "";
|
||||||
|
errorOut = "";
|
||||||
|
if (!requesterSessionKey.startsWith("ed25519/")) {
|
||||||
|
errorOut = "Unsupported requesterSessionKey";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<uint8_t> requesterEdPub;
|
||||||
|
if (!base64DecodeStd(requesterSessionKey.substring(8), requesterEdPub) || requesterEdPub.size() != 32) {
|
||||||
|
errorOut = "Bad requester session public key";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
uint8_t requesterCurvePub[32] = {};
|
||||||
|
if (crypto_sign_ed25519_pk_to_curve25519(requesterCurvePub, requesterEdPub.data()) != 0) {
|
||||||
|
errorOut = "Failed to convert requester public key";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t ephPub[32] = {};
|
||||||
|
uint8_t ephSec[32] = {};
|
||||||
|
if (crypto_box_keypair(ephPub, ephSec) != 0) {
|
||||||
|
errorOut = "Failed to create ephemeral key";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
uint8_t sharedSecret[32] = {};
|
||||||
|
if (crypto_scalarmult(sharedSecret, ephSec, requesterCurvePub) != 0) {
|
||||||
|
errorOut = "Failed to derive shared secret";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
uint8_t aesKey[32] = {};
|
||||||
|
mbedtls_sha256(sharedSecret, sizeof(sharedSecret), aesKey, 0);
|
||||||
|
|
||||||
|
uint8_t iv[12] = {};
|
||||||
|
for (size_t i = 0; i < sizeof(iv); ++i) iv[i] = (uint8_t)esp_random();
|
||||||
|
|
||||||
|
std::vector<uint8_t> plain(payloadJson.length());
|
||||||
|
for (int i = 0; i < (int)payloadJson.length(); ++i) plain[i] = (uint8_t)payloadJson.charAt(i);
|
||||||
|
std::vector<uint8_t> cipher(plain.size());
|
||||||
|
uint8_t tag[16] = {};
|
||||||
|
|
||||||
|
mbedtls_gcm_context gcm;
|
||||||
|
mbedtls_gcm_init(&gcm);
|
||||||
|
bool ok = false;
|
||||||
|
if (mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, aesKey, 256) == 0
|
||||||
|
&& mbedtls_gcm_crypt_and_tag(&gcm,
|
||||||
|
MBEDTLS_GCM_ENCRYPT,
|
||||||
|
plain.size(),
|
||||||
|
iv, sizeof(iv),
|
||||||
|
nullptr, 0,
|
||||||
|
plain.data(),
|
||||||
|
cipher.data(),
|
||||||
|
sizeof(tag), tag) == 0) {
|
||||||
|
std::vector<uint8_t> cipherWithTag = cipher;
|
||||||
|
cipherWithTag.insert(cipherWithTag.end(), tag, tag + sizeof(tag));
|
||||||
|
String envelopeJson = String("{\"v\":1,\"alg\":\"x25519-aes256-gcm\",\"ephPubB64\":\"")
|
||||||
|
+ jsonEscape(bytesToBase64String(ephPub, sizeof(ephPub)))
|
||||||
|
+ "\",\"ivB64\":\"" + jsonEscape(bytesToBase64String(iv, sizeof(iv)))
|
||||||
|
+ "\",\"cipherB64\":\"" + jsonEscape(bytesToBase64String(cipherWithTag.data(), cipherWithTag.size()))
|
||||||
|
+ "\",\"createdAtMs\":" + String((unsigned long long)shineNowMs()) + "}";
|
||||||
|
String payloadB64 = bytesToBase64String(reinterpret_cast<const uint8_t *>(envelopeJson.c_str()), envelopeJson.length());
|
||||||
|
payloadB64.replace("+", "-");
|
||||||
|
payloadB64.replace("/", "_");
|
||||||
|
while (payloadB64.endsWith("=")) payloadB64.remove(payloadB64.length() - 1);
|
||||||
|
encryptedPayloadOut = String("shine-esp-pairing-v1:") + payloadB64;
|
||||||
|
ok = true;
|
||||||
|
} else {
|
||||||
|
errorOut = "AES-GCM encryption failed";
|
||||||
|
}
|
||||||
|
mbedtls_gcm_free(&gcm);
|
||||||
|
sodium_memzero(sharedSecret, sizeof(sharedSecret));
|
||||||
|
sodium_memzero(aesKey, sizeof(aesKey));
|
||||||
|
sodium_memzero(ephSec, sizeof(ephSec));
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool approvePairingRequest(const PairingRequestUiItem &item, String &errorOut) {
|
||||||
|
errorOut = "";
|
||||||
|
String payloadJson;
|
||||||
|
if (item.requesterSessionType == 50) {
|
||||||
|
if (!createDelegatedWalletSessionPayload(item, payloadJson, errorOut)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!buildPairingSecretsPayload(item, payloadJson, errorOut)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String encryptedPayload;
|
||||||
|
if (!encryptPairingPayloadForRequester(item.requesterSessionKey, payloadJson, encryptedPayload, errorOut)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String authError;
|
||||||
|
if (!ensureShineSessionAuthenticated(authError)) {
|
||||||
|
errorOut = authError.isEmpty() ? "Homeserver is offline" : authError;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String approveReq = String("{\"pairingId\":\"") + jsonEscape(item.pairingId)
|
||||||
|
+ "\",\"encryptedPayload\":\"" + jsonEscape(encryptedPayload) + "\"}";
|
||||||
|
String response;
|
||||||
|
if (!shineWsRequest(gShineWs, "ApproveTrustedDeviceLogin", approveReq, response, SHINE_RPC_TIMEOUT_MS)) {
|
||||||
|
errorOut = "ApproveTrustedDeviceLogin failed";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
uint64_t status = 0;
|
||||||
|
if (!jsonInt64Field(response, "status", status) || status != 200) {
|
||||||
|
errorOut = "ApproveTrustedDeviceLogin rejected";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool rejectPairingRequest(const PairingRequestUiItem &item, String &errorOut) {
|
||||||
|
errorOut = "";
|
||||||
|
String authError;
|
||||||
|
if (!ensureShineSessionAuthenticated(authError)) {
|
||||||
|
errorOut = authError.isEmpty() ? "Homeserver is offline" : authError;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String rejectReq = String("{\"pairingId\":\"") + jsonEscape(item.pairingId)
|
||||||
|
+ "\",\"reason\":\"rejected_by_user\"}";
|
||||||
|
String response;
|
||||||
|
if (!shineWsRequest(gShineWs, "RejectTrustedDeviceLogin", rejectReq, response, SHINE_RPC_TIMEOUT_MS)) {
|
||||||
|
errorOut = "RejectTrustedDeviceLogin failed";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
uint64_t status = 0;
|
||||||
|
if (!jsonInt64Field(response, "status", status) || status != 200) {
|
||||||
|
errorOut = "RejectTrustedDeviceLogin rejected";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
static bool ensureShineSessionAuthenticated(String &errorOut) {
|
static bool ensureShineSessionAuthenticated(String &errorOut) {
|
||||||
errorOut = "";
|
errorOut = "";
|
||||||
String diagDetails;
|
String diagDetails;
|
||||||
@ -3788,6 +4295,10 @@ static int batteryPercentValue() {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool batteryIsChargingNow() {
|
||||||
|
return gPowerReady && gPower.isBatteryConnect() && gPower.isCharging();
|
||||||
|
}
|
||||||
|
|
||||||
static int wifiSignalLevel() {
|
static int wifiSignalLevel() {
|
||||||
if (WiFi.status() != WL_CONNECTED) {
|
if (WiFi.status() != WL_CONNECTED) {
|
||||||
return 0;
|
return 0;
|
||||||
@ -3801,6 +4312,7 @@ static int wifiSignalLevel() {
|
|||||||
|
|
||||||
static void drawTopStatusIndicators() {
|
static void drawTopStatusIndicators() {
|
||||||
int batt = batteryPercentValue();
|
int batt = batteryPercentValue();
|
||||||
|
bool charging = batteryIsChargingNow();
|
||||||
String battText = batt >= 0 ? String(batt) + "%" : "--";
|
String battText = batt >= 0 ? String(batt) + "%" : "--";
|
||||||
|
|
||||||
lv_obj_t *battLabel = lv_label_create(gRoot);
|
lv_obj_t *battLabel = lv_label_create(gRoot);
|
||||||
@ -3809,6 +4321,18 @@ static void drawTopStatusIndicators() {
|
|||||||
lv_obj_set_style_text_color(battLabel, lv_color_hex(0xC9D3DE), 0);
|
lv_obj_set_style_text_color(battLabel, lv_color_hex(0xC9D3DE), 0);
|
||||||
lv_obj_set_pos(battLabel, 297, 18);
|
lv_obj_set_pos(battLabel, 297, 18);
|
||||||
|
|
||||||
|
if (charging) {
|
||||||
|
static const lv_point_t boltPoints[] = {
|
||||||
|
{6, 0}, {1, 9}, {5, 9}, {2, 18}, {10, 7}, {6, 7}
|
||||||
|
};
|
||||||
|
lv_obj_t *chargeBolt = lv_line_create(gRoot);
|
||||||
|
lv_line_set_points(chargeBolt, boltPoints, sizeof(boltPoints) / sizeof(boltPoints[0]));
|
||||||
|
lv_obj_set_pos(chargeBolt, 337, 15);
|
||||||
|
lv_obj_set_style_line_width(chargeBolt, 2, 0);
|
||||||
|
lv_obj_set_style_line_color(chargeBolt, lv_color_hex(0xFFD34D), 0);
|
||||||
|
lv_obj_set_style_line_rounded(chargeBolt, true, 0);
|
||||||
|
}
|
||||||
|
|
||||||
lv_obj_t *battery = lv_obj_create(gRoot);
|
lv_obj_t *battery = lv_obj_create(gRoot);
|
||||||
lv_obj_set_size(battery, 32, 16);
|
lv_obj_set_size(battery, 32, 16);
|
||||||
lv_obj_set_pos(battery, 349, 20);
|
lv_obj_set_pos(battery, 349, 20);
|
||||||
@ -4170,6 +4694,22 @@ static void networkSelectCb(lv_event_t *event) {
|
|||||||
true);
|
true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void pairingSelectCb(lv_event_t *event) {
|
||||||
|
if (lv_event_get_code(event) != LV_EVENT_CLICKED || gBlockClick || gLastHandledTouchSequence == gTouchSequence) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
gLastHandledTouchSequence = gTouchSequence;
|
||||||
|
gSuppressTouchUntilRelease = true;
|
||||||
|
gBlockClick = true;
|
||||||
|
|
||||||
|
int index = static_cast<int>(reinterpret_cast<uintptr_t>(lv_event_get_user_data(event)));
|
||||||
|
if (index < 0 || index >= (int)gPairingRequests.size()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
gSelectedPairingRequestIndex = index;
|
||||||
|
showScreen(SCREEN_PAIRING_REQUEST_DETAIL);
|
||||||
|
}
|
||||||
|
|
||||||
static void editorKeyCb(lv_event_t *event) {
|
static void editorKeyCb(lv_event_t *event) {
|
||||||
if (lv_event_get_code(event) != LV_EVENT_CLICKED || gBlockClick || gLastHandledTouchSequence == gTouchSequence || !gInputTextArea) {
|
if (lv_event_get_code(event) != LV_EVENT_CLICKED || gBlockClick || gLastHandledTouchSequence == gTouchSequence || !gInputTextArea) {
|
||||||
return;
|
return;
|
||||||
@ -4244,6 +4784,14 @@ static void actionButtonCb(lv_event_t *event) {
|
|||||||
case ACTION_OPEN_WALLET_QR:
|
case ACTION_OPEN_WALLET_QR:
|
||||||
showScreen(SCREEN_WALLET_QR);
|
showScreen(SCREEN_WALLET_QR);
|
||||||
break;
|
break;
|
||||||
|
case ACTION_OPEN_PAIRING_REQUESTS: {
|
||||||
|
String error;
|
||||||
|
if (!refreshPairingRequests(error)) {
|
||||||
|
gPairingStatusMessage = error.isEmpty() ? "Failed to load requests" : error;
|
||||||
|
}
|
||||||
|
showScreen(SCREEN_PAIRING_REQUESTS);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case ACTION_REGISTER_ACCOUNT:
|
case ACTION_REGISTER_ACCOUNT:
|
||||||
prepareRegisterAccountScreen();
|
prepareRegisterAccountScreen();
|
||||||
showScreen(SCREEN_REGISTER_ACCOUNT_CONFIRM);
|
showScreen(SCREEN_REGISTER_ACCOUNT_CONFIRM);
|
||||||
@ -4401,6 +4949,44 @@ static void actionButtonCb(lv_event_t *event) {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case ACTION_PAIRING_REFRESH: {
|
||||||
|
String error;
|
||||||
|
if (!refreshPairingRequests(error)) {
|
||||||
|
gPairingStatusMessage = error.isEmpty() ? "Failed to refresh requests" : error;
|
||||||
|
}
|
||||||
|
rebuildScreen();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ACTION_PAIRING_APPROVE: {
|
||||||
|
if (gSelectedPairingRequestIndex >= 0 && gSelectedPairingRequestIndex < (int)gPairingRequests.size()) {
|
||||||
|
String error;
|
||||||
|
if (approvePairingRequest(gPairingRequests[gSelectedPairingRequestIndex], error)) {
|
||||||
|
gPairingStatusMessage = "Request approved";
|
||||||
|
refreshPairingRequests(error);
|
||||||
|
gSelectedPairingRequestIndex = -1;
|
||||||
|
showScreen(SCREEN_PAIRING_REQUESTS);
|
||||||
|
} else {
|
||||||
|
gPairingStatusMessage = error.isEmpty() ? "Approve failed" : error;
|
||||||
|
rebuildScreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ACTION_PAIRING_REJECT: {
|
||||||
|
if (gSelectedPairingRequestIndex >= 0 && gSelectedPairingRequestIndex < (int)gPairingRequests.size()) {
|
||||||
|
String error;
|
||||||
|
if (rejectPairingRequest(gPairingRequests[gSelectedPairingRequestIndex], error)) {
|
||||||
|
gPairingStatusMessage = "Request rejected";
|
||||||
|
refreshPairingRequests(error);
|
||||||
|
gSelectedPairingRequestIndex = -1;
|
||||||
|
showScreen(SCREEN_PAIRING_REQUESTS);
|
||||||
|
} else {
|
||||||
|
gPairingStatusMessage = error.isEmpty() ? "Reject failed" : error;
|
||||||
|
rebuildScreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
case ACTION_EDITOR_SAVE:
|
case ACTION_EDITOR_SAVE:
|
||||||
applyEditorValue();
|
applyEditorValue();
|
||||||
break;
|
break;
|
||||||
@ -4663,27 +5249,32 @@ static void drawSettingsMenu() {
|
|||||||
makeTitle("SETTINGS", 18, &lv_font_montserrat_24);
|
makeTitle("SETTINGS", 18, &lv_font_montserrat_24);
|
||||||
makeBody("Swipe up/down to move. Swipe right to go home.", 56, 420);
|
makeBody("Swipe up/down to move. Swipe right to go home.", 56, 420);
|
||||||
|
|
||||||
for (int visibleIndex = 0; visibleIndex < 2; ++visibleIndex) {
|
int totalItems = settingsMenuCount();
|
||||||
int itemIndex = gSettingsScrollIndex + visibleIndex;
|
if (totalItems <= 2) {
|
||||||
if (itemIndex >= static_cast<int>(kMenuCount)) {
|
gSettingsScrollIndex = 0;
|
||||||
break;
|
} else if (gSettingsScrollIndex > totalItems - 2) {
|
||||||
|
gSettingsScrollIndex = totalItems - 2;
|
||||||
|
}
|
||||||
|
if (gSettingsScrollIndex < 0) {
|
||||||
|
gSettingsScrollIndex = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
ActionId action = ACTION_NONE;
|
for (int visibleIndex = 0; visibleIndex < 2; ++visibleIndex) {
|
||||||
if (itemIndex == 0) action = ACTION_OPEN_WIFI;
|
int itemIndex = gSettingsScrollIndex + visibleIndex;
|
||||||
if (itemIndex == 1) action = ACTION_OPEN_SERVER;
|
if (itemIndex >= totalItems) {
|
||||||
if (itemIndex == 2) action = ACTION_OPEN_ACCOUNT;
|
break;
|
||||||
|
}
|
||||||
makeButton(kMenuItems[itemIndex], 22, 132 + visibleIndex * 126, 436, 104,
|
String label = settingsMenuLabel(itemIndex);
|
||||||
0x355C7D, action, &lv_font_montserrat_24);
|
makeButton(label.c_str(), 22, 132 + visibleIndex * 126, 436, 104,
|
||||||
|
0x355C7D, settingsMenuAction(itemIndex), &lv_font_montserrat_24);
|
||||||
}
|
}
|
||||||
|
|
||||||
lv_obj_t *hint = lv_label_create(gRoot);
|
lv_obj_t *hint = lv_label_create(gRoot);
|
||||||
char hintText[48];
|
char hintText[48];
|
||||||
snprintf(hintText, sizeof(hintText), "Items %d-%d of %d",
|
snprintf(hintText, sizeof(hintText), "Items %d-%d of %d",
|
||||||
gSettingsScrollIndex + 1,
|
gSettingsScrollIndex + 1,
|
||||||
min(gSettingsScrollIndex + 2, static_cast<int>(kMenuCount)),
|
min(gSettingsScrollIndex + 2, totalItems),
|
||||||
static_cast<int>(kMenuCount));
|
totalItems);
|
||||||
lv_label_set_text(hint, hintText);
|
lv_label_set_text(hint, hintText);
|
||||||
lv_obj_set_style_text_font(hint, &lv_font_montserrat_16, 0);
|
lv_obj_set_style_text_font(hint, &lv_font_montserrat_16, 0);
|
||||||
lv_obj_set_style_text_color(hint, lv_color_hex(0xA8B8C7), 0);
|
lv_obj_set_style_text_color(hint, lv_color_hex(0xA8B8C7), 0);
|
||||||
@ -4692,6 +5283,161 @@ static void drawSettingsMenu() {
|
|||||||
makeVersionTag();
|
makeVersionTag();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void drawPairingRequestsScreen() {
|
||||||
|
setRootStyle();
|
||||||
|
makeTitle("REQUESTS:", 18, &lv_font_montserrat_24);
|
||||||
|
lv_obj_t *titleLabel = lv_obj_get_child(gRoot, lv_obj_get_child_cnt(gRoot) - 1);
|
||||||
|
if (titleLabel) {
|
||||||
|
lv_obj_set_x(titleLabel, 194);
|
||||||
|
}
|
||||||
|
makeButton("REFRESH", 20, 18, 160, 44, 0x2A6F97, ACTION_PAIRING_REFRESH, &lv_font_montserrat_18);
|
||||||
|
|
||||||
|
lv_obj_t *countLabel = lv_label_create(gRoot);
|
||||||
|
String countText = String((int)gPairingRequests.size());
|
||||||
|
lv_label_set_text(countLabel, countText.c_str());
|
||||||
|
lv_obj_set_style_text_font(countLabel, &lv_font_montserrat_36, 0);
|
||||||
|
lv_obj_set_style_text_color(countLabel, lv_color_hex(0xD5DEE7), 0);
|
||||||
|
lv_obj_align(countLabel, LV_ALIGN_TOP_RIGHT, -22, 10);
|
||||||
|
|
||||||
|
if (!gPairingStatusMessage.isEmpty()) {
|
||||||
|
showMessageAt(gPairingStatusMessage, 72);
|
||||||
|
}
|
||||||
|
|
||||||
|
lv_obj_t *panel = lv_obj_create(gRoot);
|
||||||
|
lv_obj_set_size(panel, 440, 324);
|
||||||
|
lv_obj_set_pos(panel, 20, gPairingStatusMessage.isEmpty() ? 84 : 104);
|
||||||
|
lv_obj_set_style_bg_color(panel, lv_color_hex(0x0F1B27), 0);
|
||||||
|
lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0);
|
||||||
|
lv_obj_set_style_border_width(panel, 1, 0);
|
||||||
|
lv_obj_set_style_border_color(panel, lv_color_hex(0x425466), 0);
|
||||||
|
lv_obj_set_style_radius(panel, 14, 0);
|
||||||
|
lv_obj_set_style_pad_all(panel, 10, 0);
|
||||||
|
lv_obj_set_style_pad_row(panel, 10, 0);
|
||||||
|
lv_obj_set_scroll_dir(panel, LV_DIR_VER);
|
||||||
|
lv_obj_set_scrollbar_mode(panel, LV_SCROLLBAR_MODE_ACTIVE);
|
||||||
|
lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN);
|
||||||
|
lv_obj_set_flex_align(panel, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
|
||||||
|
|
||||||
|
if (gPairingRequests.empty()) {
|
||||||
|
lv_obj_t *empty = lv_label_create(panel);
|
||||||
|
lv_label_set_text(empty, "No active requests");
|
||||||
|
lv_obj_set_width(empty, 392);
|
||||||
|
lv_label_set_long_mode(empty, LV_LABEL_LONG_WRAP);
|
||||||
|
lv_obj_set_style_text_font(empty, &lv_font_montserrat_20, 0);
|
||||||
|
lv_obj_set_style_text_color(empty, lv_color_hex(0xA8B8C7), 0);
|
||||||
|
} else {
|
||||||
|
for (int i = 0; i < (int)gPairingRequests.size(); ++i) {
|
||||||
|
const PairingRequestUiItem &item = gPairingRequests[i];
|
||||||
|
String sessionNameText = String("Session: ") + pairingSessionNameLabel(item);
|
||||||
|
String sessionKindText = String("Kind: ") + pairingSessionKindLabel(item.requesterSessionType);
|
||||||
|
lv_obj_t *btn = lv_btn_create(panel);
|
||||||
|
lv_obj_set_size(btn, 410, 146);
|
||||||
|
lv_obj_set_style_radius(btn, 14, 0);
|
||||||
|
lv_obj_set_style_bg_color(btn, lv_color_hex(0x203547), 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(0x4D6C82), 0);
|
||||||
|
lv_obj_add_event_cb(btn, pairingSelectCb, LV_EVENT_CLICKED, reinterpret_cast<void *>(static_cast<uintptr_t>(i)));
|
||||||
|
|
||||||
|
lv_obj_t *codeLabel = lv_label_create(btn);
|
||||||
|
lv_label_set_text(codeLabel, formatPairingShortCode(item.shortCode).c_str());
|
||||||
|
lv_obj_set_style_text_font(codeLabel, &lv_font_montserrat_24, 0);
|
||||||
|
lv_obj_set_style_text_color(codeLabel, lv_color_hex(0xFFFFFF), 0);
|
||||||
|
lv_obj_set_pos(codeLabel, 18, 12);
|
||||||
|
|
||||||
|
lv_obj_t *nameLabel = lv_label_create(btn);
|
||||||
|
lv_label_set_text(nameLabel, sessionNameText.c_str());
|
||||||
|
lv_obj_set_width(nameLabel, 374);
|
||||||
|
lv_label_set_long_mode(nameLabel, LV_LABEL_LONG_WRAP);
|
||||||
|
lv_obj_set_style_text_font(nameLabel, &lv_font_montserrat_18, 0);
|
||||||
|
lv_obj_set_style_text_color(nameLabel, lv_color_hex(0xD5DEE7), 0);
|
||||||
|
lv_obj_set_pos(nameLabel, 18, 56);
|
||||||
|
|
||||||
|
lv_obj_t *kindLabel = lv_label_create(btn);
|
||||||
|
lv_label_set_text(kindLabel, sessionKindText.c_str());
|
||||||
|
lv_obj_set_width(kindLabel, 374);
|
||||||
|
lv_label_set_long_mode(kindLabel, LV_LABEL_LONG_WRAP);
|
||||||
|
lv_obj_set_style_text_font(kindLabel, &lv_font_montserrat_18, 0);
|
||||||
|
lv_obj_set_style_text_color(kindLabel, lv_color_hex(0xA8D6A2), 0);
|
||||||
|
lv_obj_set_pos(kindLabel, 18, 88);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
makeVersionTag();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void drawPairingRequestDetailScreen() {
|
||||||
|
setRootStyle();
|
||||||
|
makeTitle("REQUEST DETAIL", 18, &lv_font_montserrat_24);
|
||||||
|
|
||||||
|
if (gSelectedPairingRequestIndex < 0 || gSelectedPairingRequestIndex >= (int)gPairingRequests.size()) {
|
||||||
|
showMessageAt("Request not selected", 88);
|
||||||
|
makeButton("BACK", 140, 360, 200, 72, 0x5A6570, ACTION_OPEN_PAIRING_REQUESTS, &lv_font_montserrat_22);
|
||||||
|
makeVersionTag();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PairingRequestUiItem &item = gPairingRequests[gSelectedPairingRequestIndex];
|
||||||
|
String question = String("Connect session ") + pairingSessionNameLabel(item) + "?";
|
||||||
|
String explain = item.requesterSessionType == 50
|
||||||
|
? "Wallet session. No keys will be transferred."
|
||||||
|
: "Client session. Only device key will be transferred. No additional keys will be sent.";
|
||||||
|
String sessionNameText = String("Session: ") + pairingSessionNameLabel(item);
|
||||||
|
String sessionKindText = String("Kind: ") + pairingSessionKindLabel(item.requesterSessionType);
|
||||||
|
|
||||||
|
lv_obj_t *panel = lv_obj_create(gRoot);
|
||||||
|
lv_obj_set_size(panel, 440, 248);
|
||||||
|
lv_obj_set_pos(panel, 20, 86);
|
||||||
|
lv_obj_set_style_bg_color(panel, lv_color_hex(0x0F1B27), 0);
|
||||||
|
lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0);
|
||||||
|
lv_obj_set_style_border_width(panel, 1, 0);
|
||||||
|
lv_obj_set_style_border_color(panel, lv_color_hex(0x425466), 0);
|
||||||
|
lv_obj_set_style_radius(panel, 14, 0);
|
||||||
|
lv_obj_set_style_pad_all(panel, 16, 0);
|
||||||
|
lv_obj_clear_flag(panel, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
lv_obj_t *qLabel = lv_label_create(panel);
|
||||||
|
lv_label_set_text(qLabel, question.c_str());
|
||||||
|
lv_obj_set_width(qLabel, 404);
|
||||||
|
lv_label_set_long_mode(qLabel, LV_LABEL_LONG_WRAP);
|
||||||
|
lv_obj_set_style_text_font(qLabel, &lv_font_montserrat_22, 0);
|
||||||
|
lv_obj_set_style_text_color(qLabel, lv_color_hex(0xFFFFFF), 0);
|
||||||
|
lv_obj_set_pos(qLabel, 0, 0);
|
||||||
|
|
||||||
|
lv_obj_t *codeLabel = lv_label_create(panel);
|
||||||
|
lv_label_set_text(codeLabel, formatPairingShortCode(item.shortCode).c_str());
|
||||||
|
lv_obj_set_style_text_font(codeLabel, &lv_font_montserrat_24, 0);
|
||||||
|
lv_obj_set_style_text_color(codeLabel, lv_color_hex(0xD5DEE7), 0);
|
||||||
|
lv_obj_set_pos(codeLabel, 0, 48);
|
||||||
|
|
||||||
|
lv_obj_t *nameLabel = lv_label_create(panel);
|
||||||
|
lv_label_set_text(nameLabel, sessionNameText.c_str());
|
||||||
|
lv_obj_set_width(nameLabel, 404);
|
||||||
|
lv_label_set_long_mode(nameLabel, LV_LABEL_LONG_WRAP);
|
||||||
|
lv_obj_set_style_text_font(nameLabel, &lv_font_montserrat_18, 0);
|
||||||
|
lv_obj_set_style_text_color(nameLabel, lv_color_hex(0xD5DEE7), 0);
|
||||||
|
lv_obj_set_pos(nameLabel, 0, 92);
|
||||||
|
|
||||||
|
lv_obj_t *kindLabel = lv_label_create(panel);
|
||||||
|
lv_label_set_text(kindLabel, sessionKindText.c_str());
|
||||||
|
lv_obj_set_width(kindLabel, 404);
|
||||||
|
lv_label_set_long_mode(kindLabel, LV_LABEL_LONG_WRAP);
|
||||||
|
lv_obj_set_style_text_font(kindLabel, &lv_font_montserrat_18, 0);
|
||||||
|
lv_obj_set_style_text_color(kindLabel, lv_color_hex(0xA8D6A2), 0);
|
||||||
|
lv_obj_set_pos(kindLabel, 0, 124);
|
||||||
|
|
||||||
|
lv_obj_t *explainLabel = lv_label_create(panel);
|
||||||
|
lv_label_set_text(explainLabel, explain.c_str());
|
||||||
|
lv_obj_set_width(explainLabel, 404);
|
||||||
|
lv_label_set_long_mode(explainLabel, LV_LABEL_LONG_WRAP);
|
||||||
|
lv_obj_set_style_text_font(explainLabel, &lv_font_montserrat_18, 0);
|
||||||
|
lv_obj_set_style_text_color(explainLabel, lv_color_hex(0xB8C6D3), 0);
|
||||||
|
lv_obj_set_pos(explainLabel, 0, 164);
|
||||||
|
|
||||||
|
makeButton("YES", 40, 364, 180, 76, 0x2A9D8F, ACTION_PAIRING_APPROVE, &lv_font_montserrat_22);
|
||||||
|
makeButton("NO", 260, 364, 180, 76, 0x7D3A3A, ACTION_PAIRING_REJECT, &lv_font_montserrat_22);
|
||||||
|
makeVersionTag();
|
||||||
|
}
|
||||||
|
|
||||||
static lv_obj_t *makeNetworkButton(const char *text, int index, lv_coord_t y) {
|
static lv_obj_t *makeNetworkButton(const char *text, int index, lv_coord_t y) {
|
||||||
lv_obj_t *btn = lv_btn_create(gRoot);
|
lv_obj_t *btn = lv_btn_create(gRoot);
|
||||||
lv_obj_set_size(btn, 436, 52);
|
lv_obj_set_size(btn, 436, 52);
|
||||||
@ -5134,6 +5880,12 @@ static void rebuildScreen() {
|
|||||||
case SCREEN_SETTINGS_MENU:
|
case SCREEN_SETTINGS_MENU:
|
||||||
drawSettingsMenu();
|
drawSettingsMenu();
|
||||||
break;
|
break;
|
||||||
|
case SCREEN_PAIRING_REQUESTS:
|
||||||
|
drawPairingRequestsScreen();
|
||||||
|
break;
|
||||||
|
case SCREEN_PAIRING_REQUEST_DETAIL:
|
||||||
|
drawPairingRequestDetailScreen();
|
||||||
|
break;
|
||||||
case SCREEN_WIFI:
|
case SCREEN_WIFI:
|
||||||
drawWifiScreen();
|
drawWifiScreen();
|
||||||
break;
|
break;
|
||||||
@ -5198,8 +5950,8 @@ static void handleSettingsSwipe(SwipeDirection swipe) {
|
|||||||
|
|
||||||
if (swipe == SWIPE_UP) {
|
if (swipe == SWIPE_UP) {
|
||||||
gSettingsScrollIndex++;
|
gSettingsScrollIndex++;
|
||||||
if (gSettingsScrollIndex > static_cast<int>(kMenuCount) - 2) {
|
if (gSettingsScrollIndex > settingsMenuCount() - 2) {
|
||||||
gSettingsScrollIndex = static_cast<int>(kMenuCount) - 2;
|
gSettingsScrollIndex = settingsMenuCount() - 2;
|
||||||
}
|
}
|
||||||
rebuildScreen();
|
rebuildScreen();
|
||||||
return;
|
return;
|
||||||
@ -5221,6 +5973,18 @@ static void handleWifiSwipe(SwipeDirection swipe) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void handlePairingRequestsSwipe(SwipeDirection swipe) {
|
||||||
|
if (swipe == SWIPE_RIGHT) {
|
||||||
|
showScreen(SCREEN_SETTINGS_MENU);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handlePairingRequestDetailSwipe(SwipeDirection swipe) {
|
||||||
|
if (swipe == SWIPE_RIGHT) {
|
||||||
|
showScreen(SCREEN_PAIRING_REQUESTS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static void handleServerSwipe(SwipeDirection swipe) {
|
static void handleServerSwipe(SwipeDirection swipe) {
|
||||||
if (swipe == SWIPE_RIGHT) {
|
if (swipe == SWIPE_RIGHT) {
|
||||||
showScreen(SCREEN_SETTINGS_MENU);
|
showScreen(SCREEN_SETTINGS_MENU);
|
||||||
@ -5281,6 +6045,12 @@ static void handleSwipe(SwipeDirection swipe) {
|
|||||||
case SCREEN_SETTINGS_MENU:
|
case SCREEN_SETTINGS_MENU:
|
||||||
handleSettingsSwipe(swipe);
|
handleSettingsSwipe(swipe);
|
||||||
break;
|
break;
|
||||||
|
case SCREEN_PAIRING_REQUESTS:
|
||||||
|
handlePairingRequestsSwipe(swipe);
|
||||||
|
break;
|
||||||
|
case SCREEN_PAIRING_REQUEST_DETAIL:
|
||||||
|
handlePairingRequestDetailSwipe(swipe);
|
||||||
|
break;
|
||||||
case SCREEN_WIFI:
|
case SCREEN_WIFI:
|
||||||
handleWifiSwipe(swipe);
|
handleWifiSwipe(swipe);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@ -26,7 +26,7 @@
|
|||||||
- реальное чтение баланса кошелька из `Solana RPC`;
|
- реальное чтение баланса кошелька из `Solana RPC`;
|
||||||
- проверка обязательных условий перед регистрацией;
|
- проверка обязательных условий перед регистрацией;
|
||||||
- живая on-chain регистрация серверного `user_pda` в `shine_users` по нажатию кнопки на главном экране;
|
- живая on-chain регистрация серверного `user_pda` в `shine_users` по нажатию кнопки на главном экране;
|
||||||
- прототип входящих запросов с подтверждением и отклонением;
|
- живой экран заявок на подключение новых устройств через доверенную homeserver-сессию;
|
||||||
- PIN-блокировка (в текущей временной сборке вход по PIN отключён, устройство открывает `HOME` сразу после старта);
|
- PIN-блокировка (в текущей временной сборке вход по PIN отключён, устройство открывает `HOME` сразу после старта);
|
||||||
- базовые настройки, статус и главный экран;
|
- базовые настройки, статус и главный экран;
|
||||||
- сохранение `PDA` и `tx signature` после успешной регистрации.
|
- сохранение `PDA` и `tx signature` после успешной регистрации.
|
||||||
@ -34,8 +34,8 @@
|
|||||||
|
|
||||||
Что пока считается именно прототипом, а не финальной интеграцией:
|
Что пока считается именно прототипом, а не финальной интеграцией:
|
||||||
|
|
||||||
- приём реальных входящих запросов на вход/подпись пока не подключён к живой сети;
|
- пока реализован только сценарий заявок на подключение устройств через доверенную сессию;
|
||||||
- входящие запросы пока демонстрационные, чтобы можно было проверить UX и логику подтверждения.
|
- другие типы входящих запросов на подпись и произвольные approval-flow на этом устройстве ещё не подключены.
|
||||||
|
|
||||||
## Основная идея устройства
|
## Основная идея устройства
|
||||||
|
|
||||||
@ -46,7 +46,7 @@
|
|||||||
- показывает адрес кошелька устройства;
|
- показывает адрес кошелька устройства;
|
||||||
- позволяет пополнить баланс перед регистрацией;
|
- позволяет пополнить баланс перед регистрацией;
|
||||||
- после выполнения условий даёт зарегистрировать устройство как homeserver;
|
- после выполнения условий даёт зарегистрировать устройство как homeserver;
|
||||||
- после регистрации может принимать входящие запросы на вход и на подпись.
|
- после авторизации в SHiNE может подтверждать заявки на подключение новых устройств пользователя.
|
||||||
|
|
||||||
`SD`-карта не нужна для постоянного хранения секрета в этом прототипе.
|
`SD`-карта не нужна для постоянного хранения секрета в этом прототипе.
|
||||||
Основное сохранение идёт во внутреннюю flash-память через `NVS`.
|
Основное сохранение идёт во внутреннюю flash-память через `NVS`.
|
||||||
@ -120,6 +120,11 @@
|
|||||||
|
|
||||||
- Верхняя строка всегда показывает краткий статус устройства:
|
- Верхняя строка всегда показывает краткий статус устройства:
|
||||||
`PIN`, `Wi-Fi`, `сервер`, `регистрация`.
|
`PIN`, `Wi-Fi`, `сервер`, `регистрация`.
|
||||||
|
- В правом верхнем углу рядом с батареей:
|
||||||
|
- сначала процент заряда;
|
||||||
|
- затем, если устройство реально заряжается, маленькая иконка молнии;
|
||||||
|
- затем контур батареи;
|
||||||
|
- затем индикатор `Wi-Fi`.
|
||||||
- Основной язык прототипа: русский.
|
- Основной язык прототипа: русский.
|
||||||
- Для вывода текста в текущей временной сборке используется стандартный шрифт `Arduino_GFX`.
|
- Для вывода текста в текущей временной сборке используется стандартный шрифт `Arduino_GFX`.
|
||||||
- Русские строки на экране временно показываются в ASCII-транслитерации.
|
- Русские строки на экране временно показываются в ASCII-транслитерации.
|
||||||
@ -452,60 +457,80 @@
|
|||||||
|
|
||||||
## Экран REQUESTS
|
## Экран REQUESTS
|
||||||
|
|
||||||
Показывает список демонстрационных запросов:
|
Экран доступен только если homeserver уже авторизован в SHiNE.
|
||||||
|
|
||||||
- `Вход в сессию`
|
|
||||||
- `Подпись сообщения`
|
|
||||||
|
|
||||||
Для каждого запроса:
|
|
||||||
|
|
||||||
- тип;
|
|
||||||
- источник;
|
|
||||||
- короткий статус.
|
|
||||||
|
|
||||||
Кнопки:
|
|
||||||
|
|
||||||
- `Открыть запрос 1`
|
|
||||||
- `Открыть запрос 2`
|
|
||||||
- `Назад`
|
|
||||||
|
|
||||||
## Экран REQUEST_DETAIL
|
|
||||||
|
|
||||||
Показывает детали выбранного запроса:
|
|
||||||
|
|
||||||
- тип запроса;
|
|
||||||
- кто запросил;
|
|
||||||
- время;
|
|
||||||
- описание;
|
|
||||||
- отпечаток/идентификатор.
|
|
||||||
|
|
||||||
Кнопки:
|
|
||||||
|
|
||||||
- `Разрешить`
|
|
||||||
- `Отклонить`
|
|
||||||
- `Назад`
|
|
||||||
|
|
||||||
Поведение:
|
|
||||||
|
|
||||||
- после разрешения или отклонения запрос помечается обработанным;
|
|
||||||
- экран списка отражает новый статус.
|
|
||||||
|
|
||||||
## Экран SETTINGS
|
|
||||||
|
|
||||||
Показывает:
|
Показывает:
|
||||||
|
|
||||||
- текущий PIN;
|
- слева сверху кнопку `REFRESH`;
|
||||||
- базовые флаги безопасности;
|
- заголовок `REQUESTS:` немного правее стандартного левого положения;
|
||||||
- технические действия прототипа.
|
- справа сверху только большую цифру числа активных заявок;
|
||||||
|
- ниже прокручиваемый список активных pairing-заявок;
|
||||||
|
- на экране одновременно видны примерно две плитки.
|
||||||
|
|
||||||
|
Каждая плитка показывает:
|
||||||
|
|
||||||
|
- код подключения из `10` цифр в виде `5` пар: `XX XX XX XX XX`;
|
||||||
|
- строку `Session: <platform/name>`;
|
||||||
|
- строку `Kind: Client session` или `Kind: Wallet session`.
|
||||||
|
|
||||||
|
Поведение:
|
||||||
|
|
||||||
|
- список берётся из живой операции `ListTrustedDeviceLoginRequests`;
|
||||||
|
- если заявок нет, экран показывает `No active requests`;
|
||||||
|
- отдельная строка вида `Active requests: N` на этом экране не показывается;
|
||||||
|
- вертикальный скролл позволяет просматривать все активные заявки;
|
||||||
|
- нажатие по плитке открывает `REQUEST_DETAIL`;
|
||||||
|
- свайп вправо возвращает в `SETTINGS`.
|
||||||
|
|
||||||
|
## Экран REQUEST_DETAIL
|
||||||
|
|
||||||
|
Показывает детали выбранной pairing-заявки:
|
||||||
|
|
||||||
|
- вопрос `Connect session ...?`;
|
||||||
|
- код подключения `XX XX XX XX XX`;
|
||||||
|
- строку `Session: <platform/name>`;
|
||||||
|
- строку `Kind: Client session` или `Kind: Wallet session`;
|
||||||
|
- пояснение:
|
||||||
|
- для client session: `Only device key will be transferred. No additional keys will be sent.`
|
||||||
|
- для wallet session: `No keys will be transferred.`
|
||||||
|
|
||||||
Кнопки:
|
Кнопки:
|
||||||
|
|
||||||
- `Сменить PIN`
|
- `YES`
|
||||||
- `Сбросить онлайн`
|
- `NO`
|
||||||
- `Полный сброс`
|
|
||||||
- `Назад`
|
|
||||||
|
|
||||||
`Полный сброс` очищает весь локальный конфиг и возвращает устройство к стартовому состоянию.
|
Поведение:
|
||||||
|
|
||||||
|
- `YES` подтверждает заявку:
|
||||||
|
- для client session устройство передаёт только `device key`;
|
||||||
|
- для wallet session устройство выпускает отдельную `wallet-session` без передачи ключей;
|
||||||
|
- `NO` отклоняет заявку;
|
||||||
|
- после любого решения устройство возвращается в список `REQUESTS` и обновляет его;
|
||||||
|
- свайп вправо возвращает в `REQUESTS`.
|
||||||
|
|
||||||
|
## Экран SETTINGS
|
||||||
|
|
||||||
|
Показывает вертикальное меню крупных пунктов.
|
||||||
|
|
||||||
|
Если homeserver ещё не авторизован в SHiNE:
|
||||||
|
|
||||||
|
- `1. Wi-Fi`
|
||||||
|
- `2. Server`
|
||||||
|
- `3. Account`
|
||||||
|
|
||||||
|
Если homeserver уже авторизован:
|
||||||
|
|
||||||
|
- `1. Device requests`
|
||||||
|
- `2. Wi-Fi`
|
||||||
|
- `3. Server`
|
||||||
|
- `4. Account`
|
||||||
|
|
||||||
|
Поведение:
|
||||||
|
|
||||||
|
- одновременно видны только две карточки;
|
||||||
|
- список листается свайпом вверх/вниз;
|
||||||
|
- свайп вправо возвращает на `HOME`;
|
||||||
|
- пункт `Device requests` должен быть первым и появляется только для авторизованного homeserver.
|
||||||
|
|
||||||
## Экран PIN_EDIT
|
## Экран PIN_EDIT
|
||||||
|
|
||||||
|
|||||||
@ -362,7 +362,7 @@ async function startPairing({ login, usePassword, password }) {
|
|||||||
|
|
||||||
state.pairingId = String(payload?.pairingId || '').trim();
|
state.pairingId = String(payload?.pairingId || '').trim();
|
||||||
state.expiresAtMs = Number(payload?.expiresAtMs || 0);
|
state.expiresAtMs = Number(payload?.expiresAtMs || 0);
|
||||||
state.shortCode = String(payload?.shortCode || '0000000');
|
state.shortCode = String(payload?.shortCode || '');
|
||||||
state.trustedSessionOnline = !!payload?.trustedSessionOnline;
|
state.trustedSessionOnline = !!payload?.trustedSessionOnline;
|
||||||
if (!state.pairingId) {
|
if (!state.pairingId) {
|
||||||
throw new Error('Сервер не вернул pairingId.');
|
throw new Error('Сервер не вернул pairingId.');
|
||||||
@ -375,7 +375,7 @@ async function startPairing({ login, usePassword, password }) {
|
|||||||
setStatus('Wallet-session заявка создана. Ожидаем подтверждение на доверенном устройстве.', 'info');
|
setStatus('Wallet-session заявка создана. Ожидаем подтверждение на доверенном устройстве.', 'info');
|
||||||
return {
|
return {
|
||||||
pairingId: state.pairingId,
|
pairingId: state.pairingId,
|
||||||
shortCode: String(payload?.shortCode || '0000000'),
|
shortCode: String(payload?.shortCode || ''),
|
||||||
expiresAtMs: state.expiresAtMs,
|
expiresAtMs: state.expiresAtMs,
|
||||||
trustedSessionOnline: !!payload?.trustedSessionOnline,
|
trustedSessionOnline: !!payload?.trustedSessionOnline,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -59,6 +59,15 @@ export async function createRequesterPairingMaterial() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizePairingShortCode(value, digits = 10) {
|
||||||
|
return String(value || '').replace(/\D+/g, '').slice(0, digits).padStart(digits, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPairingShortCode(value) {
|
||||||
|
const normalized = normalizePairingShortCode(value, 10);
|
||||||
|
return normalized.match(/.{1,2}/g)?.join(' ') || normalized;
|
||||||
|
}
|
||||||
|
|
||||||
export async function deriveEspPairingPasswordHash(login, password) {
|
export async function deriveEspPairingPasswordHash(login, password) {
|
||||||
const loginLower = String(login || '').trim().toLowerCase();
|
const loginLower = String(login || '').trim().toLowerCase();
|
||||||
const preimage = `${PAIRING_HASH_VERSION}|${loginLower}|${String(password ?? '')}`;
|
const preimage = `${PAIRING_HASH_VERSION}|${loginLower}|${String(password ?? '')}`;
|
||||||
|
|||||||
@ -78,7 +78,7 @@
|
|||||||
|
|
||||||
<div id="pairing-card" class="card hidden">
|
<div id="pairing-card" class="card hidden">
|
||||||
<div class="card-title">Код подключения</div>
|
<div class="card-title">Код подключения</div>
|
||||||
<div id="short-code" class="code">0000000</div>
|
<div id="short-code" class="code">00 00 00 00 00</div>
|
||||||
<p id="pairing-hint" class="muted small">
|
<p id="pairing-hint" class="muted small">
|
||||||
Покажите код на доверенном устройстве в разделе «Подключить по коду».
|
Покажите код на доверенном устройстве в разделе «Подключить по коду».
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { formatPairingShortCode } from './js/lib/device-pairing.js';
|
||||||
|
|
||||||
const els = {
|
const els = {
|
||||||
serverLoginInfo: document.querySelector('#server-login-info'),
|
serverLoginInfo: document.querySelector('#server-login-info'),
|
||||||
serverAddress: document.querySelector('#server-address'),
|
serverAddress: document.querySelector('#server-address'),
|
||||||
@ -159,9 +161,9 @@ function applyState(nextState) {
|
|||||||
const pairing = state?.pairing || {};
|
const pairing = state?.pairing || {};
|
||||||
if (pairing.active) {
|
if (pairing.active) {
|
||||||
els.pairingCard.classList.remove('hidden');
|
els.pairingCard.classList.remove('hidden');
|
||||||
const shortCode = String(pairing.shortCode || els.shortCode.dataset.shortCode || els.shortCode.textContent || '0000000');
|
const shortCode = String(pairing.shortCode || els.shortCode.dataset.shortCode || els.shortCode.textContent || '');
|
||||||
els.shortCode.dataset.shortCode = shortCode;
|
els.shortCode.dataset.shortCode = shortCode;
|
||||||
els.shortCode.textContent = shortCode;
|
els.shortCode.textContent = formatPairingShortCode(shortCode);
|
||||||
els.pairingHint.textContent = pairing.trustedSessionOnline
|
els.pairingHint.textContent = pairing.trustedSessionOnline
|
||||||
? 'Покажите код на доверенном устройстве и подтвердите выпуск wallet-session.'
|
? 'Покажите код на доверенном устройстве и подтвердите выпуск wallet-session.'
|
||||||
: 'Сейчас нет онлайн доверенной сессии. Откройте другое устройство и подтвердите заявку.';
|
: 'Сейчас нет онлайн доверенной сессии. Откройте другое устройство и подтвердите заявку.';
|
||||||
@ -170,7 +172,7 @@ function applyState(nextState) {
|
|||||||
els.startBtn.disabled = true;
|
els.startBtn.disabled = true;
|
||||||
} else {
|
} else {
|
||||||
els.pairingCard.classList.add('hidden');
|
els.pairingCard.classList.add('hidden');
|
||||||
els.shortCode.textContent = '0000000';
|
els.shortCode.textContent = formatPairingShortCode('');
|
||||||
delete els.shortCode.dataset.shortCode;
|
delete els.shortCode.dataset.shortCode;
|
||||||
els.pairingExpire.textContent = '';
|
els.pairingExpire.textContent = '';
|
||||||
els.startBtn.disabled = false;
|
els.startBtn.disabled = false;
|
||||||
|
|||||||
@ -136,8 +136,8 @@ final class EspPairingSupport {
|
|||||||
| ((digest[1] & 0xFFL) << 16)
|
| ((digest[1] & 0xFFL) << 16)
|
||||||
| ((digest[2] & 0xFFL) << 8)
|
| ((digest[2] & 0xFFL) << 8)
|
||||||
| (digest[3] & 0xFFL);
|
| (digest[3] & 0xFFL);
|
||||||
long shortCodeNum = code % 10_000_000L;
|
long shortCodeNum = code % 10_000_000_000L;
|
||||||
String shortCode = String.format(Locale.ROOT, "%07d", shortCodeNum);
|
String shortCode = String.format(Locale.ROOT, "%010d", shortCodeNum);
|
||||||
return new PairingFingerprint(shortCode, toBase58(digest));
|
return new PairingFingerprint(shortCode, toBase58(digest));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.218
|
client.version=1.2.219
|
||||||
server.version=1.2.206
|
server.version=1.2.207
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { state } from '../state.js';
|
|||||||
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
||||||
|
|
||||||
const ITEMS = [
|
const ITEMS = [
|
||||||
{ pageId: 'messages-list', label: 'Личные сообщения', icon: '💬' },
|
{ pageId: 'messages-list', label: 'личные', icon: '💬' },
|
||||||
{ pageId: 'channels-list', label: 'Каналы', icon: '📢' },
|
{ pageId: 'channels-list', label: 'Каналы', icon: '📢' },
|
||||||
{ pageId: 'network-view', label: 'Связи', icon: '🕸' },
|
{ pageId: 'network-view', label: 'Связи', icon: '🕸' },
|
||||||
{ pageId: 'notifications-view', label: 'Уведомления', icon: '🔔' },
|
{ pageId: 'notifications-view', label: 'Уведомления', icon: '🔔' },
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
buildSessionAttachPayload,
|
buildSessionAttachPayload,
|
||||||
deriveEspPairingPasswordHash,
|
deriveEspPairingPasswordHash,
|
||||||
encryptPairingPayloadForRequester,
|
encryptPairingPayloadForRequester,
|
||||||
|
formatPairingShortCode,
|
||||||
} from '../services/device-pairing-service.js';
|
} from '../services/device-pairing-service.js';
|
||||||
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
||||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
@ -29,8 +30,10 @@ function setStatus(statusEl, message, kind = 'info') {
|
|||||||
statusEl.style.display = message ? '' : 'none';
|
statusEl.style.display = message ? '' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCode(value) {
|
const SESSION_TYPE_WALLET = 50;
|
||||||
return String(value || '').replace(/\D+/g, '').slice(0, 7);
|
|
||||||
|
function pairingSessionKindLabel(sessionType) {
|
||||||
|
return Number(sessionType || 0) === SESSION_TYPE_WALLET ? 'Wallet session' : 'Client session';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTransferKeys(savedKeys, { withExtras = false }) {
|
function buildTransferKeys(savedKeys, { withExtras = false }) {
|
||||||
@ -54,19 +57,19 @@ function buildTransferKeys(savedKeys, { withExtras = false }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function requestCardHtml(request) {
|
function requestCardHtml(request) {
|
||||||
const shortCode = String(request?.shortCode || '').trim() || '0000000';
|
const shortCode = formatPairingShortCode(request?.shortCode || '');
|
||||||
const client = String(request?.requesterClientPlatform || 'unknown');
|
const client = String(request?.requesterClientPlatform || 'unknown');
|
||||||
const requesterSessionType = Number(request?.requesterSessionType || 0);
|
const requesterSessionType = Number(request?.requesterSessionType || 0);
|
||||||
const expiresText = request?.expiresAtMs ? formatRelativeTime(request.expiresAtMs) : '—';
|
const expiresText = request?.expiresAtMs ? formatRelativeTime(request.expiresAtMs) : '—';
|
||||||
const sessionOnly = requesterSessionType === 50;
|
const sessionOnly = requesterSessionType === SESSION_TYPE_WALLET;
|
||||||
|
const sessionKind = pairingSessionKindLabel(requesterSessionType);
|
||||||
return `
|
return `
|
||||||
<div class="card stack" data-pairing-id="${String(request?.pairingId || '')}">
|
<div class="card stack" data-pairing-id="${String(request?.pairingId || '')}">
|
||||||
<div class="row" style="align-items:flex-start;">
|
<div class="row" style="align-items:flex-start;">
|
||||||
<div class="stack" style="gap:4px;">
|
<div class="stack" style="gap:4px;">
|
||||||
<div style="font-size:28px; font-weight:700; letter-spacing:0.14em;">${shortCode}</div>
|
<div style="font-size:28px; font-weight:700; letter-spacing:0.14em;">${shortCode}</div>
|
||||||
<span class="meta-muted">Платформа: ${client}</span>
|
<span class="meta-muted">Session name: ${client}</span>
|
||||||
<span class="meta-muted">Тип сессии: ${requesterSessionType || '—'}</span>
|
<span class="meta-muted">Session kind: ${sessionKind}</span>
|
||||||
<span class="meta-muted">Тип payload: ${Number(request?.payloadType || 0)}</span>
|
|
||||||
<span class="meta-muted">Истекает: ${expiresText}</span>
|
<span class="meta-muted">Истекает: ${expiresText}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -198,14 +201,11 @@ export function render({ navigate }) {
|
|||||||
const requestsCard = document.createElement('div');
|
const requestsCard = document.createElement('div');
|
||||||
requestsCard.className = 'card stack';
|
requestsCard.className = 'card stack';
|
||||||
requestsCard.innerHTML = `
|
requestsCard.innerHTML = `
|
||||||
<div class="row" style="align-items:flex-end; gap:10px; flex-wrap:wrap;">
|
<div class="row" style="justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;">
|
||||||
<label class="stack" style="flex:1 1 180px;">
|
<span class="field-label">Активные заявки</span>
|
||||||
<span class="field-label">Код нового устройства</span>
|
|
||||||
<input class="input" id="pairing-code-filter" inputmode="numeric" maxlength="7" placeholder="7 цифр" />
|
|
||||||
</label>
|
|
||||||
<button class="ghost-btn" type="button" id="refresh-pairing-requests">Обновить заявки</button>
|
<button class="ghost-btn" type="button" id="refresh-pairing-requests">Обновить заявки</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="meta-muted">Если код не введён, показываются все активные заявки. Для wallet-заявки можно выпустить отдельную session-only сессию без передачи постоянных ключей.</p>
|
<p class="meta-muted">Показываются все активные заявки. Для wallet-заявки можно выпустить отдельную session-only сессию без передачи постоянных ключей.</p>
|
||||||
<div class="stack" id="pairing-requests-list"></div>
|
<div class="stack" id="pairing-requests-list"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@ -213,7 +213,6 @@ export function render({ navigate }) {
|
|||||||
status.className = 'status-line is-unavailable';
|
status.className = 'status-line is-unavailable';
|
||||||
status.style.display = 'none';
|
status.style.display = 'none';
|
||||||
const keySummaryEl = keySummaryCard.querySelector('#pairing-key-summary');
|
const keySummaryEl = keySummaryCard.querySelector('#pairing-key-summary');
|
||||||
const codeFilterInput = requestsCard.querySelector('#pairing-code-filter');
|
|
||||||
const refreshBtn = requestsCard.querySelector('#refresh-pairing-requests');
|
const refreshBtn = requestsCard.querySelector('#refresh-pairing-requests');
|
||||||
const requestsListEl = requestsCard.querySelector('#pairing-requests-list');
|
const requestsListEl = requestsCard.querySelector('#pairing-requests-list');
|
||||||
|
|
||||||
@ -413,23 +412,17 @@ export function render({ navigate }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderRequests = () => {
|
const renderRequests = () => {
|
||||||
const filterCode = normalizeCode(codeFilterInput.value);
|
|
||||||
const filtered = filterCode
|
|
||||||
? requests.filter((item) => normalizeCode(item?.shortCode) === filterCode)
|
|
||||||
: requests;
|
|
||||||
requestsListEl.innerHTML = '';
|
requestsListEl.innerHTML = '';
|
||||||
|
|
||||||
if (!filtered.length) {
|
if (!requests.length) {
|
||||||
const empty = document.createElement('p');
|
const empty = document.createElement('p');
|
||||||
empty.className = 'meta-muted';
|
empty.className = 'meta-muted';
|
||||||
empty.textContent = filterCode
|
empty.textContent = 'Активных заявок сейчас нет.';
|
||||||
? 'Заявка с таким кодом пока не найдена.'
|
|
||||||
: 'Активных заявок сейчас нет.';
|
|
||||||
requestsListEl.append(empty);
|
requestsListEl.append(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
filtered.forEach((request) => {
|
requests.forEach((request) => {
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.innerHTML = requestCardHtml(request);
|
wrapper.innerHTML = requestCardHtml(request);
|
||||||
requestsListEl.append(wrapper.firstElementChild);
|
requestsListEl.append(wrapper.firstElementChild);
|
||||||
@ -470,12 +463,12 @@ export function render({ navigate }) {
|
|||||||
const approveRequest = async (request, mode) => {
|
const approveRequest = async (request, mode) => {
|
||||||
const withExtras = mode === 'with-extras';
|
const withExtras = mode === 'with-extras';
|
||||||
let payload;
|
let payload;
|
||||||
if (!withExtras && Number(request?.requesterSessionType || 0) === 50) {
|
if (!withExtras && Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET) {
|
||||||
const delegatedSession = await authService.createDelegatedSessionWithDeviceKey({
|
const delegatedSession = await authService.createDelegatedSessionWithDeviceKey({
|
||||||
login: state.session.login,
|
login: state.session.login,
|
||||||
devicePrivPkcs8: String(savedKeys?.deviceKey || '').trim(),
|
devicePrivPkcs8: String(savedKeys?.deviceKey || '').trim(),
|
||||||
sessionKey: String(request?.requesterSessionKey || '').trim(),
|
sessionKey: String(request?.requesterSessionKey || '').trim(),
|
||||||
sessionType: Number(request?.requesterSessionType || 50) || 50,
|
sessionType: Number(request?.requesterSessionType || SESSION_TYPE_WALLET) || SESSION_TYPE_WALLET,
|
||||||
clientPlatform: String(request?.requesterClientPlatform || '').trim() || 'Wallet plugin',
|
clientPlatform: String(request?.requesterClientPlatform || '').trim() || 'Wallet plugin',
|
||||||
clientInfo: 'Wallet session approved via device pairing',
|
clientInfo: 'Wallet session approved via device pairing',
|
||||||
});
|
});
|
||||||
@ -493,7 +486,7 @@ export function render({ navigate }) {
|
|||||||
}
|
}
|
||||||
const encryptedPayload = await encryptPairingPayloadForRequester(request?.requesterSessionKey, payload);
|
const encryptedPayload = await encryptPairingPayloadForRequester(request?.requesterSessionKey, payload);
|
||||||
await runPairingOpWithSessionRestore(() => authService.approveTrustedDeviceLogin(request?.pairingId, encryptedPayload));
|
await runPairingOpWithSessionRestore(() => authService.approveTrustedDeviceLogin(request?.pairingId, encryptedPayload));
|
||||||
const sessionOnly = !withExtras && Number(request?.requesterSessionType || 0) === 50;
|
const sessionOnly = !withExtras && Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET;
|
||||||
showToast(
|
showToast(
|
||||||
withExtras
|
withExtras
|
||||||
? 'Ключи переданы на новое устройство'
|
? 'Ключи переданы на новое устройство'
|
||||||
@ -600,10 +593,6 @@ export function render({ navigate }) {
|
|||||||
refreshBtn.addEventListener('click', () => {
|
refreshBtn.addEventListener('click', () => {
|
||||||
void reloadRequests();
|
void reloadRequests();
|
||||||
});
|
});
|
||||||
codeFilterInput.addEventListener('input', () => {
|
|
||||||
codeFilterInput.value = normalizeCode(codeFilterInput.value);
|
|
||||||
renderRequests();
|
|
||||||
});
|
|
||||||
|
|
||||||
requestsListEl.addEventListener('click', async (event) => {
|
requestsListEl.addEventListener('click', async (event) => {
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
|
|||||||
@ -12,7 +12,12 @@ import {
|
|||||||
terminateCurrentSession,
|
terminateCurrentSession,
|
||||||
} from '../state.js';
|
} from '../state.js';
|
||||||
import { showToast } from '../services/channels-ux.js';
|
import { showToast } from '../services/channels-ux.js';
|
||||||
import { decryptPairingPayloadFromEnvelope, deriveEspPairingPasswordHash, createRequesterPairingMaterial } from '../services/device-pairing-service.js';
|
import {
|
||||||
|
decryptPairingPayloadFromEnvelope,
|
||||||
|
deriveEspPairingPasswordHash,
|
||||||
|
createRequesterPairingMaterial,
|
||||||
|
formatPairingShortCode,
|
||||||
|
} from '../services/device-pairing-service.js';
|
||||||
import { clearClientAuthData, saveEncryptedUserSecrets } from '../services/key-vault.js';
|
import { clearClientAuthData, saveEncryptedUserSecrets } from '../services/key-vault.js';
|
||||||
import { clearStoredMessages } from '../services/message-store.js';
|
import { clearStoredMessages } from '../services/message-store.js';
|
||||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
@ -30,7 +35,7 @@ function codeCardHtml() {
|
|||||||
return `
|
return `
|
||||||
<div class="card stack">
|
<div class="card stack">
|
||||||
<p class="field-label">Код подключения</p>
|
<p class="field-label">Код подключения</p>
|
||||||
<div id="pairing-short-code" style="font-size:34px; font-weight:700; letter-spacing:0.18em;">0000000</div>
|
<div id="pairing-short-code" style="font-size:34px; font-weight:700; letter-spacing:0.12em;">00 00 00 00 00</div>
|
||||||
<p class="meta-muted" id="pairing-status-hint">Покажите этот код на уже подключённом устройстве в разделе «Подключить по коду».</p>
|
<p class="meta-muted" id="pairing-status-hint">Покажите этот код на уже подключённом устройстве в разделе «Подключить по коду».</p>
|
||||||
<p class="meta-muted" id="pairing-online-hint"></p>
|
<p class="meta-muted" id="pairing-online-hint"></p>
|
||||||
<p class="meta-muted" id="pairing-expire-hint"></p>
|
<p class="meta-muted" id="pairing-expire-hint"></p>
|
||||||
@ -47,7 +52,7 @@ function formatRemaining(ms) {
|
|||||||
|
|
||||||
function resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl) {
|
function resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl) {
|
||||||
resultWrap.style.display = 'none';
|
resultWrap.style.display = 'none';
|
||||||
shortCodeEl.textContent = '0000000';
|
shortCodeEl.textContent = formatPairingShortCode('');
|
||||||
statusHintEl.textContent = 'Покажите этот код на уже подключённом устройстве в разделе «Подключить по коду».';
|
statusHintEl.textContent = 'Покажите этот код на уже подключённом устройстве в разделе «Подключить по коду».';
|
||||||
onlineHintEl.textContent = '';
|
onlineHintEl.textContent = '';
|
||||||
expireHintEl.textContent = '';
|
expireHintEl.textContent = '';
|
||||||
@ -327,7 +332,7 @@ export function render({ navigate }) {
|
|||||||
throw new Error('Сервер не вернул pairingId.');
|
throw new Error('Сервер не вернул pairingId.');
|
||||||
}
|
}
|
||||||
|
|
||||||
shortCodeEl.textContent = String(payload?.shortCode || '0000000');
|
shortCodeEl.textContent = formatPairingShortCode(payload?.shortCode || '');
|
||||||
statusHintEl.textContent = 'Откройте на доверенном устройстве: Настройки -> Устройства -> Подключить устройство -> Подключить по коду.';
|
statusHintEl.textContent = 'Откройте на доверенном устройстве: Настройки -> Устройства -> Подключить устройство -> Подключить по коду.';
|
||||||
onlineHintEl.textContent = payload?.trustedSessionOnline
|
onlineHintEl.textContent = payload?.trustedSessionOnline
|
||||||
? 'Сейчас есть хотя бы одна онлайн доверенная сессия, которая может принять заявку.'
|
? 'Сейчас есть хотя бы одна онлайн доверенная сессия, которая может принять заявку.'
|
||||||
|
|||||||
@ -362,6 +362,7 @@ function startTone(nextToneName) {
|
|||||||
function getCallStateSnapshot() {
|
function getCallStateSnapshot() {
|
||||||
const call = getActiveCall();
|
const call = getActiveCall();
|
||||||
if (!call) return null;
|
if (!call) return null;
|
||||||
|
if (call.localUiDismissed) return null;
|
||||||
const callPhase = String(call.phase || '').trim();
|
const callPhase = String(call.phase || '').trim();
|
||||||
return {
|
return {
|
||||||
callId: call.callId,
|
callId: call.callId,
|
||||||
@ -386,6 +387,12 @@ function notifyCallState() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dismissCallUiLocally(call) {
|
||||||
|
if (!call) return;
|
||||||
|
call.localUiDismissed = true;
|
||||||
|
notifyCallState();
|
||||||
|
}
|
||||||
|
|
||||||
function setStatus(call, statusText, phase = '') {
|
function setStatus(call, statusText, phase = '') {
|
||||||
if (!call) return;
|
if (!call) return;
|
||||||
call.statusText = String(statusText || '').trim();
|
call.statusText = String(statusText || '').trim();
|
||||||
@ -1453,10 +1460,18 @@ export async function acceptIncomingCall() {
|
|||||||
export async function declineIncomingCall() {
|
export async function declineIncomingCall() {
|
||||||
const call = getActiveCall();
|
const call = getActiveCall();
|
||||||
if (!call || call.direction !== 'in' || call.phase !== 'incoming') return;
|
if (!call || call.direction !== 'in' || call.phase !== 'incoming') return;
|
||||||
|
dismissCallUiLocally(call);
|
||||||
|
const declinePromise = (async () => {
|
||||||
try {
|
try {
|
||||||
await sendSignal(call, TYPES.DECLINE_BUSY, 'decline');
|
await sendSignal(call, TYPES.DECLINE_BUSY, 'decline');
|
||||||
} catch {}
|
} catch {}
|
||||||
await finalizeCall(call, { localReasonCode: 'declined', debugReason: 'declined_by_user' });
|
})();
|
||||||
|
await finalizeCall(call, {
|
||||||
|
localReasonCode: 'declined',
|
||||||
|
debugReason: 'declined_by_user',
|
||||||
|
suppressRemoteSignal: true,
|
||||||
|
});
|
||||||
|
await declinePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleIncomingCallSignal(evt) {
|
export async function handleIncomingCallSignal(evt) {
|
||||||
@ -1499,7 +1514,27 @@ export async function handleIncomingCallSignal(evt) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
const remoteSessionLocked = Boolean(
|
||||||
|
call.initialOfferSent
|
||||||
|
|| call.connectedAtMs
|
||||||
|
|| call.phase === 'connecting'
|
||||||
|
|| call.phase === 'active'
|
||||||
|
|| call.phase === 'reconnecting',
|
||||||
|
);
|
||||||
|
const terminalSignalFromAnotherSession =
|
||||||
|
Boolean(call.remoteSessionId)
|
||||||
|
&& Boolean(fromSessionId)
|
||||||
|
&& call.remoteSessionId !== fromSessionId
|
||||||
|
&& (type === TYPES.DECLINE_BUSY || type === TYPES.TIMEOUT || type === TYPES.HANGUP);
|
||||||
if (call.remoteSessionId && fromSessionId && call.remoteSessionId !== fromSessionId) {
|
if (call.remoteSessionId && fromSessionId && call.remoteSessionId !== fromSessionId) {
|
||||||
|
if (terminalSignalFromAnotherSession && !remoteSessionLocked) {
|
||||||
|
await emitDebug(
|
||||||
|
call,
|
||||||
|
'info',
|
||||||
|
'terminal_signal_from_non_selected_session_allowed_before_lock',
|
||||||
|
`type=${type}; selected=${call.remoteSessionId}; from=${fromSessionId}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
await emitDebug(
|
await emitDebug(
|
||||||
call,
|
call,
|
||||||
'info',
|
'info',
|
||||||
@ -1508,6 +1543,7 @@ export async function handleIncomingCallSignal(evt) {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (!call.remoteSessionId && fromSessionId) {
|
if (!call.remoteSessionId && fromSessionId) {
|
||||||
call.remoteSessionId = fromSessionId;
|
call.remoteSessionId = fromSessionId;
|
||||||
}
|
}
|
||||||
@ -1632,6 +1668,7 @@ export async function handleIncomingCallSignal(evt) {
|
|||||||
export async function hangupActiveCall() {
|
export async function hangupActiveCall() {
|
||||||
if (!activeCallId) return;
|
if (!activeCallId) return;
|
||||||
const call = getCall(activeCallId);
|
const call = getCall(activeCallId);
|
||||||
|
dismissCallUiLocally(call);
|
||||||
await finalizeCall(call, {
|
await finalizeCall(call, {
|
||||||
localReasonCode: call?.connectedAtMs ? 'completed' : 'no_answer',
|
localReasonCode: call?.connectedAtMs ? 'completed' : 'no_answer',
|
||||||
debugReason: 'hangup_by_user',
|
debugReason: 'hangup_by_user',
|
||||||
|
|||||||
@ -101,6 +101,15 @@ export async function createRequesterPairingMaterial() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizePairingShortCode(value, digits = 10) {
|
||||||
|
return String(value || '').replace(/\D+/g, '').slice(0, digits).padStart(digits, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPairingShortCode(value) {
|
||||||
|
const normalized = normalizePairingShortCode(value, 10);
|
||||||
|
return normalized.match(/.{1,2}/g)?.join(' ') || normalized;
|
||||||
|
}
|
||||||
|
|
||||||
export async function encryptPairingPayloadForRequester(requesterSessionKey, payload) {
|
export async function encryptPairingPayloadForRequester(requesterSessionKey, payload) {
|
||||||
const requesterPublicB64 = extractSessionPublicKeyB64(requesterSessionKey);
|
const requesterPublicB64 = extractSessionPublicKeyB64(requesterSessionKey);
|
||||||
const requesterMontPub = edwardsToMontgomeryPub(base64ToBytes(requesterPublicB64));
|
const requesterMontPub = edwardsToMontgomeryPub(base64ToBytes(requesterPublicB64));
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user