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:
parent
abcc3cadb2
commit
cae345cf61
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
14
config.ini
14
config.ini
@ -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=
|
||||
|
||||
@ -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
460
docs/remote_scripts.md
Normal 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-версии.
|
||||
13
main.cpp
13
main.cpp
@ -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");
|
||||
|
||||
173
scripts/black/get_profile_info.js
Normal file
173
scripts/black/get_profile_info.js
Normal 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;
|
||||
}
|
||||
}
|
||||
417
services/scripting/ScriptBridges.cpp
Normal file
417
services/scripting/ScriptBridges.cpp
Normal 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
|
||||
174
services/scripting/ScriptBridges.h
Normal file
174
services/scripting/ScriptBridges.h
Normal 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
|
||||
88
services/scripting/ScriptCache.cpp
Normal file
88
services/scripting/ScriptCache.cpp
Normal 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
|
||||
29
services/scripting/ScriptCache.h
Normal file
29
services/scripting/ScriptCache.h
Normal 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
|
||||
135
services/scripting/ScriptRuntime.cpp
Normal file
135
services/scripting/ScriptRuntime.cpp
Normal 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 ¶ms,
|
||||
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
|
||||
34
services/scripting/ScriptRuntime.h
Normal file
34
services/scripting/ScriptRuntime.h
Normal 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 ¶ms,
|
||||
QString *outResult);
|
||||
};
|
||||
|
||||
} // namespace Scripting
|
||||
159
services/scripting/ScriptUpdater.cpp
Normal file
159
services/scripting/ScriptUpdater.cpp
Normal 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
|
||||
59
services/scripting/ScriptUpdater.h
Normal file
59
services/scripting/ScriptUpdater.h
Normal 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
|
||||
51
services/scripting/ScriptingConfig.cpp
Normal file
51
services/scripting/ScriptingConfig.cpp
Normal 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
|
||||
33
services/scripting/ScriptingConfig.h
Normal file
33
services/scripting/ScriptingConfig.h
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user