From c3ec590da5d501cd9e40c86e8fa5d9833fe63225 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Thu, 27 Aug 2026 03:27:15 +0300 Subject: [PATCH] =?UTF-8?q?Chestny=20Znak=20scanner:=20DataMatrix=20->=20N?= =?UTF-8?q?ational=20Catalog=20=D0=9A=D0=91=D0=96=D0=A3=20prefill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChZ module (frontend/js/chzn.js): import an anonymous session (exported from the CRPT app with tools/chzn_export.sh) and keep it alive via /authorize/refresh — /authorize/anonymous is Play Integrity-gated so BalDav can't mint its own. GS1 code normalization (reconstruct the GS separator before AI 93), check -> catalogData good_attrs КБЖУ parser (match by attr group + name prefix, since producers name the fields differently), optional nutrition/info vitamins. - chzn_http Rust command: browser UA + returns {status,body} so 204/401/427 drive the session/refresh logic. - Scanner modal (BarcodeDetector camera + photo/manual fallbacks) -> prefill the new-product form; CAMERA permission; settings section to import/refresh session. - tools/chzn_export.sh: pull + validate the anonymous session from a rooted device. --- .gitignore | 3 + PROJECT_MAP.md | 4 +- frontend/index.html | 1 + frontend/js/chzn.js | 278 ++++++++++++++++++ frontend/js/main.js | 197 ++++++++++++- .../android/app/src/main/AndroidManifest.xml | 2 + src-tauri/src/lib.rs | 37 ++- 7 files changed, 511 insertions(+), 11 deletions(-) create mode 100644 frontend/js/chzn.js diff --git a/.gitignore b/.gitignore index 310ebe7..b8ba3fd 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ db_backups/ # Session-local analysis harnesses, copied DBs, pulled 50Hz sensor dumps (large) test_recovery/ + +# Честный знак session export (tokens — never commit) +chzn_session.json diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index c71b5a6..32b8d49 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -25,6 +25,7 @@ | `js/db.js` | ~1550 | SQLite через Tauri IPC. Миграции, CRUD, 41 поле добавок, water_pct, reserves, sync. Хелперы маршрутов (routes CRUD, `updateRoutePoints` — сохранить высоты, `getRoutes.hasEle`). `saveSmoothedTrack` — чанковый `CASE`-апдейт lat/lon_smoothed (18 UPDATE на 4366 точек вместо 4366), `setSessionSmoothed` — откат де-джиттера | | `js/gps.js` | ~1650 | GPS запись, ккал (нетто-модель + MET-нетто floor, накат=базовый), altitude filter + гистерезис высоты, BLE HR polling, dead reckoning. Источник истины БД при живой записи — нативный файл (`_processIncomingPoint(p, fromNative)`: watchPosition только рисует, поллинг пишет). `recoverFromFiles` — восстановление точек с устройства (матч по id/дате + `_s` в имени, дедуп ±2.5с, `recalculateSession(noDelete)`, чистка импортированных файлов). `recomputeAllStats` — пересчёт статы всех треков без изменения точек. `filterByAccuracy` — отбор по точности (minAccuracyM) внутри `computeStats`+`filterNoise`: неточные фиксы исключаются из расчёта/линии (обратимо, non-destructive; null/≥999 keep), bearing_deg. `smoothTrackSG(pts,W,it)` — де-джиттер Савицкого-Голея (квадратичный МНК по окну, итеративно, концы закреплены; lat/lon→метры→обратно, все поля точки сохраняются). `parseRouteGPX(xml)` — парсер маршрутов (`rtept`/`trkpt`/`wpt`, читает ``, DOMParser). `routeElevStats(pts)` — профиль маршрута (накопл. дистанция, грейд к след. точке, набор/сброс с гистерезисом, макс-уклон; высоты сглаживаются). `config.gpxzKey` — ключ GPXZ (localStorage) | | `js/sync.js` | ~300 | Синхронизация между устройствами (LAN, PHP сервер на роутере) | +| `js/chzn.js` | ~330 | Честный знак (CRPT). Скан DataMatrix → карточка Нацкаталога → префилл КБЖУ. `ChZ`: сессия в localStorage (`chzn_session`), `importFromText`, `refresh`/`ensureSid` (sid 48ч, refreshToken ~год), `authedPost/Get` (userId+sid в query, авто-refresh на 401), `check`, `nutritionInfo` (витамины, часто 204), `normalizeCode` (реконструкция GS 0x1D перед AI 93), `parseCard` (группа «Энергетическая ценность…», имена атрибутов разнятся у производителей), `resolve`. `/authorize/anonymous` закрыт Play Integrity — свою сессию не завести, только импорт из приложения (`tools/chzn_export.sh`) | | `js/tilecache.js` | 178 | Оффлайн кэш тайлов карты (IndexedDB) | | `js/bmr.js` | ~70 | BMR (Mifflin-St Jeor), РСП из БД (33 нутриента), полураспад | | `css/styles.css` | ~2100 | Темная тема, мобильный дизайн, HR зоны, ландшафтный режим | @@ -38,7 +39,7 @@ | Файл | Строк | Назначение | |------|-------|------------| -| `src/lib.rs` | ~750 | Миграции v1, IPC команды, seed import, update check, tile import, road map rebuild. `native_root()` — выбор корня приложения по наличию `gps_live_points.jsonl`/`gps_points_*` (на этом Tauri `app_data_dir()` = корень напрямую; старый `.parent().parent()` читал мимо, отсюда потеря фоновых точек) | +| `src/lib.rs` | ~790 | Миграции v1, IPC команды, seed import, update check, tile import, road map rebuild. `native_root()` — выбор корня приложения по наличию `gps_live_points.jsonl`/`gps_points_*` (на этом Tauri `app_data_dir()` = корень напрямую; старый `.parent().parent()` читал мимо, отсюда потеря фоновых точек). `http_request` (GET/POST, тело) и `chzn_http` (браузерный User-Agent + возврат `{status,body}` для 204/401/427, `http_status_as_error(false)`) — прокси HTTP мимо CORS WebView | | `src/ble.rs` | ~470 | BLE HR: btleplug (desktop), file IPC (Android), FitPro + Standard HR | | `src/main.rs` | 5 | Точка входа | | `Cargo.toml` | — | tauri, tauri-plugin-sql, rusqlite, serde, btleplug, tokio, uuid | @@ -71,6 +72,7 @@ | `import_healthdiet.js` | Импорт health-diet.ru JSON в seed DB (авто-бамп версии) | | `fetch_openfoodfacts.js` | OpenFoodFacts API | | `fuzzy_dedup.js` | Нечёткая дедупликация | +| `chzn_export.sh` | Экспорт анонимной сессии приложения «Честный знак» с рут-девайса (adb) → `chzn_session.json` для импорта в BalDav. Читает `com.qrbonus.xml`, проверяет живым `refresh` | ### Корень diff --git a/frontend/index.html b/frontend/index.html index a182061..ed97ed6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -23,6 +23,7 @@ + diff --git a/frontend/js/chzn.js b/frontend/js/chzn.js new file mode 100644 index 0000000..42a6ed2 --- /dev/null +++ b/frontend/js/chzn.js @@ -0,0 +1,278 @@ +// --------------------------------------------------------------------------- +// Честный знак (CRPT) integration. +// +// Scan a product's ЧЗ DataMatrix -> resolve via mobile.api.crpt.ru -> pull КБЖУ +// from the National Catalog card -> prefill the "new product" form. +// +// Auth: the CRPT anonymous-session endpoint (/authorize/anonymous) is gated by +// Google Play Integrity (bound to the ЧЗ app package) so BalDav cannot mint its +// own session. Instead a session is exported from the ЧЗ app on a rooted device +// with tools/chzn_export.sh and imported here as a JSON file. The session's sid +// (48h) is kept alive indefinitely via /authorize/refresh (NOT integrity-gated). +// +// All requests go through the Rust `chzn_http` command (returns {status,body}) +// so there is no CORS and we can see 204/401/427 status codes. +// --------------------------------------------------------------------------- + +let ChZ = (function () { + let LS_KEY = "chzn_session"; + let DEFAULT_BASE = "https://mobile.api.crpt.ru"; + let DEFAULT_EXPIRES = 172800000; // 48h fallback + + function invoke(url, method, body) { + return window.__TAURI__.core.invoke("chzn_http", { url, method, body: body || null }); + } + + // Returns {status:int, body:string} or throws. + async function raw(url, method, body) { + let envelope = await invoke(url, method, body); + let parsed = JSON.parse(envelope); + return parsed; + } + + function getSession() { + try { return JSON.parse(localStorage.getItem(LS_KEY)) || null; } catch { return null; } + } + function setSession(s) { + localStorage.setItem(LS_KEY, JSON.stringify(s)); + } + function hasSession() { + let s = getSession(); + return !!(s && s.userId && s.sid && s.refreshToken && s.instanceId && s.deviceId); + } + function baseUrl() { + let s = getSession(); + return (s && s.baseUrl) || DEFAULT_BASE; + } + + // Import an exported chzn_session.json (text). Returns the stored session. + function importFromText(text) { + let d = JSON.parse(text); + let need = ["userId", "sid", "refreshToken", "instanceId", "deviceId"]; + for (let k of need) { + if (d[k] === undefined || d[k] === null || d[k] === "") { + throw new Error("в файле нет поля " + k); + } + } + let s = { + source: "chestny_znak", + baseUrl: d.baseUrl || DEFAULT_BASE, + userId: d.userId, + sid: d.sid, + refreshToken: d.refreshToken, + instanceId: d.instanceId, + deviceId: String(d.deviceId), + sidObtainedAt: d.sidObtainedAt || Date.now(), + expiresInMs: d.expiresInMs || DEFAULT_EXPIRES, + }; + setSession(s); + return s; + } + + function sidExpired(s) { + let exp = s.expiresInMs || DEFAULT_EXPIRES; + let got = s.sidObtainedAt || 0; + return Date.now() >= got + exp - 120000; // refresh 2 min early + } + + // POST /mobile/authorize/refresh -> new sid. Updates stored session. + async function refresh() { + let s = getSession(); + if (!s) throw new Error("нет сессии Честного знака"); + let body = JSON.stringify({ + instanceId: s.instanceId, + refresh_token: s.refreshToken, + device_id: String(s.deviceId), + client: "MOBILE", + }); + let r = await raw(baseUrl() + "/mobile/authorize/refresh", "POST", body); + if (r.status !== 200) throw new Error("refresh HTTP " + r.status); + let j = JSON.parse(r.body); + if (!j.sid) throw new Error("refresh без sid: " + r.body.slice(0, 120)); + s.sid = j.sid; + if (j.refreshToken) s.refreshToken = j.refreshToken; + s.expiresInMs = j.expiresIn || s.expiresInMs || DEFAULT_EXPIRES; + s.sidObtainedAt = Date.now(); + setSession(s); + return s; + } + + // Ensure a fresh sid before an authed call. + async function ensureSid() { + let s = getSession(); + if (!s) throw new Error("нет сессии Честного знака — импортируйте chzn_session.json"); + if (sidExpired(s)) return await refresh(); + return s; + } + + function looksLikeAuthError(r) { + if (r.status === 401 || r.status === 403) return true; + if (r.status === 400 && /"formErrors"[\s\S]*"(userId|sid)"/.test(r.body || "")) return true; + return false; + } + + // Authenticated POST with userId/sid in the query string; refreshes once on auth error. + async function authedPost(path, body) { + let s = await ensureSid(); + let mk = (sess) => baseUrl() + path + (path.includes("?") ? "&" : "?") + + "userId=" + encodeURIComponent(sess.userId) + "&sid=" + encodeURIComponent(sess.sid); + let r = await raw(mk(s), "POST", body); + if (looksLikeAuthError(r)) { + s = await refresh(); + r = await raw(mk(s), "POST", body); + } + return r; + } + async function authedGet(path) { + let s = await ensureSid(); + let mk = (sess) => baseUrl() + path + (path.includes("?") ? "&" : "?") + + "userId=" + encodeURIComponent(sess.userId) + "&sid=" + encodeURIComponent(sess.sid); + let r = await raw(mk(s), "GET"); + if (looksLikeAuthError(r)) { + s = await refresh(); + r = await raw(mk(s), "GET"); + } + return r; + } + + // POST /mobile/check with a GS1 code string. Returns parsed check JSON. + async function check(code) { + let r = await authedPost("/mobile/check", JSON.stringify({ code })); + if (r.status !== 200) throw new Error("check HTTP " + r.status + ": " + (r.body || "").slice(0, 160)); + return JSON.parse(r.body); + } + + // Optional bonus: GET /mobile/nutrition/info -> vitamins/minerals. Often 204. + async function nutritionInfo(checkId, gtin) { + try { + let r = await authedGet("/mobile/nutrition/info?checkId=" + encodeURIComponent(checkId) + + ">in=" + encodeURIComponent(gtin)); + if (r.status !== 200 || !r.body) return null; + return JSON.parse(r.body); + } catch { return null; } + } + + // ------------------------------------------------------------------ + // GS1 code normalisation. + // A GS1 DataMatrix is 012193. The GS (0x1D) + // separator between the variable-length AI 21 and AI 93 is REQUIRED for + // /mobile/check to resolve. Different decoders present it differently, so: + // - strip a leading symbology id (]d2, ]C1, {FNC1}) and leading GS chars; + // - if the string already contains a GS, keep it; + // - otherwise insert a GS before the last "93". + // ------------------------------------------------------------------ + let GS = String.fromCharCode(0x1d); + function normalizeCode(rawStr) { + let s = String(rawStr || ""); + s = s.replace(/^\]d2/, "").replace(/^\]C1/, "").replace(/^\]Q3/, ""); + s = s.replace(/^\{FNC1\}/i, "").replace(/\{GS\}/gi, GS); + s = s.replace(new RegExp("^" + GS + "+"), ""); + if (s.indexOf(GS) === -1) { + let m = s.match(/^01(\d{14})21(.+)$/); + if (m) { + let tail = m[2]; + let idx = tail.lastIndexOf("93"); + if (idx > 0) { + s = "01" + m[1] + "21" + tail.slice(0, idx) + GS + tail.slice(idx); + } + } + } + return s; + } + function extractGtin(code) { + let m = String(code || "").match(/^01(\d{14})/); + return m ? m[1] : null; + } + + // ------------------------------------------------------------------ + // Parse the National Catalog card (check.catalogData[0].good_attrs) into a + // product prefill object. КБЖУ live in the group "Энергетическая ценность, + // белки, жиры и углеводы" but attr names vary by producer + // ("Белки" vs "Белки, количество", etc.), so match by prefix within the group. + // ------------------------------------------------------------------ + function num(v) { + if (v === undefined || v === null) return null; + let n = parseFloat(String(v).replace(",", ".").replace(/[^\d.\-]/g, "")); + return isFinite(n) ? n : null; + } + function parseCard(chk) { + let cd = (chk.catalogData || [])[0] || {}; + let attrs = cd.good_attrs || []; + let inEnergy = (a) => (a.attr_group_name || "").indexOf("Энергетическая") === 0; + let find = (pred) => attrs.find(pred); + + let kcalAttr = find(a => inEnergy(a) && /Энергетическая ценность/i.test(a.attr_name) && + /ккал/i.test((a.attr_value_type || "") + " " + a.attr_name)); + let proteinAttr = find(a => inEnergy(a) && /^Белки/i.test(a.attr_name)); + let fatAttr = find(a => inEnergy(a) && /^Жиры/i.test(a.attr_name)); + let carbsAttr = find(a => inEnergy(a) && /^Углеводы/i.test(a.attr_name)); + let compAttr = find(a => /^Состав$/i.test(a.attr_name) || (a.attr_group_name === "Состав")); + let weightAttr = find(a => /вес нетто|Заявленный объ[её]м/i.test(a.attr_name)); + + let name = cd.good_name || chk.productName || ""; + let prefill = { + name: name, + kcal_per_100g: kcalAttr ? num(kcalAttr.attr_value) : null, + protein_per_100g: proteinAttr ? num(proteinAttr.attr_value) : null, + fat_per_100g: fatAttr ? num(fatAttr.attr_value) : null, + carbs_per_100g: carbsAttr ? num(carbsAttr.attr_value) : null, + composition: compAttr ? String(compAttr.attr_value).trim() : null, + net_weight_g: weightAttr ? num(weightAttr.attr_value) : null, + gtin: extractGtin(chk.code) || (chk.codeResolveData || {}).gtin || null, + source: "chzn", + micros: {}, + }; + return prefill; + } + + // Map ЧЗ vitamin/mineral names to product table columns (unit matches column). + let MICRO_MAP = { + "Кальций": "calcium_mg", "Фосфор": "phosphorus_mg", "Железо": "iron_mg", + "Магний": "magnesium_mg", "Калий": "potassium_mg", "Натрий": "sodium_mg", + "Цинк": "zinc_mg", "Медь": "copper_mg", "Селен": "selenium_mcg", + "Витамин A": "vit_a_mcg", "Витамин C": "vit_c_mg", "Витамин D": "vit_d_mcg", + "Витамин E": "vit_e_mg", "Витамин B1": "vit_b1_mg", "Витамин B2": "vit_b2_mg", + "Витамин B6": "vit_b6_mg", "Витамин B12": "vit_b12_mcg", "Фолиевая кислота": "folate_mcg", + }; + function mergeMicros(prefill, nut) { + if (!nut || !Array.isArray(nut.nutrition)) return prefill; + for (let blk of nut.nutrition) { + for (let c of (blk.components || [])) { + if (c.key !== "vitaminsAndMinerals") continue; + for (let d of (c.details || [])) { + for (let row of (d.rows || [])) { + let col = MICRO_MAP[row.name]; + let val = row.calculationValue; + if (col && val != null) prefill.micros[col] = val; + } + } + } + } + return prefill; + } + + // Full resolve: GS1 code (raw from scanner) -> prefill object. + async function resolve(rawCode) { + let code = normalizeCode(rawCode); + let chk = await check(code); + if (chk.codeFounded === false) { + let e = new Error("код не найден в Честном знаке"); + e.notFound = true; + throw e; + } + let prefill = parseCard(chk); + let cid = chk.id; + let gtin = prefill.gtin; + if (cid && gtin) { + let nut = await nutritionInfo(cid, gtin); + mergeMicros(prefill, nut); + } + return prefill; + } + + return { + getSession, setSession, hasSession, importFromText, refresh, ensureSid, + check, nutritionInfo, normalizeCode, extractGtin, parseCard, resolve, + _raw: raw, + }; +})(); diff --git a/frontend/js/main.js b/frontend/js/main.js index ebce4e6..ff55596 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -1553,6 +1553,7 @@ async function renderProducts(container) { placeholder="Название, бренд или ингредиент..." /> +
@@ -1724,6 +1725,7 @@ async function renderProducts(container) { // Add custom product button let addCustomBtn = container.querySelector("#add-custom-product"); if (addCustomBtn) addCustomBtn.onclick = () => showAddProductModal(() => doSearch(true)); + container.querySelector("#scan-chzn")?.addEventListener("click", () => showChznScanModal(() => doSearch(true))); // Export all custom data (manual products + recipes + supplements) container.querySelector("#export-all-custom")?.addEventListener("click", async () => { @@ -1768,41 +1770,55 @@ async function renderProducts(container) { }); } -function showAddProductModal(onSaved) { +function showAddProductModal(onSaved, prefill) { + let pf = prefill || {}; + let micros = pf.micros || {}; + let v = (x) => (x === null || x === undefined || x === "") ? "" : x; let microHtml = PRODUCT_MICRO_FIELDS.map(f => { + let raw = micros[f.key]; + if (raw != null && f.mult) raw = Math.round(raw / f.mult * 1000) / 1000; return `
- +
`; }).join(""); + // Честный знак source: header note + composition/weight hint (not stored as columns). + let chznNote = pf.source === "chzn" + ? `
+ Данные из Честного знака${pf.gtin ? " · GTIN " + esc(pf.gtin) : ""}${pf.net_weight_g ? " · нетто " + esc(pf.net_weight_g) + " г" : ""} + ${pf.composition ? `
Состав: ${esc(pf.composition)}
` : ""} +
` + : ""; + showModal(`

Новый продукт

+ ${chznNote}
- +
- +
- +
- +
- +
- +

Нутриенты на 100г (опционально):

${microHtml}
`, @@ -1816,7 +1832,7 @@ function showAddProductModal(onSaved) { fat_per_100g: parseFloat(overlay.querySelector("#np-fat").value) || null, carbs_per_100g: parseFloat(overlay.querySelector("#np-carbs").value) || null, water_pct: parseFloat(overlay.querySelector("#np-water").value) || null, - source: "manual", + source: pf.source === "chzn" ? "chzn" : "manual", }; for (let f of PRODUCT_MICRO_FIELDS) { let val = parseFloat(overlay.querySelector(`#np-${f.key}`).value) || null; @@ -1828,6 +1844,112 @@ function showAddProductModal(onSaved) { }); } +// Честный знак scanner: camera (BarcodeDetector) with photo/manual fallbacks. +// On a decoded DataMatrix -> ChZ.resolve -> prefill the new-product modal. +function showChznScanModal(onSaved) { + if (!ChZ.hasSession()) { + appAlert("Сессия Честного знака не импортирована.\nНастройки → Честный знак → Импорт сессии (файл chzn_session.json из tools/chzn_export.sh)."); + return; + } + let hasBD = ("BarcodeDetector" in window); + let detector = hasBD ? new BarcodeDetector({ formats: ["data_matrix", "qr_code"] }) : null; + + let ov = document.createElement("div"); + ov.className = "modal-overlay"; + ov.innerHTML = ` + `; + document.body.appendChild(ov); + + let stream = null, scanning = false, busy = false; + let base = winStack.length; + let statusEl = ov.querySelector("#cz-status"); + let video = ov.querySelector("#cz-video"); + + function cleanup() { + scanning = false; + if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; } + } + winPush("chzn-scan", () => { cleanup(); ov.remove(); }); + function close() { + let count = winStack.length - base; + for (let i = 0; i < count; i++) winStack.pop().close(); + _skipPops = count; + if (count > 0) history.go(-count); + } + + async function onDecoded(rawVal) { + if (busy) return; + busy = true; scanning = false; + statusEl.textContent = "Код распознан, запрашиваю КБЖУ..."; + try { + let prefill = await ChZ.resolve(rawVal); + cleanup(); + close(); + showAddProductModal(onSaved, prefill); + } catch (e) { + busy = false; + statusEl.textContent = (e.notFound ? "Код не найден в Честном знаке. " : "Ошибка: " + e.message + ". ") + "Наведите ещё раз."; + if (detector && stream) { scanning = true; requestAnimationFrame(loop); } + } + } + + async function loop() { + if (busy || !scanning) return; + try { + let codes = await detector.detect(video); + if (codes && codes.length) { scanning = false; onDecoded(codes[0].rawValue); return; } + } catch (e) { /* transient decode error, keep scanning */ } + requestAnimationFrame(loop); + } + + async function startCamera() { + if (!detector) { statusEl.textContent = "Сканер недоступен в этом WebView. Выберите фото кода или введите вручную."; return; } + try { + stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: "environment" } } }); + video.srcObject = stream; + await video.play(); + scanning = true; + statusEl.textContent = "Наведите на код Честного знака..."; + loop(); + } catch (e) { + statusEl.textContent = "Камера недоступна (" + (e.name || e.message) + "). Выберите фото или введите код."; + } + } + + ov.querySelector("#cz-photo").onclick = () => { + pickFile("image/*", async (buf) => { + if (!detector) { statusEl.textContent = "Сканер недоступен — введите код вручную."; return; } + try { + let bmp = await createImageBitmap(new Blob([buf])); + let codes = await detector.detect(bmp); + if (codes && codes.length) onDecoded(codes[0].rawValue); + else statusEl.textContent = "Код на фото не распознан. Попробуйте более резкое фото."; + } catch (e) { statusEl.textContent = "Ошибка распознавания: " + e.message; } + }, true); + }; + ov.querySelector("#cz-manual").onclick = async () => { + let code = await appPrompt("Содержимое кода DataMatrix (01…21…93…):", ""); + if (code) onDecoded(code.trim()); + }; + ov.querySelector("#cz-close").onclick = () => close(); + + startCamera(); +} + function showEditProductModal(p, onSaved) { let microHtml = PRODUCT_MICRO_FIELDS.map(f => { let val = p[f.key] ?? ""; @@ -5706,6 +5828,29 @@ async function renderSettings(container) { +
+
Честный знак
+
+
+
Сессия
+
${(() => { + let s = ChZ.getSession(); + if (!s) return "Не импортирована"; + let until = new Date((s.sidObtainedAt || 0) + (s.expiresInMs || 0)); + return `userId ${s.userId} · sid до ${until.toLocaleString("ru-RU")}`; + })()}
+
+ +
+
+
+
Обновить сессию
+
Продлить sid через refresh (проверка связи)
+
+ +
+
+
Карты
@@ -6019,6 +6164,40 @@ async function renderSettings(container) { showToast(`Удалено ${removed} продуктов`); }; + // Честный знак: import session JSON exported by tools/chzn_export.sh. + function chznRefreshStatus() { + let el = container.querySelector("#s-chzn-status"); + if (!el) return; + let s = ChZ.getSession(); + if (!s) { el.textContent = "Не импортирована"; return; } + let until = new Date((s.sidObtainedAt || 0) + (s.expiresInMs || 0)); + el.textContent = `userId ${s.userId} · sid до ${until.toLocaleString("ru-RU")}`; + } + container.querySelector("#s-chzn-import")?.addEventListener("click", () => { + pickFile(".json,application/json", async (text) => { + try { + ChZ.importFromText(text); + showToast("Сессия Честного знака импортирована"); + chznRefreshStatus(); + } catch (e) { + showToast("Ошибка импорта: " + e.message); + } + }); + }); + container.querySelector("#s-chzn-refresh")?.addEventListener("click", async () => { + let btn = container.querySelector("#s-chzn-refresh"); + if (!ChZ.hasSession()) { showToast("Сначала импортируйте сессию"); return; } + btn.disabled = true; btn.textContent = "..."; + try { + await ChZ.refresh(); + showToast("Сессия обновлена"); + chznRefreshStatus(); + } catch (e) { + showToast("Refresh не удался: " + e.message); + } + btn.disabled = false; btn.textContent = "Обновить"; + }); + // Import tiles from a file the user picks on the device (SAF picker on Android). container.querySelector("#s-import-tiles").onclick = async () => { let btn = container.querySelector("#s-import-tiles"); diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml index 8d9624e..7cfdf91 100644 --- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -11,6 +11,8 @@ + + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ee4bdfe..84a9229 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -84,6 +84,41 @@ async fn http_request(url: String, method: String, body: Option) -> Resu .map_err(|e| e.to_string())? } +// HTTP proxy for the "Честный знак" API. Unlike http_request it (a) sends a +// browser-ish User-Agent (the CRPT gateway is picky) and (b) returns the HTTP +// status alongside the body as a JSON envelope {"status":N,"body":"..."} so the +// caller can drive session logic: 204 = no nutrition data, 401/400 = refresh sid, +// 427 = version/attestation gate. http_status_as_error(false) keeps 4xx/5xx as +// normal responses instead of errors so we can read their status/body. +#[tauri::command] +async fn chzn_http(url: String, method: String, body: Option) -> Result { + tauri::async_runtime::spawn_blocking(move || -> Result { + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_connect(Some(std::time::Duration::from_secs(5))) + .timeout_global(Some(std::time::Duration::from_secs(15))) + .http_status_as_error(false) + .build() + .into(); + let ua = "Mozilla/5.0 (Linux; Android 15)"; + let mut resp = match method.to_uppercase().as_str() { + "POST" => { + let b = body.unwrap_or_default(); + agent.post(&url) + .header("Content-Type", "application/json") + .header("User-Agent", ua) + .send(b.as_bytes()) + .map_err(|e| e.to_string())? + } + _ => agent.get(&url).header("User-Agent", ua).call().map_err(|e| e.to_string())?, + }; + let status = resp.status().as_u16(); + let text = resp.body_mut().read_to_string().unwrap_or_default(); + Ok(serde_json::json!({ "status": status, "body": text }).to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + // Get the active points file name from gps_live_points.jsonl pointer #[tauri::command] fn get_points_filename(app: tauri::AppHandle) -> Result { @@ -869,7 +904,7 @@ pub fn run() { .add_migrations("sqlite:baldav.db", migrations) .build(), ) - .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_app_info, gps_service_command, gps_service_read_points, save_file, get_update_info, http_request, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr]) + .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_app_info, gps_service_command, gps_service_read_points, save_file, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }