diff --git a/.github/workflows/harmonyos-build.yml b/.github/workflows/harmonyos-build.yml
index 26ba97d..d156874 100644
--- a/.github/workflows/harmonyos-build.yml
+++ b/.github/workflows/harmonyos-build.yml
@@ -17,28 +17,12 @@ concurrency:
cancel-in-progress: true
jobs:
- test:
- name: Runtime tests
- runs-on: ubuntu-latest
- timeout-minutes: 10
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Set up Node.js
- uses: actions/setup-node@v6
- with:
- node-version: 22
-
- - name: Run tests
- run: npm test
-
package:
name: Build HAR and HAP
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'push' && vars.HARMONYOS_CI_ENABLED == 'true')
- needs: test
+ environment: ohpm
runs-on:
- self-hosted
- macOS
diff --git a/.github/workflows/ohpm-publish.yml b/.github/workflows/ohpm-publish.yml
index 7b0d6f9..c1de7ca 100644
--- a/.github/workflows/ohpm-publish.yml
+++ b/.github/workflows/ohpm-publish.yml
@@ -11,6 +11,7 @@ permissions:
jobs:
publish:
name: Build and publish HAR to OHPM
+ environment: ohpm
runs-on:
- self-hosted
- macOS
diff --git a/.gitignore b/.gitignore
index 822f01c..a800629 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,4 @@ oh_modules/
.clang-tidy
.clangd
oh-package-lock.json5
+.test
diff --git a/AGENTS.md b/AGENTS.md
index 2db46f3..022e510 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -25,7 +25,7 @@
- C++ must never throw or catch exceptions. It is compiled with `-fno-exceptions`.
- C++/JSVM/NAPI errors must be logged with error-level HiLog and must not terminate the process.
- ArkTS and patch JavaScript may throw.
-- The original ArkTS exception must remain pending and must not be converted or swallowed by C++.
+- Plain fallback calls preserve the original ArkTS pending exception. Explicit Patch `origin.apply(...)` calls convert a pending ArkTS exception into a JSVM `Error` so patch JavaScript can catch and recover; if the JSVM error is not caught, OhosPatch falls back to the original ArkTS implementation and preserves original crash behavior.
## Current Runtime API
@@ -57,7 +57,7 @@ Primary implementation: `ohospatch/src/main/cpp/ohospatch.cpp`.
- Patch method registrations are returned from JSVM as JSON specs.
- Native loads target classes in the ArkTS VM and installs N-API trampolines.
- Handler arguments still enter JSVM as JSON values. Method handler `this`, Component event handler `this`, nested properties, method calls, Proxy arguments, original-method results, and Proxy returns use invocation-scoped Native handles.
-- Runtime `1.4.0` creates a JS `Proxy` for the ArkTS receiver. `get`, `set`, and `apply` synchronously bridge to the original ArkTS object, preserving nested object identity and prototype method dispatch.
+- Runtime `1.7.0` includes a JS `Proxy` for the ArkTS receiver. `get`, `set`, and `apply` synchronously bridge to the original ArkTS object, preserving nested object identity and prototype method dispatch.
- Proxy handles are valid only during the current synchronous patch invocation and must not escape to timers, promises, or globals. A call can retain at most 256 handles.
- Hook failures fall back to the original ArkTS method.
- Installation failure restores hooks that were already installed.
@@ -112,20 +112,19 @@ Proposed public concepts:
- `component.state(name)` for observable component state.
- `component.node(selector)` for a built-in ArkUI node.
- `node.attrs({...})` for attribute overrides.
-- `node.event(name, rule)` for callback replacement.
-- Event modes eventually include `replace`, `before`, `after`, and `around`.
-- Event context eventually provides safe state snapshots, `setState`, component method invocation, and original callback invocation.
+- `node.event(name, handler)` for synchronous callback replacement.
+- Event handlers receive only the original ArkUI event arguments. Ordinary `function` handlers receive `this` as the current Component instance Proxy and should read/write state directly through that Proxy.
### Implemented status (2026-08-10)
-- Runtime version `1.4.0` implements `Fixit.component` and the invocation-scoped ArkTS object Proxy bridge.
+- Runtime version `1.7.0` implements `Fixit.component` and the invocation-scoped ArkTS object Proxy bridge.
- API 20 state-management V1 exported custom components are supported.
- `param().transform/replace` and `state().transform/replace` are implemented.
- Node selection by `{ type, occurrence }` is implemented with zero-based per-type counting.
-- `attr`, `attrs`, and synchronous `event(..., { mode: 'replace' })` are implemented.
-- Event `capture` and `context.setState()` are implemented; capture is limited to 16 properties.
+- `attr`, `attrs`, and synchronous `event(name, handler)` are implemented.
- Component event handlers written with normal `function` syntax receive `this` as the current Component instance Proxy. Arrow functions keep lexical `this`.
-- `node.event(...)` returns an original ArkUI event callback proxy. `origin.apply(this, arguments)` is supported and strips OhosPatch's injected event context before calling the original callback.
+- Component event handlers receive only the original ArkUI event arguments and can read/write component state through `this`.
+- `node.event(...)` returns an original ArkUI event callback proxy. `origin.apply(this, arguments)` is supported.
- Event bridges retain the original ArkTS callback and fall back to it after `clear()` or a patch handler failure.
- The Demo target is `entry/src/main/ets/demo/PatchablePanel.ets` and the dynamic script is `entry/src/main/resources/rawfile/patch.js` (served by `patch-server`).
- API 20 generated output was inspected and matches `setInitiallyProvidedValue`, `updateStateVars`, `initialRender`, and `observeComponentCreation2`.
@@ -157,7 +156,6 @@ Fail closed when a component shape, node selector, attribute, or event cannot be
- Existing mounted components need rerender/invalidation after install and clear. If that cannot be guaranteed, report that a natural rerender is required.
- Click/touch event objects may contain native state and cannot be passed through the current generic JSON bridge. Define safe event DTOs.
- Resource, Length, Color, enum, animation, gesture, and controller values need typed wire descriptors rather than arbitrary JSON.
-- A JS event context must not outlive the synchronous callback until weak-reference async semantics are implemented.
- Global ArkUI wrappers must be pass-through outside an active target render frame.
- V1 and V2 generated code differ. Keep version adapters separate.
- Every native-to-JSVM operation that creates or retrieves a JSVM value must run inside an explicit `JSVM_HandleScope`.
@@ -165,7 +163,7 @@ Fail closed when a component shape, node selector, attribute, or event cannot be
## Build And Test
-Run JS/runtime tests:
+Run real-device/runtime tests. This builds and installs the demo HAP plus `entry@ohosTest`, then executes Hypium on the connected HarmonyOS/OpenHarmony emulator or device:
```bash
npm test
@@ -203,10 +201,10 @@ Expected result: no matches.
## Tests And CI
-- JS tests: `ohospatch/src/test/js/fixit.test.mjs`
-- Native safety tests: `ohospatch/src/test/js/native-safety.test.mjs`
+- Device runtime tests: `entry/src/ohosTest/ets/test/*.ets`
+- Do not add Node `vm`-based Patch runtime tests. Runtime behavior must be validated in the real JSVM/N-API/ArkTS environment through Hypium.
+- Do not add source-scanning runtime tests for `ohospatch`. Validate behavior through inputs/outputs, and validate the compiled native binary directly when safety policy matters.
- GitHub workflow: `.github/workflows/harmonyos-build.yml`
-- Push and PR run JS tests on GitHub-hosted Ubuntu.
- HAR/HAP packaging uses a self-hosted macOS ARM64 runner labeled `harmonyos`.
- `HARMONYOS_CI_ENABLED=true` enables package builds on main pushes.
- Packaging uploads unsigned HAR/HAP artifacts.
diff --git a/CLAUDE.md b/CLAUDE.md
index 993c1a7..b05d115 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -10,12 +10,10 @@ OhosPatch is a transparent runtime JavaScript patching system for HarmonyOS/Open
## Commands
-JS runtime tests (Node 22, no device needed):
+Runtime behavior tests run on a connected HarmonyOS/OpenHarmony emulator or device. They build and install the Demo HAP plus `entry@ohosTest`, then execute Hypium against the real JSVM/N-API/ArkTS environment:
```bash
-npm test # all tests
-node --test ohospatch/src/test/js/fixit.test.mjs # one file
-node --test --test-name-pattern="proxy" ohospatch/src/test/js/fixit.test.mjs # one test
+npm test
```
Build the reusable HAR (output: `ohospatch/build/default/outputs/default/ohospatch.har`):
@@ -47,7 +45,7 @@ node patch-server/server.mjs # serves http://127.0.0.1:8080/patch.js
hdc rport tcp:8080 tcp:8080 # reverse port to device/simulator
```
-Before committing: `git diff --check`, `npm test`, and (when native changed) HAR + HAP build + exception-symbol check.
+Do not add Node `vm` Patch runtime tests or source-scanning runtime tests for `ohospatch`; validate runtime behavior through device-side inputs and outputs. Before committing: `git diff --check`, `npm test`, and (when native changed) HAR + HAP build + exception-symbol check.
## Architecture: two cooperating VMs
@@ -85,7 +83,7 @@ Do **not** move download, signature, URL, cache, or startup policy into `ohospat
## Native C++ invariants (hard rules)
-`ohospatch.cpp` is built with `-fno-exceptions`. These are enforced by `native-safety.test.mjs` (static source asserts) and the CI exception-symbol check:
+`ohospatch.cpp` is built with `-fno-exceptions`. Runtime safety is verified through device-side behavior tests, and the compiled native binary is checked directly for C++ exception symbols:
- Never add `throw`, `catch`, `std::exception`/`std::runtime_error`, `napi_throw`, or `OH_JSVM_Throw`. Use `std::nothrow` for allocation and always handle failure.
- Prefer fixed-size arrays over exception-throwing containers in native control paths.
@@ -99,10 +97,10 @@ Do **not** move download, signature, URL, cache, or startup policy into `ohospat
Several files must stay mutually consistent; a change in one usually requires the others:
-- `skills/ohospatch/references/fixit.d.js` `@version` **must equal** `Fixit.runtimeVersion` in `ohospatch/src/main/cpp/runtime/fixit.js` (currently `1.6.0`). Signatures, constraints, and globals in the declaration must match the runtime.
+- `skills/ohospatch/references/fixit.d.js` `@version` **must equal** `Fixit.runtimeVersion` in `ohospatch/src/main/cpp/runtime/fixit.js` (currently `1.7.0`). Signatures, constraints, and globals in the declaration must match the runtime.
- `skills/ohospatch/SKILL.md` + `scripts/install-skill.sh` must track `references/fixit.d.js`, README limitations, and demonstrated runtime behavior.
- `README.md` "当前边界" (current limitations) must reflect actual runtime capability.
-- `native-safety.test.mjs` pins specific symbol/handle-scope/callback-storage patterns — update it when those patterns legitimately change.
+- Do not add source-scanning tests that pin implementation tokens such as handle-scope names or callback storage identifiers. When safety policy matters, test user-visible failure behavior on device and inspect the compiled binary.
## Declarative Component DSL
diff --git a/README.md b/README.md
index 4ae40b8..dcc7e48 100644
--- a/README.md
+++ b/README.md
@@ -1,125 +1,165 @@
# OhosPatch
-OhosPatch 是 [FIXiT](https://github.com/rickytan/FIXiT) 在 HarmonyOS/OpenHarmony 上的原型实现。宿主 APP 负责下载和验证 patch,再将完整 JavaScript 字符串或本地文件绝对路径交给 OhosPatch。OhosPatch 在独立 JSVM 中执行脚本,并通过 ArkTS 主 VM 的对象原型替换业务方法。业务类不需要继承基类、添加装饰器或调用补丁分发 API。
+OhosPatch 是面向 HarmonyOS/OpenHarmony 的 ArkTS 运行时热修复框架,灵感来自 iOS 的 [FIXiT](https://github.com/rickytan/FIXiT)。它把补丁脚本放在独立 JSVM 中执行,再通过 Native N-API 在 ArkTS 主 VM 中加载目标模块、替换类原型方法和声明式组件渲染入口。
+
+项目当前不是概念验证原型,而是一套可以按生产流程接入的 HAR 能力:宿主 APP 负责下载、验签、灰度、缓存和回滚,OhosPatch 只负责在运行时安装和清理 patch。业务类和业务组件不需要继承基类、加装饰器、注册类表或调用补丁分发 API。
+
+## 背景
+
+HarmonyOS 上的 ArkTS 应用发布后,常见问题包括:
+
+- 线上业务方法抛异常,例如 `undefined is not a function`、数组越界、空值访问。
+- 声明式组件参数、状态、属性或事件回调存在错误。
+- 已发布版本需要小范围止血,但重新发版和审核周期过长。
+
+在 iOS 上,FIXiT 可以依赖 JavaScriptCore 和 Objective-C runtime 动态替换方法。HarmonyOS 的情况不同:目前公开平台能力中没有一套面向 ArkTS 方法和 ArkUI 声明式组件的通用原生热修复方案,也没有类似 Objective-C runtime 的公开方法交换入口。OhosPatch 的目标是在不侵入业务代码的前提下,为 ArkTS 提供可控、可回滚、可观测的运行时 patch 能力。
+
+## 能力概览
+
+- 以 HAR 形式接入,产物是 `ohospatch.har`。
+- Patch 使用普通 JavaScript 编写,在独立 JSVM 中运行。
+- 支持实例方法、静态方法、原方法调用 `origin.apply(...)`。
+- Patch handler 中的 `this` 是当前 ArkTS 实例 Proxy,支持点语法读写多层属性和调用方法。
+- 支持 `Fixit.import(fullPath)` 动态导入其他 ArkTS 类,调用静态方法、构造实例、访问实例方法和属性。
+- 支持声明式 Component DSL:参数、状态、节点属性和同步事件回调。
+- 支持 `console` 到 HiLog、`setTimeout`、`setInterval`、`setImmediate`、`queueMicrotask`。
+- C++ 层以 `-fno-exceptions` 构建,不抛 C++ 异常;错误走 HiLog 并 fail closed。
+- `clear()` 可恢复原方法、清空 JS registry、释放引用并取消 timer。
## 工程结构
```text
OhosPatch/
-├── skills/ohospatch/ # Codex/Claude Patch 编写 Skill 源码
-│ └── references/fixit.d.js # Patch JS Context 的 JSDoc/IDE 声明
-├── scripts/install-skill.sh # 用户级 Skill 安装脚本
-├── ohospatch/ # 可复用 HAR 模块
+├── ohospatch/ # 可复用 HAR 模块,所有 patch 能力都在这里
│ ├── Index.ets # HAR 对外 API
│ └── src/main/
-│ ├── cpp/ # JSVM、NAPI、prototype hook
+│ ├── cpp/ # JSVM、N-API、Hook、ArkTS Proxy 桥
│ │ └── runtime/ # 内置 Fixit JS runtime
-│ ├── ets/ # 字符串和本地文件执行 API
-│ └── module.json5 # 无权限、无启动任务的 HAR 清单
-├── entry/ # Demo APP
+│ ├── ets/ # ArkTS 外观 API
+│ └── module.json5 # 无下载、无验签、无启动任务
+├── entry/ # Demo APP,只负责演示宿主如何接入 HAR
│ └── src/main/ets/
-│ ├── demo/ # 未侵入的业务类和验证场景
-│ ├── patch/ # 宿主下载、验签接入点和启动策略
-│ └── pages/ # 验证页面
-└── patch-server/ # 开发期 HTTP 服务,直接服务 rawfile 下的 patch.js
+│ ├── demo/ # 未侵入的业务类和组件
+│ ├── patch/ # 宿主下载、验签接入点和加载策略
+│ └── pages/ # 两级 Navigation Demo 页面
+├── patch-server/ # 开发期 HTTP patch 服务
+├── skills/ohospatch/ # Codex/Claude patch 编写 Skill
+├── scripts/ # 设备测试和 Skill 安装脚本
+└── docs/images/ # README 效果图
```
-`ohospatch` 可以独立构建为 `ohospatch.har`。`entry` 没有 Native 或补丁实现源码,只通过 `file:../ohospatch` 依赖接入 HAR。
-
-## 实现原理
-
-iOS FIXiT 同时依赖 JavaScriptCore 和 Objective-C runtime。JavaScriptCore 负责执行补丁,Objective-C runtime 负责动态替换方法。
-
-HarmonyOS 中,`OH_JSVM_CreateVM` 创建的独立 JSVM 与 ArkTS 主 VM 不共享对象堆和原型链。因此,直接在 JSVM 中修改 `DemoViewModel.prototype` 无法影响 ArkTS 业务对象。OhosPatch 使用两层运行时协作:
-
-1. 宿主将已验证的 JavaScript 字符串或本地绝对路径传给 HAR。
-2. HAR 的 Native 模块创建独立 JSVM 并执行 JavaScript。
-3. patch 注册目标类、模块、方法和 JS handler。
-4. Native 使用 `napi_load_module_with_info` 从 ArkTS 主 VM 加载目标模块;`Fixit.import()` 额外为导出的类建立持久 Proxy。
-5. 实例方法替换 `constructor.prototype[methodName]`,静态方法替换 `constructor[methodName]`。
-6. 原函数保存为 `napi_ref`,新函数使用 Native trampoline 转入 JSVM。
-7. 每次调用建立临时 ArkTS 对象句柄表,JSVM 中的 `this` 是 `Proxy`;属性读取、写入和方法调用同步转发到原 ArkTS 实例。
-8. JS 中调用 `origin.apply(...)` 时,通过保存的 `napi_ref` 回调原 ArkTS 方法,并保留对象返回值的身份。
-9. `clear()` 恢复原型上的原函数,并释放 Hook、组件和动态导入引用。
-
-普通 public 方法调用会经过对象属性和原型查找,因此已创建的业务实例也会在替换后进入 patch。
-
-声明式组件使用 API 20 状态管理 V1 适配器。Native 在目标组件原型上包装编译产物的参数初始化、首次渲染和节点创建入口;参数和状态在原渲染前转换,节点 builder 执行后再写入属性并注册事件回调。公开 DSL 不暴露这些编译器生成的方法名。
-
-## 内置 JS Runtime
-
-`ohospatch/src/main/cpp/runtime/fixit.js` 定义 `Fixit` 构造函数和 patch 常用全局函数。CMake 在构建 HAR 时将该文件嵌入 `libohospatch.so`;JSVM 创建后先执行内置 runtime,再执行宿主传入的 patch,因此宿主和 patch 都不需要单独加载它。
-
-内置 API:
-
-- `Fixit.fix(fullPath)`:解析目标类完整 OHM 源路径并创建 patch 对象。
-- `Fixit.component(fullPath)`:解析目标组件完整 OHM 源路径并创建声明式组件 patch 对象。
-- `Fixit.import(fullPath)`:同步导入 ArkTS 主 VM 中的导出类,支持静态调用、`new`、实例属性和实例方法。
-- `component.param(name)` / `component.state(name)`:转换或替换组件参数与状态。
-- `component.node({ type, occurrence })`:按 ArkUI 节点类型和同类型出现序号选择节点。
-- `node.attr(name, ...args)` / `node.attrs({...})`:覆盖节点属性。
-- `node.event(name, rule)`:替换节点事件并按需读取、更新组件状态。
-- `Fixit.registerTarget(className, descriptor)`:注册类名到 HarmonyOS 模块描述符的映射,使后续可以使用 `Fixit.fix('ClassName')`。
-- `instanceMethod(name, handler)` / `classMethod(name, handler)`:替换实例方法或静态方法,并返回原实现代理。
-- `require(fullPath)`:`Fixit.import(fullPath)` 的兼容别名,返回可调用、可构造的 ArkTS 类 Proxy。
-- `nil` / `Nil`、`isNil`、`nilToNull`、`nullToNil`。
-- `console.debug/log/info/warn/error`:输出到 HiLog 的 `OhosPatch` tag。
-- `setTimeout` / `clearTimeout`、`setInterval` / `clearInterval`、`setImmediate` / `clearImmediate`。
-- `queueMicrotask(callback)`:将回调加入 JSVM microtask 队列。
-
-### 编辑器补全
-
-`skills/ohospatch/references/fixit.d.js` 声明 Patch JS Context 中的 `Fixit`、动态导入类代理、目标描述符、原方法代理、Component DSL、事件上下文、`require`、nil helpers、timer、microtask 和 HiLog console API。Patch 文件首行按相对路径引用声明后,VS Code、WebStorm 等支持 JavaScript/JSDoc 的编辑器即可提供类型提示和自动补全:
-
-```js
-///
+`ohospatch` 不包含下载、签名验证、版本匹配、灰度、缓存或启动策略。这些都是宿主 APP 的生产发布系统职责。
+
+## 架构
+
+```mermaid
+flowchart LR
+ subgraph Host["宿主 APP"]
+ Loader["下载 / 验签 / 灰度 / 缓存"]
+ Business["ArkTS 业务类与 ArkUI 组件"]
+ end
+
+ subgraph Har["ohospatch HAR"]
+ API["OhosPatch.executeScript / executeFile"]
+ Native["Native N-API Bridge"]
+ JSVM["独立 JSVM + Fixit Runtime"]
+ end
+
+ Loader --> API
+ API --> JSVM
+ JSVM --> Native
+ Native --> Business
+ Business --> Native
+ Native --> JSVM
```
-Hook API 直接接收完整路径;需要主动调用业务类时使用 `Fixit.import()`:
-
-```js
-var fix = Fixit.fix(
- 'com.rickytan.ohospatch/entry/src/main/ets/demo/DemoViewModel#DemoViewModel'
-);
-var Point = Fixit.import(
- 'com.rickytan.ohospatch/entry/src/main/ets/demo/Point#Point'
-);
+方法 Hook 调用链:
+
+```mermaid
+sequenceDiagram
+ participant App as ArkTS 业务调用
+ participant Proto as 被替换的 prototype 方法
+ participant Native as OhosPatch N-API trampoline
+ participant VM as 独立 JSVM
+ participant Origin as 原 ArkTS 方法
+
+ App->>Proto: vm.crashIt()
+ Proto->>Native: trampoline(this, args)
+ Native->>VM: __ohospatch_call(...)
+ VM->>VM: handler.apply(thisProxy, args)
+ alt patch 调用原方法
+ VM->>Native: origin.apply(this, arguments)
+ Native->>Origin: napi_call_function(original)
+ Origin-->>Native: result / exception
+ Native-->>VM: result / throw into JS
+ end
+ VM-->>Native: patch result
+ Native-->>App: ArkTS result
```
-声明文件只用于开发期语言服务,不需要下发给设备,也不能作为 Patch 执行。其 `@version` 应与 `Fixit.runtimeVersion` 保持一致。
-
-仓库的 `skills/ohospatch/SKILL.md` 提供 Patch 编写与审查流程,并兼容 Codex 和 Claude Code。运行以下脚本会默认安装到两个工具的用户级 Skill 目录:
+声明式组件 Hook 调用链:
-```shell
-./scripts/install-skill.sh
+```mermaid
+flowchart TD
+ A["Fixit.component(fullPath)"] --> B["Native 加载导出的 Component 类"]
+ B --> C["包装参数初始化和首次渲染入口"]
+ C --> D["包装 observeComponentCreation2 的节点 builder"]
+ D --> E["原 builder 创建 ArkUI 节点"]
+ E --> F["Patch 写入节点 attrs / event"]
+ F --> G["ArkUI 继续正常渲染"]
```
-也可以使用 `--codex` 或 `--claude` 只安装一个工具;已有安装需要更新时传入 `--force`。脚本分别支持 `CODEX_HOME` 和 `CLAUDE_HOME` 自定义配置根目录。安装后在 Codex 中使用 `$ohospatch`,在 Claude Code 中使用 `/ohospatch`。
+## 原理
-独立 JSVM 与 ArkTS 主 VM 不共享对象。`Fixit.fix(fullPath)` 和 `Fixit.component(fullPath)` 在 Runtime 内将路径解析成 Hook 描述符;安装 Hook 时,Native 根据描述符调用 `napi_load_module_with_info`,在主 ArkTS VM 中加载目标模块并取得导出的类。
+iOS FIXiT 同时依赖 JavaScriptCore 和 Objective-C runtime。HarmonyOS 上 `OH_JSVM_CreateVM` 创建的 JSVM 与 ArkTS 主 VM 不共享对象堆和原型链,因此在 JSVM 中直接改 `DemoViewModel.prototype` 不会影响 ArkTS 业务对象。
-需要在 Patch 中主动使用其他业务类时,调用 `Fixit.import(fullPath)` 或其兼容别名 `require(fullPath)`。它返回同步的类 Proxy,可调用静态方法、通过 `new` 创建实例并继续用点语法访问实例属性和方法:
+OhosPatch 使用两层运行时协作:
-```js
-var Point = Fixit.import(
- 'com.rickytan.ohospatch/entry/src/main/ets/demo/Point#Point'
-);
-var point = new Point(7, 9);
-console.info(Point.textOf(point));
-console.info(point.toText());
+1. 宿主将已验证的完整 JavaScript 字符串或本地绝对路径传给 HAR。
+2. Native 模块创建独立 JSVM,先执行内置 `fixit.js`,再执行宿主 patch。
+3. Patch 通过 `Fixit.fix()`、`Fixit.component()` 和 `Fixit.import()` 注册目标。
+4. Native 使用 `napi_load_module_with_info` 在 ArkTS 主 VM 加载业务模块。
+5. 实例方法替换 `constructor.prototype[methodName]`,静态方法替换 `constructor[methodName]`。
+6. 原方法以 `napi_ref` 保存,新方法进入 Native trampoline。
+7. JSVM handler 中的 `this` 是调用期 Proxy;属性读取、赋值、方法调用同步桥接到原 ArkTS 对象。
+8. `origin.apply(this, arguments)` 通过保存的 `napi_ref` 调回原方法。
+9. `clear()` 恢复原函数并释放 Hook、Component、动态导入对象和 timer。
+
+已创建的业务实例也会受影响,因为普通 public 方法调用会经过对象属性和原型查找。构造函数、私有实现、实例字段箭头函数和绕过属性查找的调用点不属于当前覆盖范围。
+
+## 生产接入模型
+
+OhosPatch 在生产环境中只负责“执行已可信 patch”。建议宿主侧按下面流程接入:
+
+```mermaid
+flowchart LR
+ A["启动或业务初始化"] --> B["读取本地缓存 patch"]
+ B --> C{"签名 / 版本 / 设备校验"}
+ C -- 通过 --> D["OhosPatch.executeScript"]
+ C -- 失败 --> E["丢弃并记录"]
+ A --> F["后台请求 patch 配置"]
+ F --> G["下载 patch"]
+ G --> H["验签、灰度、熔断、缓存"]
+ H --> D
+ D --> I{"hookCount > 0"}
+ I -- 是 --> J["记录版本和成功状态"]
+ I -- 否 --> K["回滚 / 禁用该 patch"]
```
-JavaScript 的 `import()` 是异步模块语法,不能覆写为普通全局函数,因此 Patch API 明确使用 `Fixit.import()`。导入的类和实例由 Native `napi_ref` 持有,在当前 Patch 生命周期内可用于同步 handler 或 timer,也可以作为属性值、方法参数和 Patch 返回值跨桥传递;`clear()` 或下一次 Patch 安装会使旧 Proxy 失效并释放引用。
-
-实例方法和静态方法的 handler 可以使用普通点语法访问原对象,包括多层属性、赋值和方法调用,例如 `this.profile.badge.text`、`this.profile.badge.text = 'fixed'` 和 `this.profile.badge.advance(1)`。嵌套对象不会被复制成 JSON 快照;Native 为其分配本次调用内的句柄,JSVM 返回对应 Proxy。Proxy 只能在当前同步 handler 或 `origin` 调用期间使用,不能保存到 timer、Promise 或全局变量后异步访问。普通参数仍按 JSON 值传入;Proxy 参数和 Proxy 返回值使用句柄 wire 格式保留原 ArkTS 对象身份。
+宿主必须负责:
-Timer callback 和参数保存在 JSVM 内,Native 仅通过宿主 N-API 的 libuv event loop 调度 timer ID。`clear()`、下一次 `executeScript` 替换 patch 或 JSVM 重置时都会取消旧 timer,避免旧 patch 的异步任务继续执行。
+- Patch 文件下载和 HTTPS 策略。
+- 非对称签名或等价安全校验。
+- App 版本、设备、系统版本、业务版本匹配。
+- 灰度发布、黑白名单、熔断和回滚。
+- Patch 缓存和清理。
+- 启动时机、超时控制和日志上报。
-Native 使用 `-fno-exceptions` 构建,不使用 C++ `throw/catch`。JSVM/NAPI 桥接失败会输出 `OhosPatch` error 级别 HiLog;patch 执行失败时回退原 ArkTS 方法,Hook 安装失败时回滚已安装的方法,`executeScript` 返回 `0`。参数校验、文件读取及业务原方法自身的异常仍保留在 ArkTS 层,其中原方法异常不会在 C++ 中捕获或转换。
+HAR 不声明网络权限,也不会把任何下载或签名逻辑放入 `ohospatch` 模块。
-## HAR 接入
+## 接入方式
-Demo APP 的模块依赖:
+Demo 的 `entry/oh-package.json5` 使用本地 HAR 依赖:
```json5
{
@@ -129,9 +169,7 @@ Demo APP 的模块依赖:
}
```
-HAR 不声明 `ohos.permission.INTERNET`,不包含 HTTP 客户端、签名实现、patch URL、缓存或 AppStartup 任务。宿主根据自己的发布系统和启动策略完成这些工作。
-
-执行完整 patch 字符串:
+业务代码不需要任何改动。宿主只在自己的 patch 管理代码里调用:
```ts
import { OhosPatch } from '@rickytan/ohospatch';
@@ -139,67 +177,86 @@ import { OhosPatch } from '@rickytan/ohospatch';
const hookCount = OhosPatch.executeScript(verifiedPatchScript);
```
-执行本地 patch 文件:
+或者执行已下载到本地的完整文件路径:
```ts
const hookCount = OhosPatch.executeFile(absolutePatchPath);
```
-`executeFile` 只接受完整绝对路径,由 HAR 使用 `fileIo.readTextSync` 读取后执行。路径来源、文件权限、下载和签名验证仍由宿主负责。
+`executeFile` 只读取绝对路径文件并执行;路径来源、文件权限、验签和缓存策略仍由宿主控制。
-Demo APP 在首屏通过按钮从 `patch-server` 下载脚本,并在交给 OhosPatch 前保留宿主验签位置。网络权限和 patch URL 都位于 `entry`;业务类 `DemoViewModel`、`PatchablePanel` 和业务调用代码不引用 OhosPatch。
+清除当前 patch:
-## Patch 格式
+```ts
+OhosPatch.clear();
+```
+
+`executeScript` 返回已安装的普通方法 hook 数量。Component rule 主要在后续渲染阶段生效,生产侧不应只用 hook 数量判断业务效果,建议同时记录 patch 版本和运行时日志。
+
+## Patch 编写
+
+Patch 脚本可以引用声明文件获得 IDE 补全:
+
+```js
+///
+```
-跨模块 Hook 直接传入包含 `bundleName/moduleName` 和目标 package 路径的完整 OHM 源路径:
+### 修复实例方法
```js
var fix = Fixit.fix(
- 'com.rickytan.ohospatch/entry/src/main/ets/demo/DemoViewModel#DemoViewModel'
+ 'com.example.app/entry/src/main/ets/model/DemoViewModel#DemoViewModel'
);
var origin = fix.instanceMethod('locationOf', function (locations, index, fallback) {
if (index < 0 || index >= locations.length) {
this.profile.badge.text = 'out of bounds';
this.profile.badge.advance(10);
- this.buttonTitle = this.profile.summary();
return fallback;
}
return origin.apply(this, arguments);
});
+```
-fix.classMethod('crash', function () {
- return 'fixed';
-});
+### 修复 `undefined is not a function`
+
+Demo 中第二屏的 `Unsafe onClick crash` 是一个普通 ArkUI `Button().onClick` 回调。未加载 patch 时点击会在 onClick 内直接调用未定义 callback,并触发 `undefined is not a function` 类错误;加载 patch 后,OhosPatch 用 Component event DSL 替换这个 Button 的 `onClick`,在回调最外层加 `try/catch`,并在 `try` 中调用原始事件回调。这样 patch 的性质和实例方法修复一致:包住原方法,捕获原始实现中的异常,再写回组件状态:
+
+```js
+var originUnsafeClick = panel.node({ type: 'Button', occurrence: 2 })
+ .event('onClick', function () {
+ try {
+ return originUnsafeClick.apply(this, arguments);
+ } catch (err) {
+ var message = err && err.message ? err.message : String(err);
+ this.tagText = 'Recovered Button.onClick crash: ' + message;
+ this.statusText = 'Patched Button.onClick recovered';
+ }
+ });
```
-需要主动调用类时使用 `Fixit.import()`;`require()` 是它的兼容别名:
+注意:OhosPatch 只会在 patch 显式调用 `origin.apply(this, arguments)` 时,把原 ArkTS pending exception 转成 JSVM 中可捕获的 `Error`。如果 handler 没有 catch,调用会回退到原 ArkTS 实现并继续保留原始异常行为。
+
+### 修复静态方法并导入其他类
```js
var Point = Fixit.import(
- 'com.rickytan.ohospatch/entry/src/main/ets/demo/Point#Point'
+ 'com.example.app/entry/src/main/ets/model/Point#Point'
);
-var point = new Point(7, 9);
-var text = Point.textOf(point) + ' / ' + point.toText();
-```
-
-完整路径格式为 `bundleName/moduleName/[packageName/]src/main/ets/File#ExportName`,也接受 `@bundle:` 前缀和 `.ets` / `.ts` 后缀。启用 `useNormalizedOHMUrl` 且 `oh-package.json5` 的 `name` 与 `moduleName` 不同时,需要提供 `packageName`;两者相同时可省略。`ExportName` 省略时默认使用文件名。以上示例自动解析为:
-```text
-modulePath = entry/src/main/ets/demo/DemoViewModel
-moduleInfo = com.rickytan.ohospatch/entry
-exportName = DemoViewModel
+fix.classMethod('crash', function () {
+ var point = new Point(7, 9);
+ return Point.textOf(point) + ' / ' + point.toText();
+});
```
-演示补丁位于 `entry/src/main/resources/rawfile/patch.js`,同时作为网络加载失败的内置兜底打入 HAP;`patch-server` 通过 HTTP 直接服务该文件,不单独维护副本。
+`require(fullPath)` 是 `Fixit.import(fullPath)` 的兼容别名。
-### 声明式组件 DSL
-
-目标必须是业务模块导出的 API 20 状态管理 V1 自定义组件。业务组件本身不引用 OhosPatch,也不需要基类、装饰器或转发代码:
+### 修复声明式组件
```js
var panel = Fixit.component(
- 'com.rickytan.ohospatch/entry/src/main/ets/demo/PatchablePanel#PatchablePanel'
+ 'com.example.app/entry/src/main/ets/components/PatchablePanel#PatchablePanel'
);
panel.param('message').replace('Patched component parameter');
@@ -209,18 +266,96 @@ panel.state('tapCount').transform(function (value) {
var originClick = panel.node({ type: 'Button', occurrence: 0 })
.attrs({ height: 52, backgroundColor: '#C44736' })
- .event('onClick', {
- mode: 'replace',
- capture: ['tapCount'],
- handler: function (_event, context) {
- this.tagText = 'captured tapCount=' + context.state.tapCount;
- this.markPrimary(10);
- return originClick.apply(this, arguments);
- }
+ .event('onClick', function () {
+ this.tagText = 'tapCount=' + this.tapCount;
+ this.markPrimary(10);
+ return originClick.apply(this, arguments);
});
```
-`occurrence` 从 `0` 开始,只在同一目标组件、同一节点类型内计数。属性参数必须可 JSON 序列化。事件首版只支持同步 `replace`;patch handler 会先收到 ArkUI 原始事件参数,最后一个参数是 OhosPatch 注入的 `context`。`event()` 返回原 ArkUI 事件回调,普通 `function` handler 的 `this` 是当前 Component 实例的同步 Proxy,可用点语法读写实例属性和调用实例方法;箭头函数仍遵循 JS 词法 `this` 规则。`originClick.apply(this, arguments)` 会自动忽略 OhosPatch 注入的 `context` 参数,只把原始事件参数传给业务回调。`capture` 最多读取 16 个组件属性,`context.setState()` 的对象会通过组件访问器写回 ArkTS 主 VM。patch handler 不存在或执行失败时,已安装的事件 trampoline 会回调原业务事件。
+Component DSL 当前支持 API 20 状态管理 V1 导出的自定义组件。`event(name, handler)` 是同步事件替换;handler 只接收 ArkUI 原始事件参数,普通 `function` 的 `this` 指向当前 Component 实例 Proxy。
+
+## 内置 JS Runtime
+
+`ohospatch/src/main/cpp/runtime/fixit.js` 会在构建 HAR 时嵌入 `libohospatch.so`。JSVM 创建后先执行内置 runtime,再执行宿主 patch。
+
+内置 API:
+
+- `Fixit.fix(fullPath)`
+- `Fixit.component(fullPath)`
+- `Fixit.import(fullPath)`
+- `Fixit.registerTarget(className, descriptor)`
+- `instanceMethod(name, handler)` / `classMethod(name, handler)`
+- `component.param(name)` / `component.state(name)`
+- `component.node(selector).attr(...)` / `.attrs(...)` / `.event(...)`
+- `require(fullPath)`
+- `nil` / `Nil`、`isNil`、`nilToNull`、`nullToNil`
+- `console.debug/log/info/warn/error`
+- `setTimeout` / `clearTimeout`
+- `setInterval` / `clearInterval`
+- `setImmediate` / `clearImmediate`
+- `queueMicrotask(callback)`
+
+`console.*` 会桥接到 HiLog 的 `OhosPatch` tag。
+
+## IDE 和 AI Patch 编写
+
+声明文件位于:
+
+```text
+skills/ohospatch/references/fixit.d.js
+```
+
+它只用于开发期语言服务,不需要下发到设备。其 `@version` 必须与 `Fixit.runtimeVersion` 保持一致。
+
+仓库内置 `ohospatch` Skill,方便 Codex 和 Claude Code 按当前 runtime 约束生成 patch:
+
+```bash
+./scripts/install-skill.sh
+```
+
+安装后:
+
+- Codex 中使用 `$ohospatch`
+- Claude Code 中使用 `/ohospatch`
+
+## Demo 效果
+
+Demo APP 是两级 Navigation:
+
+1. 第一屏:`Load patch`、`Clear patch`、进入 patch target screen。
+2. 第二屏:展示 Component 参数/状态/属性/事件、普通方法 hook、静态方法 hook、`Fixit.import()`、timer,以及一个会触发 `undefined is not a function` 的按钮。
+
+Patch 前:
+
+
+
+Patch 后:
+
+
+
+启动本地 patch 服务:
+
+```bash
+node patch-server/server.mjs
+```
+
+设备或模拟器通过 HDC 反向连接本机服务:
+
+```bash
+hdc rport tcp:8080 tcp:8080
+```
+
+验证流程:
+
+1. 安装并启动 Demo。
+2. 进入第二屏,观察原始组件参数、状态和按钮样式。
+3. 未加载 patch 时点击 `Unsafe onClick crash`,会触发未定义函数调用错误。
+4. 返回第一屏,点击 `Load patch`。
+5. 再进入第二屏,观察参数、状态、Text/Button 属性、Button/Toggle 回调均被 patch 影响。
+6. 点击 `Unsafe onClick crash`,页面不会崩溃,`tag=` 文本显示 `Recovered Button.onClick crash: ...`。
+7. 等待 100 ms 后点击 `Run method hook scenario`,结果中应包含 `timer=fired`,HiLog 中会出现 `OhosPatch setTimeout callback fired`。
+8. 点击 `Clear patch` 后重新进入第二屏,恢复原始行为。
## 构建
@@ -244,46 +379,60 @@ ohospatch/build/default/outputs/default/ohospatch.har
--mode module -p module=entry assembleHap --no-daemon
```
-## GitHub Actions
+运行真实设备/模拟器测试:
+
+```bash
+npm test
+```
-`.github/workflows/harmonyos-build.yml` 提供两级 CI:
+`npm test` 会构建 Demo HAP 和 `entry@ohosTest` HAP,安装到已连接设备并通过鸿蒙自带测试框架执行行为测试。
-- push 和 pull request 在 GitHub-hosted Ubuntu runner 上执行 JS runtime 单元测试。
-- 手动运行 `HarmonyOS CI` workflow 时,在自托管 macOS ARM64 runner 上构建 HAR 和未签名 HAP,并上传为保留 14 天的 artifact。
-- 设置仓库变量 `HARMONYOS_CI_ENABLED=true` 后,`main` 分支每次 push 也会自动打包。pull request 不会执行自托管打包任务。
+## GitHub Actions
-打包 runner 需要注册 `self-hosted`、`macOS`、`ARM64`、`harmonyos` 标签,GitHub Actions Runner 版本不低于 `2.327.1`,并预装 DevEco Studio、Command Line Tools 和 OpenHarmony API 20 SDK。默认从以下位置查找工具:
+`.github/workflows/harmonyos-build.yml`:
-```text
-/Applications/DevEco-Studio.app/Contents
-$HOME/Library/OpenHarmony/Sdk
-```
+- 手动运行或 `main` 分支 push 且 `HARMONYOS_CI_ENABLED=true` 时,在自托管 macOS ARM64 runner 上构建 HAR 和未签名 HAP。
+- 使用 GitHub Environment `ohpm` 读取环境级变量。
+- 上传未签名 HAR/HAP artifact。
+- 检查 `libohospatch.so` 是否含 C++ exception 相关符号。
-路径不同时,通过仓库变量 `DEVECO_STUDIO_HOME` 和 `OHOS_BASE_SDK_HOME` 覆盖。当前工程没有签名配置,因此 CI 产出的 HAP 仅用于编译验证;发布包仍需在受控环境注入证书与 Profile。
+`.github/workflows/ohpm-publish.yml`:
-## Demo 验证
+- tag `v*` 触发。
+- 校验 tag 与 `ohospatch/oh-package.json5` 版本一致。
+- 使用 GitHub Environment `ohpm` 中的发布凭证和 registry 配置。
+- 构建 HAR 并执行 `ohpm publish`。
-启动动态 patch 服务:
+自托管 runner 需要标签:
-```bash
-node patch-server/server.mjs
+```text
+self-hosted, macOS, ARM64, harmonyos
```
-设备或模拟器通过 HDC 反向连接本机服务:
+默认工具路径:
-```bash
-hdc rport tcp:8080 tcp:8080
+```text
+/Applications/DevEco-Studio.app/Contents
+$HOME/Library/OpenHarmony/Sdk
```
-安装并启动 Demo 后,第一屏提供 `Load patch`、`Clear patch` 和进入第二屏的按钮。先进入第二屏可看到原始组件参数、状态、按钮、Toggle、Slider 以及异常方法结果;返回第一屏点击 `Load patch` 后再次进入第二屏,应看到组件参数、多个状态初值、Text/Button 属性、Button/Toggle 回调、实例方法、静态方法、`Fixit.import()` 和 `setTimeout` 均被 patch 影响。点击 `Clear patch` 后重新进入第二屏,应恢复原始行为。远程 patch 还会执行 `setTimeout`;等待 100 ms 后点击第二屏的 `Run method hook scenario`,实例方法结果应包含 `timer=fired`,HiLog 应出现 `OhosPatch setTimeout callback fired`。
+路径不同时,通过环境或仓库变量 `DEVECO_STUDIO_HOME`、`OHOS_BASE_SDK_HOME` 覆盖。
## 当前边界
-- prototype hook 不覆盖构造函数、实例字段形式的箭头函数、私有实现或不经过属性查找的调用点。
-- Patch handler 的 `this` 通过调用期 Proxy 桥接,可保留原实例、嵌套对象、方法和循环对象身份,但不可跨越当前同步调用生命周期。`Fixit.import()` 返回的持久 Proxy 可保留到 Patch 被清理或替换,并支持静态调用、构造和实例调用;普通方法参数及新建 JS 对象仍受 JSON wire 类型限制。
-- 声明式组件 DSL 首版只支持 API 20 状态管理 V1、业务模块导出的自定义组件、`type + occurrence` 节点选择器、JSON 属性参数和同步事件替换。
-- 非导出的 `@Entry` 页面、状态管理 V2、层级/ID 选择器、资源与控制器类型、已挂载组件的主动刷新,以及 `before/after/around/origin` 事件模式尚未支持。
-- 单个 runtime 最多同时存在 256 个 timer;`setInterval(..., 0)` 会按 1 ms 调度。
-- 单个 Patch 最多保留 512 个去重后的动态导入类、实例、方法或嵌套对象句柄。
-- 生产宿主必须在调用 HAR 前完成非对称签名校验、版本和设备匹配、灰度、缓存、回滚、超时与熔断。
-- HAR 不决定下载方式和启动时机,宿主可以在 AppStartup、业务初始化或其他受控阶段调用。
+- prototype hook 不覆盖构造函数、实例字段箭头函数、私有实现或不经过属性查找的调用点。
+- Patch handler 的 `this` Proxy 只在当前同步调用或 `origin` 调用期间有效,不应保存到 timer、Promise 或全局变量后异步访问。
+- `Fixit.import()` 返回的持久 Proxy 可保留到 `OhosPatch.clear()` 或下一次 patch 替换。
+- 普通方法参数和新建 JS 对象仍受 JSON wire 类型限制。
+- Component DSL 当前支持 API 20 状态管理 V1、导出的自定义组件、`type + occurrence` 节点选择器、JSON 属性参数和同步事件替换。
+- 非导出的 `@Entry` 页面、状态管理 V2、层级/ID 选择器、资源与控制器类型、已挂载组件主动刷新,以及 `before/after/around` 事件组合尚未支持。
+- 单个 runtime 最多同时存在 256 个 timer。
+- 单个 patch 最多保留 512 个去重后的动态导入类、实例、方法或嵌套对象句柄。
+
+## 安全和稳定性
+
+- 生产宿主必须在调用 HAR 前完成签名校验、版本匹配、灰度、缓存、回滚、超时和熔断。
+- C++ 层不抛异常;JSVM/N-API 错误会记录 error 级 HiLog 并 fail closed。
+- Patch 安装失败会回滚已安装 hook。
+- Patch handler 失败时会回退原 ArkTS 方法或返回安全结果,具体取决于 hook 类型。
+- `clear()` 会恢复原方法并释放 patch 生命周期内的引用。
diff --git a/docs/images/demo-after.png b/docs/images/demo-after.png
new file mode 100644
index 0000000..051bc0a
Binary files /dev/null and b/docs/images/demo-after.png differ
diff --git a/docs/images/demo-before.png b/docs/images/demo-before.png
new file mode 100644
index 0000000..38d2642
Binary files /dev/null and b/docs/images/demo-before.png differ
diff --git a/entry/build-profile.json5 b/entry/build-profile.json5
index be4ab7c..5cdd168 100644
--- a/entry/build-profile.json5
+++ b/entry/build-profile.json5
@@ -10,6 +10,9 @@
"targets": [
{
"name": "default"
+ },
+ {
+ "name": "ohosTest",
}
]
}
diff --git a/entry/oh-package.json5 b/entry/oh-package.json5
index 65844e1..dc7c03e 100644
--- a/entry/oh-package.json5
+++ b/entry/oh-package.json5
@@ -5,5 +5,8 @@
"main": "Index.ets",
"dependencies": {
"@rickytan/ohospatch": "file:../ohospatch"
+ },
+ "devDependencies": {
+ "@ohos/hypium": "1.0.6"
}
}
diff --git a/entry/src/main/ets/demo/PatchablePanel.ets b/entry/src/main/ets/demo/PatchablePanel.ets
index 151972b..2d0ec43 100644
--- a/entry/src/main/ets/demo/PatchablePanel.ets
+++ b/entry/src/main/ets/demo/PatchablePanel.ets
@@ -8,6 +8,7 @@ export struct PatchablePanel {
@State switchOn: boolean = false;
@State sliderValue: number = 20;
@State tagText: string = 'original tag';
+ unsafeCallback?: () => string;
markPrimary(delta: number): void {
this.tapCount += delta;
@@ -68,6 +69,14 @@ export struct PatchablePanel {
}
.width('100%')
+ Button('Unsafe onClick crash')
+ .width('100%')
+ .height(44)
+ .backgroundColor('#9B2C2C')
+ .onClick(() => {
+ this.tagText = (this.unsafeCallback as () => string)();
+ })
+
Row({ space: 10 }) {
Toggle({ type: ToggleType.Switch, isOn: this.switchOn })
.onChange((isOn: boolean) => {
diff --git a/entry/src/main/resources/rawfile/patch.js b/entry/src/main/resources/rawfile/patch.js
index a0e5aea..d1310de 100644
--- a/entry/src/main/resources/rawfile/patch.js
+++ b/entry/src/main/resources/rawfile/patch.js
@@ -1,17 +1,42 @@
///
(function (Fixit) {
+ // --- Import: 跨模块加载 ArkTS 类(返回持久 Proxy,可 new / 静态 / 实例调用)---
var Point = Fixit.import(
'com.rickytan.ohospatch/entry/src/main/ets/demo/Point#Point'
);
+
+ // --- Patch 目标 ---
var fix = Fixit.fix(
'com.rickytan.ohospatch/entry/src/main/ets/demo/DemoViewModel#DemoViewModel'
);
var panel = Fixit.component(
'com.rickytan.ohospatch/entry/src/main/ets/demo/PatchablePanel#PatchablePanel'
);
+
+ // --- Timer + console 日志 ---
+ // setTimeout:一次性回调,闭包变量在 method patch 中可读。
+ // setInterval:周期回调,到达上限后 clearInterval 自清理。
+ // console.*:转发到 HiLog,覆盖 log / info / warn 多个 level。
var timerState = 'pending';
+ var tick = 0;
+
+ console.log('OhosPatch demo patch loaded');
+ setTimeout(function () {
+ timerState = 'fired';
+ console.info('OhosPatch setTimeout callback fired');
+ }, 100);
+ var intervalId = setInterval(function () {
+ tick += 1;
+ console.info('OhosPatch interval tick=' + tick);
+ if (tick >= 3) {
+ clearInterval(intervalId);
+ console.warn('OhosPatch interval cleared after ' + tick + ' ticks');
+ }
+ }, 60);
+
+ // --- Component value override: param / state ---
panel.param('message').replace('Patched component parameter');
panel.param('subtitle').replace('Patched subtitle from remote JavaScript');
panel.state('tapCount').transform(function (value) {
@@ -24,29 +49,35 @@
panel.state('switchOn').replace(true);
panel.state('sliderValue').replace(75);
+ // --- Component attribute: 静态值 ---
panel.node({ type: 'Text', occurrence: 0 })
.attrs({
fontColor: '#C44736'
});
+
+ // --- Component attribute handler: 动态属性处理器(函数形式)---
+ // 函数在每次渲染时被调用,this 绑定到当前组件实例 Proxy,
+ // 返回值作为属性参数。可与静态值混合在同一 attrs 调用中。
panel.node({ type: 'Text', occurrence: 2 })
.attrs({
backgroundColor: '#E7F7EE',
- fontColor: '#1F6B46'
+ fontColor: function () { return this.tapCount > 45 ? '#C44736' : '#1F6B46'; }
+ });
+ panel.node({ type: 'Text', occurrence: 3 })
+ .attrs({
+ fontSize: function () { return this.switchOn ? 16 : 14; }
});
+ // --- Component event handler: 替换事件回调,origin.apply 委托原始实现 ---
var originPrimaryClick = panel.node({ type: 'Button', occurrence: 0 })
.attrs({
height: 52,
backgroundColor: '#C44736'
})
- .event('onClick', {
- mode: 'replace',
- capture: ['tapCount'],
- handler: /** @this {any} */ function (_event, context) {
- this.tagText = 'captured tapCount=' + context.state.tapCount;
- this.markPrimary(10);
- return originPrimaryClick.apply(this, arguments);
- }
+ .event('onClick', /** @this {any} */ function () {
+ this.tagText = 'tapCount=' + this.tapCount;
+ this.markPrimary(10);
+ return originPrimaryClick.apply(this, arguments);
});
var originSecondaryClick = panel.node({ type: 'Button', occurrence: 1 })
@@ -59,6 +90,21 @@
return originSecondaryClick.apply(this, arguments);
});
+ var originUnsafeClick = panel.node({ type: 'Button', occurrence: 2 })
+ .attrs({
+ height: 52,
+ backgroundColor: '#1F6B46'
+ })
+ .event('onClick', /** @this {any} */ function () {
+ try {
+ return originUnsafeClick.apply(this, arguments);
+ } catch (err) {
+ var message = err && err.message ? err.message : String(err);
+ this.tagText = 'Recovered Button.onClick crash: ' + message;
+ this.statusText = 'Patched Button.onClick recovered';
+ }
+ });
+
var originToggleChange = panel.node({ type: 'Toggle', occurrence: 0 })
.event('onChange', /** @this {any} */ function (isOn) {
this.tagText = 'toggle patched before origin';
@@ -66,11 +112,8 @@
return originToggleChange.apply(this, arguments);
});
- setTimeout(function () {
- timerState = 'fired';
- console.info('OhosPatch setTimeout callback fired');
- }, 100);
-
+ // --- Method patch: 实例方法 ---
+ // locationOf:越界时改写 this 上的嵌套属性并返回默认值;命中时委托 origin。
var originLocation = fix.instanceMethod('locationOf', function (locations, index, point) {
if (index < 0 || index >= locations.length) {
this.profile.badge.text = 'out of bounds';
@@ -87,19 +130,26 @@
return originLocation.apply(this, arguments);
});
+ // crashIt:原实现抛错,patch 后返回正常字符串并读取 timer 闭包状态。
fix.instanceMethod('crashIt', function () {
- return 'Instance method fixed by remote JSVM patch; timer=' + timerState;
+ return 'Instance method fixed by remote JSVM patch; timer=' + timerState +
+ ', ticks=' + tick;
});
- fix.classMethod('crash', function () {
- var point = new Point(7, 9);
- return 'Class method fixed by remote JSVM patch; static=' + Point.textOf(point) +
- ', instance=' + point.toText();
- });
+ // 私有方法 + 同类方法互调:hiddenNote 被 revealNote 通过 this 调用。
fix.instanceMethod('hiddenNote', function () {
return 'hidden-50';
});
fix.instanceMethod('revealNote', function () {
return this.hiddenNote() + ':patched';
});
+
+ // --- Method patch: 类方法(静态)+ import 跨模块类调用 ---
+ // new Point() 构造、Point.textOf() 静态、point.toText() 实例,
+ // 全部走 import 返回的持久 Proxy。
+ fix.classMethod('crash', function () {
+ var point = new Point(7, 9);
+ return 'Class method fixed by remote JSVM patch; static=' + Point.textOf(point) +
+ ', instance=' + point.toText();
+ });
})(Fixit);
diff --git a/entry/src/ohosTest/ets/test/Fixit.test.ets b/entry/src/ohosTest/ets/test/Fixit.test.ets
new file mode 100644
index 0000000..d93630c
--- /dev/null
+++ b/entry/src/ohosTest/ets/test/Fixit.test.ets
@@ -0,0 +1,346 @@
+import { describe, it, expect, beforeEach, afterEach } from '@ohos/hypium';
+import { OhosPatch } from '@rickytan/ohospatch';
+import { DemoViewModel } from '../../../main/ets/demo/DemoViewModel';
+import { Point } from '../../../main/ets/demo/Point';
+import { DemoScenario } from '../../../main/ets/demo/DemoScenario';
+
+export default function fixitTest(): void {
+ describe('FixitRuntime', () => {
+ // Clear all patches before each test to ensure isolation.
+ beforeEach(() => {
+ OhosPatch.clear();
+ });
+
+ afterEach(() => {
+ OhosPatch.clear();
+ });
+
+ it('executeScript installs a method patch that overrides instance behavior', 0, () => {
+ const script = `
+ Fixit.fix('com.rickytan.ohospatch/entry_test/entry/src/main/ets/demo/DemoViewModel#DemoViewModel')
+ .instanceMethod('crashIt', function () {
+ return 'patched by ohtest';
+ });
+ `;
+ const hookCount = OhosPatch.executeScript(script);
+ expect(hookCount).assertLarger(0);
+
+ const vm = new DemoViewModel();
+ expect(vm.crashIt()).assertEqual('patched by ohtest');
+ });
+
+ it('executeScript installs a class (static) method patch', 0, () => {
+ const script = `
+ Fixit.fix('com.rickytan.ohospatch/entry_test/entry/src/main/ets/demo/DemoViewModel#DemoViewModel')
+ .classMethod('crash', function () {
+ return 'class method patched';
+ });
+ `;
+ OhosPatch.executeScript(script);
+ expect(DemoViewModel.crash()).assertEqual('class method patched');
+ });
+
+ it('patch context exposes builtin helpers with observable values', 0, () => {
+ const script = `
+ Fixit.fix('com.rickytan.ohospatch/entry_test/entry/src/main/ets/demo/DemoViewModel#DemoViewModel')
+ .classMethod('crash', function () {
+ return [
+ Fixit.runtimeVersion,
+ typeof require,
+ nil === null,
+ Nil === null,
+ typeof YES,
+ typeof NO,
+ isNil(null),
+ isNil(undefined),
+ isNil(0),
+ nilToNull(undefined) === null,
+ nilToNull('x'),
+ nullToNil(null) === null
+ ].join('|');
+ });
+ `;
+ OhosPatch.executeScript(script);
+ expect(DemoViewModel.crash()).assertEqual('1.7.0|function|true|true|undefined|undefined|true|true|false|true|x|true');
+ });
+
+ it('component event patch accepts direct handler syntax only', 0, () => {
+ const script = `
+ Fixit.fix('com.rickytan.ohospatch/entry_test/entry/src/main/ets/demo/DemoViewModel#DemoViewModel')
+ .classMethod('crash', function () {
+ var direct = Fixit.component('com.rickytan.ohospatch/entry_test/entry/src/main/ets/demo/PatchablePanel#PatchablePanel')
+ .node({ type: 'Button', occurrence: 2 })
+ .event('onClick', function () {
+ try {
+ return direct.apply(this, arguments);
+ } catch (err) {
+ this.tagText = 'component-event-recovered';
+ }
+ });
+ var objectRuleRejected = false;
+ try {
+ Fixit.component('com.rickytan.ohospatch/entry_test/entry/src/main/ets/demo/PatchablePanel#PatchablePanel')
+ .node({ type: 'Button', occurrence: 1 })
+ .event('onClick', {
+ mode: 'replace',
+ capture: ['tapCount'],
+ handler: function () {}
+ });
+ } catch (err) {
+ objectRuleRejected = err instanceof TypeError;
+ }
+ var attrExtraRejected = false;
+ try {
+ Fixit.component('com.rickytan.ohospatch/entry_test/entry/src/main/ets/demo/PatchablePanel#PatchablePanel')
+ .node({ type: 'Text', occurrence: 0 })
+ .attr('fontColor', function () { return '#C44736'; }, 'extra');
+ } catch (err) {
+ attrExtraRejected = err instanceof TypeError;
+ }
+ return typeof direct + '|' + objectRuleRejected + '|' + attrExtraRejected;
+ });
+ `;
+ expect(OhosPatch.executeScript(script)).assertLarger(0);
+ expect(DemoViewModel.crash()).assertEqual('function|true|true');
+ });
+
+ it('invalid full path input fails closed and later valid input still works', 0, () => {
+ const invalid = `
+ Fixit.fix('entry/src/main/ets/demo/DemoViewModel#DemoViewModel')
+ .instanceMethod('crashIt', function () { return 'should-not-install'; });
+ `;
+ expect(OhosPatch.executeScript(invalid)).assertEqual(0);
+
+ const vm = new DemoViewModel();
+ let threw = false;
+ try {
+ vm.crashIt();
+ } catch (err) {
+ threw = true;
+ }
+ expect(threw).assertTrue();
+
+ const valid = `
+ Fixit.fix('com.rickytan.ohospatch/entry_test/entry/src/main/ets/demo/DemoViewModel#DemoViewModel')
+ .instanceMethod('crashIt', function () { return 'valid-after-invalid'; });
+ `;
+ expect(OhosPatch.executeScript(valid)).assertLarger(0);
+ expect(vm.crashIt()).assertEqual('valid-after-invalid');
+ });
+
+ it('patched method can delegate to origin via origin.apply', 0, () => {
+ // DemoViewModel.locationOf throws on out-of-bounds index.
+ // Patch it to return a fallback point instead of throwing.
+ const script = `
+ var origin = Fixit.fix('com.rickytan.ohospatch/entry_test/entry/src/main/ets/demo/DemoViewModel#DemoViewModel')
+ .instanceMethod('locationOf', function (locations, index, fallback) {
+ if (index < 0 || index >= locations.length) {
+ return fallback;
+ }
+ return origin.apply(this, arguments);
+ });
+ `;
+ OhosPatch.executeScript(script);
+
+ const vm = new DemoViewModel();
+ const points: Array = [new Point(1, 2)];
+ const fallback = new Point(99, 99);
+
+ // Out of bounds: patch returns fallback instead of throwing.
+ const result = vm.locationOf(points, 5, fallback);
+ expect(result.x).assertEqual(99);
+
+ // In bounds: origin.apply delegates to the original method.
+ const hit = vm.locationOf(points, 0, fallback);
+ expect(hit.x).assertEqual(1);
+ });
+
+ it('patched method can catch an exception thrown by origin.apply', 0, () => {
+ const script = `
+ var origin = Fixit.fix('com.rickytan.ohospatch/entry_test/entry/src/main/ets/demo/DemoViewModel#DemoViewModel')
+ .instanceMethod('crashIt', function () {
+ try {
+ return origin.apply(this, arguments);
+ } catch (err) {
+ var message = err && err.message ? err.message : String(err);
+ this.buttonTitle = message;
+ return 'origin-caught:' + message;
+ }
+ });
+ `;
+ OhosPatch.executeScript(script);
+
+ const vm = new DemoViewModel();
+ expect(vm.crashIt()).assertEqual('origin-caught:instance crash');
+ expect(vm.buttonTitle).assertEqual('instance crash');
+ });
+
+ it('patched method can mutate this properties (nested proxy access)', 0, () => {
+ const script = `
+ Fixit.fix('com.rickytan.ohospatch/entry_test/entry/src/main/ets/demo/DemoViewModel#DemoViewModel')
+ .instanceMethod('revealNote', function () {
+ this.buttonTitle = 'mutated by patch';
+ this.backgroundColor = '#FF0000';
+ return this.hiddenNote() + ':patched';
+ });
+ `;
+ OhosPatch.executeScript(script);
+
+ const vm = new DemoViewModel();
+ const result = vm.revealNote();
+ expect(result).assertEqual('hidden-42:patched');
+ expect(vm.buttonTitle).assertEqual('mutated by patch');
+ expect(vm.backgroundColor).assertEqual('#FF0000');
+ });
+
+ it('patched method can access and mutate multi-level this properties', 0, () => {
+ const script = `
+ Fixit.fix('com.rickytan.ohospatch/entry_test/entry/src/main/ets/demo/DemoViewModel#DemoViewModel')
+ .instanceMethod('revealNote', function () {
+ this.profile.badge.text = 'nested';
+ this.profile.badge.advance(3);
+ return this.profile.summary();
+ });
+ `;
+ OhosPatch.executeScript(script);
+
+ const vm = new DemoViewModel();
+ expect(vm.revealNote()).assertEqual('nested@3');
+ expect(vm.profile.badge.text).assertEqual('nested');
+ expect(vm.profile.badge.revision).assertEqual(3);
+ });
+
+ it('Fixit.import returns a usable proxy for cross-module class access', 0, () => {
+ const script = `
+ var Point = Fixit.import('com.rickytan.ohospatch/entry_test/entry/src/main/ets/demo/Point#Point');
+ Fixit.fix('com.rickytan.ohospatch/entry_test/entry/src/main/ets/demo/DemoViewModel#DemoViewModel')
+ .classMethod('crash', function () {
+ var p = new Point(7, 9);
+ return Point.textOf(p) + '|' + p.toText() + '|' + p.x + ',' + p.y;
+ });
+ `;
+ OhosPatch.executeScript(script);
+
+ const result = DemoViewModel.crash();
+ expect(result).assertEqual('(7, 9)|(7, 9)|7,9');
+ });
+
+ it('require is an alias of Fixit.import', 0, () => {
+ const script = `
+ var Point = require('com.rickytan.ohospatch/entry_test/entry/src/main/ets/demo/Point#Point');
+ Fixit.fix('com.rickytan.ohospatch/entry_test/entry/src/main/ets/demo/DemoViewModel#DemoViewModel')
+ .classMethod('crash', function () {
+ var p = new Point(3, 4);
+ return p.toText();
+ });
+ `;
+ OhosPatch.executeScript(script);
+ expect(DemoViewModel.crash()).assertEqual('(3, 4)');
+ });
+
+ it('console.log/info/warn forward to HiLog without crashing', 0, () => {
+ const script = `
+ console.log('ohtest log message');
+ console.info('ohtest info message');
+ console.warn('ohtest warn message');
+ console.error('ohtest error message');
+ Fixit.fix('com.rickytan.ohospatch/entry_test/entry/src/main/ets/demo/DemoViewModel#DemoViewModel')
+ .instanceMethod('crashIt', function () { return 'console-ok'; });
+ `;
+ const count = OhosPatch.executeScript(script);
+ expect(count).assertLarger(0);
+ });
+
+ it('registerTarget alias resolves a short name to a full target', 0, () => {
+ const script = `
+ Fixit.registerTarget('DemoViewModel', {
+ modulePath: 'entry/src/main/ets/demo/DemoViewModel',
+ moduleInfo: 'com.rickytan.ohospatch/entry_test'
+ });
+ Fixit.fix('DemoViewModel')
+ .instanceMethod('crashIt', function () { return 'alias-works'; });
+ `;
+ OhosPatch.executeScript(script);
+ const vm = new DemoViewModel();
+ expect(vm.crashIt()).assertEqual('alias-works');
+ });
+
+ it('timer callbacks run in the real Patch JSVM and can update later patch results', 0, async () => {
+ const script = `
+ var timerValue = 'pending';
+ setTimeout(function (value) {
+ timerValue = value;
+ }, 10, 'timer-fired');
+ Fixit.fix('com.rickytan.ohospatch/entry_test/entry/src/main/ets/demo/DemoViewModel#DemoViewModel')
+ .classMethod('crash', function () {
+ return timerValue;
+ });
+ `;
+ expect(OhosPatch.executeScript(script)).assertLarger(0);
+ expect(DemoViewModel.crash()).assertEqual('pending');
+
+ await new Promise((resolve) => {
+ setTimeout(resolve, 60);
+ });
+
+ expect(DemoViewModel.crash()).assertEqual('timer-fired');
+ });
+
+ it('duplicate patch registration is rejected (returns 0 hooks)', 0, () => {
+ const script = `
+ var fix = Fixit.fix('com.rickytan.ohospatch/entry_test/entry/src/main/ets/demo/DemoViewModel#DemoViewModel');
+ fix.instanceMethod('crashIt', function () { return 'first'; });
+ fix.instanceMethod('crashIt', function () { return 'second'; });
+ `;
+ const count = OhosPatch.executeScript(script);
+ expect(count).assertEqual(0);
+
+ const vm = new DemoViewModel();
+ let threw = false;
+ try {
+ vm.crashIt();
+ } catch (err) {
+ threw = true;
+ }
+ expect(threw).assertTrue();
+ });
+
+ it('clear removes all patches and original behavior is restored', 0, () => {
+ const script = `
+ Fixit.fix('com.rickytan.ohospatch/entry_test/entry/src/main/ets/demo/DemoViewModel#DemoViewModel')
+ .instanceMethod('crashIt', function () { return 'patched'; });
+ `;
+ OhosPatch.executeScript(script);
+ const vm = new DemoViewModel();
+ expect(vm.crashIt()).assertEqual('patched');
+
+ OhosPatch.clear();
+
+ // After clear, the original method throws again.
+ let threw = false;
+ try {
+ vm.crashIt();
+ } catch (err) {
+ threw = true;
+ }
+ expect(threw).assertTrue();
+ });
+
+ it('DemoScenario.run produces expected results after patching', 0, () => {
+ const script = `
+ var fix = Fixit.fix('com.rickytan.ohospatch/entry_test/entry/src/main/ets/demo/DemoViewModel#DemoViewModel');
+ fix.instanceMethod('crashIt', function () { return 'scenario-fixed'; });
+ fix.instanceMethod('locationOf', function (locations, index, fallback) {
+ return fallback;
+ });
+ fix.classMethod('crash', function () { return 'class-scenario-fixed'; });
+ `;
+ OhosPatch.executeScript(script);
+
+ const result = DemoScenario.run();
+ // With the patch, crashIt returns normally and locationOf returns fallback.
+ expect(result.rows.some(r => r.includes('scenario-fixed'))).assertTrue();
+ expect(result.rows.some(r => r.includes('class-scenario-fixed'))).assertTrue();
+ });
+ });
+}
diff --git a/entry/src/ohosTest/ets/test/List.test.ets b/entry/src/ohosTest/ets/test/List.test.ets
new file mode 100644
index 0000000..6848558
--- /dev/null
+++ b/entry/src/ohosTest/ets/test/List.test.ets
@@ -0,0 +1,9 @@
+import fixitTest from './Fixit.test';
+import syntaxErrorTest from './SyntaxError.test';
+import perfTest from './Perf.test';
+
+export default function testsuite(): void {
+ fixitTest();
+ syntaxErrorTest();
+ perfTest();
+}
diff --git a/entry/src/ohosTest/ets/test/Perf.test.ets b/entry/src/ohosTest/ets/test/Perf.test.ets
new file mode 100644
index 0000000..bfc3974
--- /dev/null
+++ b/entry/src/ohosTest/ets/test/Perf.test.ets
@@ -0,0 +1,57 @@
+import { describe, it, expect, beforeEach, afterEach } from '@ohos/hypium';
+import { OhosPatch } from '@rickytan/ohospatch';
+import { DemoViewModel } from '../../../main/ets/demo/DemoViewModel';
+
+export default function perfTest(): void {
+ describe('Performance', () => {
+ beforeEach(() => {
+ OhosPatch.clear();
+ });
+
+ afterEach(() => {
+ OhosPatch.clear();
+ });
+
+ it('executeScript completes within a reasonable time for a small patch', 0, () => {
+ const script = `
+ Fixit.fix('com.rickytan.ohospatch/entry_test/entry/src/main/ets/demo/DemoViewModel#DemoViewModel')
+ .instanceMethod('crashIt', function () { return 'perf-test'; });
+ `;
+ const start = Date.now();
+ OhosPatch.executeScript(script);
+ const elapsed = Date.now() - start;
+ expect(elapsed < 500).assertTrue();
+ });
+
+ it('patched method call overhead is within acceptable bounds', 0, () => {
+ const script = `
+ Fixit.fix('com.rickytan.ohospatch/entry_test/entry/src/main/ets/demo/DemoViewModel#DemoViewModel')
+ .instanceMethod('crashIt', function () { return 'overhead-ok'; });
+ `;
+ OhosPatch.executeScript(script);
+
+ const vm = new DemoViewModel();
+ const N = 100;
+ const start = Date.now();
+ for (let i = 0; i < N; i++) {
+ vm.crashIt();
+ }
+ const elapsed = Date.now() - start;
+ expect(elapsed < 2000).assertTrue();
+ });
+
+ it('clear and re-patch cycle is fast', 0, () => {
+ const script = `
+ Fixit.fix('com.rickytan.ohospatch/entry_test/entry/src/main/ets/demo/DemoViewModel#DemoViewModel')
+ .instanceMethod('crashIt', function () { return 'cycle'; });
+ `;
+ const start = Date.now();
+ for (let i = 0; i < 10; i++) {
+ OhosPatch.clear();
+ OhosPatch.executeScript(script);
+ }
+ const elapsed = Date.now() - start;
+ expect(elapsed < 5000).assertTrue();
+ });
+ });
+}
diff --git a/entry/src/ohosTest/ets/test/SyntaxError.test.ets b/entry/src/ohosTest/ets/test/SyntaxError.test.ets
new file mode 100644
index 0000000..048644b
--- /dev/null
+++ b/entry/src/ohosTest/ets/test/SyntaxError.test.ets
@@ -0,0 +1,66 @@
+import { describe, it, expect, beforeEach, afterEach } from '@ohos/hypium';
+import { OhosPatch } from '@rickytan/ohospatch';
+import { DemoViewModel } from '../../../main/ets/demo/DemoViewModel';
+
+export default function syntaxErrorTest(): void {
+ describe('SyntaxErrorHandling', () => {
+ beforeEach(() => {
+ OhosPatch.clear();
+ });
+
+ afterEach(() => {
+ OhosPatch.clear();
+ });
+
+ it('syntax error in patch script does not crash and returns 0 hooks', 0, () => {
+ const malformed = 'Fixit.fix( { ; ))) broken syntax !!! @@@';
+ const count = OhosPatch.executeScript(malformed);
+ expect(count).assertEqual(0);
+ });
+
+ it('runtime recovers and loads a valid patch after a malformed script', 0, () => {
+ // Step 1: malformed script fails.
+ const malformed = ')))(invalid {{{ javascript';
+ OhosPatch.executeScript(malformed);
+
+ // Step 2: valid patch loads successfully.
+ const valid = `
+ Fixit.fix('com.rickytan.ohospatch/entry_test/entry/src/main/ets/demo/DemoViewModel#DemoViewModel')
+ .instanceMethod('crashIt', function () { return 'recovered'; });
+ `;
+ const count = OhosPatch.executeScript(valid);
+ expect(count).assertLarger(0);
+
+ const vm = new DemoViewModel();
+ expect(vm.crashIt()).assertEqual('recovered');
+ });
+
+ it('runtime error (throw) in patch script is caught and returns 0 hooks', 0, () => {
+ const runtimeError = 'throw new Error("patch runtime failure");';
+ const count = OhosPatch.executeScript(runtimeError);
+ expect(count).assertEqual(0);
+ });
+
+ it('valid patch works after a runtime-error script', 0, () => {
+ const runtimeError = 'throw new Error("boom");';
+ OhosPatch.executeScript(runtimeError);
+
+ const valid = `
+ Fixit.fix('com.rickytan.ohospatch/entry_test/entry/src/main/ets/demo/DemoViewModel#DemoViewModel')
+ .classMethod('crash', function () { return 'post-error-ok'; });
+ `;
+ const count = OhosPatch.executeScript(valid);
+ expect(count).assertLarger(0);
+ expect(DemoViewModel.crash()).assertEqual('post-error-ok');
+ });
+
+ it('empty script returns 0 hooks without crashing', 0, () => {
+ expect(OhosPatch.executeScript('')).assertEqual(0);
+ });
+
+ it('script with only comments returns 0 hooks', 0, () => {
+ const commentOnly = '// just a comment\n/* block comment */';
+ expect(OhosPatch.executeScript(commentOnly)).assertEqual(0);
+ });
+ });
+}
diff --git a/entry/src/ohosTest/ets/testability/TestAbility.ets b/entry/src/ohosTest/ets/testability/TestAbility.ets
new file mode 100644
index 0000000..048a4fa
--- /dev/null
+++ b/entry/src/ohosTest/ets/testability/TestAbility.ets
@@ -0,0 +1,25 @@
+import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
+import { abilityDelegatorRegistry } from '@kit.TestKit';
+import { hilog } from '@kit.PerformanceAnalysisKit';
+import { window } from '@kit.ArkUI';
+import { Hypium } from '@ohos/hypium';
+import tests from '../test/List.test';
+
+export default class TestAbility extends UIAbility {
+ onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
+ hilog.info(0x0000, 'OhosPatchTest', 'TestAbility onCreate');
+ }
+
+ onWindowStageCreate(windowStage: window.WindowStage): void {
+ hilog.info(0x0000, 'OhosPatchTest', 'TestAbility onWindowStageCreate');
+ windowStage.loadContent('testability/pages/TestPage', (err) => {
+ if (err.code) {
+ hilog.error(0x0000, 'OhosPatchTest', 'Failed to load test page: %{public}s', JSON.stringify(err));
+ }
+ });
+
+ const abilityDelegator = abilityDelegatorRegistry.getAbilityDelegator();
+ const abilityDelegatorArguments = abilityDelegatorRegistry.getArguments();
+ Hypium.hypiumTest(abilityDelegator, abilityDelegatorArguments, tests);
+ }
+}
diff --git a/entry/src/ohosTest/ets/testability/pages/TestPage.ets b/entry/src/ohosTest/ets/testability/pages/TestPage.ets
new file mode 100644
index 0000000..65d964f
--- /dev/null
+++ b/entry/src/ohosTest/ets/testability/pages/TestPage.ets
@@ -0,0 +1,17 @@
+@Entry
+@Component
+struct TestPage {
+ build() {
+ Column() {
+ Text('OhosPatch Test Runner')
+ .fontSize(20)
+ .fontWeight(FontWeight.Bold)
+ .width('100%')
+ .textAlign(TextAlign.Center)
+ .padding(20)
+ }
+ .width('100%')
+ .height('100%')
+ .justifyContent(FlexAlign.Center)
+ }
+}
diff --git a/entry/src/ohosTest/module.json5 b/entry/src/ohosTest/module.json5
new file mode 100644
index 0000000..6902687
--- /dev/null
+++ b/entry/src/ohosTest/module.json5
@@ -0,0 +1,12 @@
+{
+ "module": {
+ "name": "entry_test",
+ "type": "feature",
+ "deviceTypes": [
+ "default",
+ "tablet"
+ ],
+ "deliveryWithInstall": true,
+ "installationFree": false
+ }
+}
diff --git a/entry/src/ohosTest/resources/base/element/color.json b/entry/src/ohosTest/resources/base/element/color.json
new file mode 100644
index 0000000..1eb1aa9
--- /dev/null
+++ b/entry/src/ohosTest/resources/base/element/color.json
@@ -0,0 +1,8 @@
+{
+ "color": [
+ {
+ "name": "start_window_background",
+ "value": "#F7F8FA"
+ }
+ ]
+}
diff --git a/entry/src/ohosTest/resources/base/element/string.json b/entry/src/ohosTest/resources/base/element/string.json
new file mode 100644
index 0000000..28b5feb
--- /dev/null
+++ b/entry/src/ohosTest/resources/base/element/string.json
@@ -0,0 +1,16 @@
+{
+ "string": [
+ {
+ "name": "module_desc",
+ "value": "OhosPatch test module"
+ },
+ {
+ "name": "EntryAbility_desc",
+ "value": "OhosPatch test ability"
+ },
+ {
+ "name": "EntryAbility_label",
+ "value": "OhosPatch Test"
+ }
+ ]
+}
diff --git a/entry/src/ohosTest/resources/base/profile/test_pages.json b/entry/src/ohosTest/resources/base/profile/test_pages.json
new file mode 100644
index 0000000..b7e7343
--- /dev/null
+++ b/entry/src/ohosTest/resources/base/profile/test_pages.json
@@ -0,0 +1,5 @@
+{
+ "src": [
+ "testability/pages/Index"
+ ]
+}
diff --git a/entry/src/test/List.test.ets b/entry/src/test/List.test.ets
new file mode 100644
index 0000000..030309f
--- /dev/null
+++ b/entry/src/test/List.test.ets
@@ -0,0 +1,13 @@
+import fixitTest from '../ohosTest/ets/test/Fixit.test';
+import syntaxErrorTest from '../ohosTest/ets/test/SyntaxError.test';
+import nativeSafetyTest from '../ohosTest/ets/test/NativeSafety.test';
+import declarationTest from '../ohosTest/ets/test/Declaration.test';
+import perfTest from '../ohosTest/ets/test/Perf.test';
+
+export default function testsuite(): void {
+ fixitTest();
+ syntaxErrorTest();
+ nativeSafetyTest();
+ declarationTest();
+ perfTest();
+}
diff --git a/ohospatch/src/main/cpp/ohospatch.cpp b/ohospatch/src/main/cpp/ohospatch.cpp
index dc707fb..0fe0fa8 100644
--- a/ohospatch/src/main/cpp/ohospatch.cpp
+++ b/ohospatch/src/main/cpp/ohospatch.cpp
@@ -110,6 +110,59 @@ bool NapiString(napi_env env, napi_value value, std::string *output)
return true;
}
+bool NapiValueToString(napi_env env, napi_value value, std::string *output)
+{
+ if (!value || !output) {
+ LogError("NapiValueToString received an invalid argument");
+ return false;
+ }
+ napi_value global = nullptr;
+ napi_value stringFunction = nullptr;
+ napi_value result = nullptr;
+ return NapiOk(env, napi_get_global(env, &global), "napi_get_global(String)") &&
+ NapiOk(env, napi_get_named_property(env, global, "String", &stringFunction),
+ "napi_get_named_property(String)") &&
+ NapiOk(env, napi_call_function(env, global, stringFunction, 1, &value, &result),
+ "napi_call_function(String)") &&
+ NapiString(env, result, output);
+}
+
+bool TakePendingNapiExceptionMessage(napi_env env, const char *fallback, std::string *output)
+{
+ if (!output) {
+ LogError("TakePendingNapiExceptionMessage received an invalid argument");
+ return false;
+ }
+ *output = fallback ? fallback : "ArkTS exception";
+
+ bool pending = false;
+ napi_status pendingStatus = napi_is_exception_pending(env, &pending);
+ if (pendingStatus != napi_ok) {
+ LogError("napi_is_exception_pending", static_cast(pendingStatus));
+ return false;
+ }
+ if (!pending) {
+ return false;
+ }
+
+ napi_value exception = nullptr;
+ napi_status clearStatus = napi_get_and_clear_last_exception(env, &exception);
+ if (clearStatus != napi_ok || !exception) {
+ LogError("napi_get_and_clear_last_exception", static_cast(clearStatus));
+ return false;
+ }
+
+ napi_value message = nullptr;
+ napi_valuetype messageType = napi_undefined;
+ if (napi_get_named_property(env, exception, "message", &message) == napi_ok &&
+ napi_typeof(env, message, &messageType) == napi_ok && messageType == napi_string &&
+ NapiString(env, message, output)) {
+ return true;
+ }
+
+ return NapiValueToString(env, exception, output);
+}
+
bool NapiNamedString(napi_env env, napi_value object, const char *name, std::string *output)
{
napi_value value = nullptr;
@@ -192,7 +245,6 @@ struct ActiveInvocation {
HookRecord *hook = nullptr;
UiEventCallbackRecord *uiEvent = nullptr;
napi_value receiver = nullptr;
- bool originalExceptionPending = false;
std::array proxyValues{};
size_t proxyValueCount = 0;
};
@@ -217,7 +269,6 @@ enum class UiMethodKind {
OBSERVE_CREATION,
};
-constexpr size_t kMaxUiCaptureProperties = 16;
constexpr size_t kMaxUiNodeTypesPerRender = 64;
constexpr size_t kMaxUiEventsPerNode = 16;
@@ -234,9 +285,8 @@ struct UiRule {
uint32_t occurrence = 0;
std::string attributeName;
std::string argumentsJson;
+ bool hasAttrHandler = false;
std::string eventName;
- std::array captureProperties;
- size_t captureCount = 0;
};
struct UiComponentHook;
@@ -272,8 +322,6 @@ struct UiEventCallbackRecord {
napi_ref owner = nullptr;
napi_ref originalEvent = nullptr;
uint32_t ruleId = 0;
- std::array captureProperties;
- size_t captureCount = 0;
};
struct UiEventCaptureContext {
@@ -455,12 +503,6 @@ class JsvmRuntime
};
JSVM_Value patchResult = nullptr;
bool called = CallGlobal("__ohospatch_callPatch", patchArgs, std::size(patchArgs), &patchResult);
- bool originalExceptionPending = activeInvocation_.originalExceptionPending;
- if (originalExceptionPending) {
- restoreInvocation();
- closeScope();
- return nullptr;
- }
if (!called) {
restoreInvocation();
closeScope();
@@ -1053,41 +1095,12 @@ class JsvmRuntime
NapiOk(napiEnv, napi_get_reference_value(napiEnv, record->owner, &owner),
"napi_get_reference_value(component event owner)");
- napi_value state = nullptr;
- if (!NapiOk(napiEnv, napi_create_object(napiEnv, &state), "napi_create_object(component state)")) {
- return CallOriginalUiEvent(napiEnv, record, receiver, argc, argv);
- }
- if (owner) {
- for (size_t index = 0; index < record->captureCount; ++index) {
- napi_value value = nullptr;
- const std::string &name = record->captureProperties[index];
- if (NapiOk(napiEnv, napi_get_named_property(napiEnv, owner, name.c_str(), &value),
- "napi_get_named_property(captured component state)")) {
- NapiOk(napiEnv, napi_set_named_property(napiEnv, state, name.c_str(), value),
- "napi_set_named_property(captured component state)");
- }
- }
- }
-
napi_value envelope = nullptr;
bool handled = false;
- if (!CallUiEventHandler(napiEnv, record, argc, argv, state, owner, &envelope, &handled) || !handled) {
+ if (!CallUiEventHandler(napiEnv, record, argc, argv, owner, &envelope, &handled) || !handled) {
return CallOriginalUiEvent(napiEnv, record, receiver, argc, argv);
}
- if (owner) {
- bool hasStatePatch = false;
- if (NapiOk(napiEnv, napi_has_named_property(napiEnv, envelope, "statePatch", &hasStatePatch),
- "napi_has_named_property(component state patch)") &&
- hasStatePatch) {
- napi_value statePatch = nullptr;
- if (NapiOk(napiEnv, napi_get_named_property(napiEnv, envelope, "statePatch", &statePatch),
- "napi_get_named_property(component state patch)")) {
- ApplyTargetPatch(napiEnv, owner, statePatch);
- }
- }
- }
-
bool hasResult = false;
if (!NapiOk(napiEnv, napi_has_named_property(napiEnv, envelope, "result", &hasResult),
"napi_has_named_property(component event result)") ||
@@ -1805,7 +1818,11 @@ class JsvmRuntime
bool exceptionPending = false;
if (!CallOriginal(active.env, active.hook, active.receiver, napiArgc, napiArgv.data(), &result,
&exceptionPending)) {
- active.originalExceptionPending = exceptionPending;
+ if (exceptionPending) {
+ std::string message;
+ TakePendingNapiExceptionMessage(active.env, "Original ArkTS method threw an exception", &message);
+ return runtime->ProxyError(message.c_str());
+ }
return runtime->ProxyError("Original ArkTS method call failed");
}
return runtime->ProxyNapiValue(active, result);
@@ -1853,10 +1870,16 @@ class JsvmRuntime
}
}
- napi_value result =
- CallOriginalUiEvent(active.env, active.uiEvent, active.receiver, napiArgc, napiArgv.data());
- if (!result) {
- return Undefined(env);
+ napi_value result = nullptr;
+ bool exceptionPending = false;
+ if (!CallOriginalUiEventForPatch(active.env, active.uiEvent, active.receiver, napiArgc, napiArgv.data(),
+ &result, &exceptionPending)) {
+ if (exceptionPending) {
+ std::string message;
+ TakePendingNapiExceptionMessage(active.env, "Original component event threw an exception", &message);
+ return runtime->ProxyError(message.c_str());
+ }
+ return runtime->ProxyError("Original component event call failed");
}
return runtime->ProxyNapiValue(active, result);
}
@@ -2008,28 +2031,6 @@ class JsvmRuntime
return NapiOk(env, status, "napi_call_function(original)");
}
- static bool ApplyTargetPatch(napi_env env, napi_value target, napi_value patch)
- {
- napi_value keys = nullptr;
- if (!NapiOk(env, napi_get_property_names(env, patch, &keys), "napi_get_property_names(target patch)")) {
- return false;
- }
- uint32_t length = 0;
- if (!NapiOk(env, napi_get_array_length(env, keys, &length), "napi_get_array_length(target keys)")) {
- return false;
- }
- for (uint32_t index = 0; index < length; ++index) {
- napi_value key = nullptr;
- napi_value value = nullptr;
- if (!NapiOk(env, napi_get_element(env, keys, index, &key), "napi_get_element(target key)") ||
- !NapiOk(env, napi_get_property(env, patch, key, &value), "napi_get_property(target value)") ||
- !NapiOk(env, napi_set_property(env, target, key, value), "napi_set_property(target value)")) {
- return false;
- }
- }
- return true;
- }
-
bool CallUiValueHandler(napi_env napiEnv, uint32_t ruleId, napi_value value, napi_value *envelope, bool *handled)
{
if (!envelope || !handled) {
@@ -2075,8 +2076,7 @@ class JsvmRuntime
}
bool CallUiEventHandler(napi_env napiEnv, UiEventCallbackRecord *record, size_t argc, const napi_value *argv,
- napi_value state, napi_value owner,
- napi_value *envelope, bool *handled)
+ napi_value owner, napi_value *envelope, bool *handled)
{
if (!envelope || !handled) {
LogError("CallUiEventHandler received an invalid argument");
@@ -2097,9 +2097,7 @@ class JsvmRuntime
}
std::string eventArgsJson;
- std::string stateJson;
- if (!NapiJsonStringify(napiEnv, eventArgs, "[]", &eventArgsJson) ||
- !NapiJsonStringify(napiEnv, state, "{}", &stateJson)) {
+ if (!NapiJsonStringify(napiEnv, eventArgs, "[]", &eventArgsJson)) {
return false;
}
@@ -2110,13 +2108,12 @@ class JsvmRuntime
JSVM_Value ruleIdValue = nullptr;
JSVM_Value eventArgsValue = nullptr;
- JSVM_Value stateValue = nullptr;
JSVM_Value result = nullptr;
std::string resultJson;
bool success = record &&
JsvmOk(OH_JSVM_CreateUint32(env_, record->ruleId, &ruleIdValue),
"OH_JSVM_CreateUint32(component event rule)", env_) &&
- ParseJson(eventArgsJson, &eventArgsValue) && ParseJson(stateJson, &stateValue);
+ ParseJson(eventArgsJson, &eventArgsValue);
ActiveInvocation previous = activeInvocation_;
if (owner) {
activeInvocation_ = {};
@@ -2131,11 +2128,11 @@ class JsvmRuntime
JSVM_Value ownerHandleValue = nullptr;
JSVM_Value *args = nullptr;
size_t argc = 0;
- JSVM_Value ownerArgs[4] = {ruleIdValue, eventArgsValue, stateValue, nullptr};
- JSVM_Value plainArgs[3] = {ruleIdValue, eventArgsValue, stateValue};
+ JSVM_Value ownerArgs[3] = {ruleIdValue, eventArgsValue, nullptr};
+ JSVM_Value plainArgs[2] = {ruleIdValue, eventArgsValue};
if (owner && JsvmOk(OH_JSVM_CreateUint32(env_, 0, &ownerHandleValue),
"OH_JSVM_CreateUint32(component event owner)", env_)) {
- ownerArgs[3] = ownerHandleValue;
+ ownerArgs[2] = ownerHandleValue;
args = ownerArgs;
argc = std::size(ownerArgs);
} else {
@@ -2162,6 +2159,70 @@ class JsvmRuntime
"napi_get_value_bool(component event handled)");
}
+ bool CallUiAttrHandler(napi_env napiEnv, uint32_t ruleId, napi_value owner, napi_value *value)
+ {
+ if (!value) {
+ LogError("CallUiAttrHandler received an invalid argument");
+ return false;
+ }
+ *value = nullptr;
+
+ JSVM_HandleScope scope = nullptr;
+ if (!JsvmOk(OH_JSVM_OpenHandleScope(env_, &scope), "OH_JSVM_OpenHandleScope(component attribute)", env_)) {
+ return false;
+ }
+
+ JSVM_Value ruleIdValue = nullptr;
+ JSVM_Value ownerHandleValue = nullptr;
+ JSVM_Value result = nullptr;
+ std::string resultJson;
+ ActiveInvocation previous = activeInvocation_;
+ if (owner) {
+ activeInvocation_ = {};
+ activeInvocation_.env = napiEnv;
+ activeInvocation_.receiver = owner;
+ activeInvocation_.proxyValues[0] = owner;
+ activeInvocation_.proxyValueCount = 1;
+ }
+
+ bool success = JsvmOk(OH_JSVM_CreateUint32(env_, ruleId, &ruleIdValue),
+ "OH_JSVM_CreateUint32(component attribute rule)", env_) &&
+ JsvmOk(OH_JSVM_CreateUint32(env_, 0, &ownerHandleValue),
+ "OH_JSVM_CreateUint32(component attribute owner)", env_);
+ // ownerHandleValue is always 0 because we store the owner napi_value at
+ // proxyValues[0] above, so makeNativeProxy(0, ...) resolves to the owner
+ // via the activeInvocation_ proxy table.
+ if (success) {
+ JSVM_Value args[] = {ruleIdValue, ownerHandleValue};
+ success = CallGlobal("__ohospatch_callUiAttr", args, std::size(args), &result) &&
+ StringifyJson(result, &resultJson);
+ }
+ if (owner) {
+ activeInvocation_ = previous;
+ }
+ if (!JsvmOk(OH_JSVM_CloseHandleScope(env_, scope), "OH_JSVM_CloseHandleScope(component attribute)", env_)) {
+ success = false;
+ }
+ if (!success) {
+ return false;
+ }
+
+ napi_value envelope = nullptr;
+ if (!NapiJsonParse(napiEnv, resultJson, &envelope)) {
+ return false;
+ }
+ bool handled = false;
+ napi_value handledValue = nullptr;
+ if (!NapiOk(napiEnv, napi_get_named_property(napiEnv, envelope, "handled", &handledValue),
+ "napi_get_named_property(component attribute handled)") ||
+ !NapiOk(napiEnv, napi_get_value_bool(napiEnv, handledValue, &handled),
+ "napi_get_value_bool(component attribute handled)") || !handled) {
+ return false;
+ }
+ return NapiOk(napiEnv, napi_get_named_property(napiEnv, envelope, "value", value),
+ "napi_get_named_property(component attribute value)");
+ }
+
static napi_value CallOriginalUiEvent(napi_env env, UiEventCallbackRecord *record, napi_value receiver,
size_t argc, const napi_value *argv)
{
@@ -2185,6 +2246,38 @@ class JsvmRuntime
return result;
}
+ static bool CallOriginalUiEventForPatch(napi_env env, UiEventCallbackRecord *record, napi_value receiver,
+ size_t argc, const napi_value *argv, napi_value *result,
+ bool *exceptionPending)
+ {
+ if (exceptionPending) {
+ *exceptionPending = false;
+ }
+ if (!result) {
+ LogError("CallOriginalUiEventForPatch received an invalid result pointer");
+ return false;
+ }
+ *result = nullptr;
+ if (!record || !record->originalEvent) {
+ *result = NapiUndefined(env);
+ return true;
+ }
+ napi_value original = nullptr;
+ if (!NapiOk(env, napi_get_reference_value(env, record->originalEvent, &original),
+ "napi_get_reference_value(original component event for patch)")) {
+ return false;
+ }
+ napi_status status = napi_call_function(env, receiver, original, argc, argv, result);
+ if (status == napi_pending_exception) {
+ LogError("Original component event callback produced a pending exception");
+ if (exceptionPending) {
+ *exceptionPending = true;
+ }
+ return false;
+ }
+ return NapiOk(env, status, "napi_call_function(original component event for patch)");
+ }
+
void ApplyUiValueRules(napi_env napiEnv, const std::string &targetKey, UiRuleKind kind, napi_value target)
{
if (!target) {
@@ -2486,6 +2579,35 @@ class JsvmRuntime
continue;
}
+ napi_value attribute = nullptr;
+ napi_valuetype attributeType = napi_undefined;
+ if (!NapiOk(napiEnv,
+ napi_get_named_property(napiEnv, componentApi, rule->attributeName.c_str(), &attribute),
+ "napi_get_named_property(component attribute)") ||
+ !NapiOk(napiEnv, napi_typeof(napiEnv, attribute, &attributeType),
+ "napi_typeof(component attribute)") ||
+ attributeType != napi_function) {
+ LogError(rule->nodeType + "." + rule->attributeName + " is not an attribute function");
+ continue;
+ }
+
+ napi_value ignored = nullptr;
+ if (rule->hasAttrHandler) {
+ napi_value owner = nullptr;
+ if (!NapiOk(napiEnv, napi_get_reference_value(napiEnv, record->owner, &owner),
+ "napi_get_reference_value(component attribute owner)") || !owner) {
+ LogError("Component attribute owner is unavailable");
+ continue;
+ }
+ napi_value value = nullptr;
+ if (!CallUiAttrHandler(napiEnv, rule->ruleId, owner, &value) || !value) {
+ continue;
+ }
+ NapiOk(napiEnv, napi_call_function(napiEnv, componentApi, attribute, 1, &value, &ignored),
+ "napi_call_function(component attribute handler)");
+ continue;
+ }
+
napi_value arguments = nullptr;
uint32_t argc = 0;
if (!NapiJsonParse(napiEnv, rule->argumentsJson, &arguments) ||
@@ -2509,19 +2631,6 @@ class JsvmRuntime
if (!argsReady) {
continue;
}
-
- napi_value attribute = nullptr;
- napi_valuetype attributeType = napi_undefined;
- if (!NapiOk(napiEnv,
- napi_get_named_property(napiEnv, componentApi, rule->attributeName.c_str(), &attribute),
- "napi_get_named_property(component attribute)") ||
- !NapiOk(napiEnv, napi_typeof(napiEnv, attribute, &attributeType),
- "napi_typeof(component attribute)") ||
- attributeType != napi_function) {
- LogError(rule->nodeType + "." + rule->attributeName + " is not an attribute function");
- continue;
- }
- napi_value ignored = nullptr;
NapiOk(napiEnv, napi_call_function(napiEnv, componentApi, attribute, argc, argv.data(), &ignored),
"napi_call_function(component attribute)");
}
@@ -2548,8 +2657,6 @@ class JsvmRuntime
}
record->env = napiEnv;
record->ruleId = rule->ruleId;
- record->captureCount = rule->captureCount;
- record->captureProperties = rule->captureProperties;
record->originalEvent = capture.originalEvent;
capture.originalEvent = nullptr;
if (owner && !NapiOk(napiEnv, napi_create_reference(napiEnv, owner, 0, &record->owner),
@@ -2612,40 +2719,31 @@ class JsvmRuntime
} else if (kind == "attribute") {
rule->kind = UiRuleKind::ATTRIBUTE;
napi_value arguments = nullptr;
+ napi_value attrHandler = nullptr;
+ bool hasAttrHandler = false;
if (!NapiNamedString(napiEnv, spec, "nodeType", &rule->nodeType) ||
!NapiNamedUint32(napiEnv, spec, "occurrence", &rule->occurrence) ||
!NapiNamedString(napiEnv, spec, "attributeName", &rule->attributeName) ||
- !NapiOk(napiEnv, napi_get_named_property(napiEnv, spec, "arguments", &arguments),
- "napi_get_named_property(component attribute arguments)") ||
- !NapiJsonStringify(napiEnv, arguments, "[]", &rule->argumentsJson)) {
+ !NapiOk(napiEnv, napi_get_named_property(napiEnv, spec, "attrHandler", &attrHandler),
+ "napi_get_named_property(component attribute handler flag)") ||
+ !NapiOk(napiEnv, napi_get_value_bool(napiEnv, attrHandler, &hasAttrHandler),
+ "napi_get_value_bool(component attribute handler flag)")) {
+ return false;
+ }
+ rule->hasAttrHandler = hasAttrHandler;
+ if (!hasAttrHandler &&
+ (!NapiOk(napiEnv, napi_get_named_property(napiEnv, spec, "arguments", &arguments),
+ "napi_get_named_property(component attribute arguments)") ||
+ !NapiJsonStringify(napiEnv, arguments, "[]", &rule->argumentsJson))) {
return false;
}
} else if (kind == "event") {
rule->kind = UiRuleKind::EVENT;
- napi_value capture = nullptr;
- uint32_t captureCount = 0;
if (!NapiNamedString(napiEnv, spec, "nodeType", &rule->nodeType) ||
!NapiNamedUint32(napiEnv, spec, "occurrence", &rule->occurrence) ||
- !NapiNamedString(napiEnv, spec, "eventName", &rule->eventName) ||
- !NapiOk(napiEnv, napi_get_named_property(napiEnv, spec, "capture", &capture),
- "napi_get_named_property(component event capture)") ||
- !NapiOk(napiEnv, napi_get_array_length(napiEnv, capture, &captureCount),
- "napi_get_array_length(component event capture)")) {
- return false;
- }
- if (captureCount > kMaxUiCaptureProperties) {
- LogError("Component event capture count exceeds the OhosPatch limit");
+ !NapiNamedString(napiEnv, spec, "eventName", &rule->eventName)) {
return false;
}
- rule->captureCount = captureCount;
- for (uint32_t index = 0; index < captureCount; ++index) {
- napi_value property = nullptr;
- if (!NapiOk(napiEnv, napi_get_element(napiEnv, capture, index, &property),
- "napi_get_element(component event capture)") ||
- !NapiString(napiEnv, property, &rule->captureProperties[index])) {
- return false;
- }
- }
} else {
LogError("Unsupported component rule kind: " + kind);
return false;
diff --git a/ohospatch/src/main/cpp/runtime/fixit.js b/ohospatch/src/main/cpp/runtime/fixit.js
index 775beac..b3a573f 100644
--- a/ohospatch/src/main/cpp/runtime/fixit.js
+++ b/ohospatch/src/main/cpp/runtime/fixit.js
@@ -5,6 +5,7 @@
instance: Object.create(null),
klass: Object.create(null),
uiValues: Object.create(null),
+ uiAttrs: Object.create(null),
uiEvents: Object.create(null)
};
var specs = [];
@@ -22,7 +23,6 @@
var REMOTE_HANDLE_KEY = '__ohospatch_proxy_handle__';
var IMPORT_HANDLE_KEY = '__ohospatch_import_handle__';
var UNDEFINED_VALUE_KEY = '__ohospatch_proxy_undefined__';
- var UI_EVENT_CONTEXT_KEY = '__ohospatch_ui_event_context__';
function own(object, property) {
return Object.prototype.hasOwnProperty.call(object, property);
@@ -485,10 +485,6 @@
function makeUiEventOrigin() {
return function () {
var args = Array.prototype.slice.call(arguments);
- var tail = args.length > 0 ? args[args.length - 1] : null;
- if (tail && tail[UI_EVENT_CONTEXT_KEY] === true) {
- args.pop();
- }
return decodeNativeResponse(global.__ohospatch_eventOrigin(encodeNativeWire(args)), 0);
};
}
@@ -557,7 +553,6 @@
if (args.length === 0) {
throw new TypeError('Component attribute requires at least one argument');
}
- args = copyJsonValue(args, 'Component attribute arguments');
var target = this.component.target;
var selector = this.selector;
@@ -567,8 +562,18 @@
rule.nodeType = selector.type;
rule.occurrence = selector.occurrence;
rule.attributeName = name;
- rule.arguments = args;
- registerUiRule(uniqueKey, rule, null, null);
+
+ if (typeof args[0] === 'function') {
+ if (args.length !== 1) {
+ throw new TypeError('Component attribute handler does not accept extra arguments');
+ }
+ rule.attrHandler = true;
+ registerUiRule(uniqueKey, rule, registry.uiAttrs, args[0]);
+ } else {
+ rule.attrHandler = false;
+ rule.arguments = copyJsonValue(args, 'Component attribute arguments');
+ registerUiRule(uniqueKey, rule, null, null);
+ }
return this;
};
@@ -583,31 +588,11 @@
return this;
};
- ComponentNodeFix.prototype.event = function (eventName, ruleOrHandler) {
+ ComponentNodeFix.prototype.event = function (eventName, handler) {
var name = validateUiName(eventName, 'Component event name');
- var mode = 'replace';
- var capture = [];
- var handler = ruleOrHandler;
- if (ruleOrHandler && typeof ruleOrHandler === 'object') {
- mode = ruleOrHandler.mode || mode;
- capture = ruleOrHandler.capture || capture;
- handler = ruleOrHandler.handler;
- }
- if (mode !== 'replace') {
- throw new Error('Only replace component event mode is currently supported');
- }
if (typeof handler !== 'function') {
throw new TypeError('Component event handler must be a function');
}
- if (!Array.isArray(capture)) {
- throw new TypeError('Component event capture must be an array');
- }
- if (capture.length > 16) {
- throw new RangeError('Component event capture supports at most 16 properties');
- }
- capture = capture.map(function (propertyName) {
- return validateUiName(propertyName, 'Captured component property');
- });
var target = this.component.target;
var selector = this.selector;
@@ -617,8 +602,6 @@
rule.nodeType = selector.type;
rule.occurrence = selector.occurrence;
rule.eventName = name;
- rule.mode = mode;
- rule.capture = capture;
registerUiRule(uniqueKey, rule, registry.uiEvents, handler);
return makeUiEventOrigin();
};
@@ -670,7 +653,7 @@
};
Object.defineProperty(Fixit, 'runtimeVersion', {
- value: '1.6.0',
+ value: '1.7.0',
enumerable: true
});
@@ -798,6 +781,7 @@
registry.instance = Object.create(null);
registry.klass = Object.create(null);
registry.uiValues = Object.create(null);
+ registry.uiAttrs = Object.create(null);
registry.uiEvents = Object.create(null);
specs = [];
uiSpecs = [];
@@ -848,7 +832,7 @@
};
};
- global.__ohospatch_callUiEvent = function (ruleId, eventArgs, state, ownerHandle) {
+ global.__ohospatch_callUiEvent = function (ruleId, eventArgs, ownerHandle) {
var handler = registry.uiEvents[ruleId];
if (!handler) {
return { handled: false };
@@ -856,32 +840,30 @@
if (!Array.isArray(eventArgs)) {
eventArgs = [eventArgs || {}];
}
- var statePatch = Object.create(null);
- var context = {
- state: state || {},
- setState: function (patch) {
- if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
- throw new TypeError('Component event state patch must be an object');
- }
- Object.keys(patch).forEach(function (name) {
- statePatch[validateUiName(name, 'Component state property')] = patch[name];
- });
- }
- };
- Object.defineProperty(context, UI_EVENT_CONTEXT_KEY, { value: true });
var owner = typeof ownerHandle === 'number' ? makeNativeProxy(ownerHandle, false, ownerHandle) : undefined;
try {
- var args = eventArgs.slice();
- args.push(context);
- var result = handler.apply(owner, args);
+ var result = handler.apply(owner, eventArgs);
return {
handled: true,
- result: result,
- statePatch: statePatch
+ result: result
};
} finally {
nativeProxyMetadata = new WeakMap();
nativeProxyCache = Object.create(null);
}
};
+
+ global.__ohospatch_callUiAttr = function (ruleId, ownerHandle) {
+ var handler = registry.uiAttrs[ruleId];
+ if (!handler) {
+ return { handled: false };
+ }
+ var owner = typeof ownerHandle === 'number' ? makeNativeProxy(ownerHandle, false, ownerHandle) : undefined;
+ try {
+ return { handled: true, value: handler.call(owner) };
+ } catch (err) {
+ console.error('OhosPatch attribute handler failed: ' + (err && err.message ? err.message : err));
+ return { handled: false };
+ }
+ };
})(typeof globalThis === 'undefined' ? this : globalThis);
diff --git a/ohospatch/src/test/js/declaration.test.mjs b/ohospatch/src/test/js/declaration.test.mjs
deleted file mode 100644
index b02b4c5..0000000
--- a/ohospatch/src/test/js/declaration.test.mjs
+++ /dev/null
@@ -1,62 +0,0 @@
-import assert from 'node:assert/strict';
-import { readFile } from 'node:fs/promises';
-import test from 'node:test';
-import vm from 'node:vm';
-
-const declarationUrl = new URL('../../../../skills/ohospatch/references/fixit.d.js', import.meta.url);
-const runtimeUrl = new URL('../../main/cpp/runtime/fixit.js', import.meta.url);
-
-test('Patch context declaration is valid JavaScript and covers the public API', async () => {
- const declaration = await readFile(declarationUrl, 'utf8');
- new vm.Script(declaration, { filename: 'fixit.d.js' });
-
- for (const symbol of [
- 'class Fixit',
- 'static runtimeVersion',
- 'static fix',
- 'static component',
- 'static import',
- 'static registerTarget',
- 'instanceMethod',
- 'classMethod',
- 'OhosPatchTarget',
- 'OhosPatchMethodHandler',
- 'OhosPatchOriginalMethod',
- 'OhosPatchImportedClass',
- 'function require',
- 'var nil',
- 'var Nil',
- 'function isNil',
- 'function nilToNull',
- 'function nullToNil',
- 'function setTimeout',
- 'function clearTimeout',
- 'function setInterval',
- 'function clearInterval',
- 'function setImmediate',
- 'function clearImmediate',
- 'function queueMicrotask',
- 'OhosPatchConsole',
- 'OhosPatchComponentFix',
- 'OhosPatchComponentValueFix',
- 'OhosPatchComponentNodeFix',
- 'OhosPatchComponentEventRule',
- 'OhosPatchComponentEventContext',
- 'OhosPatchOriginalEvent'
- ]) {
- assert.ok(declaration.includes(symbol), `missing declaration: ${symbol}`);
- }
- assert.doesNotMatch(declaration, /\b(?:YES|NO)\b/);
- assert.doesNotMatch(declaration, /__ohospatch_/);
-});
-
-test('Patch context declaration version matches the embedded runtime', async () => {
- const [declaration, runtime] = await Promise.all([
- readFile(declarationUrl, 'utf8'),
- readFile(runtimeUrl, 'utf8')
- ]);
- const declarationVersion = declaration.match(/@version\s+([0-9.]+)/)?.[1];
- const runtimeVersion = runtime.match(/runtimeVersion'[\s\S]*?value:\s*'([0-9.]+)'/)?.[1];
-
- assert.equal(declarationVersion, runtimeVersion);
-});
diff --git a/ohospatch/src/test/js/fixit.test.mjs b/ohospatch/src/test/js/fixit.test.mjs
deleted file mode 100644
index 6b25f8f..0000000
--- a/ohospatch/src/test/js/fixit.test.mjs
+++ /dev/null
@@ -1,561 +0,0 @@
-import assert from 'node:assert/strict';
-import { readFile } from 'node:fs/promises';
-import test from 'node:test';
-import vm from 'node:vm';
-
-const runtimeUrl = new URL('../../main/cpp/runtime/fixit.js', import.meta.url);
-const runtimeSource = await readFile(runtimeUrl, 'utf8');
-
-function createRuntime() {
- const logs = [];
- const origins = [];
- const eventOrigins = [];
- const scheduledTimers = [];
- const cancelledTimers = [];
- const handles = [{}];
- const importedHandles = [];
- const importedClasses = new Map();
-
- function retain(value) {
- const existing = handles.indexOf(value);
- if (existing !== -1) {
- return existing;
- }
- handles.push(value);
- return handles.length - 1;
- }
-
- function response(value) {
- if (value === undefined) {
- return ['undefined'];
- }
- if ((typeof value === 'object' && value !== null) || typeof value === 'function') {
- return [typeof value === 'function' ? 'function' : 'object', retain(value)];
- }
- return ['value', value];
- }
-
- function retainImported(value) {
- const existing = importedHandles.indexOf(value);
- if (existing !== -1) {
- return existing;
- }
- importedHandles.push(value);
- return importedHandles.length - 1;
- }
-
- function importedResponse(value) {
- if (value === undefined) {
- return ['undefined'];
- }
- if ((typeof value === 'object' && value !== null) || typeof value === 'function') {
- return [typeof value === 'function' ? 'function' : 'object', retainImported(value)];
- }
- return ['value', value];
- }
-
- function decodeWire(value) {
- if (!value || typeof value !== 'object') {
- return value;
- }
- if (Object.hasOwn(value, '__ohospatch_proxy_handle__')) {
- return handles[value.__ohospatch_proxy_handle__];
- }
- if (Object.hasOwn(value, '__ohospatch_import_handle__')) {
- return importedHandles[value.__ohospatch_import_handle__];
- }
- if (Object.hasOwn(value, '__ohospatch_proxy_undefined__')) {
- return undefined;
- }
- Object.keys(value).forEach((key) => {
- value[key] = decodeWire(value[key]);
- });
- return value;
- }
-
- const context = vm.createContext({
- __ohospatch_hilog(level, message) {
- logs.push({ level, message });
- },
- __ohospatch_origin(wire) {
- const args = decodeWire(JSON.parse(wire));
- origins.push({ receiver: handles[0], args });
- return response('origin-result');
- },
- __ohospatch_eventOrigin(wire) {
- const args = decodeWire(JSON.parse(wire));
- eventOrigins.push({ receiver: handles[0], args });
- return response('event-origin-result');
- },
- __ohospatch_proxyGet(handle, property) {
- return response(handles[handle][property]);
- },
- __ohospatch_proxySet(handle, property, wire) {
- handles[handle][property] = decodeWire(JSON.parse(wire));
- return ['ok'];
- },
- __ohospatch_proxyCall(functionHandle, receiverHandle, wire) {
- const args = decodeWire(JSON.parse(wire));
- return response(handles[functionHandle].apply(handles[receiverHandle], args));
- },
- __ohospatch_import(targetJson) {
- const target = JSON.parse(targetJson);
- const imported = importedClasses.get(
- `${target.moduleInfo}|${target.modulePath}#${target.exportName}`
- );
- return imported === undefined
- ? ['error', `Missing import ${target.exportName}`]
- : importedResponse(imported);
- },
- __ohospatch_importGet(handle, property) {
- return importedResponse(importedHandles[handle][property]);
- },
- __ohospatch_importSet(handle, property, wire) {
- importedHandles[handle][property] = decodeWire(JSON.parse(wire));
- return ['ok'];
- },
- __ohospatch_importCall(functionHandle, receiverHandle, wire) {
- const args = decodeWire(JSON.parse(wire));
- return importedResponse(importedHandles[functionHandle].apply(importedHandles[receiverHandle], args));
- },
- __ohospatch_importConstruct(constructorHandle, wire) {
- const args = decodeWire(JSON.parse(wire));
- return importedResponse(Reflect.construct(importedHandles[constructorHandle], args));
- },
- __ohospatch_scheduleTimer(id, delay, repeating) {
- scheduledTimers.push({ id, delay, repeating });
- return true;
- },
- __ohospatch_cancelTimer(id) {
- cancelledTimers.push(id);
- return true;
- }
- });
- vm.runInContext(runtimeSource, context, { filename: 'fixit.js' });
- return {
- context,
- logs,
- origins,
- eventOrigins,
- scheduledTimers,
- cancelledTimers,
- registerImport(fullPath, value) {
- const target = context.Fixit.fix(fullPath).target;
- importedClasses.set(`${target.moduleInfo}|${target.modulePath}#${target.exportName}`, value);
- },
- setProxyRoot(value) {
- handles.length = 1;
- handles[0] = value;
- }
- };
-}
-
-function plain(value) {
- return JSON.parse(JSON.stringify(value));
-}
-
-test('installs Fixit and common globals', () => {
- const { context, logs } = createRuntime();
-
- assert.equal(typeof context.Fixit, 'function');
- assert.equal(context.Fixit.runtimeVersion, '1.6.0');
- assert.equal(context.require, context.Fixit.import);
- assert.equal(context.nil, null);
- assert.equal(context.Nil, null);
- assert.equal(context.YES, undefined);
- assert.equal(context.NO, undefined);
- assert.equal(context.isNil(null), true);
- assert.equal(context.isNil(undefined), true);
- assert.equal(context.isNil(0), false);
- assert.equal(context.nilToNull(undefined), null);
- assert.equal(context.nullToNil(null), null);
- context.console.warn('patch', { count: 2 });
- assert.deepEqual(logs, [{ level: 'warn', message: 'patch {"count":2}' }]);
-});
-
-test('provides timeout, interval, immediate, and microtask globals', async () => {
- const { context, scheduledTimers, cancelledTimers } = createRuntime();
- const calls = [];
-
- const timeoutId = context.setTimeout((...args) => calls.push(['timeout', ...args]), 12.9, 'a', 2);
- assert.deepEqual(scheduledTimers[0], { id: timeoutId, delay: 12, repeating: false });
- context.__ohospatch_fireTimer(timeoutId);
- context.__ohospatch_fireTimer(timeoutId);
- assert.deepEqual(calls, [['timeout', 'a', 2]]);
-
- const intervalId = context.setInterval(() => calls.push(['interval']), 0);
- assert.deepEqual(scheduledTimers[1], { id: intervalId, delay: 1, repeating: true });
- context.__ohospatch_fireTimer(intervalId);
- context.__ohospatch_fireTimer(intervalId);
- context.clearInterval(intervalId);
- assert.deepEqual(cancelledTimers, [intervalId]);
- assert.deepEqual(calls.slice(1), [['interval'], ['interval']]);
-
- const immediateId = context.setImmediate((value) => calls.push(['immediate', value]), 3);
- assert.deepEqual(scheduledTimers[2], { id: immediateId, delay: 0, repeating: false });
- context.clearImmediate(immediateId);
- assert.deepEqual(cancelledTimers, [intervalId, immediateId]);
-
- const staleId = context.setTimeout(() => calls.push(['stale']), 100);
- context.__ohospatch_clear();
- context.__ohospatch_fireTimer(staleId);
- assert.deepEqual(cancelledTimers, [intervalId, immediateId, staleId]);
- assert.equal(calls.some((call) => call[0] === 'stale'), false);
-
- context.queueMicrotask(() => calls.push(['microtask']));
- await Promise.resolve();
- assert.deepEqual(calls.at(-1), ['microtask']);
- assert.throws(() => context.setTimeout('not a function', 0), /must be a function/);
- assert.throws(() => context.queueMicrotask(null), /must be a function/);
-});
-
-test('fix and component parse full OHM source paths internally', () => {
- const { context } = createRuntime();
-
- assert.deepEqual(plain(context.Fixit.fix(
- 'com.example.app/feature/src/main/ets/model/FeatureModel'
- ).target), {
- className: 'FeatureModel',
- modulePath: 'feature/src/main/ets/model/FeatureModel',
- moduleInfo: 'com.example.app/feature',
- exportName: 'FeatureModel',
- bundleName: 'com.example.app',
- moduleName: 'feature',
- packageName: 'feature'
- });
- assert.deepEqual(plain(context.Fixit.component(
- '@bundle:com.example.app/entry/entry_api/src/main/ets/model/DefaultModel.ets#default'
- ).target), {
- className: 'DefaultModel',
- modulePath: 'entry_api/src/main/ets/model/DefaultModel',
- moduleInfo: 'com.example.app/entry',
- exportName: 'default',
- bundleName: 'com.example.app',
- moduleName: 'entry',
- packageName: 'entry_api'
- });
-
- assert.throws(() => context.Fixit.fix('entry/src/main/ets/Test'), /must use/);
- assert.throws(() => context.Fixit.component('/com.example/entry/src/main/ets/Test'), /must use/);
- assert.throws(
- () => context.Fixit.fix('com.example/entry/src/main/ets/Test#invalid-name'),
- /export name is invalid/
- );
-});
-
-test('imports ArkTS classes and calls static and instance methods through persistent proxies', () => {
- const { context, registerImport, setProxyRoot } = createRuntime();
-
- class Point {
- constructor(x, y) {
- this.x = x;
- this.y = y;
- }
-
- toText() {
- return `(${this.x}, ${this.y})`;
- }
-
- static textOf(point) {
- return point.toText();
- }
- }
-
- const pointPath = 'com.example.app/entry/src/main/ets/model/Point#Point';
- registerImport(pointPath, Point);
- const ImportedPoint = context.Fixit.import(pointPath);
- const RequiredPoint = context.require(pointPath);
- const point = new ImportedPoint(1.25, 2.5);
-
- assert.equal(RequiredPoint.textOf(point), '(1.25, 2.5)');
- assert.equal(point.toText(), '(1.25, 2.5)');
- assert.equal(ImportedPoint.textOf(point), '(1.25, 2.5)');
- point.x = 3;
- assert.equal(point.x, 3);
-
- const fix = context.Fixit.fix(
- 'com.example.app/entry/src/main/ets/model/ViewModel#ViewModel'
- );
- fix.instanceMethod('makePoint', function () {
- this.point = point;
- return point;
- });
- const target = { point: null };
- setProxyRoot(target);
- const spec = JSON.parse(context.__ohospatch_specs())[0];
- const result = context.__ohospatch_callPatch(spec.targetKey, 'makePoint', false, 0, []);
-
- assert.ok(target.point instanceof Point);
- assert.equal(target.point.x, 3);
- assert.deepEqual(plain(result.result), { kind: 'imported', handle: 1 });
-});
-
-test('registers declarative component value, attribute, and event rules', () => {
- const { context } = createRuntime();
- const component = context.Fixit.component(
- 'com.example.app/entry/src/main/ets/components/DemoPanel#DemoPanel'
- );
-
- component.param('title').transform((value) => value || 'patched title');
- component.state('rows').replace([]);
- const button = component.node({ type: 'Button', occurrence: 0 });
- button.attrs({ height: 48, backgroundColor: '#1677FF' });
- button.event('onClick', {
- capture: ['title', 'rows'],
- handler(event, componentContext) {
- componentContext.setState({ title: `clicked:${event.source}` });
- return 'event handled';
- }
- });
-
- const specs = JSON.parse(context.__ohospatch_uiSpecs());
- assert.equal(specs.length, 5);
- assert.deepEqual(specs.map((spec) => spec.kind), [
- 'param', 'state', 'attribute', 'attribute', 'event'
- ]);
- assert.deepEqual(specs[2].arguments, [48]);
- assert.equal(specs[4].nodeType, 'Button');
- assert.equal(specs[4].occurrence, 0);
- assert.deepEqual(specs[4].capture, ['title', 'rows']);
-
- assert.deepEqual(
- plain(context.__ohospatch_callUiValue(specs[0].ruleId, '')),
- { handled: true, value: 'patched title' }
- );
- assert.deepEqual(
- plain(context.__ohospatch_callUiValue(specs[1].ruleId, ['old'])),
- { handled: true, value: [] }
- );
- assert.deepEqual(
- plain(context.__ohospatch_callUiEvent(
- specs[4].ruleId,
- { source: 'button' },
- { title: 'before', rows: ['row'] }
- )),
- {
- handled: true,
- result: 'event handled',
- statePatch: { title: 'clicked:button' }
- }
- );
-
- assert.throws(() => button.attr('height', 52), /Duplicate component patch rule/);
- assert.throws(
- () => component.node('Button').event('onChange', { mode: 'around', handler() {} }),
- /Only replace/
- );
- assert.throws(() => component.node({ type: 'Text', occurrence: -1 }), /non-negative uint32 integer/);
- assert.throws(
- () => component.node('Text').event('onClick', { capture: Array(17).fill('value'), handler() {} }),
- /at most 16/
- );
-
- context.__ohospatch_clear();
- assert.deepEqual(JSON.parse(context.__ohospatch_uiSpecs()), []);
- assert.deepEqual(plain(context.__ohospatch_callUiValue(specs[0].ruleId, 'old')), { handled: false });
-});
-
-test('binds component event handler this to the current component owner proxy', () => {
- const { context, eventOrigins, setProxyRoot } = createRuntime();
- const component = context.Fixit.component(
- 'com.example.app/entry/src/main/ets/components/DemoPanel#DemoPanel'
- );
- const origin = component.node('Button').event('onClick', {
- capture: ['tapCount'],
- handler: function (event, componentContext) {
- this.tapCount = this.tapCount + event.delta;
- this.profile.title = this.profile.title.toUpperCase();
- componentContext.setState({ tapCount: this.tapCount });
- return `${this.describe(this.profile.title)}:${origin.apply(this, arguments)}`;
- }
- });
- assert.equal(typeof origin, 'function');
-
- const owner = {
- tapCount: 2,
- profile: { title: 'patched' },
- describe(title) {
- return `${title}:${this.tapCount}`;
- }
- };
- setProxyRoot(owner);
- const spec = JSON.parse(context.__ohospatch_uiSpecs())[0];
- const result = context.__ohospatch_callUiEvent(
- spec.ruleId,
- { delta: 3 },
- { tapCount: 2 },
- 0
- );
-
- assert.equal(owner.tapCount, 5);
- assert.equal(owner.profile.title, 'PATCHED');
- assert.deepEqual(plain(result), {
- handled: true,
- result: 'PATCHED:5:event-origin-result',
- statePatch: { tapCount: 5 }
- });
- assert.equal(eventOrigins.length, 1);
- assert.deepEqual(eventOrigins[0].args, [{ delta: 3 }]);
-});
-
-test('passes original component event arguments before the injected context', () => {
- const { context, eventOrigins, setProxyRoot } = createRuntime();
- const component = context.Fixit.component(
- 'com.example.app/entry/src/main/ets/components/DemoPanel#DemoPanel'
- );
- const origin = component.node('Toggle').event('onChange', {
- capture: ['switchOn'],
- handler: function (isOn, componentContext) {
- this.switchOn = isOn;
- componentContext.setState({ switchOn: isOn });
- return origin.apply(this, arguments);
- }
- });
-
- const owner = { switchOn: true };
- setProxyRoot(owner);
- const spec = JSON.parse(context.__ohospatch_uiSpecs())[0];
- const result = context.__ohospatch_callUiEvent(
- spec.ruleId,
- [false],
- { switchOn: true },
- 0
- );
-
- assert.equal(owner.switchOn, false);
- assert.deepEqual(plain(result), {
- handled: true,
- result: 'event-origin-result',
- statePatch: { switchOn: false }
- });
- assert.equal(eventOrigins.length, 1);
- assert.deepEqual(eventOrigins[0].args, [false]);
-});
-
-test('registers instance and class methods and invokes the original method', () => {
- const { context, origins, setProxyRoot } = createRuntime();
- const fix = context.Fixit.fix({
- className: 'DemoViewModel',
- modulePath: 'entry/src/main/ets/demo/DemoViewModel',
- moduleInfo: 'com.rickytan.ohospatch/entry',
- exportName: 'DemoViewModel'
- });
-
- let origin;
- origin = fix.instanceMethod('locationOf', function (items, index) {
- this.lastIndex = index;
- return index < items.length ? items[index] : origin.apply(this, arguments);
- });
- fix.classMethod('crash', function () {
- return 'fixed';
- });
-
- const specs = JSON.parse(context.__ohospatch_specs());
- assert.equal(specs.length, 2);
- assert.deepEqual(specs.map((spec) => spec.classMethod), [false, true]);
-
- const target = { lastIndex: -1 };
- setProxyRoot(target);
- const handled = context.__ohospatch_callPatch(
- specs[0].targetKey, 'locationOf', false, 0, [['zero'], 0]
- );
- assert.deepEqual(plain(handled), {
- handled: true,
- result: { kind: 'wire', value: 'zero' }
- });
- assert.equal(target.lastIndex, 0);
-
- const fallback = context.__ohospatch_callPatch(
- specs[0].targetKey, 'locationOf', false, 0, [[], 3]
- );
- assert.deepEqual(plain(fallback.result), { kind: 'wire', value: 'origin-result' });
- assert.equal(origins.length, 1);
- assert.deepEqual(origins[0].args, [[], 3]);
-
- setProxyRoot({});
- const classResult = context.__ohospatch_callPatch(
- specs[1].targetKey, 'crash', true, 0, []
- );
- assert.deepEqual(plain(classResult.result), { kind: 'wire', value: 'fixed' });
-});
-
-test('proxies nested instance properties and methods to the original object', () => {
- const { context, setProxyRoot } = createRuntime();
- const fix = context.Fixit.fix(
- 'com.example.app/entry/src/main/ets/model/ViewModel#ViewModel'
- );
- fix.instanceMethod('repair', function () {
- const profile = this.account.profile;
- this.account.profile.name = profile.name.toUpperCase();
- this.account.profile.increment(2);
- this.alias = this.account.profile;
- return this.account.profile;
- });
-
- const target = {
- account: {
- profile: {
- name: 'patch',
- count: 3,
- toJSON() {
- return { name: this.name, count: this.count };
- },
- increment(amount) {
- this.count += amount;
- return this.count;
- }
- }
- },
- alias: null
- };
- setProxyRoot(target);
- const spec = JSON.parse(context.__ohospatch_specs())[0];
- const result = context.__ohospatch_callPatch(spec.targetKey, 'repair', false, 0, []);
-
- assert.equal(target.account.profile.name, 'PATCH');
- assert.equal(target.account.profile.count, 5);
- assert.equal(target.alias, target.account.profile);
- assert.deepEqual(plain(result.result), { kind: 'remote', handle: 2 });
-});
-
-test('keeps same-named classes from different modules isolated', () => {
- const { context, setProxyRoot } = createRuntime();
- context.Fixit.fix(
- 'com.example.app/entry/src/main/ets/model/ViewModel#ViewModel'
- ).instanceMethod('value', function () { return 'entry'; });
- context.Fixit.fix(
- 'com.example.app/feature/src/main/ets/model/ViewModel#ViewModel'
- ).instanceMethod('value', function () { return 'feature'; });
-
- const specs = JSON.parse(context.__ohospatch_specs());
- assert.notEqual(specs[0].targetKey, specs[1].targetKey);
- setProxyRoot({});
- assert.equal(context.__ohospatch_callPatch(
- specs[0].targetKey, 'value', false, 0, []
- ).result.value, 'entry');
- assert.equal(context.__ohospatch_callPatch(
- specs[1].targetKey, 'value', false, 0, []
- ).result.value, 'feature');
-});
-
-test('supports class-name aliases and clears registrations', () => {
- const { context } = createRuntime();
- context.Fixit.registerTarget('DemoViewModel', {
- modulePath: 'entry/src/main/ets/demo/DemoViewModel',
- moduleInfo: 'com.rickytan.ohospatch/entry'
- });
-
- const fix = context.Fixit.fix('DemoViewModel');
- fix.instanceMethod('crashIt', function () {
- return 'fixed';
- });
- assert.equal(JSON.parse(context.__ohospatch_specs()).length, 1);
- assert.throws(
- () => fix.instanceMethod('crashIt', function () {}),
- /Duplicate patch/
- );
-
- context.__ohospatch_clear();
- assert.deepEqual(JSON.parse(context.__ohospatch_specs()), []);
- assert.throws(() => context.Fixit.fix('DemoViewModel'), /className and modulePath/);
-});
diff --git a/ohospatch/src/test/js/native-safety.test.mjs b/ohospatch/src/test/js/native-safety.test.mjs
deleted file mode 100644
index 8af0023..0000000
--- a/ohospatch/src/test/js/native-safety.test.mjs
+++ /dev/null
@@ -1,89 +0,0 @@
-import assert from 'node:assert/strict';
-import { readFile } from 'node:fs/promises';
-import test from 'node:test';
-
-const cppUrl = new URL('../../main/cpp/ohospatch.cpp', import.meta.url);
-const cmakeUrl = new URL('../../main/cpp/CMakeLists.txt', import.meta.url);
-
-test('native layer is built without C++ exceptions', async () => {
- const [source, cmake] = await Promise.all([
- readFile(cppUrl, 'utf8'),
- readFile(cmakeUrl, 'utf8')
- ]);
-
- assert.doesNotMatch(source, /\bthrow\b/);
- assert.doesNotMatch(source, /\bcatch\b/);
- assert.doesNotMatch(source, /std::(?:exception|runtime_error)/);
- assert.doesNotMatch(source, /(?:napi_throw|OH_JSVM_Throw)/);
- assert.match(source, /OH_LOG_Print\(LOG_APP, LOG_ERROR/);
- assert.match(cmake, /target_compile_options\(ohospatch PRIVATE -fno-exceptions\)/);
-});
-
-test('native timer bridge uses the host event loop', async () => {
- const [source, cmake] = await Promise.all([
- readFile(cppUrl, 'utf8'),
- readFile(cmakeUrl, 'utf8')
- ]);
-
- assert.match(source, /napi_get_uv_event_loop/);
- assert.match(source, /napi_open_handle_scope\(hostEnv_, &napiScope\)/);
- assert.match(source, /__ohospatch_scheduleTimer/);
- assert.match(source, /__ohospatch_cancelTimer/);
- assert.match(source, /OH_JSVM_PerformMicrotaskCheckpoint/);
- assert.match(cmake, /libuv\.so/);
-});
-
-test('JSVM calls own handle scopes and native callbacks have stable storage', async () => {
- const source = await readFile(cppUrl, 'utf8');
-
- assert.match(source, /OH_JSVM_OpenHandleScope\(env_, &scope\).*patch install/s);
- assert.match(source, /OH_JSVM_OpenHandleScope\(env_, &scope\).*method patch/s);
- assert.match(source, /OH_JSVM_OpenHandleScope\(env_, &scope\).*clear registry/s);
- assert.match(source, /static JSVM_CallbackStruct originCallback/);
- assert.match(source, /static JSVM_CallbackStruct proxyGetCallback/);
- assert.match(source, /static JSVM_CallbackStruct proxySetCallback/);
- assert.match(source, /static JSVM_CallbackStruct proxyCallCallback/);
- assert.match(source, /static JSVM_CallbackStruct scheduleTimerCallback/);
-});
-
-test('native proxy bridge resolves live ArkTS values without target snapshot writeback', async () => {
- const source = await readFile(cppUrl, 'utf8');
-
- assert.match(source, /__ohospatch_proxyGet/);
- assert.match(source, /__ohospatch_proxySet/);
- assert.match(source, /__ohospatch_proxyCall/);
- assert.match(source, /ResolveBridgeWireValue/);
- assert.match(source, /proxyValues\[0\] = receiver/);
- assert.doesNotMatch(source, /targetJson/);
-});
-
-test('native import bridge retains classes and supports property, call, and construct operations', async () => {
- const source = await readFile(cppUrl, 'utf8');
-
- assert.match(source, /__ohospatch_import/);
- assert.match(source, /__ohospatch_importGet/);
- assert.match(source, /__ohospatch_importSet/);
- assert.match(source, /__ohospatch_importCall/);
- assert.match(source, /__ohospatch_importConstruct/);
- assert.match(source, /napi_load_module_with_info\(napiEnv/);
- assert.match(source, /napi_new_instance/);
- assert.match(source, /napi_create_reference\(hostEnv_, value, 1/);
- assert.match(source, /ClearImportedValues/);
-});
-
-test('native component adapter covers values, node builders, attributes, and events', async () => {
- const source = await readFile(cppUrl, 'utf8');
-
- assert.match(source, /__ohospatch_uiSpecs/);
- assert.match(source, /setInitiallyProvidedValue/);
- assert.match(source, /updateStateVars/);
- assert.match(source, /initialRender/);
- assert.match(source, /observeComponentCreation2/);
- assert.match(source, /PrepareUiEventCaptures/);
- assert.match(source, /ApplyUiAttributes/);
- assert.match(source, /__ohospatch_callUiEvent/);
- assert.match(source, /__ohospatch_eventOrigin/);
- assert.match(source, /napi_create_array_with_length\(napiEnv, argc, &eventArgs/);
- assert.match(source, /CallUiEventHandler\(napiEnv, record, argc, argv, state, owner/);
- assert.match(source, /activeInvocation_\.proxyValues\[0\] = owner/);
-});
diff --git a/ohospatch/src/test/js/skill.test.mjs b/ohospatch/src/test/js/skill.test.mjs
deleted file mode 100644
index 7cc5e82..0000000
--- a/ohospatch/src/test/js/skill.test.mjs
+++ /dev/null
@@ -1,86 +0,0 @@
-import assert from 'node:assert/strict';
-import { spawnSync } from 'node:child_process';
-import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
-import { tmpdir } from 'node:os';
-import { dirname, join } from 'node:path';
-import test from 'node:test';
-import { fileURLToPath } from 'node:url';
-
-const projectRoot = fileURLToPath(new URL('../../../../', import.meta.url));
-const installer = join(projectRoot, 'scripts/install-skill.sh');
-const sourceSkill = join(projectRoot, 'skills/ohospatch/SKILL.md');
-const sourceDeclaration = join(projectRoot, 'skills/ohospatch/references/fixit.d.js');
-
-function runInstaller(home, args = []) {
- return spawnSync(installer, args, {
- cwd: projectRoot,
- encoding: 'utf8',
- env: {
- ...process.env,
- HOME: home,
- CODEX_HOME: join(home, 'codex'),
- CLAUDE_HOME: join(home, 'claude')
- }
- });
-}
-
-test('OhosPatch Skill uses the short cross-tool name', async () => {
- const [skill, installerStat] = await Promise.all([
- readFile(sourceSkill, 'utf8'),
- stat(installer)
- ]);
-
- assert.match(skill, /^---\nname: ohospatch\n/m);
- assert.doesNotMatch(skill, /ohospatch-patch-authoring/);
- assert.notEqual(installerStat.mode & 0o111, 0, 'installer must be executable');
-});
-
-test('Skill installer supports Codex and Claude Code', async (context) => {
- const home = await mkdtemp(join(tmpdir(), 'ohospatch-skill-'));
- context.after(() => rm(home, { recursive: true, force: true }));
-
- const installed = runInstaller(home);
- assert.equal(installed.status, 0, installed.stderr);
- assert.match(installed.stdout, /Codex/);
- assert.match(installed.stdout, /Claude Code/);
-
- const [source, declaration, codexSkill, claudeSkill, codexDeclaration, claudeDeclaration] =
- await Promise.all([
- readFile(sourceSkill, 'utf8'),
- readFile(sourceDeclaration, 'utf8'),
- readFile(join(home, 'codex/skills/ohospatch/SKILL.md'), 'utf8'),
- readFile(join(home, 'claude/skills/ohospatch/SKILL.md'), 'utf8'),
- readFile(join(home, 'codex/skills/ohospatch/references/fixit.d.js'), 'utf8'),
- readFile(join(home, 'claude/skills/ohospatch/references/fixit.d.js'), 'utf8')
- ]);
-
- assert.equal(codexSkill, source);
- assert.equal(claudeSkill, source);
- assert.equal(codexDeclaration, declaration);
- assert.equal(claudeDeclaration, declaration);
-
- const duplicate = runInstaller(home);
- assert.equal(duplicate.status, 1);
- assert.match(duplicate.stderr, /--force/);
-
- const replaced = runInstaller(home, ['--all', '--force']);
- assert.equal(replaced.status, 0, replaced.stderr);
-});
-
-test('Skill installer can target Codex only', async (context) => {
- const home = await mkdtemp(join(tmpdir(), 'ohospatch-skill-codex-'));
- context.after(() => rm(home, { recursive: true, force: true }));
-
- const installed = runInstaller(home, ['--codex']);
- assert.equal(installed.status, 0, installed.stderr);
- assert.match(installed.stdout, /Codex/);
- assert.doesNotMatch(installed.stdout, /Claude Code/);
-
- const codexSkill = await readFile(join(home, 'codex/skills/ohospatch/SKILL.md'), 'utf8');
- assert.match(codexSkill, /^---\nname: ohospatch\n/m);
-
- await assert.rejects(
- readFile(join(home, 'claude/skills/ohospatch/SKILL.md'), 'utf8'),
- { code: 'ENOENT' }
- );
-});
diff --git a/package.json b/package.json
index ac9b063..c360338 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,8 @@
"license": "MIT",
"scripts": {
"install:skill": "./scripts/install-skill.sh",
- "test": "node --test ohospatch/src/test/js/*.test.mjs"
+ "test": "./scripts/test-device.sh",
+ "test:device": "./scripts/test-device.sh"
},
"dependencies": {
"@ohos/hvigor": "6.23.6",
diff --git a/scripts/test-device.sh b/scripts/test-device.sh
new file mode 100755
index 0000000..0d02116
--- /dev/null
+++ b/scripts/test-device.sh
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+deveco_home="${DEVECO_STUDIO_HOME:-/Applications/DevEco-Studio.app/Contents}"
+sdk_home="${OHOS_BASE_SDK_HOME:-$HOME/Library/OpenHarmony/Sdk}"
+default_hdc="$sdk_home/20/toolchains/hdc"
+if [[ ! -x "$default_hdc" ]]; then
+ default_hdc="$deveco_home/sdk/default/openharmony/toolchains/hdc"
+fi
+hdc="${HDC:-$default_hdc}"
+hvigorw="${HVIGORW:-$deveco_home/tools/hvigor/bin/hvigorw}"
+
+test -x "$hdc"
+test -x "$hvigorw"
+
+hdc_retry() {
+ local attempt
+ for attempt in 1 2 3; do
+ if "$hdc" "$@"; then
+ return 0
+ fi
+ sleep 1
+ done
+ return 1
+}
+
+hdc_retry list targets
+
+"$hvigorw" --mode module -p module=entry clean assembleHap --no-daemon
+"$hvigorw" --mode module -p module=entry@ohosTest assembleHap --no-daemon
+
+hdc_retry install -r entry/build/default/outputs/default/entry-default-unsigned.hap
+hdc_retry install -r entry/build/default/outputs/ohosTest/entry-ohosTest-unsigned.hap
+
+output_file="$(mktemp "${TMPDIR:-/tmp}/ohospatch-ohostest.XXXXXX")"
+trap 'rm -f "$output_file"' EXIT
+
+hdc_retry shell aa test -b com.rickytan.ohospatch -m entry_test -s unittest OpenHarmonyTestRunner -s timeout 60000 > "$output_file"
+cat "$output_file"
+
+if ! grep -q 'OHOS_REPORT_CODE: 0' "$output_file"; then
+ exit 1
+fi
diff --git a/skills/ohospatch/SKILL.md b/skills/ohospatch/SKILL.md
index d173df3..41d64da 100644
--- a/skills/ohospatch/SKILL.md
+++ b/skills/ohospatch/SKILL.md
@@ -64,23 +64,19 @@ panel.state('count').transform(function (value) {
});
var originClick = panel.node({ type: 'Button', occurrence: 0 })
.attrs({ height: 48, backgroundColor: '#1677FF' })
- .event('onClick', {
- mode: 'replace',
- capture: ['count'],
- handler: function (_event, context) {
- this.count = context.state.count + 1;
- return originClick.apply(this, arguments);
- }
+ .event('onClick', function () {
+ this.count = this.count + 1;
+ return originClick.apply(this, arguments);
});
```
- Target an exported custom component.
- Select nodes only by built-in type plus zero-based occurrence.
- Keep attribute arguments and replacement values JSON-serializable.
-- Use synchronous event mode `replace`; capture at most 16 properties.
- Use normal `function` syntax when a Component event patch needs `this`; it is bound to the current Component instance proxy.
-- Component event handlers receive the original ArkUI event arguments first and the OhosPatch context as the final argument.
+- Component event handlers receive only the original ArkUI event arguments; read and write component state through `this`.
- `node.event(...)` returns the original ArkUI event callback proxy; call it with `origin.apply(this, arguments)` when the patch should preserve original event behavior.
+- Wrap `origin.apply(this, arguments)` in `try/catch` when the patch is intended to recover from an original ArkTS exception. OhosPatch converts that explicit origin-call exception into a JSVM `Error`; uncaught origin-call errors fall back to the original ArkTS behavior.
- Do not generate `before`, `after`, `around`, route interception, V2 state, resource/controller values, ID/hierarchy selectors, or forced refresh logic.
## Runtime Reference
@@ -104,7 +100,7 @@ bundleName/moduleName/[packageName/]src/main/ets/File#ExportName
- Prototype hooks do not cover constructors, instance-field arrow functions, private members, or call sites that bypass property lookup.
- The handler `this` Proxy is valid only for the current synchronous invocation or `origin` call; it must not escape to timers, promises, or globals. `Fixit.import()` Proxies persist until `OhosPatch.clear()` or patch replacement.
-- Component DSL supports only API 20 state-management V1 exported custom components, `type + occurrence` node selection, JSON-serializable attributes, and synchronous `replace` events. Not supported: `before`/`after`/`around` events, non-exported `@Entry` route pages, state-management V2, ID/hierarchy selectors, resource/controller values, and forced refresh of mounted components.
+- Component DSL supports only API 20 state-management V1 exported custom components, `type + occurrence` node selection, JSON-serializable attributes, and synchronous event replacement. Not supported: `before`/`after`/`around` event composition, non-exported `@Entry` route pages, state-management V2, ID/hierarchy selectors, resource/controller values, and forced refresh of mounted components.
- At most 256 active timers per runtime; `setInterval(..., 0)` schedules at 1 ms.
- At most 512 deduped dynamic-import class, instance, method, or nested-object handles per patch.
- Download, signature verification, version matching, rollout, caching, rollback, timeout, and circuit breaking are host responsibilities; the HAR owns none of them.
diff --git a/skills/ohospatch/references/fixit.d.js b/skills/ohospatch/references/fixit.d.js
index feaaf8a..fe0bc42 100644
--- a/skills/ohospatch/references/fixit.d.js
+++ b/skills/ohospatch/references/fixit.d.js
@@ -8,7 +8,7 @@
*
* ///
*
- * @version 1.6.0
+ * @version 1.7.0
*/
/**
@@ -61,17 +61,10 @@
* @property {number=} occurrence Zero-based occurrence; defaults to `0`.
*/
-/**
- * @typedef {Object} OhosPatchComponentEventContext
- * @property {Record} state Snapshot of properties listed by `capture`.
- * @property {(patch: Record) => void} setState Writes captured component properties.
- */
-
/**
* @callback OhosPatchComponentEventHandler
* @this {any} Synchronous Proxy for the current declarative Component instance.
- * @param {...any} args Original ArkUI event arguments followed by an
- * `OhosPatchComponentEventContext` as the final argument.
+ * @param {...any} args Original ArkUI event arguments.
* @returns {any}
*/
@@ -81,29 +74,30 @@
* @typedef {(this: any, ...args: any[]) => any} OhosPatchOriginalEvent
*/
-/**
- * Only synchronous `replace` mode is currently supported and at most 16
- * properties may be captured.
- *
- * @typedef {Object} OhosPatchComponentEventRule
- * @property {'replace'=} mode
- * @property {Array=} capture
- * @property {OhosPatchComponentEventHandler} handler
- */
-
/**
* @typedef {Object} OhosPatchComponentValueFix
* @property {(handler: (value: any) => any) => OhosPatchComponentFix} transform Transform the incoming value.
* @property {(value: OhosPatchJsonValue) => OhosPatchComponentFix} replace Replace with a JSON value.
*/
+/**
+ * @typedef {(this: any) => OhosPatchJsonValue} OhosPatchAttrHandler
+ * Attribute resolver invoked on each render with `this` bound to the current component instance
+ * proxy; the return value is applied as the attribute argument.
+ */
+
/**
* @typedef {Object} OhosPatchComponentNodeFix
- * @property {(attributeName: string, ...args: OhosPatchJsonValue[]) => OhosPatchComponentNodeFix} attr
- * Override one node attribute. At least one argument is required.
- * @property {(attributes: Record) => OhosPatchComponentNodeFix} attrs
- * Override multiple single-argument attributes.
- * @property {(eventName: string, rule: OhosPatchComponentEventRule | OhosPatchComponentEventHandler) =>
+ * @property {(attributeName: string, value: OhosPatchJsonValue | OhosPatchAttrHandler,
+ * ...args: OhosPatchJsonValue[]) => OhosPatchComponentNodeFix} attr
+ * Override one node attribute. When `value` is a function it is invoked with `this` bound to the
+ * current component instance on each render and its return value is applied as the single attribute
+ * argument; extra arguments are rejected in handler mode. Otherwise the JSON arguments are applied
+ * verbatim. At least one argument is required.
+ * @property {(attributes: Record) =>
+ * OhosPatchComponentNodeFix} attrs
+ * Override multiple single-argument attributes. Each value may be a JSON value or a resolver function.
+ * @property {(eventName: string, handler: OhosPatchComponentEventHandler) =>
* OhosPatchOriginalEvent} event Replace a synchronous node event callback and return the original callback proxy.
*/
@@ -128,7 +122,7 @@ class Fixit {
}
/** @readonly @type {string} */
- static runtimeVersion = '1.6.0';
+ static runtimeVersion = '1.7.0';
/**
* @param {string | OhosPatchTarget} target Full OHM class path, registered alias, or target descriptor.