chore: зафиксированы оставшиеся локальные изменения
This commit is contained in:
parent
a332ddc828
commit
c27da63a3e
@ -1,52 +0,0 @@
|
|||||||
# Настройка PWA + FCM для веб-клиента (Chrome/Edge/Firefox/Safari iOS)
|
|
||||||
|
|
||||||
## 1) Что нужно создать в Firebase
|
|
||||||
1. Создать проект Firebase.
|
|
||||||
2. Включить Cloud Messaging.
|
|
||||||
3. Создать Web App и получить конфиг:
|
|
||||||
- apiKey
|
|
||||||
- authDomain
|
|
||||||
- projectId
|
|
||||||
- messagingSenderId
|
|
||||||
- appId
|
|
||||||
4. В Cloud Messaging -> Web Push certificates сгенерировать VAPID key.
|
|
||||||
5. Для серверной отправки взять **Server key** (legacy) или настроить HTTP v1 (service account).
|
|
||||||
|
|
||||||
## 2) Куда вставить токены в клиенте
|
|
||||||
Файл: `shine-UI/index.html` и `shine-UI/firebase-messaging-sw.js`.
|
|
||||||
|
|
||||||
Заполнить:
|
|
||||||
- `window.__SHINE_FIREBASE_CONFIG__`
|
|
||||||
- `window.__SHINE_FIREBASE_VAPID_KEY__`
|
|
||||||
- `FIREBASE_CONFIG` (в service worker)
|
|
||||||
|
|
||||||
## 3) Куда вставить серверный ключ FCM
|
|
||||||
Файл: `src/main/resources/application.properties`
|
|
||||||
|
|
||||||
Добавить:
|
|
||||||
```
|
|
||||||
fcm.server.key=YOUR_FCM_SERVER_KEY
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4) PWA требования
|
|
||||||
1. Открывать сайт только по HTTPS (или localhost).
|
|
||||||
2. Разрешить уведомления в браузере.
|
|
||||||
3. Убедиться, что `manifest.webmanifest` доступен.
|
|
||||||
4. Убедиться, что `firebase-messaging-sw.js` зарегистрирован.
|
|
||||||
|
|
||||||
## 5) Safari / iPhone (iOS)
|
|
||||||
- Нужен iOS 16.4+.
|
|
||||||
- Пользователь должен добавить сайт на Home Screen.
|
|
||||||
- После запуска PWA с Home Screen дать разрешение на уведомления.
|
|
||||||
- Без Home Screen web push в Safari iOS не работает.
|
|
||||||
|
|
||||||
## 6) Проверка
|
|
||||||
1. Логин в приложении.
|
|
||||||
2. Клиент вызывает `UpsertPushToken` и отправляет FCM токен на сервер.
|
|
||||||
3. Вызов `SendDirectMessage` пользователю без активной WS доставки.
|
|
||||||
4. Сервер шлет push через FCM.
|
|
||||||
|
|
||||||
## 7) Поддержка разных браузеров
|
|
||||||
- Chrome/Edge/Opera/Android Browser: FCM web push поддерживается нативно.
|
|
||||||
- Firefox: поддержка web push есть, но тестировать отдельно (поведение токенов отличается).
|
|
||||||
- Safari macOS/iOS: web push есть, но требуется PWA режим и Apple-ограничения.
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
# Уведомления: продуктовые заглушки + правило intake в AGENTS
|
|
||||||
|
|
||||||
- краткое описание фичи:
|
|
||||||
- На вкладке `Уведомления` удалены демонстрационные карточки из разделов `Ответы` и `События`.
|
|
||||||
- В каждом табе добавлена отдельная продуктовая заглушка:
|
|
||||||
- `Ответы`: про ответы и комментарии на сообщения в публичных каналах;
|
|
||||||
- `События`: про подписки, добавления, лайки и прочие действия.
|
|
||||||
- В обоих табах добавлено явное сообщение, что раздел находится в разработке.
|
|
||||||
- В `AGENTS.md` добавлен обязательный блок: при новом задании сначала пересказ, вопросы, при необходимости идеи, оценка фичи и обязательное подтверждение перед началом реализации.
|
|
||||||
|
|
||||||
- что именно проверять:
|
|
||||||
- Открыть `Уведомления` и проверить, что в `Ответы` отображается только заглушка (без примеров карточек).
|
|
||||||
- Переключить на `События` и проверить отдельную заглушку с текстом про события.
|
|
||||||
- Убедиться, что в обоих табах присутствует сообщение о разработке и будущем добавлении функционала.
|
|
||||||
- Проверить наличие нового блока `Коммуникация по новым задачам (обязательно)` в `AGENTS.md`.
|
|
||||||
|
|
||||||
- ожидаемый результат:
|
|
||||||
- Вкладка уведомлений содержит только две заглушки по табам и не показывает тестовые данные.
|
|
||||||
- Правило работы с новыми задачами зафиксировано в `AGENTS.md`.
|
|
||||||
|
|
||||||
- статус:
|
|
||||||
- pending
|
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
## Краткое описание
|
||||||
|
|
||||||
|
На экране `Кошелёк -> Solana кошелёк` добавлен блок создания нового Solana-кошелька:
|
||||||
|
- генерация случайного кошелька;
|
||||||
|
- генерация публичного ключа из введённого приватного ключа Base58 (32 байта).
|
||||||
|
|
||||||
|
Добавлены:
|
||||||
|
- валидация формата Base58;
|
||||||
|
- проверка точной длины приватного ключа (ровно 32 байта после декодирования);
|
||||||
|
- запрет ввода слишком длинного значения (`maxlength=44`);
|
||||||
|
- статус `Подходит` для валидного ввода;
|
||||||
|
- нередактируемое поле публичного ключа с возможностью копирования.
|
||||||
|
|
||||||
|
## Что проверять
|
||||||
|
|
||||||
|
1. Открыть `Кошелёк -> Solana кошелёк`.
|
||||||
|
2. В блоке создания кошелька нажать `Сгенерировать случайный кошелёк`.
|
||||||
|
3. Проверить, что появились:
|
||||||
|
- приватный ключ Base58;
|
||||||
|
- публичный ключ Base58 (в нередактируемом поле).
|
||||||
|
4. Нажать `Копировать приватный` и `Копировать публичный` — убедиться, что значения копируются.
|
||||||
|
5. Ввести невалидный приватный ключ (символы не из Base58) — увидеть ошибку формата.
|
||||||
|
6. Ввести слишком короткий ключ — увидеть сообщение, что значение слишком короткое.
|
||||||
|
7. Ввести валидный Base58-ключ на 32 байта — увидеть статус `Подходит`.
|
||||||
|
8. Нажать `Сгенерировать из приватного ключа` — публичный ключ должен сгенерироваться.
|
||||||
|
9. Проверить, что в поле ввода приватного ключа нельзя вставить/ввести более 44 символов.
|
||||||
|
|
||||||
|
## Ожидаемый результат
|
||||||
|
|
||||||
|
- Оба сценария генерации работают стабильно.
|
||||||
|
- Для невалидного ввода показываются корректные сообщения.
|
||||||
|
- Поле публичного ключа не редактируется, но значение можно скопировать.
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
|
||||||
|
`pending`
|
||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.58
|
client.version=1.2.59
|
||||||
server.version=1.2.52
|
server.version=1.2.53
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { state } from '../state.js';
|
import { state } from '../state.js';
|
||||||
import {
|
import {
|
||||||
|
createRandomSolanaWallet,
|
||||||
|
createSolanaWalletFromPrivateBase58,
|
||||||
formatSol,
|
formatSol,
|
||||||
getBalanceSol,
|
getBalanceSol,
|
||||||
getTopupSiteUrl,
|
getTopupSiteUrl,
|
||||||
@ -17,6 +19,7 @@ import {
|
|||||||
} from '../services/arweave-wallet-service.js';
|
} from '../services/arweave-wallet-service.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'wallet-view', title: 'Кошелёк' };
|
export const pageMeta = { id: 'wallet-view', title: 'Кошелёк' };
|
||||||
|
const SOLANA_PRIVATE_BASE58_MAX_LEN = 44;
|
||||||
|
|
||||||
function nowRu() {
|
function nowRu() {
|
||||||
return new Date().toLocaleString('ru-RU');
|
return new Date().toLocaleString('ru-RU');
|
||||||
@ -165,6 +168,203 @@ export function render({ navigate }) {
|
|||||||
const sendBtn = actions.querySelector('#send-sol');
|
const sendBtn = actions.querySelector('#send-sol');
|
||||||
const topupBtn = actions.querySelector('#topup-sol');
|
const topupBtn = actions.querySelector('#topup-sol');
|
||||||
|
|
||||||
|
const generatedCard = document.createElement('div');
|
||||||
|
generatedCard.className = 'card stack';
|
||||||
|
generatedCard.innerHTML = `
|
||||||
|
<h3 style="margin:0;">Создание нового кошелька Solana</h3>
|
||||||
|
<p class="meta-muted" style="margin:0;">Введите приватный ключ Base58 (32 байта) или сгенерируйте случайный.</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const privateLabel = document.createElement('label');
|
||||||
|
privateLabel.className = 'meta-muted';
|
||||||
|
privateLabel.textContent = 'Приватный ключ (Base58, 32 байта)';
|
||||||
|
privateLabel.setAttribute('for', 'solana-private-base58-input');
|
||||||
|
|
||||||
|
const privateInput = document.createElement('input');
|
||||||
|
privateInput.id = 'solana-private-base58-input';
|
||||||
|
privateInput.type = 'text';
|
||||||
|
privateInput.placeholder = 'Введите приватный ключ Base58';
|
||||||
|
privateInput.maxLength = SOLANA_PRIVATE_BASE58_MAX_LEN;
|
||||||
|
privateInput.autocomplete = 'off';
|
||||||
|
privateInput.spellcheck = false;
|
||||||
|
|
||||||
|
const privateState = document.createElement('p');
|
||||||
|
privateState.className = 'meta-muted';
|
||||||
|
privateState.textContent = 'Ожидается Base58-строка приватного ключа.';
|
||||||
|
|
||||||
|
const generatedPublicLabel = document.createElement('label');
|
||||||
|
generatedPublicLabel.className = 'meta-muted';
|
||||||
|
generatedPublicLabel.textContent = 'Публичный ключ (Base58)';
|
||||||
|
generatedPublicLabel.setAttribute('for', 'solana-generated-public-key');
|
||||||
|
|
||||||
|
const generatedPublicInput = document.createElement('input');
|
||||||
|
generatedPublicInput.id = 'solana-generated-public-key';
|
||||||
|
generatedPublicInput.type = 'text';
|
||||||
|
generatedPublicInput.readOnly = true;
|
||||||
|
generatedPublicInput.placeholder = 'Будет сгенерирован после нажатия кнопки';
|
||||||
|
|
||||||
|
const generatedPrivateLabel = document.createElement('label');
|
||||||
|
generatedPrivateLabel.className = 'meta-muted';
|
||||||
|
generatedPrivateLabel.textContent = 'Сгенерированный приватный ключ (Base58)';
|
||||||
|
generatedPrivateLabel.setAttribute('for', 'solana-generated-private-key');
|
||||||
|
|
||||||
|
const generatedPrivateInput = document.createElement('input');
|
||||||
|
generatedPrivateInput.id = 'solana-generated-private-key';
|
||||||
|
generatedPrivateInput.type = 'text';
|
||||||
|
generatedPrivateInput.readOnly = true;
|
||||||
|
generatedPrivateInput.placeholder = 'Появится после генерации';
|
||||||
|
|
||||||
|
const generationActions = document.createElement('div');
|
||||||
|
generationActions.className = 'row';
|
||||||
|
generationActions.innerHTML = `
|
||||||
|
<button class="primary-btn" id="generate-random-solana" style="width:100%;">Сгенерировать случайный кошелёк</button>
|
||||||
|
<button class="primary-btn" id="generate-from-private-solana" style="width:100%;">Сгенерировать из приватного ключа</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const copyGeneratedActions = document.createElement('div');
|
||||||
|
copyGeneratedActions.className = 'row';
|
||||||
|
copyGeneratedActions.innerHTML = `
|
||||||
|
<button class="text-btn" id="copy-generated-private-solana" style="width:100%;">Копировать приватный</button>
|
||||||
|
<button class="text-btn" id="copy-generated-public-solana" style="width:100%;">Копировать публичный</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
generatedCard.append(
|
||||||
|
privateLabel,
|
||||||
|
privateInput,
|
||||||
|
privateState,
|
||||||
|
generationActions,
|
||||||
|
generatedPrivateLabel,
|
||||||
|
generatedPrivateInput,
|
||||||
|
generatedPublicLabel,
|
||||||
|
generatedPublicInput,
|
||||||
|
copyGeneratedActions,
|
||||||
|
);
|
||||||
|
|
||||||
|
const randomGenerateBtn = generationActions.querySelector('#generate-random-solana');
|
||||||
|
const fromPrivateGenerateBtn = generationActions.querySelector('#generate-from-private-solana');
|
||||||
|
const copyGeneratedPrivateBtn = copyGeneratedActions.querySelector('#copy-generated-private-solana');
|
||||||
|
const copyGeneratedPublicBtn = copyGeneratedActions.querySelector('#copy-generated-public-solana');
|
||||||
|
|
||||||
|
const BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]+$/;
|
||||||
|
const validatePrivateInput = () => {
|
||||||
|
const value = String(privateInput.value || '').trim();
|
||||||
|
if (!value) {
|
||||||
|
privateState.textContent = 'Ожидается Base58-строка приватного ключа.';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!BASE58_RE.test(value)) {
|
||||||
|
privateState.textContent = 'Недопустимый формат: используйте только Base58.';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (value.length > SOLANA_PRIVATE_BASE58_MAX_LEN) {
|
||||||
|
privateState.textContent = 'Слишком длинное значение.';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||||
|
let num = 0n;
|
||||||
|
for (const c of value) {
|
||||||
|
num = num * 58n + BigInt(alphabet.indexOf(c));
|
||||||
|
}
|
||||||
|
let hex = num.toString(16);
|
||||||
|
if (hex.length % 2) hex = `0${hex}`;
|
||||||
|
const decoded = hex ? hex.match(/.{1,2}/g)?.map((h) => parseInt(h, 16)) || [] : [];
|
||||||
|
let leadingZeros = 0;
|
||||||
|
while (leadingZeros < value.length && value[leadingZeros] === '1') leadingZeros += 1;
|
||||||
|
const byteLen = leadingZeros + decoded.length;
|
||||||
|
if (byteLen < 32) {
|
||||||
|
privateState.textContent = 'Слишком короткое значение: нужно 32 байта.';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (byteLen > 32) {
|
||||||
|
privateState.textContent = 'Слишком длинное значение: нужно ровно 32 байта.';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
privateState.textContent = 'Ошибка декодирования Base58.';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
privateState.textContent = 'Подходит';
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
privateInput.addEventListener('input', () => {
|
||||||
|
validatePrivateInput();
|
||||||
|
});
|
||||||
|
|
||||||
|
const setGenerationDisabled = (disabled) => {
|
||||||
|
randomGenerateBtn.disabled = disabled;
|
||||||
|
fromPrivateGenerateBtn.disabled = disabled;
|
||||||
|
copyGeneratedPrivateBtn.disabled = disabled;
|
||||||
|
copyGeneratedPublicBtn.disabled = disabled;
|
||||||
|
};
|
||||||
|
|
||||||
|
randomGenerateBtn.addEventListener('click', async () => {
|
||||||
|
setGenerationDisabled(true);
|
||||||
|
try {
|
||||||
|
const generated = await createRandomSolanaWallet();
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
|
generatedPrivateInput.value = generated.privateKey32Base58;
|
||||||
|
generatedPublicInput.value = generated.address;
|
||||||
|
privateState.textContent = 'Случайный кошелёк создан.';
|
||||||
|
setStatus('Случайный кошелёк Solana успешно сгенерирован.');
|
||||||
|
} catch (error) {
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
|
setStatus(`Ошибка генерации случайного кошелька: ${error?.message || 'unknown'}`);
|
||||||
|
} finally {
|
||||||
|
setGenerationDisabled(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fromPrivateGenerateBtn.addEventListener('click', async () => {
|
||||||
|
if (!validatePrivateInput()) {
|
||||||
|
setStatus('Исправьте приватный ключ перед генерацией.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setGenerationDisabled(true);
|
||||||
|
try {
|
||||||
|
const generated = await createSolanaWalletFromPrivateBase58(privateInput.value);
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
|
generatedPrivateInput.value = generated.privateKey32Base58;
|
||||||
|
generatedPublicInput.value = generated.address;
|
||||||
|
privateState.textContent = 'Подходит';
|
||||||
|
setStatus('Публичный ключ сгенерирован из введённого приватного ключа.');
|
||||||
|
} catch (error) {
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
|
setStatus(`Ошибка генерации из приватного ключа: ${error?.message || 'unknown'}`);
|
||||||
|
} finally {
|
||||||
|
setGenerationDisabled(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
copyGeneratedPrivateBtn.addEventListener('click', async () => {
|
||||||
|
const value = String(generatedPrivateInput.value || '').trim();
|
||||||
|
if (!value) {
|
||||||
|
setStatus('Сначала сгенерируйте приватный ключ.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setStatus('Приватный ключ скопирован.');
|
||||||
|
} catch {
|
||||||
|
setStatus('Не удалось скопировать приватный ключ в этом браузере.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
copyGeneratedPublicBtn.addEventListener('click', async () => {
|
||||||
|
const value = String(generatedPublicInput.value || '').trim();
|
||||||
|
if (!value) {
|
||||||
|
setStatus('Сначала сгенерируйте публичный ключ.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setStatus('Публичный ключ скопирован.');
|
||||||
|
} catch {
|
||||||
|
setStatus('Не удалось скопировать публичный ключ в этом браузере.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const refreshBalance = async () => {
|
const refreshBalance = async () => {
|
||||||
if (!walletAddress) {
|
if (!walletAddress) {
|
||||||
setStatus('Кошелёк не инициализирован.');
|
setStatus('Кошелёк не инициализирован.');
|
||||||
@ -265,7 +465,7 @@ export function render({ navigate }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
content.append(backBtn, card, actions);
|
content.append(backBtn, card, actions, generatedCard);
|
||||||
setStatus('Инициализация wallet.key...');
|
setStatus('Инициализация wallet.key...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ const DEFAULT_SOLANA_ENDPOINT = 'https://api.devnet.solana.com';
|
|||||||
const TOPUP_SITE_URL = 'https://shine-promo-solana-devnet.shineup.me/';
|
const TOPUP_SITE_URL = 'https://shine-promo-solana-devnet.shineup.me/';
|
||||||
|
|
||||||
let solanaLibPromise = null;
|
let solanaLibPromise = null;
|
||||||
|
const BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]+$/;
|
||||||
|
|
||||||
function normalizeEndpoint(url) {
|
function normalizeEndpoint(url) {
|
||||||
const raw = String(url || '').trim();
|
const raw = String(url || '').trim();
|
||||||
@ -37,6 +38,34 @@ export async function deriveWalletFromPassword(password) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createRandomSolanaWallet() {
|
||||||
|
const solana = await loadSolanaLib();
|
||||||
|
const keypair = solana.Keypair.generate();
|
||||||
|
const privateKey32Base58 = solana.bs58.encode(keypair.secretKey.slice(0, 32));
|
||||||
|
return {
|
||||||
|
address: keypair.publicKey.toBase58(),
|
||||||
|
privateKey32Base58,
|
||||||
|
keypair,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSolanaWalletFromPrivateBase58(privateKey32Base58) {
|
||||||
|
const solana = await loadSolanaLib();
|
||||||
|
const clean = String(privateKey32Base58 || '').trim();
|
||||||
|
if (!clean) throw new Error('Введите приватный ключ');
|
||||||
|
if (!BASE58_RE.test(clean)) throw new Error('Разрешены только символы Base58');
|
||||||
|
const privateBytes = solana.bs58.decode(clean);
|
||||||
|
if (privateBytes.length !== 32) {
|
||||||
|
throw new Error('Приватный ключ должен быть ровно 32 байта в Base58');
|
||||||
|
}
|
||||||
|
const keypair = solana.Keypair.fromSeed(privateBytes);
|
||||||
|
return {
|
||||||
|
address: keypair.publicKey.toBase58(),
|
||||||
|
privateKey32Base58: clean,
|
||||||
|
keypair,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function getWalletFromStoredDeviceKey({ login, storagePwd }) {
|
export async function getWalletFromStoredDeviceKey({ login, storagePwd }) {
|
||||||
const cleanLogin = String(login || '').trim();
|
const cleanLogin = String(login || '').trim();
|
||||||
const cleanPwd = String(storagePwd || '').trim();
|
const cleanPwd = String(storagePwd || '').trim();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user