- 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.
26 KiB
Удалённые JS-скрипты задач
Документ описывает scripting-слой, который позволяет обновлять логику банковских
скриптов без пересборки и переустановки приложения — JS-файлы тянутся с сервера
и исполняются внутри QJSEngine.
Статус: PoC. Полностью реализован фреймворк, удалённое обновление и порт одного скрипта —
Black::GetProfileInfoScript. Остальные скрипты остаются на C++ и обновляются обычным релизом приложения.
1. Зачем это нужно
Банковские UI меняются часто: переезжают кнопки, появляются новые экраны подтверждения, шрифты ломают OCR. Раньше любая такая правка требовала:
- Обновить C++-код скрипта.
- Пересобрать релиз под Windows/macOS.
- Раскатить новый 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)
[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-скрипта:
[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:
{
"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. |
Алгоритм апдейтера:
GET manifest_url. Сетевые ошибки → лог[ScriptUpdater] manifest fetch failed, скрипт остаётся прежним.- Для каждой записи: если
ScriptCache::installedVersion(bank, script) == version— пропускаем. - Иначе
GET url, считаем SHA-256, сравниваем. - При совпадении —
QSaveFile(атомарно: запись в.tmp→commit/rename). При несовпадении — лог и пропуск. - Старый
.jsостаётся до успешного апдейта. На устройстве всегда либо валидный новый, либо валидный старый.
Версии используются как ключ кэша, поэтому версию обязательно нужно увеличивать при каждой публикации — иначе обновление пропустится.
5. JS-API (что доступно из скрипта)
Каждый запуск получает глобальный объект host. Полный набор:
host.log
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
host.timer.sleep(ms) // блокирующий sleep с проверкой interrupt каждые 100мс
host.timer.isInterrupted() // true, если watchdog запросил прерывание потока
host.adb — обёртка над AdbUtils
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
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) живёт ровно
один прогон скрипта.
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:
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
host.dump.beginScope(bank, taskKind, deviceId, metaObj)
host.dump.endScope(errorMessage) // "" = success
metaObj: { materialId, amount, phone, card } (все поля опциональны).
Если endScope не вызвать, деструктор DumpBridge закроет скоуп без ошибки.
host.params
То, что передал C++-вызыватель. Для PoC:
host.params.deviceId
host.params.appCode
6. Контракт run(host)
JS-файл обязан определить функцию run(host) в глобальной области:
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/
- macOS:
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. Отладка и поведение при ошибке
Локальная разработка
- Поставить
enabled=true,use_js_for=black/get_profile_infoвconfig.ini. - Положить отредактированный
get_profile_info.jsвbundled_dir(например<build>/scripts/black/) — кэш не используется при отсутствии manifest_url. - Запустить приложение. В логах
application.logискать строки[js:black/get_profile_info].
Falback на C++
Любая из этих ситуаций триггерит fallback:
- В
config.inienabled=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):
- C++-сторона. В
android/black/PayByCardScript.cpp::doStart()в начало функции вставить ту же ветку, что уже есть вGetProfileInfoScript.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++"; } - Bridges. Если скрипту нужны новые методы парсера или хелперов — добавить
Q_INVOKABLEвBlackParserBridge/BlackHelpersBridgeвservices/scripting/ScriptBridges.h/.cpp. - JS. Положить
scripts/black/pay_by_card.jsсfunction run(host). Использоватьhost.params.cardNumber,host.params.amount. - Конфиг. В
config.iniдобавить:[scripting] enabled=true use_js_for=black/get_profile_info, black/pay_by_card - Манифест. Опубликовать новую запись в
<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)
- Подпись манифеста (Ed25519). Сервер подписывает JSON приватным ключом, клиент проверяет публичным ключом, хардкоднутым в бинарник (или зашифрованным конфигом).
- Аутентификация манифеста.
Bearer access_tokenизSettingsDAOуже есть вNetworkService— переиспользовать. - Hot-reload по push. Сервер шлёт нотификацию «обновись», апдейтер тянет манифест немедленно, а не только при старте.
- Telemetry. Отправлять на
/monitoring/logсобытие при каждом успешном/ неуспешном fallback'е — поможет понять, какие скрипты часто падают. - Расширение покрытия. Перенести остальные
*ScriptBlack-банка, затем Ozon и Dushanbe. - Тесты. Завести golden-tests: для каждого
<bank>/<script>записанный набор XML-дампов + ожидаемые ADB-операции (моки), прогоняется на JS-версии.