卡片级定时器管控:runscript 默认放行 + 卡片按 rootId 特批(Timer Policy)

状态:设计稿(管控体系的前提实现之二) 运行时:JSH(JSVM),单 Env 关联文档:

  • js-native-call-origin.md——来源管控主方案(本文仅复用其 'timer' 场景打标:回调内 has.* 策略;注册动作不再经 ETS,不涉及第 7 参来源传输)
  • js-native-call-entry-guard.md——执行入口禁止(复用其受保护注册表机制:特批入口真身入 __protectedRegistry,业务不可达)
  • js-native-capability-boundary.md——白名单保护化与「加载时 capture」约束(框架代码须在加载期 capture 真身,运行期不得解析 globalThis)

需求(已确认,v2):定时器管控粒度从 scope 级升级为卡片级(rootId):

  1. runscript(runScriptFromUri 执行的业务脚本)默认放行——宿主主动执行的脚本视为可信,不做管控;
  2. FlexView 卡片默认拒绝,需要时由 ETS 侧按卡片特批;
  3. 失败管控:卡片被拒 = 受控失败——初始化阶段(loadInstance)被拒 → 卡片加载失败回调 + onJsException 双回调;运行期被拒 → 同步抛错走 onJsException。

旧 scope 级 FlexUIEngineContext.setTimerPermission(permitted) 与 EngineInitParams.allowTimer(init 参数)已废弃移除。

1. 背景:为什么从 scope 粒度升级到卡片粒度

1.1 旧机制(scope 级,已废弃)

业务 JS → global.setTimeout(检查包装,protect() 时安装)
  → 查 entry_guard.js 模块闭包权限标志 _timerPermitted(scope 级单布尔)
  → deny → throw 'setTimeout:fail permission denied (timer not permitted)'
  → allow → internalBinding('TimerModule') → C++ TimerModule(零改动)
  • JS context 按 scope 隔离(C++ Scope ↔ JsDriver ↔ FlexUIEngineContextImpl),_timerPermitted 随 scope 的 Env 实例独立;
  • 问题:同一 scope 内多张卡片(multi-rootView 模式,多 rootView 共享一个 JS context/globalThis)共享同一个开关,卡片之间无法独立控制;runscript 与卡片 bundle 同走 C++ RunScript,也无从区分。

1.2 新机制的粒度模型

执行入口 定时器策略
runscript(runScriptFromUri) 默认放行(脚本模式,无卡片上下文)
卡片 bundle 顶层代码(模块级,与 runscript 同走 C++ RunScript) 按脚本模式放行(已知边界,见 §3.4)
卡片实例化(loadInstance)及之后全部业务执行(事件/渲染/定时器回调) 默认拒绝,按卡片 rootId 特批

2. 关键架构事实(决定方案走向)

  1. 检查点是「创建时」:业务代码同步调 setTimeout 时判定,不需要跟踪触发时刻 → 只需知道「当前同步执行属于哪张卡(还是脚本模式)」;
  2. 框架 JS 已有按 rootId 区分卡片的机制:FlexUIBridge.activeRootId/setActiveRoot、NativeBackendContext.rootViewId——上下文跟踪有现成基础;
  3. 业务 JS 执行边界可枚举:runscript 顶层 / 卡片 bundle 顶层 / __loadInstance__(Event.js,id 即 rootId)/ 事件回调(C++ 直调 listener)/ 定时器回调 / 渲染 flush;
  4. 多卡片共享 scope context 是受支持的用法(demo 所有 FlexView 共用 SCOPE_KEY),因此不能用「scope 单开关」表达卡片权限。

3. 设计:卡片执行上下文跟踪 + per-card 标志

3.1 检查点(entry_guard.js)

卡片业务 JS → global.setTimeout(检查包装)
  → ① 查卡片执行上下文栈 _rootContextStack:
       空(脚本模式,runscript/bundle 顶层)→ 放行
       栈顶 rootId → 查 _perCardTimerPermitted[rootId](默认拒绝)
  → ② deny → throw 'xxx:fail permission denied (timer not permitted, card=<rootId>)'
             (错误对象带 timerPermissionDenied 标记,供失败管控识别)
  → ③ allow → 回调包上卡片上下文(_withRootContext)→ 原路径 internalBinding('TimerModule')
  → 到点 C++ 直调 JS 回调(回调内再建定时器/执行业务代码,上下文已恢复,归属本卡片)
  • 标志:_perCardTimerPermitted = { rootId: boolean }(模块闭包,业务不可达);
  • 上下文栈:_rootContextStack = [rootId, ...](空 = 脚本模式 = 放行);
  • 框架内部入口 pushRootContext/popRootContext/withRootContext/clearCardTimerPermission 经 globalThis.__flexuiTimerPolicy 暴露(加载期 capture;protect() 后不在白名单 → 替换为抛错代理,业务不可达——见 js-native-capability-boundary.md §5.1);
  • ETS 特批入口 __timer_policy.setTimerPermission 仍注册进 __protectedRegistry(native2js.js callJsModule 优先查真身,业务不可达),参数升级为 {rootId, permitted}。

3.2 卡片执行边界挂点(框架 JS,加载期 capture 真身)

边界 挂点 动作
卡片实例化 lib/global/Event.js __loadInstance__ push/pop(id 即 rootId),包住 receiveNativeEvent(['@hp:loadInstance']) + appRegister[name].run(params);被拒异常 → 上报 ETS 卡片加载失败(见 §3.3),异常在此终止不外抛
卡片销毁 lib/global/Event.js __unloadInstance__ clearCardTimerPermission(id)(rootId 复用不串权)+ NativeBackendContext.destroy 兜底清理
事件回调 NativeBackendContext.receiveComponentEvent(C++ 事件 → 业务 handler 的唯一入口) push/pop rootViewId(tap/click/scroll 等 handler 内的定时器归属正确)
渲染 flush NativeBackendContext.flushAndBuild(已有 setActiveRoot) push/pop rootViewId(computed/watch/render 内业务代码归属正确)
定时器回调 检查包装内 创建时 withRootContext(rootId, cb)(C++ 到点直调 JS 回调时上下文已恢复)

为什么事件挂点在 receiveComponentEvent 而不是 FlexUIBridge.addEventListener:native 注册的是稳定的 per-node 框架回调(NativeBackendElement.setListenerStats 的 cb),真正的业务 handler 在 EventDispatcher 内分发;在 NativeBackendContext.receiveComponentEvent 包夹一次即可覆盖全部业务事件 handler,且不存在 add/remove 包装函数身份匹配问题。

3.3 失败管控(卡片维度)

规则(CLAUDE.md「JS 框架代码异常处理规范」):所有 JS 异常捕获 + 上报,不外抛。

  • deny 一律同步 throw(fail 语义,业务可 catch——如 canvas-demo 的图表重试降级已有 catch 先例);
  • 统一捕获点:框架回调兜底 safeCallback(@openflexui/core func_arr.ts,生命周期/事件/observer 统一入口)捕获所有业务 JS 异常 → dispatchError 上报(console 日志 + globalOptions.onError 扩展点)→ 不 rethrow(不中断回调链、不造成组件半初始化);
  • 初始化阶段(loadInstance 同步代码)若 deny 异常冒泡到入口(非 safeCallback 路径):
    • __loadInstance__ catch 识别 timerPermissionDenied → callNative('FlexUIEngineInternal', 'cardLoadFailed', rootId, reason) → ETS FlexUIEngineModule → 引擎按 rootId 查 cardSessions → notifyModuleLoaded(STATUS_ERR_INSTANCE, reason, session) → FlexViewClient.onLoadCompleted 失败码;异常在此终止(不 rethrow,避免二次未捕获异常);
    • 非受控异常保持既有行为:冒泡到 C++ Scope::LoadInstance TryCatch → HandleException("uncaughtException") → ETS onJsException(引擎「捕获 + 上报」通道,J-03);
  • 运行期(事件/回调内)deny → 同步抛错 → safeCallback 捕获 + 上报(日志 + onError),卡片不中断;
  • 业务自行 try/catch 拦截 → 按业务逻辑处理(不触发加载失败上报)。

3.4 已知边界与取舍(已确认)

边界 策略 说明
卡片 bundle 顶层代码 脚本模式放行 index.pack.js 模块级与 runscript 同走 C++ RunScript,JS 层无法区分。实际业务定时器几乎都在实例化/回调内创建,风险极低。若要求顶层也受控,需把卡片 bundle 执行改经 JS 分发层(runCardScript 包装)或 C++ 加卡片标记——改动面更大,一期不做
ETS→JS 模块调用(callJavaScriptModule) 脚本模式放行 宿主显式发起的调用(sendEvent、__ascfCallbacks 等),非卡片自主行为。若需归属卡片,可复用来源归因机制(origin 文档)按 callId 反查——一期不做
UIManagerModule 命令式回调(callUIFunction 的 callback) 脚本模式放行 C++ 经 callbackId 直调业务闭包,无 JS 分发层。同属低频边界,一期不做

3.5 ETS 接口与数据流

ETS:engine.getScopeContext(scopeKey)?.setCardTimerPermission(rootId, permitted)
  → callJavaScriptModule('__timer_policy', 'setTimerPermission', JSON.stringify({rootId, permitted}))
  → native2js 'callJsModule' → __protectedRegistry 真身(业务不可达)
  → 写入 entry_guard.js _perCardTimerPermitted[rootId]
  • 静态声明:FlexView.allowTimer?: boolean(默认 false)→ loadCard(..., allowTimer?) → ModuleLoadParams.allowTimer → CardSession.allowTimer → loadJsModule 在 runBundle 之前经 __timer_policy.setTimerPermission 下发(早于 loadInstance,覆盖实例化及之后全部业务执行);
  • 动态切换:FlexUIEngineContext.setCardTimerPermission(rootId, permitted)(任意时刻可调);
  • 收回语义:setCardTimerPermission(rootId, false) 只阻断新注册;存量活跃定时器自然到期(业务可用 clear 类自行清理)。如需强收回(清掉存量),后续可加 C++ 清表接口,本期不做;
  • 清理:卡片销毁(__unloadInstance__)清除该 rootId 标志,rootId 复用不串权;
  • runscript:无需任何接口(脚本模式恒放行)。

4. 与原方案/旧实现的差异

项 旧实现(scope 级,v1) 本方案(卡片级,v2)
粒度 scope 单布尔 _timerPermitted 卡片 _perCardTimerPermitted[rootId] + 脚本模式恒放行
runscript 受 scope 开关管控 默认放行(无需特批)
FlexView 卡片 随 scope 开关(同 scope 卡片无法独立控制) 默认拒绝,按 rootId 特批(FlexView.allowTimer / setCardTimerPermission)
失败语义 deny 同步抛错(无加载失败信号) deny 同步抛错 + 初始化阶段转加载失败回调(STATUS_ERR_INSTANCE)+ onJsException 双回调
ETS 特批 API setTimerPermission(permitted)(废弃) setCardTimerPermission(rootId, permitted)(新增)
init 参数 EngineInitParams.allowTimer(废弃移除) FlexView.allowTimer(卡片参数)替代
框架挂点 无(单一检查点) __loadInstance__/__unloadInstance__/receiveComponentEvent/flushAndBuild/定时器回调 wrap
JS→ETS 通道 无 新增引擎内置 native 模块 FlexUIEngineInternal.cardLoadFailed

5. 安全性

  • 标志与上下文栈存于 entry_guard.js 模块闭包(bootstrap require 独立函数作用域,业务拿不到);
  • 特批入口真身只在 __protectedRegistry(jsModuleList / globalThis 不暴露);框架内部入口 __flexuiTimerPolicy 不在白名单,protect() 后替换为抛错代理——业务均无法自开开关或伪造卡片上下文;
  • 业务改 global.setTimeout、删 entry_guard 白名单均无效(检查包装安装于 protect() 时,业务只能看到检查包装)。

6. 影响面清单

层 文件 改动
JS lib/global/ohos/entry_guard.js _perCardTimerPermitted + _rootContextStack + _setTimerPermission({rootId, permitted}) + push/pop/withRootContext + __flexuiTimerPolicy 暴露 + 检查包装(deny 带 timerPermissionDenied 标记、allow 时回调 wrap);移除 _timerPermitted/allowTimer 读取
JS lib/global/Event.js __loadInstance__ push/pop + deny 上报 FlexUIEngineInternal.cardLoadFailed(不外抛);__unloadInstance__ 清理 per-card 标志
JS packages/flexui/src/func_arr.ts safeCallback 明确为统一异常捕获点:捕获所有业务异常 → dispatchError 上报(日志 + globalOptions.onError)→ 不外抛(规则见 CLAUDE.md「JS 框架代码异常处理规范」)
JS packages/flexui-backend/src/native-backend/engine-timer-policy.ts(新增) enterCardContext/leaveCardContext/withCardContext/clearCardTimerPermission(加载期 capture __flexuiTimerPolicy)
JS packages/flexui-backend/src/native-backend/NativeBackendContext.ts receiveComponentEvent/flushAndBuild push/pop;destroy 兜底清理
ETS FlexUIEngineContext.ets(接口)+ FlexUIEngineContextImpl.ets setCardTimerPermission(rootId, permitted)(替换 setTimerPermission);reportCardLoadFailed + setCardLoadFailedCallback
ETS FlexUIEngine.ets(framework):EngineInitParams 删 allowTimer;ModuleLoadParams 加 allowTimer;ModuleLoadStatus 加 STATUS_ERR_INSTANCE = -205 参数下沉/状态码
ETS FlexUIBridgeManagerImpl.ets 删 mAllowTimer/构造参数/global config allowTimer 推送
ETS FlexUIModuleManagerImpl.ets + modules/native/FlexUIEngineModule.ets(新增) 注册引擎内置 native 模块 FlexUIEngineInternal(cardLoadFailed → 引擎路由)
ETS FlexUIEngineManagerImpl.ets CardSession.allowTimer;loadModuleWithListener 注册失败路由回调;loadJsModule bundle 加载前下发 per-card 权限
ETS flexui/FlexUIEngine.ets(facade)、FlexView.ets、FlexViewBuilder.ets loadCard(..., allowTimer?);FlexView.allowTimer;FlexViewBuilder.setAllowTimer;删 FlexUIEngineConfig.allowTimer
文档 docs/design/js-native-call-timer-policy.md、docs/flexui-engine/flexui-engine-arkts-api.md(2026-09 自 docs/learn/ 迁移)、docs/design/js-native-call-origin.md 粒度升级、runscript 放行、失败管控语义、API 变更

注意:driver/js/lib/** 改动后需 cd flexui-engine/driver/js && npm run buildcore;driver/js/packages/** 改动后需 ./build.sh build-jsfwk(或 build-engine)。

7. 验证清单

  1. runscript(runScriptFromUri)默认可建定时器,无需任何特批;
  2. 卡片默认拒绝:卡片 JS setTimeout/setInterval/requestIdleCallback/requestAnimationFrame → 同步抛 permission denied (timer not permitted, card=<rootId>);
  3. 卡片初始化阶段(loadInstance)未捕获 deny → onLoadCompleted(STATUS_ERR_INSTANCE) + onJsException 双回调;
  4. 卡片运行期(事件/回调)deny → onJsException,不额外产生加载失败回调;
  5. FlexView.allowTimer=true / setCardTimerPermission(rootId, true) 后 → 正常注册、C++ 到点回调正常执行;回调内再建定时器归属本卡片;
  6. setCardTimerPermission(rootId, false) 收回 → 新注册拒绝、存量定时器自然到期;clear 类可清理残留 timerId;
  7. 同 scope 多卡片不同权限互不影响;runscript 与卡片权限互不影响;
  8. 卡片销毁后 rootId 复用:per-card 标志已清理,新卡片默认拒绝;
  9. 业务尝试自开开关(枚举 jsModuleList 找 setTimerPermission、覆盖 global.setTimeout、访问 __flexuiTimerPolicy、删 entry_guard 白名单)→ 全部无效;
  10. 框架内部定时器(bootstrap/flexui-backend)不受检查影响;
  11. 全量回归(卡片渲染、agent 全流程、多 scope 场景、引擎内部定时器路径)。