1
0
forked from BRT/arc
arc/docs/remote_scripts.md

481 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Удалённые JS-скрипты задач
Документ описывает scripting-слой, который позволяет обновлять логику банковских
скриптов **без пересборки и переустановки приложения** — JS-файлы тянутся с сервера
и исполняются внутри `QJSEngine`.
> **Статус.** Фреймворк и удалённое обновление полностью реализованы. На JS
> перенесены: `Black::GetProfileInfo` (PoC); **все 7 скриптов Ozon** —
> `get_profile_info`, `login_and_check_accounts`, `get_card_info`, `pay_by_card`,
> `pay_by_phone`, `get_last_transactions`. Остальные банки (Dushanbe, оставшийся
> Black) — на C++, мигрируются.
---
## 1. Зачем это нужно
Банковские UI меняются часто: переезжают кнопки, появляются новые экраны
подтверждения, шрифты ломают OCR. Раньше любая такая правка требовала:
1. Обновить C++-код скрипта.
2. Пересобрать релиз под Windows/macOS.
3. Раскатить новый exe на все десктопы.
При живом потоке задач каждый раз это «окно простоя» в несколько часов. Удалённые
JS-скрипты позволяют деплоить правку парсинга/последовательности тапов за минуты,
без релизного цикла.
C++-скрипты при этом **не убираются**: при любой ошибке загрузки/исполнения JS
runtime прозрачно откатывается на текущую C++-реализацию. Это страхует от
ситуации «сервер отдал битый JS — все устройства встали».
---
## 2. Архитектура
```
┌──────────────────────────────────────────────────────────────────┐
│ config.ini [scripting] │
│ enabled, use_js_for, manifest_url, cache_dir, bundled_dir │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ main.cpp → startScriptUpdater() │
│ └─ Scripting::ScriptUpdater │
│ └─ GET manifest_url → JSON {scripts:[{bank,script, │
│ version,url,sha256}]} │
│ └─ для каждой записи: GET url → SHA-256 check → │
│ ScriptCache::installFromBytes │
└──────────────────────────────────────────────────────────────────┘
▼ (атомарно через QSaveFile)
<cache_dir>/<bank>/<script>.js + <script>.version
┌──────────────────────────────────────────────────────────────────┐
│ Worker-поток EventHandler'а
│ Black::GetProfileInfoScript::doStart() │
│ ├─ ScriptingConfig::isJsEnabledFor("black/get_profile_info")│
│ ├─ ScriptRuntime::run("black","get_profile_info",params) │
│ │ ┌──────────────────────────────────────────────┐ │
│ │ │ QJSEngine │ │
│ │ │ host = {log, timer, adb, dao, dump, │ │
│ │ │ parser (BlackParserBridge), │ │
│ │ │ helpers (BlackHelpersBridge), │ │
│ │ │ params} │ │
│ │ │ eval(<bank>/<script>.js) → run(host) │ │
│ │ └──────────────────────────────────────────────┘ │
│ ├─ Completed → emit finishedWithResult(jsResult) │
│ └─ EngineError → лог + fallback на C++-ветку │
└──────────────────────────────────────────────────────────────────┘
```
**Ключевые свойства:**
- `QJSEngine` пересоздаётся на каждый запуск — состояние от прошлой задачи не
утечёт в следующую.
- JS исполняется в **том же** worker-потоке, что и C++-скрипт, поэтому
`host.timer.sleep()` блокирующий, `host.dao.*` напрямую читают SQLite,
всё остальное работает как раньше.
- `host.timer.sleep()` дробит большой интервал на 100-мс куски и проверяет
`QThread::isInterruptionRequested()` — watchdog EventHandler'а (5 мин)
по-прежнему может остановить зависший JS-поток.
- Резолв скрипта: сначала `cache_dir/<bank>/<script>.js`, потом
`bundled_dir/<bank>/<script>.js` (рядом с exe). Если ничего нет — `Disabled`,
fallback на C++.
---
## 3. Конфигурация (`config.ini`)
```ini
[scripting]
enabled=false
use_js_for=black/get_profile_info
manifest_url=
cache_dir=
bundled_dir=
```
| Ключ | Тип | Default | Назначение |
|---------------|--------|---------|--------------------------------------------------------------------|
| `enabled` | bool | `false` | Глобальный выключатель. При `false` весь слой бездействует. |
| `use_js_for` | csv | пусто | Список `<bank>/<script>`, для которых пробуем JS-версию. |
| `manifest_url`| string | пусто | URL JSON-манифеста. Пусто → удалённого обновления нет. |
| `cache_dir` | path | пусто | Кастомный каталог кэша. Пусто → `<AppDataLocation>/scripts/`. |
| `bundled_dir` | path | пусто | Каталог bundled-скриптов. Пусто → `<applicationDirPath>/scripts/`. |
**Включение JS для PoC-скрипта:**
```ini
[scripting]
enabled=true
use_js_for=black/get_profile_info
manifest_url=https://your-host/api/v1/scripts/manifest
```
При `enabled=false` (текущий default) приложение ведёт себя ровно как до PoC — JS
никогда не запускается, никакой нагрузки.
---
## 4. Формат манифеста
`GET <manifest_url>` должен возвращать `application/json`:
```json
{
"scripts": [
{
"bank": "black",
"script": "get_profile_info",
"version": "1.2.3",
"url": "https://cdn.example.com/scripts/black/get_profile_info-1.2.3.js",
"sha256": "9af15e8c1b1d4a2a3c9f7c5b...64-hex-chars..."
}
]
}
```
Поля:
| Поле | Обязательно | Назначение |
|-----------|-------------|---------------------------------------------------------------------|
| `bank` | да | Идентификатор банка (например `black`, `ozon`). |
| `script` | да | Имя скрипта без расширения (`get_profile_info`). |
| `version` | да | Произвольная строка. Сохраняется рядом со скриптом как `*.version`. |
| `url` | да | Прямая ссылка на `.js`-файл. HTTPS обязателен. |
| `sha256` | да | SHA-256 содержимого `.js` в lowercase hex. |
**Алгоритм апдейтера:**
1. `GET manifest_url`. Сетевые ошибки → лог `[ScriptUpdater] manifest fetch failed`, скрипт остаётся прежним.
2. Для каждой записи: если `ScriptCache::installedVersion(bank, script) == version` — пропускаем.
3. Иначе `GET url`, считаем SHA-256, сравниваем.
4. При совпадении — `QSaveFile` (атомарно: запись в `.tmp``commit`/rename). При несовпадении — лог и пропуск.
5. Старый `.js` остаётся до успешного апдейта. **На устройстве всегда либо валидный новый, либо валидный старый.**
Версии используются как ключ кэша, поэтому **версию обязательно нужно увеличивать** при каждой публикации — иначе обновление пропустится.
---
## 5. JS-API (что доступно из скрипта)
Каждый запуск получает глобальный объект `host`. Полный набор:
### `host.log`
```javascript
host.log.debug(msg)
host.log.info(msg)
host.log.warn(msg)
host.log.error(msg)
```
Уходит в `qDebug/qInfo/qWarning/qCritical` с тегом `[js:<bank>/<script>]`.
### `host.timer`
```javascript
host.timer.sleep(ms) // блокирующий sleep с проверкой interrupt каждые 100мс
host.timer.isInterrupted() // true, если watchdog запросил прерывание потока
```
### `host.adb` — обёртка над `AdbUtils`
```javascript
host.adb.getScreenDumpAsXml(deviceId, idx) string
host.adb.getRawScreenDumpAsXml(deviceId) string
host.adb.makeTap(deviceId, x, y) bool
host.adb.makeSwipe(deviceId, x1, y1, x2, y2) bool
host.adb.makeSwipeWithDuration(deviceId, x1, y1, x2, y2, ms) bool
host.adb.tryToStartApplication(deviceId, package) bool
host.adb.tryToKillApplication(deviceId, package) bool
host.adb.tryToGetRunningAppPid(deviceId, package) string
host.adb.takeScreenshot(deviceId, name) bool
host.adb.inputText(deviceId, text) bool
host.adb.inputAdbIMEText(deviceId, text) bool
host.adb.enableAdbIMEKeyboard(deviceId) bool
host.adb.disableAdbIMEKeyboard(deviceId) bool
host.adb.hideKeyboard(deviceId) bool
host.adb.goBack(deviceId) bool
host.adb.unlockScreen(deviceId, w, h) void
host.adb.getDeviceTimezone(deviceId) string
host.adb.captureScreenshotBase64(deviceId) string // PNG → base64
```
### `host.dao` — чтение/запись в SQLite
```javascript
host.dao.deviceGetById(id) DeviceObj | {}
host.dao.deviceFindByAdbSerial(serial) DeviceObj | {}
host.dao.bankProfileGet(deviceId, appCode) ProfileObj | {}
host.dao.bankProfileUpdateAmount(id, amount) bool
host.dao.bankProfileUpdateProfileInfo(id, fullName, phone, email) bool
host.dao.bankProfileUpdateComment(id, comment) bool
host.dao.materialGetAccounts(deviceId, appCode) Array<MaterialObj>
```
`DeviceObj`: `{ id, adbSerial, name, screenWidth, screenHeight, apiId }`.
Если устройство не найдено — пустой объект `{}` (без поля `id`).
`ProfileObj`: `{ id, appCode, name, package, pinCode, status, amount, fullName, phone, email, currency, bankProfileId }`.
`MaterialObj`: `{ id, materialId, appCode, deviceId, lastNumbers, amount, currency, description, status }`.
### `host.script` (опционально)
```javascript
host.script.emitTransactionParsed(jsonString)
```
Эмитит Qt-сигнал `transactionParsed(QJsonObject)` на C++ скрипте, который
передан в `ScriptRuntime::run(... , this)`. Используется
`get_last_transactions.js` для трансляции спарсенных транзакций в EventHandler.
Без `signalSink` метод — no-op.
### `host.ocr`
```javascript
host.ocr.recognizeFromScreen(deviceId) string // grayscale + 300dpi, rus+eng
host.ocr.extractTransactionId(ocrText) string // "ID операции..." / "№ Ф-..."
```
Реализация в `android/ocr/OcrUtils`. Используется `get_last_transactions.js`
для извлечения `bank_transaction_id` с экрана "Документы" Ozon.
### `host.parser` (Black и Ozon)
Обёртка над `Black::ScreenXmlParser`. Состояние (распаршенный XML) живёт ровно
один прогон скрипта.
```javascript
host.parser.parseAndSaveXml(xml)
host.parser.isHomeScreen() bool
host.parser.isSideMenu() bool
host.parser.isPinCodeScreen() bool
host.parser.isProfilePage() bool
// ... все is*Screen() методы
host.parser.findHolaButton() NodeObj
host.parser.findNodeByContentDesc(text) NodeObj
host.parser.findNodeByContentDescContains(text) NodeObj
host.parser.findButtonNode(text, contains) NodeObj
host.parser.findFirstEditText() NodeObj
host.parser.findPinCodeEditText() NodeObj
host.parser.findEditTextByHint(hint) NodeObj
host.parser.parseUserNameFromHomeScreen() string
host.parser.parseBalanceFromHomeScreen() number
host.parser.parseProfileNombre()/Apellido/Email/Phone string
host.parser.parseCVUNumber() string
// ... остальные методы Black::ScreenXmlParser
```
`NodeObj`: `{ x, y, x1, y1, x2, y2, width, height, text, contentDesc, resourceId, className, hint, clickable, focusable, password, isEmpty }`.
`x`/`y` — центр элемента (как в C++ `Node::x()` / `Node::y()`).
### `host.helpers` (только для `black`)
Высокоуровневые операции, повторяющие `Black::CommonScript`:
```javascript
host.helpers.runAppAndGoToHomeScreen(deviceId, package, pin, w, h) bool
host.helpers.postAccountsData(arrayOfMaterialObj) void
host.helpers.createBankProfileIfNeeded(deviceId, appCode, balance) void
```
Парсер у `helpers` и `host.parser` **один и тот же** — состояние XML
синхронизировано.
### `host.dump` — скоуп `TaskDumpRecorder`
```javascript
host.dump.beginScope(bank, taskKind, deviceId, metaObj)
host.dump.endScope(errorMessage) // "" = success
```
`metaObj`: `{ materialId, amount, phone, card }` (все поля опциональны).
Если `endScope` не вызвать, деструктор `DumpBridge` закроет скоуп без ошибки.
### `host.params`
То, что передал C++-вызыватель. Для PoC:
```javascript
host.params.deviceId
host.params.appCode
```
---
## 6. Контракт `run(host)`
JS-файл **обязан** определить функцию `run(host)` в глобальной области:
```javascript
function run(host) {
// ...
return ""; // success
// return "human-readable error"; // бизнес-ошибка
// throw new Error("..."); // engine error → fallback на C++
}
```
Возвращаемое значение трактуется так:
| Возврат | Result | Что увидит EventHandler |
|--------------------------------------|---------------|--------------------------------------------------|
| `""`, `null`, `undefined` | `Completed` | `m_error` пустой → success |
| любая непустая строка | `Completed` | `m_error = "<строка>"` → ошибка задачи |
| `throw` | `EngineError` | Лог + fallback на C++ doStart() |
| eval/parse не прошёл, нет `run(host)`| `EngineError` | То же |
**Главное различие:** возврат строки — это «скрипт отработал, но обнаружил
бизнес-проблему» (например, не нашёл нужный экран). Это **не** триггерит
fallback. Throw — это «скрипт сломался» и **триггерит** fallback.
---
## 7. Версионирование, кэш и обновление
- Источник истины: `<cache_dir>/<bank>/<script>.js`. Рядом лежит `<script>.version`
с произвольной строкой версии.
- При старте приложения `ScriptUpdater::start()` сравнивает версии с манифестом
и докачивает только то, что устарело.
- `cache_dir` по умолчанию:
- macOS: `~/Library/Application Support/<orgName>/ARC/scripts/`
- Windows: `%APPDATA%/<orgName>/ARC/scripts/`
- `bundled_dir` — каталог `scripts/` рядом с exe, кладётся CMake'ом из
`<source>/scripts/`. Это **запасной парашют**: если кэш пуст или повреждён —
работает bundled.
- Атомарность установки гарантируется `QSaveFile` (запись в `.tmp`
`commit` = rename).
**Сценарии:**
| Состояние | Что произойдёт |
|----------------------------------------|-------------------------------------------------------------|
| Кэш пуст, bundled есть | Используется bundled |
| Кэш заполнен, обновлений нет | Используется кэш |
| Манифест недоступен | Лог warning, используется то, что было |
| SHA-256 не сошлась | Лог warning, файл **не** перезаписывается |
| Обновление прошло | Новый `.js` + новый `.version`. Следующий запуск увидит его |
---
## 8. Безопасность
- **SHA-256 обязателен** — без него `ScriptUpdater` отклоняет запись манифеста.
- Хеш проверяется ПОСЛЕ скачивания, ПЕРЕД заменой кэша. При несовпадении старый
скрипт остаётся.
- Транспорт — HTTPS (HTTP/CDN тоже сработает, но не используйте в проде).
- **Из коробки нет подписи ключом сервера.** Если manifest_url подменят
(DNS hijack, MITM при кривом TLS), злоумышленник может подсунуть свой JS с
его же sha256. Если такая модель угроз релевантна, нужно добавить Ed25519
подпись манифеста (см. раздел «Дальнейшие шаги»).
- JS работает в `QJSEngine` без доступа к файловой системе/сети напрямую — только
через bridge-объекты. То есть фактически он может только делать то, что уже
умеет делать C++ Black-скрипт. Это **не** sandbox для враждебного кода, но
ограничение поверхности.
---
## 9. Отладка и поведение при ошибке
### Локальная разработка
1. Поставить `enabled=true`, `use_js_for=black/get_profile_info` в `config.ini`.
2. Положить отредактированный `get_profile_info.js` в **`bundled_dir`** (например `<build>/scripts/black/`) — кэш не используется при отсутствии manifest_url.
3. Запустить приложение. В логах `application.log` искать строки `[js:black/get_profile_info]`.
### Falback на C++
Любая из этих ситуаций триггерит fallback:
- В `config.ini` `enabled=false` или скрипта нет в `use_js_for`.
- Файл не найден ни в кэше, ни в bundled.
- `engine.evaluate()` вернул ошибку (синтаксис).
- В JS-коде нет `function run(host)`.
- Из `run(host)` бросилось исключение.
В каждом случае пишется warning вида:
```
[Black::GetProfileInfo] JS path failed ( "<диагностика>" ) — fallback to C++ implementation
```
После этого `Black::GetProfileInfoScript::doStart()` отрабатывает по своей
исторической C++-логике, и пользователь/сервер ничего не замечает.
### Дамп задачи
JS вызывает `host.dump.beginScope(...)`/`endScope(error)` ровно так же, как C++
дёргает `TaskDumpRecorder::Scope` — папка дампа, скриншоты, error-caption работают
без изменений. Если JS не успел вызвать `endScope`, деструктор `DumpBridge` закроет
скоуп с пустой ошибкой.
---
## 10. Как добавить новый скрипт
На PoC поддержан один скрипт. Чтобы добавить ещё один (например `black/pay_by_card`):
1. **C++-сторона.** В `android/black/PayByCardScript.cpp::doStart()` в начало
функции вставить ту же ветку, что уже есть в `GetProfileInfoScript.cpp`:
```cpp
if (Scripting::ScriptingConfig::isEnabled()
&& Scripting::ScriptingConfig::isJsEnabledFor("black/pay_by_card")) {
QString jsOut;
const QVariantMap params{
{"deviceId", m_deviceId},
{"appCode", m_appCode},
{"cardNumber", m_cardNumber},
{"amount", m_amount},
};
const auto status = Scripting::ScriptRuntime::run(
"black", "pay_by_card", params, &jsOut);
if (status == Scripting::ScriptRuntime::Result::Completed) {
m_error = jsOut;
emit finishedWithResult(m_error);
return;
}
qWarning() << "[Black::PayByCard] JS failed (" << jsOut << ") fallback C++";
}
```
2. **Bridges.** Если скрипту нужны новые методы парсера или хелперов добавить
`Q_INVOKABLE` в `BlackParserBridge` / `BlackHelpersBridge` в
`services/scripting/ScriptBridges.h/.cpp`.
3. **JS.** Положить `scripts/black/pay_by_card.js` с `function run(host)`.
Использовать `host.params.cardNumber`, `host.params.amount`.
4. **Конфиг.** В `config.ini` добавить:
```ini
[scripting]
enabled=true
use_js_for=black/get_profile_info, black/pay_by_card
```
5. **Манифест.** Опубликовать новую запись в `<manifest_url>` с новым `sha256` и
`version`.
Если нужен скрипт для другого банка добавьте `*ParserBridge` и `*HelpersBridge`
по образцу Black, и в `ScriptRuntime::run` развилку `if (bank == "ozon") ...`.
---
## 11. Известные ограничения
- Парсер/хелперы реализованы для `black` (минимум для get_profile_info) и `ozon`
(полный набор). Для Dushanbe и оставшихся Black-скриптов bridges нужно
добавить аналогично см. секцию 10.
- Нет hot-reload: обновление подхватится при следующем запуске задачи (новый
`QJSEngine` создаётся на каждый запуск, но файл читается уже из обновлённого
кэша).
- Нет подписи манифеста целостность только sha256.
- Нет аутентификации при `GET <manifest_url>` сервер должен сам ограничивать
доступ (IP-allowlist, отдельный токен в URL, basic-auth на ревёрсе и т.п.).
- `host.timer.sleep` блокирующий. Это плюс (читаемый процедурный код), но
любое await/promise-стиль писать **нельзя** нет event loop внутри `QJSEngine`.
---
## 12. Дальнейшие шаги (после PoC)
1. **Подпись манифеста (Ed25519).** Сервер подписывает JSON приватным ключом,
клиент проверяет публичным ключом, хардкоднутым в бинарник (или
зашифрованным конфигом).
2. **Аутентификация манифеста.** `Bearer access_token` из `SettingsDAO` уже есть
в `NetworkService` переиспользовать.
3. **Hot-reload по push.** Сервер шлёт нотификацию «обновись», апдейтер тянет
манифест немедленно, а не только при старте.
4. **Telemetry.** Отправлять на `/monitoring/log` событие при каждом успешном/
неуспешном fallback'е поможет понять, какие скрипты часто падают.
5. **Расширение покрытия.** Перенести остальные `*Script` Black-банка, затем
Ozon и Dushanbe.
6. **Тесты.** Завести golden-tests: для каждого `<bank>/<script>` записанный
набор XML-дампов + ожидаемые ADB-операции (моки), прогоняется на JS-версии.