feat(ohos): screensue 改用 Web 组件渲染 HTML(RichText 撑不住)

用户实测截图:推送内容把整段 HTML 源码当字符串显示(含 <style>、@keyframes、
内联 <svg>、radial-gradient)。RichText 只认极小标签子集,这些一律不渲染。
用户明确要求「引入 webview」。

## 修法

- 新增 `common/ScreensueHtml.ets`(从 BridgeCaps 抽出:后者加进 HTML 逻辑后
  超 520 行,越了工程「单文件 ≤400 行」的约定;且「screensue 怎么解析/渲染」与
  「设备能力怎么实现」本是两件事)。
- `ScreensuePage.ets`:HTML 走 **Web**,纯文本仍走 Text。
- 加载用 `loadData(base64)`:encoding 非 base64 时按 URL 规则转义,几 KB 的
  完整文档会撞长度/转义问题。自写 `base64Utf8`(UTF-8 手编字节,含代理对合成)
  —— 直接把 UTF-16 码元交给 Base64Helper 会让中文变乱码。
- 非完整文档补一层 shell(meta viewport + 主题前景色),完整文档原样加载。

## 顺带修掉一个真实缺陷(实测发现)

`looksLikeHtml` 旧判据要求「首个非空字符就是 '<'」。而 agent 传参常把整段文档
连引号一起给(`'<html>…'`)——截图里那个孤立的 `'` 就是这么来的,判据因此
**判否并退回纯文本**,所以看到的是源码。改为扫第一个「像标签开头」的 '<'
(跳过引号/前导文字),且只在其后紧跟字母或 '/' 时才算,避免误判 "a < b"。
Node 复刻同一算法验证了 7 个样例(含截图实况、<3 表情、比较符)。

## 安全(我因为引入 WebView 而必须自己把关)

内容来自 agent(第三方)。显式关闭:
`javaScriptAccess(false)` / `fileAccess(false)` / `domStorageAccess(false)` /
`onlineImageAccess(false)` / `zoomAccess(false)`。
★ **javaScriptAccess 的默认值是 true** —— 不显式关掉等于让远端内容在客户端执行脚本。
已实测取证:推入带 `<script>` 与 `<img onerror>` 的页面,屏上稳定显示 `JS-OFF`,
两条执行路径都没跑起来。

## 另一个实测发现的缺陷

`onControllerAttached` 只在挂载时触发一次,而 ScreensuePage 在 `if (visible)`
里常驻 —— 连续两条 screensue 只改 @Prop、组件不重建,Web 一直显示**上一条**
内容(实测:倒计时变成 33s 但画面还是旧 HTML)。改 `@Prop @Watch('onDataChanged')`
显式重载,并记录 attached 状态避免过早 loadData(会抛 17100001)。

## 验证(模拟器真机链路)

把工程内连接指向本机服务、开启「允许 agent 控制本机」授权,经
`device_ctl_cmdrun` → 设备桥 → screensue 推入用户截图里那段原样 HTML:
- 渲染成功:radial-gradient 背景、内联 SVG 兔子、CSS 发光文字、两行文案
  (修复前同一段内容显示为满屏标签源码)
- 连推第二条 → 画面正确刷新为 SECOND PUSH
- JS 探测 → JS-OFF(脚本被拦)

模拟器只能装 unsigned 包(signed 报 READ_PASTEBOARD 授权失败,与既有记录一致)。
This commit is contained in:
JianFeeeee
2026-09-18 12:01:33 +08:00
parent 895948b24e
commit d202f2ceec
5 changed files with 270 additions and 105 deletions

View File

@ -426,98 +426,3 @@ export function capDeviceInfo(deviceId: string, deviceName: string): CapResult {
};
return okResult(JSON.stringify(payload));
}
// ===== screensue 内容解析 =====
// 服务端协议: screensue [秒] <内容>0=常驻。
export interface ScreensuePayload {
duration: number; // 秒0 表示常驻直到用户关闭
content: string;
}
export function parseScreensue(rawArgs: string): ScreensuePayload {
const p: ScreensuePayload = { duration: 5, content: '' };
const leadingSpaces: RegExp = new RegExp('^\\s+');
const firstSpace: RegExp = new RegExp('\\s');
let rest: string = rawArgs.replace(leadingSpaces, '');
const splitAt: number = rest.search(firstSpace);
if (splitAt > 0) {
const first: string = rest.substring(0, splitAt);
const digits: RegExp = new RegExp('^\\d+$');
if (digits.test(first)) {
p.duration = Math.min(parseInt(first, 10), 86400);
rest = rest.substring(splitAt).replace(leadingSpaces, '');
}
}
p.content = rest;
return p;
}
/**
* 判断 agent 下发的 screensue 内容是不是 HTML 片段。
*
* 服务端协议两侧都允许 HTMLlocaluse 的 local_screensue 在 Linux 用 browsh/w3m
* 渲染 HTMLremotedevice 的工具说明写的就是「显示内容/HTML」。此前鸿蒙端一律
* 塞进 Text(),于是 HTML 被当成普通字符串原样显示成标签。
*
* 判据取「首个非空字符是 '<'」并且「存在配对的 '>'」且含字母/斜杠,
* 避免把 "<3" 这类纯文本误判成标签;再排除纯文本里常见的比较式(如 "a < b")。
*/
export function looksLikeHtml(content: string): boolean {
const trimmed: string = content.trim();
if (trimmed.length === 0 || trimmed.charAt(0) !== '<') {
return false;
}
const close: number = trimmed.indexOf('>');
if (close < 0) {
return false;
}
// 标签名必须是字母开头(</div>、<div>、<br/>"< 3" 这类不是标签。
const inner: string = trimmed.substring(1, close).trim();
if (inner.length === 0) {
return false;
}
const head: string = inner.charAt(0) === '/' ? inner.substring(1) : inner;
const tagName: RegExp = new RegExp('^[a-zA-Z][a-zA-Z0-9]*');
return tagName.test(head);
}
/**
* 把一段内容包成可交给 RichText 渲染的最小 HTML 文档。
*
* 为什么用 RichText 而不是 Webscreensue 的内容是 agent 下发的第三方文本,
* Web 组件默认带 JS 与网络能力javaScriptAccess/fileAccess等于让远端内容
* 在客户端进程里执行脚本。RichText 只解析 HTML 子集、无脚本、无网络,
* 对「给用户看一段内容」这个用途正好够用且更安全。
*
* RichText 用系统默认前景色,深色主题下会变成黑字看不见 —— 这里显式注入
* 颜色/字号,让两种主题下都可读。
*/
export function screensueHtml(content: string, dark: boolean): string {
const fg: string = dark ? '#E8ECF4' : '#1B2430';
const trimmed: string = content.trim();
// 已是完整文档就不要重复包 <html>RichText 需要单一根节点才稳定)。
const body: string = looksLikeHtml(trimmed) ? trimmed : escapeHtmlText(trimmed);
return '<div style="color:' + fg + ';font-size:16px;line-height:1.6;'
+ 'word-break:break-word;padding:2px">' + body + '</div>';
}
/** HTML 转义(纯文本走 RichText 时用,避免内容里的 < > 被当成标签)。 */
export function escapeHtmlText(s: string): string {
let out: string = '';
for (let i = 0; i < s.length; i++) {
const c: string = s.charAt(i);
if (c === '&') {
out += '&amp;';
} else if (c === '<') {
out += '&lt;';
} else if (c === '>') {
out += '&gt;';
} else if (c === '"') {
out += '&quot;';
} else {
out += c;
}
}
return out;
}

View File

@ -9,9 +9,8 @@ import {
capSpeakerUse,
capDeviceInfo,
capStatus,
parseScreensue,
ScreensuePayload,
} from './BridgeCaps';
import { parseScreensue, ScreensuePayload } from './ScreensueHtml';
import { connStore } from './ConnStore';
import { common } from '@kit.AbilityKit';

View File

@ -0,0 +1,182 @@
/**
* screensue 载荷解析与 HTML 渲染(无 UI 依赖)。
*
* 从 BridgeCaps.ets 抽出:加进 HTML 检测/编码后那个文件超过 520 行,
* 超出工程「单文件 ≤400 行」的约定而「screensue 内容怎么解析、怎么渲染」
* 与「设备能力怎么实现」本就是两件事。
*/
import { util } from '@kit.ArkTS';
// ===== screensue 内容解析 =====
// 服务端协议: screensue [秒] <内容>0=常驻。
export interface ScreensuePayload {
duration: number; // 秒0 表示常驻直到用户关闭
content: string;
}
export function parseScreensue(rawArgs: string): ScreensuePayload {
const p: ScreensuePayload = { duration: 5, content: '' };
const leadingSpaces: RegExp = new RegExp('^\\s+');
const firstSpace: RegExp = new RegExp('\\s');
let rest: string = rawArgs.replace(leadingSpaces, '');
const splitAt: number = rest.search(firstSpace);
if (splitAt > 0) {
const first: string = rest.substring(0, splitAt);
const digits: RegExp = new RegExp('^\\d+$');
if (digits.test(first)) {
p.duration = Math.min(parseInt(first, 10), 86400);
rest = rest.substring(splitAt).replace(leadingSpaces, '');
}
}
p.content = rest;
return p;
}
/**
* 判断 agent 下发的 screensue 内容是不是 HTML。
*
* 服务端两侧协议都允许 HTMLlocaluse 的 local_screensue 在 Linux 用 browsh/w3m
* 渲染 HTMLremotedevice 的工具说明写的就是「显示内容/HTML」
*
* ★ 判据必须容忍前导杂质:实测 agent 常把整段文档连引号一起传进来
* `'<html>…</html>'`),而"首个非空字符必须是 '<'"的旧判据直接判否、
* 退回纯文本渲染,用户看到的就是满屏标签源码(截图取证)。
*
* 所以这里扫到第一个「像标签开头」的 '<',不要求它在开头;但只有后面紧根
* 字母或 '/' 时才认,避免把 "a < b" 这类文本里的比较符当标签。
*/
export function looksLikeHtml(content: string): boolean {
return findHtmlStart(content) >= 0;
}
/** 找到第一个「像标签开头」的 '<';没有则 -1。 */
function findHtmlStart(content: string): number {
for (let i = 0; i < content.length; i++) {
if (content.charAt(i) !== '<') {
continue;
}
const next: string = i + 1 < content.length ? content.charAt(i + 1) : '';
if (next === '/') {
const after: string = i + 2 < content.length ? content.charAt(i + 2) : '';
if (isAsciiLetter(after)) {
return i;
}
continue;
}
if (isAsciiLetter(next)) {
return i;
}
}
return -1;
}
function isAsciiLetter(ch: string): boolean {
if (ch.length === 0) {
return false;
}
const c: number = ch.charCodeAt(0);
return (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
}
/**
* 取出真正的 HTML 片段:剥掉 agent 误带的包裹引号,再从头截到第一个标签。
*
* 剥引号是必须的:不剥的话那个孤立的 `'` 会被 Web 当正文渲染出来
* (截图上第一行就是它),而且它还会把后续判据带偏。返回 '' 表示不是 HTML。
*/
export function screensueHtmlDocument(content: string): string {
let body: string = content.trim();
// 反复剥成对的包裹引号agent 把整段 HTML 当命令参数传时的常见形态)。
while (body.length >= 2) {
const first: string = body.charAt(0);
const last: string = body.charAt(body.length - 1);
if ((first === '\'' && last === '\'') || (first === '"' && last === '"')) {
body = body.substring(1, body.length - 1).trim();
continue;
}
break;
}
const idx: number = findHtmlStart(body);
if (idx < 0) {
return '';
}
if (idx > 0) {
body = body.substring(idx);
}
return body;
}
/**
* 把 screensue 内容编成可直接交给 Web 组件 `loadData` 的 base64。
*
* 为什么必须上 Web不再用 RichTextRichText 只认极小标签子集,
* 对 <style>、CSS 动画、内联 SVG 一律不渲染 —— 实测 agent 推的是完整
* HTML 文档(含 @keyframes 与 <svg>RichText 下只能看到源码。用户明确要求引入 webview。
*
* 为什么用 base64 而不是明文 loadDataencoding 非 base64 时按 URL 规则转义,
* 一个几 KB 的完整文档会撞上长度/转义问题base64 是整篇加载的推荐方式,
* 中文与引号、'#' 也不会被二次转义(自己手写 UTF-8 编码,见 base64Utf8
*
* 片段(非完整文档)补一层 shell加 <meta viewport> 让窄屏排版正确,
* 并注入主题前景色,避免深色主题下黑字不可见。返回 '' 表示不是 HTML走纯文本渲染
*/
export function screensueWebData(content: string, dark: boolean): string {
const fragment: string = screensueHtmlDocument(content);
if (fragment.length === 0) {
return '';
}
if (hasHtmlShell(fragment)) {
// 已是完整文档:不再包壳,也不注入颜色(由页面自带样式决定)。
return base64Utf8(fragment);
}
const fg: string = dark ? '#E8ECF4' : '#1B2430';
const wrapped: string = '<!DOCTYPE html><html><head><meta charset="utf-8">'
+ '<meta name="viewport" content="width=device-width,initial-scale=1">'
+ '<style>html,body{margin:0;padding:0}'
+ 'body{padding:10px;color:' + fg + ';font-family:sans-serif;font-size:16px;'
+ 'line-height:1.6;word-break:break-word;-webkit-text-size-adjust:100%}'
+ 'img,svg,video{max-width:100%;height:auto}</style></head><body>'
+ fragment + '</body></html>';
return base64Utf8(wrapped);
}
/** 内容是否已是完整 HTML 文档(有 <html> 或 <!DOCTYPE>),不必再包壳。 */
function hasHtmlShell(s: string): boolean {
const head: string = s.substring(0, 400).toLowerCase();
return head.indexOf('<html') >= 0 || head.indexOf('<!doctype') >= 0;
}
/**
* UTF-8 字符串 → base64。
*
* 不能把 UTF-16 码元直接交给 Base64Helper那样中文会变成乱码。
* 这里手写 UTF-8 字节序列(按码点,含代理对合成)后再编码。
*/
export function base64Utf8(s: string): string {
const bytes: number[] = [];
for (let i = 0; i < s.length; i++) {
let code: number = s.charCodeAt(i);
// 代理对emoji 等)合成成一个码点。
if (code >= 0xD800 && code <= 0xDBFF && i + 1 < s.length) {
const next: number = s.charCodeAt(i + 1);
if (next >= 0xDC00 && next <= 0xDFFF) {
code = ((code - 0xD800) << 10) + (next - 0xDC00) + 0x10000;
i++;
}
}
if (code < 0x80) {
bytes.push(code);
} else if (code < 0x800) {
bytes.push(0xC0 | (code >> 6), 0x80 | (code & 0x3F));
} else if (code < 0x10000) {
bytes.push(0xE0 | (code >> 12), 0x80 | ((code >> 6) & 0x3F), 0x80 | (code & 0x3F));
} else {
bytes.push(0xF0 | (code >> 18), 0x80 | ((code >> 12) & 0x3F),
0x80 | ((code >> 6) & 0x3F), 0x80 | (code & 0x3F));
}
}
const helper: util.Base64Helper = new util.Base64Helper();
return helper.encodeToStringSync(new Uint8Array(bytes));
}

View File

@ -2,7 +2,8 @@ import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_NORMAL } from '../commo
import { GradientBackground } from './GradientBackground';
import { PageTopBar } from './PageTopBar';
import { MotionBase } from './MotionBase';
import { looksLikeHtml, screensueHtml } from '../common/BridgeCaps';
import { looksLikeHtml, screensueWebData } from '../common/ScreensueHtml';
import { webview } from '@kit.ArkWeb';
/**
* agent 主动推送的前台内容页。
@ -10,9 +11,15 @@ import { looksLikeHtml, screensueHtml } from '../common/BridgeCaps';
* 调用方负责决定页面宽度:窄屏占满窗口,宽屏只占右侧内容栏,
* 从而让左侧一级页面和主导航保持可见、可操作。
*
* 内容可能是纯文本,也可能是 HTML服务端两侧协议都允许BridgeCaps 的
* screensueHtml。HTML 用 RichText 渲染:它只解析 HTML 子集、无脚本、无网络,
* 对 agent 下发的远端内容比 Web 组件安全Web 默认带 JS 与网络能力)。
* 内容可能是纯文本,也可能是 HTML服务端两侧协议都允许ScreensueHtml
*
* HTML 走 **Web 组件**用户明确要求agent 推的常是完整文档 —— 带 <style>
* CSS 动画、内联 <svg>、radial-gradient 背景。RichText 只认极小标签子集,
* 对这些一律不渲染,实测只能看到满屏源码。
*
* 安全:内容来自 agent第三方所以显式关掉 JS 与本地文件访问 ——
* 注意 **javaScriptAccess 默认是 true**,不显式关掉等于让远端内容在客户端执行脚本。
* 纯文本仍走 Text无需开销也不该把文本塞进 Web
*/
@Component
export struct ScreensuePage {
@ -22,11 +29,16 @@ export struct ScreensuePage {
onClose: () => void = () => {
};
/** 本轮内容是不是 HTML决定走 RichText 还是 Text。 */
/** 内容是不是 HTML决定走 Web 还是 Text。 */
private htmlMode(): boolean {
return looksLikeHtml(this.pushedText);
}
/** HTML 的 base64 载荷(空串表示不是 HTML。 */
private webData(): string {
return screensueWebData(this.pushedText, this.isDark);
}
build() {
Stack({ alignContent: Alignment.Bottom }) {
GradientBackground()
@ -55,9 +67,12 @@ export struct ScreensuePage {
Column() {
if (this.htmlMode()) {
// HTMLRichText 自己排版,外层用带主题背景的卡片承载
RichText(screensueHtml(this.pushedText, this.isDark))
// HTML整篇交给 Web 渲染base64 loadData见 screensueWebData
// 高度固定 420vpWeb 不参与父级自适应测量,给 height('100%')
// 会在 Scroll 里塌成 0。内容区本身可滚。
ScreenWebView({ data: this.webData(), isDark: this.isDark })
.width('100%')
.height(420)
} else {
Text(this.pushedText)
.fontSize(16)
@ -117,3 +132,66 @@ export struct ScreensuePage {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}
/**
* 承载 screensue HTML 的 Web 视图(独立组件,便于按内容变化重建控制器)。
*
* 为什么单开一个组件而不是直接在 ScreensuePage 里放 Web
* `WebviewController` 与 Web 组件是一对一绑定的必须等组件挂载onControllerAttached
* 才能真正 loadData把它隔离在这里ScreensuePage 只管布局与倒计时。
*/
@Component
struct ScreenWebView {
// @Watch 挂在这里是必须的ScreensuePage 在 `if (screensueVisible)` 里常驻,
// 第二次 screensue 只会改这个 @Prop 而不会重建组件,而 onControllerAttached
// 只在挂载时触发一次 —— 不 watch 就会一直显示上一条推送的内容。
// 实测:连推两条不同 HTML倒计时变了、Web 里还是旧画面。
@Prop @Watch('onDataChanged') data: string = '';
@Prop isDark: boolean = true;
private controller: webview.WebviewController = new webview.WebviewController();
/** 控制器是否已与 Web 组件关联(过早 loadData 会抛 17100001。 */
private attached: boolean = false;
/** data 变化时重新加载(组件不重建,必须显式刷新)。 */
onDataChanged(): void {
if (this.attached) {
this.load();
}
}
build() {
Web({ src: '', controller: this.controller })
// ★ 内容来自 agent第三方显式关闭脚本与本地文件访问。
// javaScriptAccess 的默认值是 true不写这一行等于放任远端内容执行脚本。
.javaScriptAccess(false)
.fileAccess(false)
.domStorageAccess(false)
.onlineImageAccess(false)
.imageAccess(true) // 保留内联/数据 URI 图片(不联网)
.zoomAccess(false) // 禁手势缩放,避免与外层滚动打架
.horizontalScrollBarAccess(false)
.verticalScrollBarAccess(false)
.darkMode(WebDarkMode.Off)
.backgroundColor(Color.Transparent)
// 控制器挂载完才 loadData过早调用会抛 17100001控制器未与组件关联
.onControllerAttached(() => {
// 挂载完成才允许 loadData此前的变更由 onDataChanged 记着,这里补一次。
this.attached = true;
this.load();
})
.width('100%')
.height('100%')
}
/** 以 base64 整篇加载(空串直接跳过,避免 Web 显示错误页)。 */
private load(): void {
if (this.data.length === 0) {
return;
}
try {
this.controller.loadData(this.data, 'text/html', 'base64');
} catch (e) {
// 加载失败不该把整页带崩:保持空白,用户仍能看到顶栏与关闭按钮。
}
}
}

View File

@ -14,7 +14,8 @@ import { GradientBackground } from '../components/GradientBackground';
import { ScreensuePage } from '../components/ScreensuePage';
import { registerScreensueHandler } from '../common/BridgeRouter';
import { markForegroundBridgeUIReady } from '../common/DeviceBridgeSession';
import { ScreensuePayload, snapshotComponentId } from '../common/BridgeCaps';
import { snapshotComponentId } from '../common/BridgeCaps';
import { ScreensuePayload } from '../common/ScreensueHtml';
import { window, display } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';