1
0
forked from BRT/arc
arc/docs/remote_scripts.md
slava cae345cf61 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.
2026-05-13 15:42:10 +07:00

26 KiB
Raw Blame History

Удалённые 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)

[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.

Алгоритм апдейтера:

  1. GET manifest_url. Сетевые ошибки → лог [ScriptUpdater] manifest fetch failed, скрипт остаётся прежним.
  2. Для каждой записи: если ScriptCache::installedVersion(bank, script) == version — пропускаем.
  3. Иначе GET url, считаем SHA-256, сравниваем.
  4. При совпадении — QSaveFile (атомарно: запись в .tmpcommit/rename). При несовпадении — лог и пропуск.
  5. Старый .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/
  • bundled_dir — каталог scripts/ рядом с exe, кладётся CMake'ом из <source>/scripts/. Это запасной парашют: если кэш пуст или повреждён — работает bundled.
  • Атомарность установки гарантируется QSaveFile (запись в .tmpcommit = 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:
    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 добавить:
    [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-версии.