- Add Proof of Concept (PoC) for executing JavaScript scripts fetched remotely. - Integrate `Black::GetProfileInfoScript` logic into a JS version with fallback to C++ on errors. - Extend `ScreenXmlParser` with additional JS-to-C++ bridge methods. - Update `config.ini` for scripting configuration. - Ensure scripts are securely fetched with SHA-256 validation and use an atomic caching mechanism. - Add developer tools for debugging JS scripts and enable easier script updates without app re-deployment.
461 lines
26 KiB
Markdown
461 lines
26 KiB
Markdown
# Удалённые JS-скрипты задач
|
||
|
||
Документ описывает scripting-слой, который позволяет обновлять логику банковских
|
||
скриптов **без пересборки и переустановки приложения** — JS-файлы тянутся с сервера
|
||
и исполняются внутри `QJSEngine`.
|
||
|
||
> **Статус: PoC.** Полностью реализован фреймворк, удалённое обновление и порт
|
||
> одного скрипта — `Black::GetProfileInfoScript`. Остальные скрипты остаются на 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.parser` (только для `black`)
|
||
Обёртка над `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. Известные ограничения PoC
|
||
|
||
- Парсер/хелперы есть только для банка `black`. Скрипты других банков можно
|
||
писать, но `host.parser`/`host.helpers` у них будут `undefined`.
|
||
- Нет 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-версии.
|