1
0
forked from BRT/arc

Implement JS-based remote scripting framework for dynamic script updates:

- 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.
This commit is contained in:
slava 2026-05-13 15:42:10 +07:00
parent abcc3cadb2
commit cae345cf61
17 changed files with 1881 additions and 0 deletions

View File

@ -58,6 +58,7 @@ find_package(Qt6 REQUIRED COMPONENTS
Widgets
Sql
Xml
Qml
)
file(GLOB_RECURSE DB_SOURCES CONFIGURE_DEPENDS
@ -156,6 +157,7 @@ if (WIN32)
Qt6::Xml
Qt6::Sql
Qt6::Network
Qt6::Qml
Tesseract::libtesseract
)
@ -171,6 +173,10 @@ if (WIN32)
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/config.ini" DESTINATION "${CMAKE_BINARY_DIR}")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/no_devices.png" DESTINATION "${CMAKE_BINARY_DIR}")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets")
# Bundled JS scripts fallback на случай, когда удалённый manifest недоступен
if (EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/scripts")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/scripts" DESTINATION "${CMAKE_BINARY_DIR}")
endif ()
# adb копируется в post-build чтобы не падать при configure если adb.exe занят
file(GLOB _adb_files "${CMAKE_CURRENT_SOURCE_DIR}/tools/adb/windows/*")
foreach(_adb_file ${_adb_files})
@ -228,6 +234,14 @@ if (WIN32)
"$<TARGET_FILE_DIR:ARC>/assets/ADBKeyboard.apk"
)
if (EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/scripts")
add_custom_command(TARGET ARC POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_CURRENT_SOURCE_DIR}/scripts"
"$<TARGET_FILE_DIR:ARC>/scripts"
)
endif ()
# Copy OpenSSL DLLs required for HTTPS (Qt SSL backend)
file(GLOB _openssl_dlls "${VCPKG_BIN_DIR}/libssl*.dll"
"${VCPKG_BIN_DIR}/libcrypto*.dll")
@ -276,6 +290,7 @@ elseif (APPLE)
Qt6::Sql
Qt6::Xml
Qt6::Network
Qt6::Qml
tesseract
leptonica
)
@ -291,6 +306,9 @@ elseif (APPLE)
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/config.ini" DESTINATION "${CMAKE_BINARY_DIR}")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/no_devices.png" DESTINATION "${CMAKE_BINARY_DIR}")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets")
if (EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/scripts")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/scripts" DESTINATION "${CMAKE_BINARY_DIR}")
endif ()
endif ()
target_include_directories(ARC PRIVATE

View File

@ -3,6 +3,7 @@
#include <QJsonDocument>
#include <QJsonObject>
#include <QThread>
#include <QVariantMap>
#include "adb/AdbUtils.h"
#include "dao/MaterialDAO.h"
@ -10,6 +11,8 @@
#include "dao/DeviceDAO.h"
#include "dump/TaskDumpRecorder.h"
#include "net/NetworkService.h"
#include "scripting/ScriptRuntime.h"
#include "scripting/ScriptingConfig.h"
namespace Black {
@ -25,6 +28,26 @@ GetProfileInfoScript::GetProfileInfoScript(
GetProfileInfoScript::~GetProfileInfoScript() = default;
void GetProfileInfoScript::doStart() {
// PoC удалённых JS-скриптов: пробуем JS-версию, при engine-ошибке делаем
// прозрачный fallback на C++ ниже. См. docs/remote_scripts.md.
if (Scripting::ScriptingConfig::isEnabled()
&& Scripting::ScriptingConfig::isJsEnabledFor("black/get_profile_info")) {
QString jsOut;
const QVariantMap params{
{"deviceId", m_deviceId},
{"appCode", m_appCode},
};
const auto status = Scripting::ScriptRuntime::run(
"black", "get_profile_info", params, &jsOut);
if (status == Scripting::ScriptRuntime::Result::Completed) {
m_error = jsOut;
emit finishedWithResult(m_error);
return;
}
qWarning() << "[Black::GetProfileInfo] JS path failed (" << jsOut
<< ") — fallback to C++ implementation";
}
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);

View File

@ -41,3 +41,17 @@ currency=TJS
country=tajikistan
name=Dushanbe City
phone_code=992
[scripting]
# Глобальный выключатель JS-скриптов. При false весь scripting-слой выключен.
enabled=false
# Список <bank>/<script>, для которых пробуем JS-версию (через запятую).
# При engine-ошибке runtime прозрачно откатывается на C++.
use_js_for=black/get_profile_info
# URL JSON-манифеста удалённых обновлений. Пустой → без удалённых обновлений,
# работает только bundled+cache (см. docs/remote_scripts.md).
manifest_url=
# Каталог локального кэша. Если пусто — берётся <AppDataLocation>/scripts/.
cache_dir=
# Каталог bundled-скриптов. Если пусто — рядом с бинарником: ./scripts/
bundled_dir=

View File

@ -11,6 +11,7 @@
| Раздел | Описание |
|--------|----------|
| [События и задачи](events.md) | Очередь задач: получение из API, хранение в БД, выполнение скриптов |
| [Удалённые JS-скрипты](remote_scripts.md) | Scripting-слой: обновляемые JS-версии банковских скриптов через `QJSEngine` |
### API Backend (`Adb Core Backend v1.0.0`)

460
docs/remote_scripts.md Normal file
View File

@ -0,0 +1,460 @@
# Удалённые 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-версии.

View File

@ -55,6 +55,7 @@ static LONG WINAPI crashHandler(EXCEPTION_POINTERS *ep) {
#include "dao/SettingsDAO.h"
#include "EventHandler.h"
#include "net/NetworkService.h"
#include "scripting/ScriptUpdater.h"
#include "black/PayByCardScript.h"
#include "ozon/PayByPhoneScript.h"
#include "widget/loader/ErrorWindow.h"
@ -165,6 +166,16 @@ void startBlackPayByCardTest() {
thread->start();
}
void startScriptUpdater() {
// Опрашивает manifest и подтягивает обновлённые JS-скрипты, если в
// config.ini включена секция [scripting]. При выключенной секции
// start() сразу эмитит finished — никакой нагрузки.
auto *updater = new Scripting::ScriptUpdater;
QObject::connect(updater, &Scripting::ScriptUpdater::finished,
updater, &QObject::deleteLater);
QMetaObject::invokeMethod(updater, "start", Qt::QueuedConnection);
}
void startNetworkService(const QCoreApplication &app) {
auto *paymentThread = new QThread;
auto *networkService = new NetworkService;
@ -360,6 +371,7 @@ int main(int argc, char *argv[]) {
startNetworkService(app);
startDeviceScreener(app);
startEventHandler(app);
startScriptUpdater();
// startBlackPayByCardTest();
}, Qt::QueuedConnection);
};
@ -412,6 +424,7 @@ int main(int argc, char *argv[]) {
startNetworkService(app);
startDeviceScreener(app);
startEventHandler(app);
startScriptUpdater();
// startBlackPayByCardTest();
} else if (result == 0) {
SettingsDAO::remove("api_key");

View File

@ -0,0 +1,173 @@
// black/get_profile_info.js
//
// JS-порт Black::GetProfileInfoScript. Логика 1:1 с C++ (см.
// android/black/GetProfileInfoScript.cpp), обращения к ADB/DAO/парсеру
// идут через host.*-мост.
//
// Контракт:
// params.deviceId — серийный или android_id
// params.appCode — "black"
// Возвращает:
// "" / null / undefined → success
// строку → бизнес-ошибка (попадёт в EventInfo.error)
// При выбросе исключения runtime сделает fallback на C++.
function run(host) {
var deviceId = host.params.deviceId;
var appCode = host.params.appCode;
var counter = 0;
// 1. Резолвим device (id может быть как android_id, так и adb serial).
var device = host.dao.deviceGetById(deviceId);
if (!device || !device.id) {
device = host.dao.deviceFindByAdbSerial(deviceId);
}
if (device && device.adbSerial) {
deviceId = device.adbSerial;
}
var app = host.dao.bankProfileGet(device.id, appCode);
if (!device || !device.id || !app || app.id < 0) {
var msg = "Device or app not found: " + deviceId + " " + appCode;
host.log.error(msg);
return msg;
}
host.dump.beginScope("black", "FETCH_PROFILE_INFO", deviceId, {});
try {
// 2. Читаем текущий экран
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
// 3. Если не на домашнем экране — перезапускаем приложение
if (!host.parser.isHomeScreen()) {
host.log.debug("Not on home screen, restarting app...");
if (!host.helpers.runAppAndGoToHomeScreen(deviceId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
var err = "Cannot reach home screen: " + device.name + " (" + deviceId + ") " + appCode;
host.log.warn(err);
host.adb.tryToKillApplication(deviceId, app.package);
return err;
}
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
}
// 4. Тапаем "¡Hola!"
var holaNode = host.parser.findHolaButton();
if (holaNode.isEmpty) {
var err2 = "Cannot find Hola button on home screen: " + deviceId;
host.log.warn(err2);
host.adb.tryToKillApplication(deviceId, app.package);
return err2;
}
// 5. Парсим имя и баланс с домашнего экрана
var fullName = host.parser.parseUserNameFromHomeScreen();
var balance = host.parser.parseBalanceFromHomeScreen();
host.log.debug("fullName from home: " + fullName + " balance: " + balance);
// Сохраняем баланс
host.dao.bankProfileUpdateAmount(app.id, balance);
host.adb.makeTap(deviceId, holaNode.x, holaNode.y);
host.timer.sleep(2000);
// 6. Ждём боковое меню
var sideMenuFound = false;
for (var i = 0; i < 5; ++i) {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
if (host.parser.isSideMenu()) {
sideMenuFound = true;
break;
}
host.timer.sleep(1000);
}
if (!sideMenuFound) {
var err3 = "Side menu not detected: " + deviceId;
host.log.warn(err3);
host.adb.tryToKillApplication(deviceId, app.package);
return err3;
}
// 7. Тапаем "Tu Información"
var tuInfoNode = host.parser.findNodeByContentDesc("Tu Información");
if (tuInfoNode.isEmpty) {
var err4 = "Cannot find Tu Información button: " + deviceId;
host.log.warn(err4);
host.adb.tryToKillApplication(deviceId, app.package);
return err4;
}
host.adb.makeTap(deviceId, tuInfoNode.x, tuInfoNode.y);
host.timer.sleep(2500);
// 8. Ждём страницу профиля
var profileFound = false;
for (var j = 0; j < 5; ++j) {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
if (host.parser.isProfilePage()) {
profileFound = true;
break;
}
host.timer.sleep(1000);
}
if (!profileFound) {
var err5 = "Profile page not detected: " + deviceId;
host.log.warn(err5);
host.adb.tryToKillApplication(deviceId, app.package);
return err5;
}
// 9. Парсим данные профиля
var nombre = host.parser.parseProfileNombre();
var apellido = host.parser.parseProfileApellido();
var email = host.parser.parseProfileEmail();
// 10. Скроллим вниз чтобы увидеть телефон
var x = Math.floor(device.screenWidth / 2);
var fromY = Math.floor(device.screenHeight * 3 / 4);
var toY = Math.floor(device.screenHeight / 4);
host.adb.makeSwipe(deviceId, x, fromY, x, toY);
host.timer.sleep(1500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
var phone = host.parser.parseProfilePhone();
// Собираем полное имя
var parsedFullName;
if (nombre && apellido) {
parsedFullName = nombre + " " + apellido;
} else if (nombre) {
parsedFullName = nombre;
} else {
parsedFullName = fullName;
}
host.log.debug("nombre: " + nombre + " apellido: " + apellido
+ " email: " + email + " phone: " + phone
+ " fullName: " + parsedFullName);
// 11. Сохраняем профиль
if (!host.dao.bankProfileUpdateProfileInfo(app.id, parsedFullName, phone, email)) {
host.log.warn("Failed to save profile info to DB");
}
// 12. Создаём bank profile на сервере если нет
host.helpers.createBankProfileIfNeeded(deviceId, appCode, -1);
// 13. Отправляем аккаунты без material_id на сервер
var allAccounts = host.dao.materialGetAccounts(device.id, appCode);
var accountsToPost = [];
for (var k = 0; k < allAccounts.length; ++k) {
if (!allAccounts[k].materialId) accountsToPost.push(allAccounts[k]);
}
if (accountsToPost.length > 0) {
host.helpers.postAccountsData(accountsToPost);
}
host.dump.endScope("");
return ""; // success
} catch (e) {
// Любая JS-ошибка → engine error для runtime → fallback на C++.
host.dump.endScope(String(e));
throw e;
}
}

View File

@ -0,0 +1,417 @@
#include "ScriptBridges.h"
#include <QBuffer>
#include <QByteArray>
#include <QDebug>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSettings>
#include <QString>
#include <QThread>
#include <QRegularExpression>
#include "adb/AdbUtils.h"
#include "black/ScreenXmlParser.h"
#include "android/xml/Node.h"
#include "db/BankProfileInfo.h"
#include "db/DeviceInfo.h"
#include "db/MaterialInfo.h"
#include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h"
#include "dao/MaterialDAO.h"
#include "dump/TaskDumpRecorder.h"
#include "net/NetworkService.h"
namespace Scripting {
QVariantMap nodeToVariantMap(const Node &node) {
Node copy = node; // isEmpty() не const, нельзя дернуть на const-ссылке
QVariantMap m;
m["x1"] = copy.x1();
m["y1"] = copy.y1();
m["x2"] = copy.x2();
m["y2"] = copy.y2();
m["x"] = copy.x();
m["y"] = copy.y();
m["width"] = copy.width();
m["height"] = copy.height();
m["text"] = copy.text;
m["contentDesc"] = copy.contentDesc;
m["resourceId"] = copy.resourceId;
m["className"] = copy.className;
m["hint"] = copy.hint;
m["clickable"] = copy.clickable;
m["focusable"] = copy.focusable;
m["password"] = copy.password;
m["isEmpty"] = copy.isEmpty();
return m;
}
// ─── LogBridge ──────────────────────────────────────────────────────────────
void LogBridge::debug(const QString &message) { qDebug().noquote() << "[js:" + m_tag + "]" << message; }
void LogBridge::info (const QString &message) { qInfo().noquote() << "[js:" + m_tag + "]" << message; }
void LogBridge::warn (const QString &message) { qWarning().noquote() << "[js:" + m_tag + "]" << message; }
void LogBridge::error(const QString &message) { qCritical().noquote() << "[js:" + m_tag + "]" << message; }
// ─── TimerBridge ────────────────────────────────────────────────────────────
void TimerBridge::sleep(int ms) {
if (ms <= 0) return;
// Дробим длинный sleep на 100мс шаги, чтобы можно было прервать поток.
const int step = 100;
while (ms > 0) {
if (QThread *t = QThread::currentThread(); t && t->isInterruptionRequested()) return;
const int chunk = ms > step ? step : ms;
QThread::msleep(static_cast<unsigned long>(chunk));
ms -= chunk;
}
}
bool TimerBridge::isInterrupted() const {
QThread *t = QThread::currentThread();
return t && t->isInterruptionRequested();
}
// ─── AdbBridge ──────────────────────────────────────────────────────────────
QString AdbBridge::getScreenDumpAsXml(const QString &deviceId, int idx) {
return AdbUtils::getScreenDumpAsXml(deviceId, idx);
}
QString AdbBridge::getRawScreenDumpAsXml(const QString &deviceId) {
return AdbUtils::getRawScreenDumpAsXml(deviceId);
}
bool AdbBridge::makeTap(const QString &deviceId, int x, int y) {
return AdbUtils::makeTap(deviceId, x, y);
}
bool AdbBridge::makeSwipe(const QString &deviceId, int x1, int y1, int x2, int y2) {
return AdbUtils::makeSwipe(deviceId, x1, y1, x2, y2);
}
bool AdbBridge::makeSwipeWithDuration(const QString &deviceId, int x1, int y1, int x2, int y2, int durationMs) {
return AdbUtils::makeSwipe(deviceId, x1, y1, x2, y2, durationMs);
}
bool AdbBridge::tryToStartApplication(const QString &deviceId, const QString &packageName) {
return AdbUtils::tryToStartApplication(deviceId, packageName);
}
bool AdbBridge::tryToKillApplication(const QString &deviceId, const QString &packageName) {
return AdbUtils::tryToKillApplication(deviceId, packageName);
}
QString AdbBridge::tryToGetRunningAppPid(const QString &deviceId, const QString &packageName) {
return AdbUtils::tryToGetRunningAppPid(deviceId, packageName);
}
bool AdbBridge::takeScreenshot(const QString &deviceId, const QString &name) {
return AdbUtils::takeScreenshot(deviceId, name);
}
bool AdbBridge::inputText(const QString &deviceId, const QString &text) {
return AdbUtils::inputText(deviceId, text);
}
bool AdbBridge::inputAdbIMEText(const QString &deviceId, const QString &text) {
return AdbUtils::inputAdbIMEText(deviceId, text);
}
bool AdbBridge::enableAdbIMEKeyboard(const QString &deviceId) {
return AdbUtils::enableAdbIMEKeyboard(deviceId);
}
bool AdbBridge::disableAdbIMEKeyboard(const QString &deviceId) {
return AdbUtils::disableAdbIMEKeyboard(deviceId);
}
bool AdbBridge::hideKeyboard(const QString &deviceId) {
return AdbUtils::hideKeyboard(deviceId);
}
bool AdbBridge::goBack(const QString &deviceId) {
return AdbUtils::goBack(deviceId);
}
void AdbBridge::unlockScreen(const QString &deviceId, int screenWidth, int screenHeight) {
AdbUtils::unlockScreen(deviceId, screenWidth, screenHeight);
}
QString AdbBridge::getDeviceTimezone(const QString &deviceId) {
return AdbUtils::getDeviceTimezone(deviceId);
}
QString AdbBridge::captureScreenshotBase64(const QString &deviceId) {
const QByteArray bytes = AdbUtils::captureScreenshotBytes(deviceId);
return QString::fromLatin1(bytes.toBase64());
}
// ─── DaoBridge ──────────────────────────────────────────────────────────────
static QVariantMap deviceToMap(const DeviceInfo &d) {
QVariantMap m;
if (d.id.isEmpty()) return m; // пустая мапа = null
m["id"] = d.id;
m["adbSerial"] = d.adbSerial;
m["name"] = d.name;
m["screenWidth"] = d.screenWidth;
m["screenHeight"] = d.screenHeight;
m["apiId"] = d.apiId;
return m;
}
static QVariantMap bankProfileToMap(const BankProfileInfo &p) {
QVariantMap m;
if (p.id == -1) return m;
m["id"] = p.id;
m["appCode"] = p.code; // BankProfileInfo.code = код банка в config.ini ("black", "ozon"...)
m["name"] = p.name;
m["package"] = p.package;
m["pinCode"] = p.pinCode;
m["status"] = p.status;
m["amount"] = p.amount;
m["fullName"] = p.fullName;
m["phone"] = p.phone;
m["email"] = p.email;
m["currency"] = p.currency;
m["bankProfileId"] = p.bankProfileId;
return m;
}
static QVariantMap materialToMap(const MaterialInfo &a) {
QVariantMap m;
if (a.id == -1) return m;
m["id"] = a.id;
m["materialId"] = a.materialId;
m["appCode"] = a.appCode;
m["deviceId"] = a.deviceId;
m["lastNumbers"] = a.lastNumbers;
m["amount"] = a.amount;
m["currency"] = a.currency;
m["description"] = a.description;
m["status"] = materialStatusToString(a.status);
return m;
}
QVariantMap DaoBridge::deviceGetById(const QString &id) {
return deviceToMap(DeviceDAO::getDeviceById(id));
}
QVariantMap DaoBridge::deviceFindByAdbSerial(const QString &serial) {
return deviceToMap(DeviceDAO::findByAdbSerial(serial));
}
QVariantMap DaoBridge::bankProfileGet(const QString &deviceId, const QString &appCode) {
return bankProfileToMap(BankProfileDAO::getApplication(deviceId, appCode));
}
bool DaoBridge::bankProfileUpdateAmount(int profileId, double amount) {
return BankProfileDAO::updateAmount(profileId, amount);
}
bool DaoBridge::bankProfileUpdateProfileInfo(int profileId, const QString &fullName,
const QString &phone, const QString &email) {
return BankProfileDAO::updateProfileInfo(profileId, fullName, phone, email);
}
bool DaoBridge::bankProfileUpdateComment(int profileId, const QString &comment) {
return BankProfileDAO::updateComment(profileId, comment);
}
QVariantList DaoBridge::materialGetAccounts(const QString &deviceId, const QString &appCode) {
QVariantList list;
for (const MaterialInfo &acc : MaterialDAO::getAccounts(deviceId, appCode)) {
list.append(materialToMap(acc));
}
return list;
}
// ─── DumpBridge ─────────────────────────────────────────────────────────────
void DumpBridge::beginScope(const QString &bank, const QString &taskKind, const QString &adbDeviceId,
const QVariantMap &meta) {
if (m_scope) {
qWarning() << "[Scripting] DumpBridge: beginScope called twice, ignoring";
return;
}
TaskDumpMeta tm;
tm.materialId = meta.value("materialId").toString();
tm.amount = meta.value("amount").toDouble();
tm.phone = meta.value("phone").toString();
tm.card = meta.value("card").toString();
m_errorPtr = new QString();
m_scope = new TaskDumpRecorder::Scope(bank, taskKind, adbDeviceId, tm, m_errorPtr);
}
void DumpBridge::endScope(const QString &errorMessage) {
if (!m_scope) return;
if (m_errorPtr) *m_errorPtr = errorMessage;
delete static_cast<TaskDumpRecorder::Scope *>(m_scope);
m_scope = nullptr;
delete m_errorPtr;
m_errorPtr = nullptr;
}
// ─── BlackParserBridge ──────────────────────────────────────────────────────
BlackParserBridge::BlackParserBridge(QObject *parent)
: QObject(parent), m_parser(new Black::ScreenXmlParser(this)) {}
BlackParserBridge::~BlackParserBridge() = default;
void BlackParserBridge::parseAndSaveXml(const QString &xml) { m_parser->parseAndSaveXml(xml); }
bool BlackParserBridge::isHomeScreen() { return m_parser->isHomeScreen(); }
bool BlackParserBridge::isSideMenu() { return m_parser->isSideMenu(); }
bool BlackParserBridge::isPinCodeScreen() { return m_parser->isPinCodeScreen(); }
bool BlackParserBridge::isProfilePage() { return m_parser->isProfilePage(); }
bool BlackParserBridge::isCVUScreen() { return m_parser->isCVUScreen(); }
bool BlackParserBridge::isTransactionLoadingScreen() { return m_parser->isTransactionLoadingScreen(); }
bool BlackParserBridge::isTransactionListScreen() { return m_parser->isTransactionListScreen(); }
bool BlackParserBridge::isPayInputCardNumberScreen() { return m_parser->isPayInputCardNumberScreen(); }
bool BlackParserBridge::isPayTransferConfirmScreen() { return m_parser->isPayTransferConfirmScreen(); }
bool BlackParserBridge::isPayInputSumScreen() { return m_parser->isPayInputSumScreen(); }
bool BlackParserBridge::isPayConfirmScreen() { return m_parser->isPayConfirmScreen(); }
bool BlackParserBridge::isPaySuccessScreen() { return m_parser->isPaySuccessScreen(); }
QVariantMap BlackParserBridge::findHolaButton() { return nodeToVariantMap(m_parser->findHolaButton()); }
QVariantMap BlackParserBridge::findTextNode(const QString &text) { return nodeToVariantMap(m_parser->findTextNode(text)); }
QVariantMap BlackParserBridge::findNodeByContentDesc(const QString &desc) { return nodeToVariantMap(m_parser->findNodeByContentDesc(desc)); }
QVariantMap BlackParserBridge::findNodeByContentDescContains(const QString &desc) { return nodeToVariantMap(m_parser->findNodeByContentDesc(desc, true)); }
QVariantMap BlackParserBridge::findButtonNode(const QString &text, bool contains) { return nodeToVariantMap(m_parser->findButtonNode(text, contains)); }
QVariantMap BlackParserBridge::findFirstEditText() { return nodeToVariantMap(m_parser->findFirstEditText()); }
QVariantMap BlackParserBridge::findPinCodeEditText() { return nodeToVariantMap(m_parser->findPinCodeEditText()); }
QVariantMap BlackParserBridge::findVerMasAfterTuActividad() { return nodeToVariantMap(m_parser->findVerMasAfterTuActividad()); }
QVariantMap BlackParserBridge::findEditTextByHint(const QString &hint) { return nodeToVariantMap(m_parser->findEditTextByHint(hint)); }
QString BlackParserBridge::parseUserNameFromHomeScreen() { return m_parser->parseUserNameFromHomeScreen(); }
double BlackParserBridge::parseBalanceFromHomeScreen() { return m_parser->parseBalanceFromHomeScreen(); }
QString BlackParserBridge::parseProfileNombre() { return m_parser->parseProfileNombre(); }
QString BlackParserBridge::parseProfileApellido() { return m_parser->parseProfileApellido(); }
QString BlackParserBridge::parseProfileEmail() { return m_parser->parseProfileEmail(); }
QString BlackParserBridge::parseProfilePhone() { return m_parser->parseProfilePhone(); }
QString BlackParserBridge::parseCVUNumber() { return m_parser->parseCVUNumber(); }
QString BlackParserBridge::parseRecipientNameFromConfirm(){ return m_parser->parseRecipientNameFromConfirm(); }
QString BlackParserBridge::parsePaySuccessDateTime() { return m_parser->parsePaySuccessDateTime(); }
QString BlackParserBridge::parsePaySuccessCoelsaId() { return m_parser->parsePaySuccessCoelsaId(); }
// ─── BlackHelpersBridge ─────────────────────────────────────────────────────
// Локальная копия логики Black::CommonScript::runAppAndGoToHomeScreen — нужна
// чтобы JS мог вызвать готовый высокоуровневый flow, не переписывая login заново.
bool BlackHelpersBridge::runAppAndGoToHomeScreen(const QString &deviceId, const QString &packageName,
const QString &pinCode, int width, int height) {
Black::ScreenXmlParser *parser = m_parserBridge->parser();
AdbUtils::unlockScreen(deviceId, width, height);
if (const QString pid = AdbUtils::tryToGetRunningAppPid(deviceId, packageName); !pid.isEmpty()) {
qDebug() << "[js:black/helpers] Process already running, pid:" << pid;
AdbUtils::getScreenDumpAsXml(deviceId, 1000);
AdbUtils::tryToKillApplication(deviceId, packageName);
}
if (!AdbUtils::tryToStartApplication(deviceId, packageName)) {
AdbUtils::takeScreenshot(deviceId, "run_app_error");
qWarning() << "[js:black/helpers] Process has not started";
return false;
}
QThread::msleep(4500);
int counter = 0;
bool pinInjected = false;
int xmlCounter = 0;
while (true) {
if (QThread *t = QThread::currentThread(); t && t->isInterruptionRequested()) return false;
counter++;
if (counter > 10) {
AdbUtils::takeScreenshot(deviceId, "too_many_attempts");
return false;
}
parser->parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++xmlCounter));
if (parser->isHomeScreen()) return true;
if (!pinInjected && parser->isPinCodeScreen()) {
// Inline-копия inputPinCode (тоже из CommonScript)
Node editText = parser->findPinCodeEditText();
if (!editText.isEmpty()) {
AdbUtils::makeTap(deviceId, editText.x(), editText.y());
QThread::msleep(500);
AdbUtils::inputText(deviceId, pinCode);
QThread::msleep(500);
} else {
qWarning() << "[js:black/helpers] PIN EditText not found";
}
pinInjected = true;
QThread::msleep(3000);
continue;
}
QThread::msleep(1000);
}
}
void BlackHelpersBridge::postAccountsData(const QVariantList &accounts) {
if (accounts.isEmpty()) return;
auto *thread = new QThread;
auto *worker = new NetworkService;
QJsonArray list;
QString deviceId;
for (const QVariant &v : accounts) {
const QVariantMap acc = v.toMap();
QJsonObject obj;
obj["appName"] = acc.value("appCode").toString();
obj["lastNumbers"] = acc.value("lastNumbers").toString();
obj["amount"] = acc.value("amount").toDouble();
obj["description"] = acc.value("description").toString();
obj["currency"] = acc.value("currency").toString();
obj["status"] = acc.value("status").toString();
list.append(obj);
if (deviceId.isEmpty()) deviceId = acc.value("deviceId").toString();
}
worker->setDeviceId(deviceId);
worker->setPayload(QJsonDocument(list).toJson());
worker->moveToThread(thread);
QObject::connect(thread, &QThread::started, worker, &NetworkService::postAccountsData);
QObject::connect(worker, &NetworkService::finished, thread, &QThread::quit);
QObject::connect(worker, &NetworkService::finished, worker, &QObject::deleteLater);
QObject::connect(thread, &QThread::finished, thread, &QThread::deleteLater);
thread->start();
}
void BlackHelpersBridge::createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance) {
const BankProfileInfo app = BankProfileDAO::getApplication(deviceId, appCode);
if (app.fullName.trimmed().isEmpty() || app.phone.trimmed().isEmpty()) {
qWarning() << "[js:black/helpers] createBankProfileIfNeeded: skipping — fullName or phone is empty";
return;
}
const QMap<QString, int> currencyCodes = {
{"RUB", 643}, {"USD", 840}, {"KRW", 410}, {"AZN", 944}, {"ARS", 32}, {"TJS", 972}
};
const int currencyCode = currencyCodes.value(app.currency.toUpper(), 840);
const QStringList parts = app.fullName.split(' ', Qt::SkipEmptyParts);
const QSettings settings("config.ini", QSettings::IniFormat);
const QString phoneCode = settings.value(appCode + "/phone_code").toString();
QString phone = app.phone;
phone.remove(QRegularExpression("[^0-9]"));
if (!phoneCode.isEmpty() && !phone.startsWith(phoneCode)) {
if (phone.startsWith('8') && phoneCode != "8") {
phone.replace(0, 1, phoneCode);
} else {
phone.prepend(phoneCode);
}
}
phone.prepend('+');
const DeviceInfo device = DeviceDAO::getDeviceById(deviceId);
QJsonObject profileBody;
profileBody["first_name"] = parts.value(0);
profileBody["middle_name"] = parts.size() >= 3 ? parts.value(1) : QString("");
profileBody["last_name"] = parts.size() >= 3 ? parts.value(2) : parts.value(1);
profileBody["bank_name"] = appCode;
profileBody["phone_number"] = phone;
const double balance = (parsedBalance >= 0.0) ? parsedBalance : app.amount;
profileBody["balance"] = QString::number(qRound64(balance * 100));
profileBody["state"] = (app.status == "active") ? "enabled" : "disabled";
profileBody["device_id"] = device.apiId;
profileBody["currency"] = currencyCode;
NetworkService ns;
ns.setDeviceId(deviceId);
ns.setPayload(QJsonDocument(profileBody).toJson());
if (app.bankProfileId.isEmpty()) {
ns.addExtra("app_id", app.id);
ns.postBankProfile();
} else {
ns.addExtra("bank_profile_id", app.bankProfileId);
ns.patchBankProfile();
}
}
} // namespace Scripting

View File

@ -0,0 +1,174 @@
#pragma once
#include <QObject>
#include <QString>
#include <QVariantList>
#include <QVariantMap>
#include "android/xml/Node.h"
class QJSEngine;
namespace Black { class ScreenXmlParser; }
namespace Scripting {
// Лог из JS попадает в qDebug/qWarning с префиксом банка/скрипта.
class LogBridge final : public QObject {
Q_OBJECT
public:
LogBridge(QString tag, QObject *parent = nullptr) : QObject(parent), m_tag(std::move(tag)) {}
public slots:
void debug(const QString &message);
void info(const QString &message);
void warn(const QString &message);
void error(const QString &message);
private:
QString m_tag;
};
// Sleep + проверка interrupt (скрипт крутится в worker-потоке EventHandler'а).
class TimerBridge final : public QObject {
Q_OBJECT
public:
explicit TimerBridge(QObject *parent = nullptr) : QObject(parent) {}
public slots:
void sleep(int ms); // блокирующий, проверяет interrupt
bool isInterrupted() const; // ручная проверка из JS
};
// Прокси над AdbUtils. Все методы статические в C++ — здесь оборачиваем как Q_INVOKABLE,
// чтобы JS видел их как host.adb.<method>.
class AdbBridge final : public QObject {
Q_OBJECT
public:
explicit AdbBridge(QObject *parent = nullptr) : QObject(parent) {}
public slots:
QString getScreenDumpAsXml(const QString &deviceId, int idx);
QString getRawScreenDumpAsXml(const QString &deviceId);
bool makeTap(const QString &deviceId, int x, int y);
bool makeSwipe(const QString &deviceId, int x1, int y1, int x2, int y2);
bool makeSwipeWithDuration(const QString &deviceId, int x1, int y1, int x2, int y2, int durationMs);
bool tryToStartApplication(const QString &deviceId, const QString &packageName);
bool tryToKillApplication(const QString &deviceId, const QString &packageName);
QString tryToGetRunningAppPid(const QString &deviceId, const QString &packageName);
bool takeScreenshot(const QString &deviceId, const QString &name);
bool inputText(const QString &deviceId, const QString &text);
bool inputAdbIMEText(const QString &deviceId, const QString &text);
bool enableAdbIMEKeyboard(const QString &deviceId);
bool disableAdbIMEKeyboard(const QString &deviceId);
bool hideKeyboard(const QString &deviceId);
bool goBack(const QString &deviceId);
void unlockScreen(const QString &deviceId, int screenWidth, int screenHeight);
QString getDeviceTimezone(const QString &deviceId);
QString captureScreenshotBase64(const QString &deviceId);
};
// DAO-прокси: read для устройства/профиля/материалов, write для баланса/профиля.
class DaoBridge final : public QObject {
Q_OBJECT
public:
explicit DaoBridge(QObject *parent = nullptr) : QObject(parent) {}
public slots:
// DeviceDAO
QVariantMap deviceGetById(const QString &id);
QVariantMap deviceFindByAdbSerial(const QString &serial);
// BankProfileDAO
QVariantMap bankProfileGet(const QString &deviceId, const QString &appCode);
bool bankProfileUpdateAmount(int profileId, double amount);
bool bankProfileUpdateProfileInfo(int profileId, const QString &fullName,
const QString &phone, const QString &email);
bool bankProfileUpdateComment(int profileId, const QString &comment);
// MaterialDAO
QVariantList materialGetAccounts(const QString &deviceId, const QString &appCode);
};
// Скоуп дампа задачи. JS-style API: begin/end вместо RAII (вешаем на try/finally).
class DumpBridge final : public QObject {
Q_OBJECT
public:
explicit DumpBridge(QObject *parent = nullptr) : QObject(parent) {}
~DumpBridge() override { endScope(QString()); }
public slots:
void beginScope(const QString &bank, const QString &taskKind, const QString &adbDeviceId,
const QVariantMap &meta);
void endScope(const QString &errorMessage); // пусто = success
private:
void *m_scope = nullptr;
QString *m_errorPtr = nullptr;
};
// Парсер экрана для банка Black. Каждый запуск скрипта получает свой инстанс,
// поэтому m_xml/m_nodes не разделяются между задачами.
class BlackParserBridge final : public QObject {
Q_OBJECT
public:
explicit BlackParserBridge(QObject *parent = nullptr);
~BlackParserBridge() override;
Black::ScreenXmlParser *parser() const { return m_parser; }
public slots:
void parseAndSaveXml(const QString &xml);
bool isHomeScreen();
bool isSideMenu();
bool isPinCodeScreen();
bool isProfilePage();
bool isCVUScreen();
bool isTransactionLoadingScreen();
bool isTransactionListScreen();
bool isPayInputCardNumberScreen();
bool isPayTransferConfirmScreen();
bool isPayInputSumScreen();
bool isPayConfirmScreen();
bool isPaySuccessScreen();
QVariantMap findHolaButton();
QVariantMap findTextNode(const QString &text);
QVariantMap findNodeByContentDesc(const QString &contentDesc);
QVariantMap findNodeByContentDescContains(const QString &contentDesc);
QVariantMap findButtonNode(const QString &text, bool contains);
QVariantMap findFirstEditText();
QVariantMap findPinCodeEditText();
QVariantMap findVerMasAfterTuActividad();
QVariantMap findEditTextByHint(const QString &hint);
QString parseUserNameFromHomeScreen();
double parseBalanceFromHomeScreen();
QString parseProfileNombre();
QString parseProfileApellido();
QString parseProfileEmail();
QString parseProfilePhone();
QString parseCVUNumber();
QString parseRecipientNameFromConfirm();
QString parsePaySuccessDateTime();
QString parsePaySuccessCoelsaId();
private:
Black::ScreenXmlParser *m_parser;
};
// Высокоуровневые операции, повторяющие методы Black::CommonScript:
// runAppAndGoToHomeScreen, postAccountsData, createBankProfileIfNeeded.
// Используют ТОТ ЖЕ парсер, что и BlackParserBridge — состояние (XML) общее.
class BlackHelpersBridge final : public QObject {
Q_OBJECT
public:
BlackHelpersBridge(BlackParserBridge *parser, QObject *parent = nullptr)
: QObject(parent), m_parserBridge(parser) {}
public slots:
bool runAppAndGoToHomeScreen(const QString &deviceId, const QString &packageName,
const QString &pinCode, int width, int height);
void postAccountsData(const QVariantList &accountsWithEmptyMaterialId);
void createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance);
private:
BlackParserBridge *m_parserBridge;
};
// Утилиты конверсии Node ↔ QVariantMap. Объявлены здесь чтобы парсер-бридж
// мог их использовать без зависимости от QJSEngine.
QVariantMap nodeToVariantMap(const Node &node);
} // namespace Scripting

View File

@ -0,0 +1,88 @@
#include "ScriptCache.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QSaveFile>
#include <QString>
#include "ScriptingConfig.h"
namespace Scripting {
namespace {
QString scriptRelPath(const QString &bank, const QString &scriptName) {
return bank + "/" + scriptName + ".js";
}
QString versionRelPath(const QString &bank, const QString &scriptName) {
return bank + "/" + scriptName + ".version";
}
} // namespace
bool ScriptCache::resolve(const QString &bank, const QString &scriptName,
QString *outPath, QString *outError) {
const QString rel = scriptRelPath(bank, scriptName);
const QString cached = QDir(ScriptingConfig::cacheDir()).filePath(rel);
if (QFileInfo::exists(cached)) {
if (outPath) *outPath = cached;
return true;
}
const QString bundled = QDir(ScriptingConfig::bundledDir()).filePath(rel);
if (QFileInfo::exists(bundled)) {
if (outPath) *outPath = bundled;
return true;
}
if (outError) {
*outError = "not found in cache (" + cached + ") nor bundled (" + bundled + ")";
}
return false;
}
bool ScriptCache::installFromBytes(const QString &bank, const QString &scriptName,
const QString &version, const QByteArray &body,
QString *outError) {
const QString dirPath = QDir(ScriptingConfig::cacheDir()).filePath(bank);
QDir().mkpath(dirPath);
const QString fullPath = QDir(ScriptingConfig::cacheDir()).filePath(scriptRelPath(bank, scriptName));
QSaveFile f(fullPath);
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
if (outError) *outError = "cannot open for write: " + fullPath + "" + f.errorString();
return false;
}
if (f.write(body) != body.size()) {
if (outError) *outError = "short write to " + fullPath;
return false;
}
if (!f.commit()) {
if (outError) *outError = "commit failed for " + fullPath + ": " + f.errorString();
return false;
}
// Sidecar с версией. Не критичен — если упал, скрипт всё равно установлен.
const QString verPath = QDir(ScriptingConfig::cacheDir()).filePath(versionRelPath(bank, scriptName));
QFile vf(verPath);
if (vf.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
vf.write(version.toUtf8());
vf.close();
}
return true;
}
QString ScriptCache::installedVersion(const QString &bank, const QString &scriptName) {
const QString verPath = QDir(ScriptingConfig::cacheDir()).filePath(versionRelPath(bank, scriptName));
QFile vf(verPath);
if (!vf.open(QIODevice::ReadOnly)) return {};
const QByteArray b = vf.readAll();
vf.close();
return QString::fromUtf8(b).trimmed();
}
} // namespace Scripting

View File

@ -0,0 +1,29 @@
#pragma once
#include <QByteArray>
#include <QString>
namespace Scripting {
// Локальное хранилище JS-скриптов. Источник истины — cacheDir() из ScriptingConfig.
// Если в кэше скрипта нет — fallback на bundledDir() (рядом с бинарником).
class ScriptCache {
public:
// Резолвит абсолютный путь к файлу для <bank>/<scriptName>.
// 1. <cacheDir>/<bank>/<scriptName>.js
// 2. <bundledDir>/<bank>/<scriptName>.js
// Возвращает true только если файл существует.
static bool resolve(const QString &bank, const QString &scriptName,
QString *outPath, QString *outError);
// Сохраняет скрипт в кэш атомарно (через .tmp + rename).
// body — содержимое .js-файла. version — для диагностики, пишется в *.version.
static bool installFromBytes(const QString &bank, const QString &scriptName,
const QString &version, const QByteArray &body,
QString *outError);
// Текущая установленная версия (содержимое sidecar *.version). Пусто если не установлено.
static QString installedVersion(const QString &bank, const QString &scriptName);
};
} // namespace Scripting

View File

@ -0,0 +1,135 @@
#include "ScriptRuntime.h"
#include <QDebug>
#include <QFile>
#include <QJSEngine>
#include <QJSValue>
#include <QString>
#include <QThread>
#include "ScriptBridges.h"
#include "ScriptCache.h"
#include "ScriptingConfig.h"
namespace Scripting {
namespace {
// Конверсия QJSValue → строка-результат скрипта.
// null/undefined → пустая строка (успех).
// "" → пустая строка (успех).
// строка → текст бизнес-ошибки.
// число/булево/объект → toString() как diagnostic (рассматриваем как ошибку).
QString jsValueToResultString(const QJSValue &v) {
if (v.isUndefined() || v.isNull()) return QString();
if (v.isString()) return v.toString();
return v.toString();
}
QString locationOf(const QJSValue &err) {
const int line = err.property("lineNumber").toInt();
const QString file = err.property("fileName").toString();
if (line > 0 || !file.isEmpty()) {
return QStringLiteral(" at %1:%2").arg(file.isEmpty() ? QStringLiteral("<script>") : file).arg(line);
}
return {};
}
} // namespace
ScriptRuntime::Result ScriptRuntime::run(const QString &bank,
const QString &scriptName,
const QVariantMap &params,
QString *outResult) {
QString sink;
if (!outResult) outResult = &sink;
*outResult = QString();
const QString key = bank + "/" + scriptName;
if (!ScriptingConfig::isEnabled() || !ScriptingConfig::isJsEnabledFor(key)) {
*outResult = "JS disabled for " + key;
return Result::Disabled;
}
QString sourcePath;
QString loadError;
if (!ScriptCache::resolve(bank, scriptName, &sourcePath, &loadError)) {
*outResult = "Script not available: " + loadError;
return Result::Disabled;
}
QFile f(sourcePath);
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
*outResult = "Cannot read script file: " + sourcePath;
return Result::EngineError;
}
const QString source = QString::fromUtf8(f.readAll());
f.close();
QJSEngine engine;
engine.installExtensions(QJSEngine::ConsoleExtension);
// Bridges живут на стеке здесь, в worker-потоке. Эти объекты не должны
// переживать engine — QJSEngine удаляет их при разрушении (мы их newQObject'нули),
// поэтому передаём nullptr как родителя — engine берёт владение.
auto *log = new LogBridge(key);
auto *timer = new TimerBridge();
auto *adb = new AdbBridge();
auto *dao = new DaoBridge();
auto *dump = new DumpBridge();
// Bank-specific bridges. На PoC поддержан только Black.
QObject *parserBridge = nullptr;
QObject *helpersBridge = nullptr;
if (bank == QLatin1String("black")) {
auto *pb = new BlackParserBridge();
parserBridge = pb;
helpersBridge = new BlackHelpersBridge(pb);
}
QJSValue host = engine.newObject();
host.setProperty("log", engine.newQObject(log));
host.setProperty("timer", engine.newQObject(timer));
host.setProperty("adb", engine.newQObject(adb));
host.setProperty("dao", engine.newQObject(dao));
host.setProperty("dump", engine.newQObject(dump));
if (parserBridge) host.setProperty("parser", engine.newQObject(parserBridge));
if (helpersBridge) host.setProperty("helpers", engine.newQObject(helpersBridge));
// params: конвертируем QVariantMap → JS object
QJSValue jsParams = engine.toScriptValue(params);
host.setProperty("params", jsParams);
engine.globalObject().setProperty("host", host);
// Оборачиваем исходник так, чтобы он МОГ просто определять run(host) и не
// быть IIFE. Сначала evaluate определений, потом достаём `run` из globalObject.
QJSValue evalResult = engine.evaluate(source, sourcePath);
if (evalResult.isError()) {
*outResult = QStringLiteral("JS parse/eval error: %1%2")
.arg(evalResult.toString(), locationOf(evalResult));
qWarning() << "[Scripting]" << *outResult;
return Result::EngineError;
}
QJSValue runFn = engine.globalObject().property("run");
if (!runFn.isCallable()) {
*outResult = QStringLiteral("Script %1 did not define function run(host)").arg(key);
qWarning() << "[Scripting]" << *outResult;
return Result::EngineError;
}
QJSValue callResult = runFn.call(QJSValueList{} << host);
if (callResult.isError()) {
*outResult = QStringLiteral("JS runtime error: %1%2")
.arg(callResult.toString(), locationOf(callResult));
qWarning() << "[Scripting]" << *outResult;
return Result::EngineError;
}
*outResult = jsValueToResultString(callResult);
return Result::Completed;
}
} // namespace Scripting

View File

@ -0,0 +1,34 @@
#pragma once
#include <QString>
#include <QVariantMap>
namespace Scripting {
class ScriptRuntime {
public:
enum class Result {
// Скрипт выполнился до конца. outResult — пустая строка или бизнес-ошибка
// (то, что вернёт `run()` из JS как строку). EventHandler трактует строку
// как ошибку задачи; пусто — как success.
Completed,
// Не удалось загрузить/распарсить/выполнить скрипт (JS-ошибка, не business).
// В outResult — диагностика. Caller обязан сделать fallback на C++.
EngineError,
// Скрипт не разрешён к запуску (флаг выключен / нет файла / нет SHA).
// outResult — причина. Caller делает fallback.
Disabled
};
// Синхронно запускает скрипт <bank>/<scriptName> в свежем QJSEngine.
// Должен вызываться из того же потока, где будет использоваться (worker-поток
// EventHandler'а — там разрешён блокирующий sleep и DAO).
//
// params видны JS как host.params.<key>.
static Result run(const QString &bank,
const QString &scriptName,
const QVariantMap &params,
QString *outResult);
};
} // namespace Scripting

View File

@ -0,0 +1,159 @@
#include "ScriptUpdater.h"
#include <QCryptographicHash>
#include <QDebug>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QUrl>
#include "ScriptCache.h"
#include "ScriptingConfig.h"
namespace Scripting {
namespace {
constexpr char kReplyKindManifest[] = "manifest";
constexpr char kReplyKindScript[] = "script";
}
ScriptUpdater::ScriptUpdater(QObject *parent)
: QObject(parent), m_net(new QNetworkAccessManager(this)) {}
ScriptUpdater::~ScriptUpdater() = default;
void ScriptUpdater::start() {
if (!ScriptingConfig::isEnabled()) {
qDebug() << "[ScriptUpdater] disabled in config";
emit finished();
return;
}
const QString url = ScriptingConfig::manifestUrl();
if (url.isEmpty()) {
qDebug() << "[ScriptUpdater] manifest_url empty — пропускаю обновление, использую bundled/cache";
emit finished();
return;
}
QNetworkRequest req((QUrl(url)));
req.setRawHeader("Accept", "application/json");
QNetworkReply *reply = m_net->get(req);
reply->setProperty("kind", kReplyKindManifest);
connect(reply, &QNetworkReply::finished, this, &ScriptUpdater::onManifestReply);
}
void ScriptUpdater::onManifestReply() {
auto *reply = qobject_cast<QNetworkReply *>(sender());
if (!reply) { scheduleFinish(); return; }
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) {
qWarning() << "[ScriptUpdater] manifest fetch failed:" << reply->errorString();
scheduleFinish();
return;
}
const QByteArray body = reply->readAll();
QJsonParseError err{};
const QJsonDocument doc = QJsonDocument::fromJson(body, &err);
if (err.error != QJsonParseError::NoError || !doc.isObject()) {
qWarning() << "[ScriptUpdater] manifest parse error:" << err.errorString();
scheduleFinish();
return;
}
const QJsonArray arr = doc.object().value("scripts").toArray();
for (const QJsonValue &v : arr) {
const QJsonObject o = v.toObject();
ScriptItem item;
item.bank = o.value("bank").toString();
item.script = o.value("script").toString();
item.version = o.value("version").toString();
item.url = o.value("url").toString();
item.sha256 = o.value("sha256").toString().toLower();
if (item.bank.isEmpty() || item.script.isEmpty() || item.url.isEmpty() || item.sha256.isEmpty()) {
qWarning() << "[ScriptUpdater] skipping invalid manifest entry:" << o;
continue;
}
const QString installed = ScriptCache::installedVersion(item.bank, item.script);
if (!installed.isEmpty() && installed == item.version) {
qDebug() << "[ScriptUpdater]" << item.bank << "/" << item.script
<< "version" << item.version << "already installed";
continue;
}
m_queue.append(item);
}
if (m_queue.isEmpty()) {
qDebug() << "[ScriptUpdater] nothing to update";
scheduleFinish();
return;
}
qInfo() << "[ScriptUpdater] queued" << m_queue.size() << "script update(s)";
downloadNext();
}
void ScriptUpdater::downloadNext() {
if (m_queue.isEmpty()) {
if (m_inFlight == 0) scheduleFinish();
return;
}
const ScriptItem item = m_queue.takeFirst();
QNetworkRequest req{QUrl(item.url)};
QNetworkReply *reply = m_net->get(req);
reply->setProperty("kind", kReplyKindScript);
reply->setProperty("bank", item.bank);
reply->setProperty("script", item.script);
reply->setProperty("version", item.version);
reply->setProperty("sha256", item.sha256);
m_inFlight++;
connect(reply, &QNetworkReply::finished, this, &ScriptUpdater::onScriptReply);
}
void ScriptUpdater::onScriptReply() {
auto *reply = qobject_cast<QNetworkReply *>(sender());
if (!reply) { scheduleFinish(); return; }
reply->deleteLater();
m_inFlight--;
const QString bank = reply->property("bank").toString();
const QString script = reply->property("script").toString();
const QString version = reply->property("version").toString();
const QString expectedSha = reply->property("sha256").toString().toLower();
const QString tag = bank + "/" + script;
if (reply->error() != QNetworkReply::NoError) {
qWarning() << "[ScriptUpdater] download failed for" << tag << ":" << reply->errorString();
downloadNext();
return;
}
const QByteArray body = reply->readAll();
const QString actualSha = QString::fromLatin1(
QCryptographicHash::hash(body, QCryptographicHash::Sha256).toHex());
if (actualSha != expectedSha) {
qWarning() << "[ScriptUpdater] SHA-256 mismatch for" << tag
<< "expected" << expectedSha << "actual" << actualSha;
downloadNext();
return;
}
QString installErr;
if (!ScriptCache::installFromBytes(bank, script, version, body, &installErr)) {
qWarning() << "[ScriptUpdater] install failed for" << tag << ":" << installErr;
downloadNext();
return;
}
qInfo() << "[ScriptUpdater] installed" << tag << "version" << version;
downloadNext();
}
void ScriptUpdater::scheduleFinish() {
// Гарантирует, что finished() уйдёт после возврата управления event loop'у.
QMetaObject::invokeMethod(this, [this]() { emit finished(); }, Qt::QueuedConnection);
}
} // namespace Scripting

View File

@ -0,0 +1,59 @@
#pragma once
#include <QObject>
#include <QString>
class QNetworkAccessManager;
class QNetworkReply;
namespace Scripting {
// Опрашивает /scripts/manifest, скачивает обновлённые скрипты,
// сверяет SHA-256 и кладёт их в ScriptCache (атомарно).
//
// Manifest формат (JSON):
// {
// "scripts": [
// { "bank": "black", "script": "get_profile_info",
// "version": "1.0.0", "url": "...", "sha256": "<hex>" },
// ...
// ]
// }
//
// Поведение fallback: если что-то пошло не так (сеть, hash mismatch),
// в кэше остаётся ПРЕДЫДУЩИЙ скрипт; если кэш пуст — runtime возьмёт bundled.
class ScriptUpdater final : public QObject {
Q_OBJECT
public:
explicit ScriptUpdater(QObject *parent = nullptr);
~ScriptUpdater() override;
public slots:
// Запускает разовое обновление. Вызывать после успешной авторизации.
void start();
signals:
void finished();
private slots:
void onManifestReply();
void onScriptReply();
private:
void downloadNext();
void scheduleFinish();
struct ScriptItem {
QString bank;
QString script;
QString version;
QString url;
QString sha256;
};
QNetworkAccessManager *m_net;
QList<ScriptItem> m_queue;
int m_inFlight = 0;
};
} // namespace Scripting

View File

@ -0,0 +1,51 @@
#include "ScriptingConfig.h"
#include <QCoreApplication>
#include <QDir>
#include <QSettings>
#include <QStandardPaths>
#include <QStringList>
namespace Scripting {
bool ScriptingConfig::isEnabled() {
const QSettings s("config.ini", QSettings::IniFormat);
return s.value("scripting/enabled", false).toBool();
}
QSet<QString> ScriptingConfig::parseEnabledScripts() {
const QSettings s("config.ini", QSettings::IniFormat);
const QString raw = s.value("scripting/use_js_for").toString();
QSet<QString> out;
for (QString item : raw.split(',', Qt::SkipEmptyParts)) {
item = item.trimmed();
if (!item.isEmpty()) out.insert(item);
}
return out;
}
bool ScriptingConfig::isJsEnabledFor(const QString &bankSlashScript) {
return parseEnabledScripts().contains(bankSlashScript);
}
QString ScriptingConfig::manifestUrl() {
const QSettings s("config.ini", QSettings::IniFormat);
return s.value("scripting/manifest_url").toString().trimmed();
}
QString ScriptingConfig::cacheDir() {
const QSettings s("config.ini", QSettings::IniFormat);
const QString custom = s.value("scripting/cache_dir").toString().trimmed();
if (!custom.isEmpty()) return custom;
const QString base = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
return QDir(base).filePath("scripts");
}
QString ScriptingConfig::bundledDir() {
const QSettings s("config.ini", QSettings::IniFormat);
const QString custom = s.value("scripting/bundled_dir").toString().trimmed();
if (!custom.isEmpty()) return custom;
return QDir(QCoreApplication::applicationDirPath()).filePath("scripts");
}
} // namespace Scripting

View File

@ -0,0 +1,33 @@
#pragma once
#include <QSet>
#include <QString>
namespace Scripting {
// Тонкая обёртка над секцией [scripting] в config.ini.
// Все методы перечитывают конфиг по требованию — изменения без рестарта подхватятся.
class ScriptingConfig {
public:
// Глобальный выключатель: [scripting] enabled = true|false. Default: false.
static bool isEnabled();
// Список скриптов, для которых разрешён JS-вариант.
// [scripting] use_js_for = black/get_profile_info, ozon/get_profile_info
static bool isJsEnabledFor(const QString &bankSlashScript);
// URL манифеста (опционально, по умолчанию пусто = remote-update выключен).
// [scripting] manifest_url = http://.../api/v1/scripts/manifest
static QString manifestUrl();
// Каталог локального кэша. Default: <AppDataLocation>/scripts/
static QString cacheDir();
// Запасной каталог bundled-скриптов (рядом с бинарником). Default: ./scripts/
static QString bundledDir();
private:
static QSet<QString> parseEnabledScripts();
};
} // namespace Scripting