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 前: + +![Demo before patch](docs/images/demo-before.png) + +Patch 后: + +![Demo after patch](docs/images/demo-after.png) + +启动本地 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.